Feat/agentflow Add support for create credential (#6048)

* Initial flow

* Fix styling to look the same as v2

* Add new atoms - SwitxhInput, Dropdown and ToolTipWithParser similar to v2

* Add test cases for all changes

* Refactor input default value handling and consolidate utility functions

* Allow imports from utils for atoms

* Fix gemini comments

* Fixed buold issues

* FIx styling and create credentiall behavior

* Add support for edit credentials

* Fix bug to display tool and vector embeddign parameters

* Add CredentialTypeSelector for HTTP node

* Fix to add falback icon in case of error

* Move inputDefaults under core/primitives

* Added fix to check if all mandatory fields have value before submittign a credential

* Fix new credential not being selected on creating

* Fixes made to address comments

* Gemini comment fix

* Fix test error by adding theme

* Refactor getDefaultValueForType to remove null/undefined handling

* Fixed getDefaultValueForType to be generic

* Fixed getDefaultValueForType to take single destructured object parameter
This commit is contained in:
j-sanaa
2026-03-25 15:00:22 -07:00
committed by GitHub
parent 5cfda919a8
commit 02813aa703
38 changed files with 2351 additions and 458 deletions
+2 -2
View File
@@ -119,8 +119,8 @@ module.exports = {
{
target: './src/atoms',
from: './src/core',
except: ['./types', './theme'],
message: 'Atoms can only import from core/types and core/theme, not utilities or business logic.'
except: ['./types', './theme', './primitives'],
message: 'Atoms can only import from core/types, core/theme, and core/primitives.'
},
// core/ cannot import from anything (leaf node)
{
+15 -3
View File
@@ -42,7 +42,7 @@ atoms/
- No API calls
- Stateless or minimal local state
- Imported by features, never the reverse
- **Forbidden**: Importing from `features/` or `infrastructure/` (except types from `core/types` for prop definitions and design tokens from `core/theme`)
- **Forbidden**: Importing from `features/` or `infrastructure/` (except types from `core/types` for prop definitions, design tokens from `core/theme`, and primitives from `core/primitives`)
**Goal:** 100% visual consistency.
@@ -110,6 +110,9 @@ features/
core/
├── types/ # Global interfaces (Node, Edge, Flow)
│ └── index.ts
├── primitives/ # Domain-free utilities (safe for atoms)
│ ├── inputDefaults.ts # getDefaultValueForType
│ └── index.ts
├── node-config/ # Node configuration (icons, colors, default types)
│ ├── nodeIcons.ts # AGENTFLOW_ICONS, DEFAULT_AGENTFLOW_NODES
│ └── ...
@@ -124,7 +127,7 @@ core/
│ ├── flowValidation.ts # validateFlow, validateNode
│ ├── connectionValidation.ts # isValidConnectionAgentflowV2
│ └── ...
├── utils/ # Generic utilities
├── utils/ # Domain-aware utilities (NOT importable by atoms)
│ ├── nodeFactory.ts # initNode, getUniqueNodeId
│ └── ...
└── index.ts # Barrel export (use sparingly)
@@ -138,6 +141,15 @@ core/
- Pure functions where possible
- Can be tested in isolation
#### `core/primitives/` vs `core/utils/`
`core/` contains two utility directories with different import permissions:
- **`primitives/`** — Domain-free, general-purpose functions with no knowledge of nodes, flows, or any business concept. These are pure data transformations (e.g., computing a default value from a type string). **Safe to import from `atoms/`.**
- **`utils/`** — Domain-aware utilities that understand node structures, flow data, or validation logic (e.g., `initNode`, `buildDynamicOutputAnchors`). **Only importable by `features/` and `infrastructure/`.**
When adding a new utility, ask: _"Does this function need to know what a Node or Flow is?"_ If no → `primitives/`. If yes → `utils/`.
**Goal:** To be the framework-agnostic source of truth.
---
@@ -210,7 +222,7 @@ infrastructure/
- `features` → `atoms`, `infrastructure`, `core` ✅
- `infrastructure` → `core` ✅
- `atoms` → `core/types` and `core/theme` only (for type definitions and design tokens) ✅
- `atoms` → `core/types`, `core/theme`, and `core/primitives` only ✅
- `core` → nothing (leaf node) ✅
- **Atoms and Core are "leaf" nodes** - they cannot import from `features/` or `infrastructure/`
+2
View File
@@ -80,7 +80,9 @@
"@uiw/codemirror-theme-vscode": "^4.21.0",
"@uiw/react-codemirror": "^4.21.0",
"axios": "^1.7.2",
"dompurify": "^3.2.6",
"flowise-react-json-view": "^1.21.7",
"html-react-parser": "^3.0.16",
"lodash": "^4.17.21",
"lowlight": "^3.3.0",
"uuid": "^10.0.0"
+2 -17
View File
@@ -4,6 +4,7 @@ import { Box, Button, Chip, IconButton } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { IconPlus, IconTrash } from '@tabler/icons-react'
import { getDefaultValueForType } from '@/core/primitives'
import type { InputParam, NodeData } from '@/core/types'
import { type AsyncInputProps, type ConfigInputComponentProps, NodeInputHandler } from './NodeInputHandler'
@@ -75,23 +76,7 @@ export function ArrayInput({
if (inputParam.array) {
for (const field of inputParam.array) {
if (field.default !== undefined) {
newItem[field.name] = field.default
} else {
switch (field.type) {
case 'number':
newItem[field.name] = 0
break
case 'boolean':
newItem[field.name] = false
break
case 'array':
newItem[field.name] = []
break
default:
newItem[field.name] = ''
}
}
newItem[field.name] = getDefaultValueForType(field)
}
}
@@ -4,6 +4,7 @@ import { Box, Button, Chip, IconButton, Typography } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { IconPlus, IconTrash } from '@tabler/icons-react'
import { getDefaultValueForType } from '@/core/primitives'
import type { InputParam, NodeData } from '@/core/types'
import { NodeInputHandler } from './NodeInputHandler'
@@ -58,20 +59,7 @@ export function ConditionBuilder({
const newItem: Record<string, unknown> = {}
if (inputParam.array) {
for (const field of inputParam.array) {
if (field.default != null) {
newItem[field.name] = field.default
} else {
switch (field.type) {
case 'number':
newItem[field.name] = 0
break
case 'boolean':
newItem[field.name] = false
break
default:
newItem[field.name] = ''
}
}
newItem[field.name] = getDefaultValueForType(field)
}
}
onDataChange?.({ inputParam, newValue: [...arrayItems, newItem] })
@@ -0,0 +1,119 @@
import { fireEvent, render, screen } from '@testing-library/react'
import type { ComponentCredentialSchema } from '@/core/types'
import { CredentialTypeSelector } from './CredentialTypeSelector'
// ─── Mocks ────────────────────────────────────────────────────────────────────
jest.mock('@tabler/icons-react', () => ({
IconKey: () => <span data-testid='icon-key-fallback' />,
IconSearch: () => <span data-testid='icon-search' />,
IconX: (props: { onClick?: () => void }) => <button data-testid='icon-x' onClick={props.onClick} />
}))
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const schemas: ComponentCredentialSchema[] = [
{ label: 'HTTP Basic Auth', name: 'httpBasicAuth', inputs: [] },
{ label: 'HTTP Bearer Token', name: 'httpBearerToken', inputs: [] },
{ label: 'HTTP Api Key', name: 'httpApiKey', inputs: [] }
]
const apiBaseUrl = 'http://localhost:3000'
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('CredentialTypeSelector', () => {
it('renders search input with placeholder', () => {
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)
expect(screen.getByPlaceholderText('Search credential')).toBeInTheDocument()
})
it('renders all credential cards with labels and icons', () => {
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)
expect(screen.getByText('HTTP Basic Auth')).toBeInTheDocument()
expect(screen.getByText('HTTP Bearer Token')).toBeInTheDocument()
expect(screen.getByText('HTTP Api Key')).toBeInTheDocument()
const images = screen.getAllByRole('img')
expect(images).toHaveLength(3)
expect(images[0]).toHaveAttribute('src', 'http://localhost:3000/api/v1/components-credentials-icon/httpBasicAuth')
expect(images[0]).toHaveAttribute('alt', 'httpBasicAuth')
})
it('calls onSelect with the clicked schema', () => {
const onSelect = jest.fn()
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={onSelect} />)
fireEvent.click(screen.getByText('HTTP Bearer Token'))
expect(onSelect).toHaveBeenCalledTimes(1)
expect(onSelect).toHaveBeenCalledWith(schemas[1])
})
it('filters schemas by search input', () => {
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)
const searchInput = screen.getByPlaceholderText('Search credential')
fireEvent.change(searchInput, { target: { value: 'bearer' } })
expect(screen.getByText('HTTP Bearer Token')).toBeInTheDocument()
expect(screen.queryByText('HTTP Basic Auth')).not.toBeInTheDocument()
expect(screen.queryByText('HTTP Api Key')).not.toBeInTheDocument()
})
it('search is case-insensitive', () => {
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)
fireEvent.change(screen.getByPlaceholderText('Search credential'), { target: { value: 'API' } })
expect(screen.getByText('HTTP Api Key')).toBeInTheDocument()
expect(screen.queryByText('HTTP Basic Auth')).not.toBeInTheDocument()
})
it('clears search when clear button is clicked', () => {
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)
const searchInput = screen.getByPlaceholderText('Search credential')
fireEvent.change(searchInput, { target: { value: 'bearer' } })
expect(screen.queryByText('HTTP Basic Auth')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('icon-x'))
expect(screen.getByText('HTTP Basic Auth')).toBeInTheDocument()
expect(screen.getByText('HTTP Bearer Token')).toBeInTheDocument()
expect(screen.getByText('HTTP Api Key')).toBeInTheDocument()
})
it('shows no cards when search matches nothing', () => {
render(<CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)
fireEvent.change(screen.getByPlaceholderText('Search credential'), { target: { value: 'nonexistent' } })
expect(screen.queryByText('HTTP Basic Auth')).not.toBeInTheDocument()
expect(screen.queryByText('HTTP Bearer Token')).not.toBeInTheDocument()
expect(screen.queryByText('HTTP Api Key')).not.toBeInTheDocument()
expect(screen.queryAllByRole('img')).toHaveLength(0)
})
it('renders empty list when schemas is empty', () => {
render(<CredentialTypeSelector schemas={[]} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)
expect(screen.getByPlaceholderText('Search credential')).toBeInTheDocument()
expect(screen.queryAllByRole('img')).toHaveLength(0)
})
it('shows fallback key icon when credential icon fails to load', () => {
render(<CredentialTypeSelector schemas={[schemas[0]]} apiBaseUrl={apiBaseUrl} onSelect={jest.fn()} />)
const img = screen.getByAltText('httpBasicAuth')
fireEvent.error(img)
expect(screen.queryByAltText('httpBasicAuth')).not.toBeInTheDocument()
expect(screen.getByTestId('icon-key-fallback')).toBeInTheDocument()
})
})
@@ -0,0 +1,129 @@
import { type SyntheticEvent, useState } from 'react'
import { Box, InputAdornment, List, ListItemButton, OutlinedInput, Typography } from '@mui/material'
import { alpha, useTheme } from '@mui/material/styles'
import { IconKey, IconSearch, IconX } from '@tabler/icons-react'
import type { ComponentCredentialSchema } from '@/core/types'
export interface CredentialTypeSelectorProps {
schemas: ComponentCredentialSchema[]
apiBaseUrl: string
onSelect: (schema: ComponentCredentialSchema) => void
}
/**
* Search + grid selector for choosing a credential type.
* Renders a search bar and a 3-column grid of credential cards with icons.
*/
export function CredentialTypeSelector({ schemas, apiBaseUrl, onSelect }: CredentialTypeSelectorProps) {
const theme = useTheme()
const [searchValue, setSearchValue] = useState('')
const filtered = schemas.filter((s) => s.label.toLowerCase().includes(searchValue.toLowerCase()))
return (
<>
<Box sx={{ backgroundColor: theme.palette.background.paper, pt: 2, position: 'sticky', top: 0, zIndex: 10 }}>
<OutlinedInput
sx={{ width: '100%', pr: 2, pl: 2 }}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
placeholder='Search credential'
startAdornment={
<InputAdornment position='start'>
<IconSearch stroke={1.5} size='1rem' color={theme.palette.grey[500]} />
</InputAdornment>
}
endAdornment={
<InputAdornment
position='end'
sx={{ cursor: 'pointer', color: theme.palette.grey[500], '&:hover': { color: theme.palette.grey[900] } }}
title='Clear Search'
>
<IconX stroke={1.5} size='1rem' onClick={() => setSearchValue('')} style={{ cursor: 'pointer' }} />
</InputAdornment>
}
/>
</Box>
<List
sx={{
width: '100%',
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 2,
py: 0,
zIndex: 9,
borderRadius: '10px',
[theme.breakpoints.down('md')]: {
maxWidth: 370
}
}}
>
{filtered.map((schema) => (
<ListItemButton
key={schema.name}
onClick={() => onSelect(schema)}
sx={{
border: 1,
borderColor: alpha(theme.palette.grey[900], 0.25),
borderRadius: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'start',
textAlign: 'left',
gap: 1,
p: 2
}}
>
<CredentialIcon name={schema.name} apiBaseUrl={apiBaseUrl} />
<Typography>{schema.label}</Typography>
</ListItemButton>
))}
</List>
</>
)
}
/** Circular credential icon with fallback to a key icon on load error. */
export function CredentialIcon({ name, apiBaseUrl }: { name: string; apiBaseUrl: string }) {
const theme = useTheme()
const [failed, setFailed] = useState(false)
const handleError = (e: SyntheticEvent<HTMLImageElement>) => {
e.currentTarget.onerror = null
setFailed(true)
}
return (
<div
style={{
width: 50,
height: 50,
borderRadius: '50%',
backgroundColor: theme.palette.common.white,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
{failed ? (
<IconKey size={30} stroke={1.5} />
) : (
<img
style={{
width: '100%',
height: '100%',
padding: 7,
borderRadius: '50%',
objectFit: 'contain'
}}
alt={name}
src={`${apiBaseUrl}/api/v1/components-credentials-icon/${name}`}
onError={handleError}
/>
)}
</div>
)
}
@@ -0,0 +1,77 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { Dropdown, DropdownOption } from './Dropdown'
const mockOnSelect = jest.fn()
const options: DropdownOption[] = [
{ label: 'GPT-4o', name: 'gpt-4o' },
{ label: 'Claude 3', name: 'claude-3' },
{ label: 'Gemini', name: 'gemini', description: 'Google model', imageSrc: 'http://test/gemini.png' }
]
beforeEach(() => {
jest.clearAllMocks()
})
describe('Dropdown', () => {
it('renders without crashing', () => {
render(<Dropdown value='' options={options} onSelect={mockOnSelect} />)
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
it('displays options when opened', async () => {
render(<Dropdown value='' options={options} onSelect={mockOnSelect} />)
fireEvent.mouseDown(screen.getByRole('combobox'))
await waitFor(() => {
expect(screen.getByText('GPT-4o')).toBeInTheDocument()
expect(screen.getByText('Claude 3')).toBeInTheDocument()
expect(screen.getByText('Gemini')).toBeInTheDocument()
})
})
it('calls onSelect with option name when option is clicked', async () => {
render(<Dropdown value='' options={options} onSelect={mockOnSelect} />)
const input = screen.getByRole('combobox')
fireEvent.change(input, { target: { value: 'GPT' } })
await waitFor(() => screen.getByText('GPT-4o'))
fireEvent.click(screen.getByText('GPT-4o'))
expect(mockOnSelect).toHaveBeenCalledWith('gpt-4o')
})
it('shows image in option when imageSrc is present', async () => {
render(<Dropdown value='' options={options} onSelect={mockOnSelect} />)
fireEvent.mouseDown(screen.getByRole('combobox'))
await waitFor(() => {
const img = screen.getByAltText('Gemini')
expect(img).toBeInTheDocument()
expect(img).toHaveAttribute('src', 'http://test/gemini.png')
})
})
it('shows description in option when present', async () => {
render(<Dropdown value='' options={options} onSelect={mockOnSelect} />)
fireEvent.mouseDown(screen.getByRole('combobox'))
await waitFor(() => {
expect(screen.getByText('Google model')).toBeInTheDocument()
})
})
it('renders in disabled state', () => {
render(<Dropdown value='' options={options} onSelect={mockOnSelect} disabled />)
expect(screen.getByRole('combobox')).toBeDisabled()
})
it('shows selected value label', () => {
render(<Dropdown value='gpt-4o' options={options} onSelect={mockOnSelect} />)
expect(screen.getByRole('combobox')).toHaveValue('GPT-4o')
})
})
+130
View File
@@ -0,0 +1,130 @@
import { Box, FormControl, Popper, TextField, Typography } from '@mui/material'
import Autocomplete, { autocompleteClasses } from '@mui/material/Autocomplete'
import { alpha, styled, useTheme } from '@mui/material/styles'
const StyledPopper = styled(Popper)({
boxShadow: '0px 8px 10px -5px rgb(0 0 0 / 20%), 0px 16px 24px 2px rgb(0 0 0 / 14%), 0px 6px 30px 5px rgb(0 0 0 / 12%)',
borderRadius: '10px',
[`& .${autocompleteClasses.listbox}`]: {
boxSizing: 'border-box',
'& ul': {
padding: 10,
margin: 10
}
}
})
export interface DropdownOption {
label: string
name: string
description?: string
imageSrc?: string
}
export interface DropdownProps {
name?: string
value: string
options: DropdownOption[]
onSelect: (value: string) => void
disabled?: boolean
loading?: boolean
freeSolo?: boolean
disableClearable?: boolean
}
/**
* Autocomplete-based dropdown with search, image, and description support.
* Mirrors the original Flowise Dropdown component.
*/
export function Dropdown({
name,
value,
options = [],
onSelect,
disabled = false,
loading = false,
freeSolo = false,
disableClearable = false
}: DropdownProps) {
const theme = useTheme()
const resolvedValue = value ?? 'choose an option'
const findMatchingOption = (val: string) => options.find((option) => option.name === val) ?? null
return (
<FormControl sx={{ mt: 1, width: '100%' }} size='small'>
<Autocomplete
id={name}
disabled={disabled}
freeSolo={freeSolo}
disableClearable={disableClearable}
size='small'
loading={loading}
options={options}
value={findMatchingOption(resolvedValue)}
getOptionLabel={(option) => (typeof option === 'string' ? option : option.label)}
isOptionEqualToValue={(option, val) => option.name === val.name}
onChange={(_e, selection) => {
const newValue = selection && typeof selection !== 'string' ? selection.name : ''
onSelect(newValue)
}}
PopperComponent={StyledPopper}
renderInput={(params) => {
const matchingOption = findMatchingOption(resolvedValue)
return (
<TextField
{...params}
sx={{
height: '100%',
'& .MuiInputBase-root': {
height: '100%',
'& fieldset': {
borderColor: alpha(theme.palette.grey[900], 0.25)
}
}
}}
InputProps={{
...params.InputProps,
startAdornment: matchingOption?.imageSrc ? (
<Box
component='img'
src={matchingOption.imageSrc}
alt={matchingOption.label || 'Selected Option'}
sx={{
width: 32,
height: 32,
borderRadius: '50%'
}}
/>
) : null
}}
/>
)
}}
renderOption={(props, option) => (
<Box component='li' {...props} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{option.imageSrc && (
<img
src={option.imageSrc}
alt={option.label}
style={{
width: 30,
height: 30,
padding: 1,
borderRadius: '50%'
}}
/>
)}
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant='h5'>{option.label}</Typography>
{option.description && (
<Typography sx={{ color: theme.palette.text.secondary }}>{option.description}</Typography>
)}
</div>
</Box>
)}
sx={{ height: '100%' }}
/>
</FormControl>
)
}
@@ -7,12 +7,8 @@ import {
Dialog,
DialogContent,
DialogTitle,
FormControlLabel,
IconButton,
MenuItem,
Popover,
Select,
Switch,
TextField,
Tooltip,
TooltipProps,
@@ -21,17 +17,20 @@ import {
import Autocomplete from '@mui/material/Autocomplete'
import { styled, useTheme } from '@mui/material/styles'
import { tooltipClasses } from '@mui/material/Tooltip'
import { IconArrowsMaximize, IconInfoCircle, IconVariable } from '@tabler/icons-react'
import { IconArrowsMaximize, IconVariable } from '@tabler/icons-react'
import type { InputAnchor, InputParam, NodeData } from '@/core/types'
import ArrayInput from './ArrayInput'
import { CodeInput } from './CodeInput'
import { Dropdown } from './Dropdown'
import { ExpandTextDialog } from './ExpandTextDialog'
import { JsonInput } from './JsonInput'
import { RichTextEditor } from './RichTextEditor.lazy'
import type { VariableItem } from './SelectVariable'
import { SelectVariable } from './SelectVariable'
import { SwitchInput } from './SwitchInput'
import { TooltipWithParser } from './TooltipWithParser'
const CustomWidthTooltip = styled(({ className, ...props }: TooltipProps) => <Tooltip {...props} classes={{ popper: className }} />)({
[`& .${tooltipClasses.tooltip}`]: {
@@ -224,50 +223,20 @@ export function NodeInputHandler({
)
case 'boolean':
return <SwitchInput disabled={disabled} value={!!value} onChange={(checked) => handleDataChange(checked)} />
case 'options': {
const dropdownOptions = (inputParam.options ?? []).map((opt) => (typeof opt === 'string' ? { label: opt, name: opt } : opt))
return (
<FormControlLabel
control={<Switch disabled={disabled} checked={!!value} onChange={(e) => handleDataChange(e.target.checked)} />}
label=''
sx={{ mt: 1 }}
<Dropdown
disabled={disabled}
name={inputParam.name}
options={dropdownOptions}
onSelect={(newValue) => handleDataChange(newValue)}
value={String(value || '')}
/>
)
case 'options':
return (
<Select
fullWidth
size='small'
disabled={disabled}
value={value || ''}
onChange={(e) => handleDataChange(e.target.value)}
sx={{ mt: 1 }}
renderValue={(selected) => {
const match = inputParam.options?.find((o) => (typeof o === 'string' ? o : o.name) === selected)
return match ? (typeof match === 'string' ? match : match.label) : String(selected)
}}
>
{inputParam.options?.map((option) => {
const isObj = typeof option !== 'string'
const key = isObj ? option.name : option
const label = isObj ? option.label : option
const desc = isObj ? option.description : undefined
return (
<MenuItem key={key} value={key}>
{desc ? (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant='body2'>{label}</Typography>
<Typography variant='caption' color='text.secondary'>
{desc}
</Typography>
</Box>
) : (
label
)}
</MenuItem>
)
})}
</Select>
)
}
case 'multiOptions': {
// Stored as JSON-serialized array of names, e.g. '["option1","option2"]'
@@ -442,7 +411,7 @@ export function NodeInputHandler({
<Box sx={{ p: 2 }}>
<Typography>
{inputAnchor.label}
{!inputAnchor.optional && <span style={{ color: 'red' }}>&nbsp;*</span>}
{!inputAnchor.optional && <span style={{ color: theme.palette.error.main }}>&nbsp;*</span>}
</Typography>
</Box>
</>
@@ -470,14 +439,8 @@ export function NodeInputHandler({
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<Typography>
{inputParam?.label}
{!inputParam?.optional && <span style={{ color: 'red' }}>&nbsp;*</span>}
{inputParam?.description && (
<Tooltip title={inputParam.description} placement='top'>
<span style={{ display: 'inline-flex', verticalAlign: 'middle', marginLeft: 6, cursor: 'pointer' }}>
<IconInfoCircle size={16} style={{ opacity: 0.6 }} />
</span>
</Tooltip>
)}
{!inputParam?.optional && <span style={{ color: theme.palette.error.main }}>&nbsp;*</span>}
{inputParam?.description && <TooltipWithParser title={inputParam.description} />}
</Typography>
<div style={{ flexGrow: 1 }} />
{showVariableButton && (
@@ -488,7 +451,7 @@ export function NodeInputHandler({
disabled={disabled}
onClick={(e) => setVariableAnchorEl(e.currentTarget)}
>
<IconVariable size={20} style={{ color: 'teal' }} />
<IconVariable size={20} style={{ color: theme.palette.info.main }} />
</IconButton>
</Tooltip>
)}
@@ -25,11 +25,14 @@ jest.mock('./NodeInputHandler', () => ({
}))
jest.mock('@tabler/icons-react', () => ({
IconInfoCircle: () => <span data-testid='icon-info' />,
IconPlus: () => <span data-testid='icon-plus' />,
IconTrash: () => <span data-testid='icon-trash' />
}))
jest.mock('./TooltipWithParser', () => ({
TooltipWithParser: ({ title }: { title: string }) => <span data-testid='tooltip-with-parser'>{title}</span>
}))
const scenarioInputParam: InputParam = {
id: 'conditionAgentScenarios',
name: 'conditionAgentScenarios',
@@ -61,7 +64,7 @@ describe('ScenariosInput', () => {
const paramWithDesc: InputParam = { ...scenarioInputParam, description: 'Define scenarios for splitting' }
render(<ScenariosInput inputParam={paramWithDesc} data={mockNodeData} onDataChange={mockOnDataChange} />)
expect(screen.getByTestId('icon-info')).toBeInTheDocument()
expect(screen.getByTestId('tooltip-with-parser')).toBeInTheDocument()
})
it('should render scenario items with "Scenario N" labels', () => {
@@ -1,12 +1,13 @@
import { useCallback, useMemo } from 'react'
import { Box, Button, Chip, IconButton, Tooltip, Typography } from '@mui/material'
import { Box, Button, Chip, IconButton, Typography } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { IconInfoCircle, IconPlus, IconTrash } from '@tabler/icons-react'
import { IconPlus, IconTrash } from '@tabler/icons-react'
import type { InputParam, NodeData } from '@/core/types'
import { NodeInputHandler } from './NodeInputHandler'
import { TooltipWithParser } from './TooltipWithParser'
import { useStableKeys } from './useStableKeys'
export interface ScenariosInputProps {
@@ -79,14 +80,8 @@ export function ScenariosInput({ inputParam, data, disabled = false, onDataChang
<Box sx={{ p: 2 }}>
<Typography>
{inputParam.label}
{!inputParam.optional && <span style={{ color: 'red' }}>&nbsp;*</span>}
{inputParam.description && (
<Tooltip title={inputParam.description} placement='top'>
<span style={{ display: 'inline-flex', verticalAlign: 'middle', marginLeft: 6, cursor: 'pointer' }}>
<IconInfoCircle size={16} style={{ opacity: 0.6 }} />
</span>
</Tooltip>
)}
{!inputParam.optional && <span style={{ color: theme.palette.error.main }}>&nbsp;*</span>}
{inputParam.description && <TooltipWithParser title={inputParam.description} />}
</Typography>
{arrayItems.map((itemValues, index) => {
@@ -9,11 +9,14 @@ const mockOnDataChange = jest.fn()
jest.mock('@tabler/icons-react', () => ({
IconArrowsMaximize: () => <span data-testid='icon-arrows-maximize' />,
IconInfoCircle: () => <span data-testid='icon-info-circle' />,
IconPlus: () => <span data-testid='icon-plus' />,
IconTrash: () => <span data-testid='icon-trash' />
}))
jest.mock('./TooltipWithParser', () => ({
TooltipWithParser: ({ title }: { title: string }) => <span data-testid='tooltip-with-parser'>{title}</span>
}))
jest.mock('./CodeInput', () => ({
CodeInput: ({ value, onChange, language }: { value: string; onChange: (v: string) => void; language?: string }) => (
<textarea data-testid='code-input' data-language={language} value={value} onChange={(e) => onChange(e.target.value)} />
@@ -366,7 +369,7 @@ describe('StructuredOutputBuilder', () => {
render(<StructuredOutputBuilder inputParam={mockInputParam} data={dataWithEntries} onDataChange={mockOnDataChange} />)
expect(screen.getByText('Enum Values')).toBeInTheDocument()
expect(screen.getByTestId('icon-info-circle')).toBeInTheDocument()
expect(screen.getByTestId('tooltip-with-parser')).toBeInTheDocument()
})
it('should render info icon and expand icon next to JSON Schema label', () => {
@@ -380,7 +383,7 @@ describe('StructuredOutputBuilder', () => {
render(<StructuredOutputBuilder inputParam={mockInputParam} data={dataWithEntries} onDataChange={mockOnDataChange} />)
expect(screen.getByText('JSON Schema')).toBeInTheDocument()
expect(screen.getByTestId('icon-info-circle')).toBeInTheDocument()
expect(screen.getByTestId('tooltip-with-parser')).toBeInTheDocument()
expect(screen.getByTitle('Expand')).toBeInTheDocument()
})
@@ -1,13 +1,14 @@
import { useCallback, useMemo, useState } from 'react'
import { Box, Button, Chip, IconButton, MenuItem, Select, TextField, Tooltip, Typography } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { IconArrowsMaximize, IconInfoCircle, IconPlus, IconTrash } from '@tabler/icons-react'
import { Box, Button, Chip, IconButton, MenuItem, Select, TextField, Typography } from '@mui/material'
import { alpha, useTheme } from '@mui/material/styles'
import { IconArrowsMaximize, IconPlus, IconTrash } from '@tabler/icons-react'
import { ExpandTextDialog } from '@/atoms'
import type { InputParam, NodeData } from '@/core/types'
import { CodeInput } from './CodeInput'
import { TooltipWithParser } from './TooltipWithParser'
import { useStableKeys } from './useStableKeys'
const OUTPUT_TYPES = [
@@ -102,7 +103,7 @@ export function StructuredOutputBuilder({ inputParam, data, disabled = false, on
mt: 2,
mb: 1,
border: 1,
borderColor: theme.palette.grey[900] + 25,
borderColor: alpha(theme.palette.grey[900], 0.25),
borderRadius: 2,
position: 'relative'
}}
@@ -119,7 +120,7 @@ export function StructuredOutputBuilder({ inputParam, data, disabled = false, on
width: '35px',
right: 10,
top: 10,
'&:hover': { color: 'red' }
'&:hover': { color: theme.palette.error.main }
}}
>
<IconTrash />
@@ -134,7 +135,7 @@ export function StructuredOutputBuilder({ inputParam, data, disabled = false, on
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<Typography>
Key
<span style={{ color: 'red' }}>&nbsp;*</span>
<span style={{ color: theme.palette.error.main }}>&nbsp;*</span>
</Typography>
</div>
<TextField
@@ -153,7 +154,7 @@ export function StructuredOutputBuilder({ inputParam, data, disabled = false, on
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<Typography>
Type
<span style={{ color: 'red' }}>&nbsp;*</span>
<span style={{ color: theme.palette.error.main }}>&nbsp;*</span>
</Typography>
</div>
<Select
@@ -178,11 +179,7 @@ export function StructuredOutputBuilder({ inputParam, data, disabled = false, on
<Box sx={{ p: 2 }}>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<Typography>Enum Values</Typography>
<Tooltip title='Enum values. Separated by comma' placement='top'>
<span style={{ display: 'inline-flex', marginLeft: 6, cursor: 'pointer' }}>
<IconInfoCircle size={16} style={{ opacity: 0.6 }} />
</span>
</Tooltip>
<TooltipWithParser title='Enum values. Separated by comma' />
</div>
<TextField
fullWidth
@@ -202,11 +199,7 @@ export function StructuredOutputBuilder({ inputParam, data, disabled = false, on
<Box sx={{ p: 2 }}>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<Typography>JSON Schema</Typography>
<Tooltip title='JSON schema for the structured output' placement='top'>
<span style={{ display: 'inline-flex', marginLeft: 6, cursor: 'pointer' }}>
<IconInfoCircle size={16} style={{ opacity: 0.6 }} />
</span>
</Tooltip>
<TooltipWithParser title='JSON schema for the structured output' />
<div style={{ flexGrow: 1 }} />
<IconButton
size='small'
@@ -234,7 +227,7 @@ export function StructuredOutputBuilder({ inputParam, data, disabled = false, on
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<Typography>
Description
<span style={{ color: 'red' }}>&nbsp;*</span>
<span style={{ color: theme.palette.error.main }}>&nbsp;*</span>
</Typography>
</div>
<TextField
@@ -0,0 +1,67 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { SwitchInput } from './SwitchInput'
const mockOnChange = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
})
describe('SwitchInput', () => {
it('renders switch element', () => {
render(<SwitchInput value={false} onChange={mockOnChange} />)
expect(screen.getByRole('checkbox')).toBeInTheDocument()
})
it('shows label when provided', () => {
render(<SwitchInput label='Enable feature' value={false} onChange={mockOnChange} />)
expect(screen.getByText('Enable feature')).toBeInTheDocument()
})
it('does not show label when not provided', () => {
const { container } = render(<SwitchInput value={false} onChange={mockOnChange} />)
expect(container.querySelector('.MuiTypography-root')).not.toBeInTheDocument()
})
it('switch is checked when value is true', () => {
render(<SwitchInput value={true} onChange={mockOnChange} />)
expect(screen.getByRole('checkbox')).toBeChecked()
})
it('switch is unchecked when value is false', () => {
render(<SwitchInput value={false} onChange={mockOnChange} />)
expect(screen.getByRole('checkbox')).not.toBeChecked()
})
it('calls onChange when toggled', () => {
render(<SwitchInput value={false} onChange={mockOnChange} />)
fireEvent.click(screen.getByRole('checkbox'))
expect(mockOnChange).toHaveBeenCalledWith(true)
})
it('switch is disabled when disabled prop is true', () => {
render(<SwitchInput value={false} onChange={mockOnChange} disabled={true} />)
expect(screen.getByRole('checkbox')).toBeDisabled()
})
it('handles string value coercion (truthy string → true)', () => {
render(<SwitchInput value='hello' onChange={mockOnChange} />)
expect(screen.getByRole('checkbox')).toBeChecked()
})
it('handles undefined value (defaults to false)', () => {
render(<SwitchInput value={undefined} onChange={mockOnChange} />)
expect(screen.getByRole('checkbox')).not.toBeChecked()
})
})
@@ -0,0 +1,32 @@
import { FormControl, Switch, Typography } from '@mui/material'
export interface SwitchInputProps {
label?: string
value: boolean | string | undefined
onChange: (checked: boolean) => void
disabled?: boolean
}
/**
* A reusable switch input with optional label.
* Mirrors the original Flowise SwitchInput component.
*/
export function SwitchInput({ label, value, onChange, disabled = false }: SwitchInputProps) {
const checked = value !== undefined ? !!value : false
return (
<FormControl
sx={{ mt: 1, width: '100%', display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}
size='small'
>
{label && <Typography>{label}</Typography>}
<Switch
disabled={disabled}
checked={checked}
onChange={(event) => {
onChange(event.target.checked)
}}
/>
</FormControl>
)
}
@@ -0,0 +1,34 @@
import { render, screen } from '@testing-library/react'
import { TooltipWithParser } from './TooltipWithParser'
jest.mock('html-react-parser', () => {
const mockParser = (html: string) => html
mockParser.displayName = 'mockParser'
return { __esModule: true, default: mockParser }
})
describe('TooltipWithParser', () => {
it('renders info icon button', () => {
render(<TooltipWithParser title='Some tooltip' />)
expect(screen.getByRole('button')).toBeInTheDocument()
expect(screen.getByTestId('InfoIcon')).toBeInTheDocument()
})
it('renders tooltip with parsed HTML content', async () => {
render(<TooltipWithParser title='<b>Bold</b> text' />)
// Hover over the button to trigger the tooltip
const button = screen.getByRole('button')
button.focus()
expect(await screen.findByRole('tooltip')).toHaveTextContent('<b>Bold</b> text')
})
it('applies custom sx props', () => {
render(<TooltipWithParser title='Tooltip' sx={{ color: 'red' }} />)
expect(screen.getByTestId('InfoIcon')).toBeInTheDocument()
})
})
@@ -0,0 +1,35 @@
import { Info } from '@mui/icons-material'
import { IconButton, type SxProps, Tooltip } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import DOMPurify from 'dompurify'
import parser from 'html-react-parser'
export interface TooltipWithParserProps {
title: string
sx?: SxProps
}
/**
* An info-icon tooltip that parses HTML in the title string.
* Mirrors the original Flowise TooltipWithParser component.
*/
export function TooltipWithParser({ title, sx }: TooltipWithParserProps) {
const theme = useTheme()
const isDarkMode = theme.palette.mode === 'dark'
return (
<Tooltip title={parser(DOMPurify.sanitize(title))} placement='right'>
<IconButton sx={{ height: 15, width: 15, ml: 2, mt: -0.5 }}>
<Info
sx={{
...sx,
background: 'transparent',
color: isDarkMode ? 'white' : 'inherit',
height: 15,
width: 15
}}
/>
</IconButton>
</Tooltip>
)
}
+4
View File
@@ -2,6 +2,8 @@
export { ArrayInput, type ArrayInputProps } from './ArrayInput'
export { CodeInput, type CodeInputProps } from './CodeInput'
export { ConditionBuilder, type ConditionBuilderProps } from './ConditionBuilder'
export { CredentialTypeSelector, type CredentialTypeSelectorProps } from './CredentialTypeSelector'
export { Dropdown, type DropdownOption, type DropdownProps } from './Dropdown'
export { ExpandTextDialog, type ExpandTextDialogProps } from './ExpandTextDialog'
export { JsonInput, type JsonInputProps } from './JsonInput'
export { MainCard, type MainCardProps } from './MainCard'
@@ -15,3 +17,5 @@ export type { RichTextEditorProps } from './RichTextEditor'
export { RichTextEditor } from './RichTextEditor.lazy'
export { ScenariosInput, type ScenariosInputProps } from './ScenariosInput'
export { StructuredOutputBuilder, type StructuredOutputBuilderProps, type StructuredOutputEntry } from './StructuredOutputBuilder'
export { SwitchInput, type SwitchInputProps } from './SwitchInput'
export { TooltipWithParser, type TooltipWithParserProps } from './TooltipWithParser'
@@ -0,0 +1,2 @@
// Domain-free utility functions safe for use in any layer (including atoms)
export { getDefaultValueForType } from './inputDefaults'
@@ -0,0 +1,59 @@
import { getDefaultValueForType } from './inputDefaults'
describe('getDefaultValueForType', () => {
it('returns default when defined', () => {
expect(getDefaultValueForType({ type: 'string', default: 'custom' })).toBe('custom')
})
it('returns default even when it is a falsy value', () => {
expect(getDefaultValueForType({ type: 'number', default: 0 })).toBe(0)
expect(getDefaultValueForType({ type: 'boolean', default: false })).toBe(false)
expect(getDefaultValueForType({ type: 'string', default: '' })).toBe('')
})
it('returns false for boolean', () => {
expect(getDefaultValueForType({ type: 'boolean' })).toBe(false)
})
it('returns 0 for number', () => {
expect(getDefaultValueForType({ type: 'number' })).toBe(0)
})
it("returns '{}' for json", () => {
expect(getDefaultValueForType({ type: 'json' })).toBe('{}')
})
it('returns [] for array', () => {
expect(getDefaultValueForType({ type: 'array' })).toEqual([])
})
it('returns first option name for object options', () => {
expect(
getDefaultValueForType({
type: 'options',
options: [{ name: 'first' }, { name: 'second' }]
})
).toBe('first')
})
it('returns first option value for string options', () => {
expect(getDefaultValueForType({ type: 'options', options: ['alpha', 'beta'] })).toBe('alpha')
})
it("returns '' for options with no options", () => {
expect(getDefaultValueForType({ type: 'options' })).toBe('')
expect(getDefaultValueForType({ type: 'options', options: [] })).toBe('')
})
it("returns '' for string", () => {
expect(getDefaultValueForType({ type: 'string' })).toBe('')
})
it("returns '' for password", () => {
expect(getDefaultValueForType({ type: 'password' })).toBe('')
})
it("returns '' for unknown type", () => {
expect(getDefaultValueForType({ type: 'somethingElse' })).toBe('')
})
})
@@ -0,0 +1,39 @@
/**
* Returns the appropriate default value for an input based on its type.
* If a `default` is provided, it is returned as-is.
*
* Accepts a destructured object so it stays decoupled from any domain type
* (InputParam, CredentialSchemaInput, etc.) while allowing callers to pass
* those objects directly.
*/
export function getDefaultValueForType({
type,
default: defaultValue,
options
}: {
type: string
default?: unknown
options?: Array<{ name: string } | string>
}): unknown {
if (defaultValue !== undefined) return defaultValue
switch (type) {
case 'boolean':
return false
case 'number':
return 0
case 'json':
return '{}'
case 'array':
return []
case 'options': {
const first = options?.[0]
if (!first) return ''
return typeof first === 'string' ? first : first.name
}
case 'string':
case 'password':
default:
return ''
}
}
@@ -32,6 +32,10 @@ export function createAgentflowTheme(isDarkMode: boolean): Theme {
// Custom card color (now type-safe thanks to types.ts)
card: {
main: tokens.colors.background.card[mode]
},
warningBanner: {
background: tokens.colors.semantic.warningBg,
text: tokens.colors.semantic.warningText
}
},
typography: {
@@ -41,6 +41,8 @@ const baseColors = {
success: '#4caf50',
error: '#f44336',
warning: '#ff9800',
warningBg: '#fefcbf',
warningText: '#744210',
info: '#2196f3',
// Node type colors (brand colors)
@@ -112,6 +114,8 @@ export const tokens = {
success: baseColors.success,
error: baseColors.error,
warning: baseColors.warning,
warningBg: baseColors.warningBg,
warningText: baseColors.warningText,
info: baseColors.info
},
@@ -12,12 +12,20 @@ declare module '@mui/material/styles' {
card: {
main: string
}
warningBanner: {
background: string
text: string
}
}
interface PaletteOptions {
card?: {
main: string
}
warningBanner?: {
background: string
text: string
}
}
}
+27
View File
@@ -43,3 +43,30 @@ export interface Credential {
updatedDate?: string
workspaceID?: string
}
export interface CredentialSchemaInput {
label: string
name: string
type: 'password' | 'string' | 'number' | 'boolean' | 'options' | 'json'
default?: unknown
optional?: boolean
description?: string
placeholder?: string
options?: Array<{ label: string; name: string }>
hidden?: boolean
rows?: number
warning?: string
}
export interface ComponentCredentialSchema {
label: string
name: string
description?: string
inputs?: CredentialSchemaInput[]
}
export interface CreateCredentialBody {
name: string
credentialName: string
plainDataObj: Record<string, unknown>
}
@@ -1,4 +1,5 @@
import type { FlowNode, NodeData, OutputAnchor } from '../types'
import { getDefaultValueForType } from '../primitives'
import type { FlowNode, InputParam, NodeData, OutputAnchor } from '../types'
import { buildDynamicOutputAnchors } from './dynamicOutputAnchors'
@@ -55,11 +56,11 @@ export function getUniqueNodeLabel(nodeData: NodeData, nodes: FlowNode[]): strin
* Initialize default values for node parameters.
* Falls back to '' for params without a default — needed by show/hide condition evaluation.
*/
function initializeDefaultNodeData(nodeParams: Array<{ name: string; default?: unknown }>): Record<string, unknown> {
function initializeDefaultNodeData(nodeParams: Pick<InputParam, 'name' | 'type' | 'default' | 'options'>[]): Record<string, unknown> {
const initialValues: Record<string, unknown> = {}
for (const input of nodeParams) {
initialValues[input.name] = input.default ?? ''
initialValues[input.name] = getDefaultValueForType(input)
}
return initialValues
@@ -15,10 +15,31 @@ jest.mock('reactflow', () => ({
jest.mock('@tabler/icons-react', () => ({
IconArrowsMaximize: () => <span data-testid='icon-expand' />,
IconEdit: () => <span data-testid='icon-edit' />,
IconVariable: () => <span data-testid='icon-variable' />,
IconRefresh: () => <span data-testid='icon-refresh' />
}))
jest.mock('./CreateCredentialDialog', () => ({
CreateCredentialDialog: ({
open,
onCreated,
onClose,
editCredentialId
}: {
open: boolean
onCreated: (id: string) => void
onClose: () => void
editCredentialId?: string
}) =>
open ? (
<div data-testid={editCredentialId ? 'edit-credential-dialog' : 'create-credential-dialog'}>
<button onClick={() => onCreated(editCredentialId ?? 'new-cred-id')}>{editCredentialId ? 'Save' : 'Create'}</button>
<button onClick={onClose}>Close</button>
</div>
) : null
}))
interface MockAsyncResult {
options: Array<{ label: string; name: string; imageSrc?: string; description?: string }>
loading: boolean
@@ -418,3 +439,189 @@ describe('NodeInputHandler – asyncMultiOptions', () => {
expect(screen.getByText('Tool B')).toBeTruthy()
})
})
describe('AsyncInput – Create New credential', () => {
it('"- Create New -" option appears when credentialNames is set', async () => {
mockUseAsyncOptions.mockReturnValue({
...idleResult(),
options: [{ label: 'My API Key', name: 'cred-1' }]
})
render(
<AsyncInput
inputParam={makeParam({ type: 'asyncOptions', credentialNames: ['openAIApi'] })}
value=''
disabled={false}
onChange={jest.fn()}
/>
)
fireEvent.mouseDown(screen.getByRole('combobox'))
await waitFor(() => {
expect(screen.getByText('- Create New -')).toBeTruthy()
})
})
it('"- Create New -" does NOT appear for non-credential async dropdowns', async () => {
mockUseAsyncOptions.mockReturnValue({
...idleResult(),
options: [{ label: 'GPT-4o', name: 'gpt-4o' }]
})
render(<AsyncInput inputParam={makeParam({ type: 'asyncOptions' })} value='' disabled={false} onChange={jest.fn()} />)
fireEvent.mouseDown(screen.getByRole('combobox'))
await waitFor(() => {
expect(screen.getByText('GPT-4o')).toBeTruthy()
})
expect(screen.queryByText('- Create New -')).toBeNull()
})
it('selecting "- Create New -" opens CreateCredentialDialog and does not call onChange', async () => {
const mockChange = jest.fn()
mockUseAsyncOptions.mockReturnValue({
...idleResult(),
options: [{ label: 'My API Key', name: 'cred-1' }]
})
render(
<AsyncInput
inputParam={makeParam({ type: 'asyncOptions', credentialNames: ['openAIApi'] })}
value=''
disabled={false}
onChange={mockChange}
/>
)
fireEvent.mouseDown(screen.getByRole('combobox'))
await waitFor(() => screen.getByText('- Create New -'))
fireEvent.click(screen.getByText('- Create New -'))
expect(mockChange).not.toHaveBeenCalled()
expect(screen.getByTestId('create-credential-dialog')).toBeTruthy()
})
it('after credential creation, onChange is called with new ID and component remounts to refetch', async () => {
const mockChange = jest.fn()
mockUseAsyncOptions.mockReturnValue({
...idleResult(),
options: [{ label: 'My API Key', name: 'cred-1' }]
})
render(
<AsyncInput
inputParam={makeParam({ type: 'asyncOptions', credentialNames: ['openAIApi'] })}
value=''
disabled={false}
onChange={mockChange}
/>
)
const initialCallCount = mockUseAsyncOptions.mock.calls.length
// Open dropdown and select "- Create New -"
fireEvent.mouseDown(screen.getByRole('combobox'))
await waitFor(() => screen.getByText('- Create New -'))
fireEvent.click(screen.getByText('- Create New -'))
// Click the Create button in the mocked dialog
fireEvent.click(screen.getByText('Create'))
expect(mockChange).toHaveBeenCalledWith('new-cred-id')
// The inner dropdown component remounts (via key change), re-running useAsyncOptions
expect(mockUseAsyncOptions.mock.calls.length).toBeGreaterThan(initialCallCount)
})
})
describe('AsyncInput – Edit credential', () => {
it('edit button does NOT appear when no credential is selected', () => {
mockUseAsyncOptions.mockReturnValue({
...idleResult(),
options: [{ label: 'My API Key', name: 'cred-1' }]
})
render(
<AsyncInput
inputParam={makeParam({ type: 'asyncOptions', credentialNames: ['openAIApi'] })}
value=''
disabled={false}
onChange={jest.fn()}
/>
)
expect(screen.queryByTitle('Edit Credential')).toBeNull()
})
it('edit button appears when a credential is selected', () => {
mockUseAsyncOptions.mockReturnValue({
...idleResult(),
options: [{ label: 'My API Key', name: 'cred-1' }]
})
render(
<AsyncInput
inputParam={makeParam({ type: 'asyncOptions', credentialNames: ['openAIApi'] })}
value='cred-1'
disabled={false}
onChange={jest.fn()}
/>
)
expect(screen.getByTitle('Edit Credential')).toBeTruthy()
})
it('edit button does NOT appear for non-credential async dropdowns', () => {
mockUseAsyncOptions.mockReturnValue({
...idleResult(),
options: [{ label: 'GPT-4o', name: 'gpt-4o' }]
})
render(<AsyncInput inputParam={makeParam({ type: 'asyncOptions' })} value='gpt-4o' disabled={false} onChange={jest.fn()} />)
expect(screen.queryByTitle('Edit Credential')).toBeNull()
})
it('clicking edit button opens the edit dialog', () => {
mockUseAsyncOptions.mockReturnValue({
...idleResult(),
options: [{ label: 'My API Key', name: 'cred-1' }]
})
render(
<AsyncInput
inputParam={makeParam({ type: 'asyncOptions', credentialNames: ['openAIApi'] })}
value='cred-1'
disabled={false}
onChange={jest.fn()}
/>
)
fireEvent.click(screen.getByTitle('Edit Credential'))
expect(screen.getByTestId('edit-credential-dialog')).toBeTruthy()
})
it('after editing, onChange is called with credential ID and component remounts to refetch', () => {
const mockChange = jest.fn()
mockUseAsyncOptions.mockReturnValue({
...idleResult(),
options: [{ label: 'My API Key', name: 'cred-1' }]
})
render(
<AsyncInput
inputParam={makeParam({ type: 'asyncOptions', credentialNames: ['openAIApi'] })}
value='cred-1'
disabled={false}
onChange={mockChange}
/>
)
const initialCallCount = mockUseAsyncOptions.mock.calls.length
fireEvent.click(screen.getByTitle('Edit Credential'))
fireEvent.click(screen.getByText('Save'))
expect(mockChange).toHaveBeenCalledWith('cred-1')
expect(mockUseAsyncOptions.mock.calls.length).toBeGreaterThan(initialCallCount)
})
})
@@ -1,13 +1,17 @@
import { Fragment } from 'react'
import { Fragment, useCallback, useState } from 'react'
import { Box, CircularProgress, IconButton, TextField, Typography } from '@mui/material'
import Autocomplete from '@mui/material/Autocomplete'
import { IconRefresh } from '@tabler/icons-react'
import { IconEdit, IconRefresh } from '@tabler/icons-react'
import type { AsyncInputProps } from '@/atoms'
import type { NodeOption } from '@/core/types'
import { useAsyncOptions } from '@/infrastructure/api/hooks'
import { CreateCredentialDialog } from './CreateCredentialDialog'
const CREATE_NEW_SENTINEL = '-create-'
/**
* Build the params object for useAsyncOptions.
* Only includes inputValues when the loadMethod actually needs them (e.g. listToolInputArgs).
@@ -25,6 +29,93 @@ function buildAsyncParams(
}
function AsyncOptionsInput({ inputParam, value, disabled, onChange, nodeName, inputValues }: AsyncInputProps) {
const isCredential = !!inputParam.credentialNames?.length
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [reloadKey, setReloadKey] = useState(0)
// Holds the credential ID that was just created/edited. Passed directly to
// the remounted AsyncOptionsDropdown so it doesn't depend on the parent
// state cascade (which may not have flushed yet when inside ConfigInput).
const [pendingValue, setPendingValue] = useState<string | null>(null)
const effectiveValue = pendingValue ?? value
const selectedCredentialId = isCredential && typeof effectiveValue === 'string' && effectiveValue ? effectiveValue : null
const handleCreated = useCallback(
(newCredentialId: string) => {
setCreateDialogOpen(false)
onChange(newCredentialId)
setPendingValue(newCredentialId)
setReloadKey((k) => k + 1)
},
[onChange]
)
const handleEdited = useCallback(
(credentialId: string) => {
setEditDialogOpen(false)
onChange(credentialId)
setPendingValue(credentialId)
setReloadKey((k) => k + 1)
},
[onChange]
)
return (
<>
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'center', mt: 1 }}>
<AsyncOptionsDropdown
key={reloadKey}
inputParam={inputParam}
value={pendingValue ?? value}
disabled={disabled}
onChange={(v) => {
setPendingValue(null)
onChange(v)
}}
nodeName={nodeName}
inputValues={inputValues}
isCredential={isCredential}
onCreateNew={() => setCreateDialogOpen(true)}
/>
{selectedCredentialId && (
<IconButton title='Edit Credential' color='primary' size='small' onClick={() => setEditDialogOpen(true)}>
<IconEdit size={18} />
</IconButton>
)}
</Box>
{isCredential && (
<CreateCredentialDialog
open={createDialogOpen}
credentialNames={inputParam.credentialNames!}
onClose={() => setCreateDialogOpen(false)}
onCreated={handleCreated}
/>
)}
{isCredential && selectedCredentialId && (
<CreateCredentialDialog
open={editDialogOpen}
credentialNames={inputParam.credentialNames!}
onClose={() => setEditDialogOpen(false)}
onCreated={handleEdited}
editCredentialId={selectedCredentialId}
/>
)}
</>
)
}
/** Inner component that owns the useAsyncOptions hook. Remounted via key to force a fresh fetch. */
function AsyncOptionsDropdown({
inputParam,
value,
disabled,
onChange,
nodeName,
inputValues,
isCredential,
onCreateNew
}: AsyncInputProps & { isCredential: boolean; onCreateNew: () => void }) {
const params = buildAsyncParams(inputParam.loadMethod, nodeName, inputValues)
const { options, loading, error, refetch } = useAsyncOptions({
loadMethod: inputParam.loadMethod,
@@ -45,34 +136,51 @@ function AsyncOptionsInput({ inputParam, value, disabled, onChange, nodeName, in
)
}
const matchedValue = options.find((o) => o.name === value) ?? null
// Append "- Create New -" sentinel for credential dropdowns
const displayOptions = isCredential ? [...options, { label: '- Create New -', name: CREATE_NEW_SENTINEL }] : options
const matchedValue = displayOptions.find((o) => o.name === value) ?? null
return (
<Autocomplete<NodeOption>
size='small'
disabled={disabled}
options={options}
options={displayOptions}
value={matchedValue}
getOptionLabel={(o) => o.label}
isOptionEqualToValue={(o, v) => o.name === v.name}
onChange={(_e, selection) => onChange(selection?.name ?? '')}
onChange={(_e, selection) => {
if (selection?.name === CREATE_NEW_SENTINEL) {
onCreateNew()
return
}
onChange(selection?.name ?? '')
}}
loading={loading}
noOptionsText={loading ? 'Loading…' : 'No options available'}
sx={{ mt: 1 }}
sx={{ flexGrow: 1 }}
renderOption={(props, option) => (
<Box component='li' {...props} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{option.imageSrc && (
<Box
component='img'
src={option.imageSrc}
alt={option.label}
sx={{ width: 30, height: 30, padding: '1px', borderRadius: '50%', flexShrink: 0 }}
/>
{option.name === CREATE_NEW_SENTINEL ? (
<Typography variant='h5' color='primary'>
{option.label}
</Typography>
) : (
<>
{option.imageSrc && (
<Box
component='img'
src={option.imageSrc}
alt={option.label}
sx={{ width: 30, height: 30, padding: '1px', borderRadius: '50%', flexShrink: 0 }}
/>
)}
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant='h5'>{option.label}</Typography>
{option.description && <Typography variant='caption'>{option.description}</Typography>}
</Box>
</>
)}
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant='h5'>{option.label}</Typography>
{option.description && <Typography variant='caption'>{option.description}</Typography>}
</Box>
</Box>
)}
renderInput={(params) => (
@@ -2,10 +2,11 @@ import { type ComponentType, useCallback, useEffect, useMemo, useRef, useState }
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import { Accordion, AccordionDetails, AccordionSummary, Box, Typography } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { alpha, useTheme } from '@mui/material/styles'
import { IconSettings } from '@tabler/icons-react'
import { type AsyncInputProps, NodeInputHandler } from '@/atoms'
import { getDefaultValueForType } from '@/core/primitives'
import type { InputParam, NodeData } from '@/core/types'
import { evaluateFieldVisibility, initNode } from '@/core/utils'
import { useApiContext } from '@/infrastructure/store'
@@ -33,12 +34,14 @@ export interface ConfigInputProps {
function initializeDefaults(params: InputParam[]): Record<string, unknown> {
const defaults: Record<string, unknown> = {}
for (const p of params) {
defaults[p.name] = p.default ?? ''
defaults[p.name] = getDefaultValueForType(p)
}
return defaults
}
/** Read the current selection value from parent data, handling array context. */
/** Read the current selection value from parent data, handling array context.
* When data is already scoped to the array item (ArrayInput passes itemData),
* the parent array won't exist in data.inputValues — fall back to direct read. */
function readCurrentValue(
data: NodeData,
paramName: string,
@@ -50,12 +53,14 @@ function readCurrentValue(
if (Array.isArray(arr) && arr[arrayIndex]) {
return arr[arrayIndex][paramName] as string | undefined
}
return undefined
// data may already be scoped to the array item (via ArrayInput's itemData)
return data.inputValues?.[paramName] as string | undefined
}
return data.inputValues?.[paramName] as string | undefined
}
/** Read existing config from parent data, handling array context. */
/** Read existing config from parent data, handling array context.
* Falls back to direct read when data is already item-scoped. */
function readExistingConfig(
data: NodeData,
paramName: string,
@@ -68,7 +73,8 @@ function readExistingConfig(
if (Array.isArray(arr) && arr[arrayIndex]) {
return arr[arrayIndex][configKey] as Record<string, unknown> | undefined
}
return undefined
// data may already be scoped to the array item (via ArrayInput's itemData)
return data.inputValues?.[configKey] as Record<string, unknown> | undefined
}
return data.inputValues?.[configKey] as Record<string, unknown> | undefined
}
@@ -196,7 +202,7 @@ export function ConfigInput({
mt: 1,
mb: 1,
border: 1,
borderColor: theme.palette.grey[900] + 25,
borderColor: alpha(theme.palette.grey[900], 0.25),
borderRadius: 2
}}
>
@@ -0,0 +1,425 @@
import type { ReactElement } from 'react'
import { ThemeProvider } from '@mui/material/styles'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createAgentflowTheme } from '@/core/theme/createAgentflowTheme'
import type { ComponentCredentialSchema } from '@/core/types'
import { CreateCredentialDialog } from './CreateCredentialDialog'
const theme = createAgentflowTheme(false)
function renderWithTheme(ui: ReactElement) {
return render(<ThemeProvider theme={theme}>{ui}</ThemeProvider>)
}
// ─── Mocks ────────────────────────────────────────────────────────────────────
jest.mock('@tabler/icons-react', () => ({
IconAlertTriangle: () => <span data-testid='icon-alert-triangle' />,
IconArrowsMaximize: () => <span data-testid='icon-expand' />,
IconSearch: () => <span data-testid='icon-search' />,
IconX: () => <span data-testid='icon-x' />
}))
jest.mock('html-react-parser', () => ({
__esModule: true,
default: (html: string) => <span data-testid='parsed-html'>{html}</span>
}))
jest.mock('@/atoms/JsonInput', () => ({
JsonInput: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
<textarea data-testid='json-input' value={value} onChange={(e) => onChange(e.target.value)} />
)
}))
jest.mock('@/atoms/SwitchInput', () => ({
SwitchInput: ({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) => (
<input data-testid='switch-input' type='checkbox' checked={!!value} onChange={(e) => onChange(e.target.checked)} />
)
}))
jest.mock('@/atoms/TooltipWithParser', () => ({
TooltipWithParser: ({ title }: { title: string }) => <span data-testid='tooltip-with-parser'>{title}</span>
}))
jest.mock('@/atoms/Dropdown', () => ({
Dropdown: ({
value,
onSelect,
options
}: {
value: string
onSelect: (v: string) => void
options: Array<{ name: string; label: string }>
}) => (
<select data-testid='dropdown' value={value} onChange={(e) => onSelect(e.target.value)}>
{options.map((o) => (
<option key={o.name} value={o.name}>
{o.label}
</option>
))}
</select>
)
}))
const mockGetComponentCredentialSchema = jest.fn()
const mockCreateCredential = jest.fn()
const mockGetCredentialById = jest.fn()
const mockUpdateCredential = jest.fn()
const mockApiContext = {
credentialsApi: {
getComponentCredentialSchema: mockGetComponentCredentialSchema,
createCredential: mockCreateCredential,
getCredentialById: mockGetCredentialById,
updateCredential: mockUpdateCredential
},
apiBaseUrl: 'http://localhost:3000'
}
jest.mock('@/infrastructure/store/ApiContext', () => ({
useApiContext: () => mockApiContext
}))
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const openAISchema: ComponentCredentialSchema = {
label: 'OpenAI API',
name: 'openAIApi',
description: 'Your <a href="https://openai.com">OpenAI</a> API key.',
inputs: [{ label: 'OpenAI Api Key', name: 'openAIApiKey', type: 'password' }]
}
const awsSchema: ComponentCredentialSchema = {
label: 'AWS security credentials',
name: 'awsApi',
description: 'Your AWS security credentials.',
inputs: [
{ label: 'AWS Access Key', name: 'awsKey', type: 'string', optional: true, description: 'The access key' },
{ label: 'AWS Secret Key', name: 'awsSecret', type: 'password', optional: true },
{ label: 'Role ARN', name: 'roleArn', type: 'string', optional: true, placeholder: 'arn:aws:iam::role/name' }
]
}
const schemaWithAllTypes: ComponentCredentialSchema = {
label: 'Full Schema',
name: 'fullSchema',
inputs: [
{ label: 'Text Field', name: 'textField', type: 'string' },
{ label: 'Secret', name: 'secret', type: 'password' },
{ label: 'Count', name: 'count', type: 'number' },
{ label: 'Enabled', name: 'enabled', type: 'boolean' },
{
label: 'Format',
name: 'format',
type: 'options',
options: [
{ label: 'JSON', name: 'json' },
{ label: 'XML', name: 'xml' }
]
},
{ label: 'Config', name: 'config', type: 'json' },
{ label: 'Hidden', name: 'hiddenField', type: 'string', hidden: true },
{ label: 'Multiline', name: 'multiline', type: 'string', rows: 4, placeholder: 'Enter text...' }
]
}
const schemaWithWarning: ComponentCredentialSchema = {
label: 'Warning Schema',
name: 'warningSchema',
inputs: [{ label: 'Dangerous Field', name: 'danger', type: 'string', warning: 'This may break things!' }]
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
const defaultProps = {
open: true,
credentialNames: ['openAIApi'],
onClose: jest.fn(),
onCreated: jest.fn()
}
beforeEach(() => {
jest.clearAllMocks()
mockGetComponentCredentialSchema.mockResolvedValue(openAISchema)
mockCreateCredential.mockResolvedValue({ id: 'new-cred-123' })
})
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('CreateCredentialDialog', () => {
it('shows loading spinner while fetching schema', async () => {
let resolveSchema!: (v: ComponentCredentialSchema) => void
mockGetComponentCredentialSchema.mockReturnValue(
new Promise<ComponentCredentialSchema>((r) => {
resolveSchema = r
})
)
renderWithTheme(<CreateCredentialDialog {...defaultProps} />)
expect(screen.getByRole('progressbar')).toBeInTheDocument()
// Resolve to avoid hanging cleanup
await act(async () => {
resolveSchema(openAISchema)
})
})
it('fetches schema and renders form for single credentialName', async () => {
renderWithTheme(<CreateCredentialDialog {...defaultProps} />)
await waitFor(() => {
expect(mockGetComponentCredentialSchema).toHaveBeenCalledWith('openAIApi')
expect(screen.getByText('OpenAI API')).toBeInTheDocument()
expect(screen.getByText('Credential Name')).toBeInTheDocument()
expect(screen.getByText('OpenAI Api Key')).toBeInTheDocument()
})
})
it('renders credential icon in dialog title', async () => {
renderWithTheme(<CreateCredentialDialog {...defaultProps} />)
await waitFor(() => {
const img = screen.getByAltText('openAIApi')
expect(img).toBeInTheDocument()
expect(img).toHaveAttribute('src', 'http://localhost:3000/api/v1/components-credentials-icon/openAIApi')
})
})
it('renders description banner with parsed HTML', async () => {
renderWithTheme(<CreateCredentialDialog {...defaultProps} />)
await waitFor(() => {
expect(screen.getByTestId('parsed-html')).toBeInTheDocument()
})
})
it('shows error alert when schema fetch fails', async () => {
mockGetComponentCredentialSchema.mockRejectedValue(new Error('Network error'))
renderWithTheme(<CreateCredentialDialog {...defaultProps} />)
await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument()
})
})
it('credential name starts empty with label as placeholder', async () => {
renderWithTheme(<CreateCredentialDialog {...defaultProps} />)
await waitFor(() => {
const nameInput = screen.getByPlaceholderText('OpenAI API')
expect(nameInput).toHaveValue('')
})
})
it('Add button is disabled when credential name is empty', async () => {
renderWithTheme(<CreateCredentialDialog {...defaultProps} />)
await waitFor(() => {
expect(screen.getByText('Add')).toBeDisabled()
})
})
it('Add button is disabled when required fields are empty', async () => {
renderWithTheme(<CreateCredentialDialog {...defaultProps} />)
await waitFor(() => screen.getByPlaceholderText('OpenAI API'))
// Fill credential name but leave required field empty
fireEvent.change(screen.getByPlaceholderText('OpenAI API'), { target: { value: 'My Key' } })
expect(screen.getByText('Add')).toBeDisabled()
})
it('Add button is enabled when credential name and required fields are filled', async () => {
renderWithTheme(<CreateCredentialDialog {...defaultProps} />)
await waitFor(() => screen.getByPlaceholderText('OpenAI API'))
fireEvent.change(screen.getByPlaceholderText('OpenAI API'), { target: { value: 'My Key' } })
fireEvent.change(screen.getByDisplayValue(''), { target: { value: 'sk-test-key' } })
expect(screen.getByText('Add')).not.toBeDisabled()
})
it('submits credential and calls onCreated with new ID', async () => {
renderWithTheme(<CreateCredentialDialog {...defaultProps} />)
await waitFor(() => screen.getByPlaceholderText('OpenAI API'))
fireEvent.change(screen.getByPlaceholderText('OpenAI API'), { target: { value: 'My OpenAI Key' } })
fireEvent.change(screen.getByDisplayValue(''), { target: { value: 'sk-test-key' } })
await act(async () => {
fireEvent.click(screen.getByText('Add'))
})
expect(mockCreateCredential).toHaveBeenCalledWith({
name: 'My OpenAI Key',
credentialName: 'openAIApi',
plainDataObj: { openAIApiKey: 'sk-test-key' }
})
expect(defaultProps.onCreated).toHaveBeenCalledWith('new-cred-123')
})
it('shows error when credential creation fails', async () => {
mockCreateCredential.mockRejectedValue(new Error('Server error'))
renderWithTheme(<CreateCredentialDialog {...defaultProps} />)
await waitFor(() => screen.getByPlaceholderText('OpenAI API'))
fireEvent.change(screen.getByPlaceholderText('OpenAI API'), { target: { value: 'My Key' } })
fireEvent.change(screen.getByDisplayValue(''), { target: { value: 'sk-test-key' } })
await act(async () => {
fireEvent.click(screen.getByText('Add'))
})
await waitFor(() => {
expect(screen.getByText('Server error')).toBeInTheDocument()
})
})
it('calls onClose when Cancel is clicked', async () => {
renderWithTheme(<CreateCredentialDialog {...defaultProps} />)
await waitFor(() => screen.getByText('Cancel'))
fireEvent.click(screen.getByText('Cancel'))
expect(defaultProps.onClose).toHaveBeenCalled()
})
it('does not render when open is false', () => {
renderWithTheme(<CreateCredentialDialog {...defaultProps} open={false} />)
expect(screen.queryByText('Credential Name')).not.toBeInTheDocument()
})
})
describe('CreateCredentialDialog – multiple credential types', () => {
it('shows type selection when multiple credentialNames provided', async () => {
mockGetComponentCredentialSchema.mockImplementation((name: string) => {
if (name === 'openAIApi') return Promise.resolve(openAISchema)
return Promise.resolve(awsSchema)
})
renderWithTheme(<CreateCredentialDialog {...defaultProps} credentialNames={['openAIApi', 'awsApi']} />)
await waitFor(() => {
expect(screen.getByText('Add New Credential')).toBeInTheDocument()
expect(screen.getByText('OpenAI API')).toBeInTheDocument()
expect(screen.getByText('AWS security credentials')).toBeInTheDocument()
})
})
it('selecting a type shows the form for that credential', async () => {
mockGetComponentCredentialSchema.mockImplementation((name: string) => {
if (name === 'openAIApi') return Promise.resolve(openAISchema)
return Promise.resolve(awsSchema)
})
renderWithTheme(<CreateCredentialDialog {...defaultProps} credentialNames={['openAIApi', 'awsApi']} />)
await waitFor(() => screen.getByText('AWS security credentials'))
fireEvent.click(screen.getByText('AWS security credentials'))
expect(screen.getByText('AWS Access Key')).toBeInTheDocument()
expect(screen.getByText('AWS Secret Key')).toBeInTheDocument()
expect(screen.getByText('Role ARN')).toBeInTheDocument()
})
})
describe('CredentialField – field types', () => {
it('renders all visible field types and hides hidden fields', async () => {
mockGetComponentCredentialSchema.mockResolvedValue(schemaWithAllTypes)
renderWithTheme(<CreateCredentialDialog {...defaultProps} credentialNames={['fullSchema']} />)
await waitFor(() => {
expect(screen.getByText('Text Field')).toBeInTheDocument()
expect(screen.getByText('Secret')).toBeInTheDocument()
expect(screen.getByText('Count')).toBeInTheDocument()
expect(screen.getByText('Enabled')).toBeInTheDocument()
expect(screen.getByText('Format')).toBeInTheDocument()
expect(screen.getByText('Config')).toBeInTheDocument()
expect(screen.getByText('Multiline')).toBeInTheDocument()
expect(screen.queryByText('Hidden')).not.toBeInTheDocument()
})
})
it('renders boolean field with SwitchInput', async () => {
mockGetComponentCredentialSchema.mockResolvedValue(schemaWithAllTypes)
renderWithTheme(<CreateCredentialDialog {...defaultProps} credentialNames={['fullSchema']} />)
await waitFor(() => {
expect(screen.getByTestId('switch-input')).toBeInTheDocument()
})
})
it('renders json field with JsonInput', async () => {
mockGetComponentCredentialSchema.mockResolvedValue(schemaWithAllTypes)
renderWithTheme(<CreateCredentialDialog {...defaultProps} credentialNames={['fullSchema']} />)
await waitFor(() => {
expect(screen.getByTestId('json-input')).toBeInTheDocument()
})
})
it('renders options field with Dropdown', async () => {
mockGetComponentCredentialSchema.mockResolvedValue(schemaWithAllTypes)
renderWithTheme(<CreateCredentialDialog {...defaultProps} credentialNames={['fullSchema']} />)
await waitFor(() => {
expect(screen.getByTestId('dropdown')).toBeInTheDocument()
})
})
it('renders multiline string field with expand button', async () => {
mockGetComponentCredentialSchema.mockResolvedValue(schemaWithAllTypes)
renderWithTheme(<CreateCredentialDialog {...defaultProps} credentialNames={['fullSchema']} />)
await waitFor(() => {
expect(screen.getByTitle('Expand')).toBeInTheDocument()
})
})
it('renders placeholder for string fields', async () => {
mockGetComponentCredentialSchema.mockResolvedValue(awsSchema)
renderWithTheme(<CreateCredentialDialog {...defaultProps} credentialNames={['awsApi']} />)
await waitFor(() => {
expect(screen.getByPlaceholderText('arn:aws:iam::role/name')).toBeInTheDocument()
})
})
it('renders tooltip for fields with description', async () => {
mockGetComponentCredentialSchema.mockResolvedValue(awsSchema)
renderWithTheme(<CreateCredentialDialog {...defaultProps} credentialNames={['awsApi']} />)
await waitFor(() => {
expect(screen.getByTestId('tooltip-with-parser')).toBeInTheDocument()
})
})
it('renders required indicator for non-optional fields', async () => {
mockGetComponentCredentialSchema.mockResolvedValue(openAISchema)
renderWithTheme(<CreateCredentialDialog {...defaultProps} />)
await waitFor(() => {
// OpenAI Api Key has optional undefined (required)
const stars = screen.getAllByText('*')
expect(stars.length).toBeGreaterThanOrEqual(1)
})
})
it('renders warning banner when field has warning', async () => {
mockGetComponentCredentialSchema.mockResolvedValue(schemaWithWarning)
renderWithTheme(<CreateCredentialDialog {...defaultProps} credentialNames={['warningSchema']} />)
await waitFor(() => {
expect(screen.getByText('This may break things!')).toBeInTheDocument()
expect(screen.getByTestId('icon-alert-triangle')).toBeInTheDocument()
})
})
})
@@ -0,0 +1,392 @@
import { useCallback, useEffect, useState } from 'react'
import {
Alert,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
OutlinedInput,
Typography
} from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { IconAlertTriangle, IconArrowsMaximize } from '@tabler/icons-react'
import DOMPurify from 'dompurify'
import parser from 'html-react-parser'
import { CredentialIcon, CredentialTypeSelector } from '@/atoms/CredentialTypeSelector'
import { Dropdown } from '@/atoms/Dropdown'
import { JsonInput } from '@/atoms/JsonInput'
import { SwitchInput } from '@/atoms/SwitchInput'
import { TooltipWithParser } from '@/atoms/TooltipWithParser'
import { getDefaultValueForType } from '@/core/primitives'
import type { ComponentCredentialSchema, CredentialSchemaInput } from '@/core/types'
import { useApiContext } from '@/infrastructure/store/ApiContext'
export interface CreateCredentialDialogProps {
open: boolean
credentialNames: string[]
onClose: () => void
onCreated: (credentialId: string) => void
/** When set, the dialog opens in edit mode for the given credential ID. */
editCredentialId?: string
}
/**
* Dialog for creating or editing a credential from within the node editor.
* Fetches the credential schema from the backend and renders a dynamic form.
*/
export function CreateCredentialDialog({ open, credentialNames, onClose, onCreated, editCredentialId }: CreateCredentialDialogProps) {
const { credentialsApi, apiBaseUrl } = useApiContext()
const theme = useTheme()
const isEditMode = !!editCredentialId
const [schemas, setSchemas] = useState<ComponentCredentialSchema[]>([])
const [selectedSchema, setSelectedSchema] = useState<ComponentCredentialSchema | null>(null)
const [credentialName, setCredentialName] = useState('')
const [formValues, setFormValues] = useState<Record<string, unknown>>({})
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Stable string key for credentialNames — a new array reference on every render
// (e.g. inline credentialNames={['openAIApi']}) would otherwise restart the effect.
// Using the joined string as the dependency ensures value-based comparison.
const credentialNamesKey = credentialNames.join('\0')
const selectSchema = useCallback((schema: ComponentCredentialSchema) => {
setSelectedSchema(schema)
setCredentialName('')
// Initialize default values for each input
const defaults: Record<string, unknown> = {}
for (const input of schema.inputs ?? []) {
if (input.hidden) continue
defaults[input.name] = getDefaultValueForType(input)
}
setFormValues(defaults)
}, [])
// Fetch credential schema(s) when dialog opens, and load existing data in edit mode
useEffect(() => {
if (!open) return
let cancelled = false
async function fetchSchemas() {
setLoading(true)
setError(null)
setSchemas([])
setSelectedSchema(null)
setCredentialName('')
setFormValues({})
// Reconstruct names from the stable key to avoid depending on the array reference
const names = credentialNamesKey.split('\0')
try {
if (names.length === 1) {
const schema = await credentialsApi.getComponentCredentialSchema(names[0])
if (!cancelled) {
setSchemas([schema])
selectSchema(schema)
}
} else {
const results = await Promise.all(names.map((name) => credentialsApi.getComponentCredentialSchema(name)))
if (!cancelled) {
setSchemas(results)
}
}
// In edit mode, fetch existing credential and populate the form
if (editCredentialId && !cancelled) {
const existing = await credentialsApi.getCredentialById(editCredentialId)
if (!cancelled) {
setCredentialName(existing.name)
if (existing.plainDataObj) {
setFormValues(existing.plainDataObj)
}
}
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load credential schema')
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
fetchSchemas()
return () => {
cancelled = true
}
}, [open, credentialNamesKey, credentialsApi, selectSchema, editCredentialId])
const handleFieldChange = useCallback((fieldName: string, value: unknown) => {
setFormValues((prev) => ({ ...prev, [fieldName]: value }))
}, [])
const handleSubmit = useCallback(async () => {
if (!selectedSchema || !credentialName.trim()) return
setSubmitting(true)
setError(null)
try {
const plainDataObj: Record<string, unknown> = {}
for (const key of Object.keys(formValues)) {
const input = selectedSchema.inputs?.find((i) => i.name === key)
const val = formValues[key]
if (input?.type === 'number' && typeof val === 'string' && val) {
plainDataObj[key] = Number(val)
} else {
plainDataObj[key] = val
}
}
const body = {
name: credentialName.trim(),
credentialName: selectedSchema.name,
plainDataObj
}
const result = isEditMode
? await credentialsApi.updateCredential(editCredentialId!, body)
: await credentialsApi.createCredential(body)
onCreated(result.id)
} catch (err) {
setError(err instanceof Error ? err.message : isEditMode ? 'Failed to update credential' : 'Failed to create credential')
} finally {
setSubmitting(false)
}
}, [selectedSchema, credentialName, formValues, credentialsApi, onCreated, isEditMode, editCredentialId])
const handleClose = useCallback(() => {
if (!submitting) onClose()
}, [submitting, onClose])
// Check if all required fields have values
const hasEmptyRequiredFields = selectedSchema?.inputs
?.filter((input) => !input.hidden && !input.optional)
.some((input) => {
const val = formValues[input.name]
return val === undefined || val === null || val === ''
})
// Schema selection step (multiple credential types)
const showSelection = !loading && !selectedSchema && schemas.length > 1
return (
<Dialog open={open} onClose={handleClose} fullWidth maxWidth={showSelection ? 'md' : 'sm'}>
<DialogTitle sx={{ fontSize: '1rem', p: 3, pb: 0 }}>
{selectedSchema ? (
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<div style={{ marginRight: 10 }}>
<CredentialIcon name={selectedSchema.name} apiBaseUrl={apiBaseUrl} />
</div>
{selectedSchema.label}
</div>
) : (
'Add New Credential'
)}
</DialogTitle>
<DialogContent
sx={showSelection ? { display: 'flex', flexDirection: 'column', gap: 2, maxHeight: '75vh', px: 3, pb: 3 } : undefined}
>
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
)}
{error && (
<Alert severity='error' sx={{ mb: 2 }}>
{error}
</Alert>
)}
{showSelection && <CredentialTypeSelector schemas={schemas} apiBaseUrl={apiBaseUrl} onSelect={selectSchema} />}
{selectedSchema && (
<>
{selectedSchema.description && (
<Box sx={{ pl: 2, pr: 2 }}>
<div
style={{
display: 'flex',
flexDirection: 'row',
borderRadius: 10,
background: theme.palette.warningBanner.background,
padding: 10,
marginTop: 10,
marginBottom: 10
}}
>
<span style={{ color: theme.palette.warningBanner.text }}>
{parser(DOMPurify.sanitize(selectedSchema.description))}
</span>
</div>
</Box>
)}
<Box sx={{ p: 2 }}>
<Typography variant='overline'>
Credential Name
<span style={{ color: theme.palette.error.main }}>&nbsp;*</span>
</Typography>
<OutlinedInput
fullWidth
type='string'
placeholder={selectedSchema.label}
value={credentialName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCredentialName(e.target.value)}
autoFocus
/>
</Box>
{(selectedSchema.inputs ?? [])
.filter((input) => !input.hidden)
.map((input) => (
<CredentialField
key={input.name}
input={input}
value={formValues[input.name]}
onChange={(value) => handleFieldChange(input.name, value)}
/>
))}
</>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={submitting}>
Cancel
</Button>
{selectedSchema && (
<Button
variant='contained'
onClick={handleSubmit}
disabled={!credentialName.trim() || hasEmptyRequiredFields || submitting}
>
{submitting ? (isEditMode ? 'Saving...' : 'Adding...') : isEditMode ? 'Save' : 'Add'}
</Button>
)}
</DialogActions>
</Dialog>
)
}
// ---------------------------------------------------------------------------
// Dynamic form field renderer
// ---------------------------------------------------------------------------
interface CredentialFieldProps {
input: CredentialSchemaInput
value: unknown
onChange: (value: unknown) => void
disabled?: boolean
}
function CredentialField({ input, value, onChange, disabled = false }: CredentialFieldProps) {
const theme = useTheme()
const [expandOpen, setExpandOpen] = useState(false)
const showExpand = input.type === 'string' && !!input.rows
return (
<Box sx={{ p: 2 }}>
{/* Label row — always rendered */}
<div style={{ display: 'flex', flexDirection: 'row' }}>
<Typography>
{input.label}
{!input.optional && <span style={{ color: theme.palette.error.main }}>&nbsp;*</span>}
{input.description && <TooltipWithParser title={input.description} />}
</Typography>
<div style={{ flexGrow: 1 }} />
{showExpand && (
<IconButton
size='small'
sx={{ height: 25, width: 25 }}
title='Expand'
color='primary'
onClick={() => setExpandOpen(true)}
>
<IconArrowsMaximize />
</IconButton>
)}
</div>
{/* Warning banner — rendered when present */}
{input.warning && (
<div
style={{
display: 'flex',
flexDirection: 'row',
borderRadius: 10,
background: theme.palette.warningBanner.background,
padding: 10,
marginTop: 10,
marginBottom: 10
}}
>
<IconAlertTriangle size={36} color={theme.palette.warning.main} />
<span style={{ color: theme.palette.warningBanner.text, marginLeft: 10 }}>{input.warning}</span>
</div>
)}
{/* Input — conditional on type */}
{input.type === 'boolean' && (
<SwitchInput disabled={disabled} value={value as boolean | undefined} onChange={(checked) => onChange(checked)} />
)}
{(input.type === 'string' || input.type === 'password' || input.type === 'number') && (
<OutlinedInput
disabled={disabled}
fullWidth
type={input.type === 'password' ? 'password' : input.type === 'number' ? 'number' : 'text'}
multiline={!!input.rows}
rows={input.rows}
placeholder={input.placeholder}
value={(value as string) ?? ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
/>
)}
{input.type === 'json' && (
<JsonInput disabled={disabled} value={(value as string) ?? '{}'} onChange={(json) => onChange(json)} />
)}
{input.type === 'options' && input.options && (
<Dropdown
disabled={disabled}
name={input.name}
options={input.options}
onSelect={(newValue) => onChange(newValue)}
value={(value as string) ?? 'choose an option'}
/>
)}
{/* Expand dialog for multiline string fields */}
{showExpand && (
<Dialog open={expandOpen} onClose={() => setExpandOpen(false)} fullWidth maxWidth='md'>
<DialogTitle>{input.label}</DialogTitle>
<DialogContent>
<OutlinedInput
disabled={disabled}
fullWidth
multiline
minRows={12}
placeholder={input.placeholder}
value={(value as string) ?? ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setExpandOpen(false)}>Close</Button>
</DialogActions>
</Dialog>
)}
</Box>
)
}
@@ -1,10 +1,12 @@
import type { AxiosInstance } from 'axios'
import { bindCredentialsApi } from './credentials'
import type { DeduplicatedClient } from './deduplicatedClient'
const mockClient = {
get: jest.fn()
} as unknown as jest.Mocked<AxiosInstance>
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
clearCache: jest.fn()
} as unknown as jest.Mocked<DeduplicatedClient>
beforeEach(() => {
jest.clearAllMocks()
@@ -13,35 +15,69 @@ beforeEach(() => {
describe('bindCredentialsApi', () => {
const api = bindCredentialsApi(mockClient)
describe('getAllCredentials', () => {
it('should call GET /credentials', async () => {
const mockCredentials = [{ id: '1', name: 'My OpenAI Key', credentialName: 'openAIApi' }]
;(mockClient.get as jest.Mock).mockResolvedValue({ data: mockCredentials })
it('getAllCredentials calls GET /credentials', async () => {
const mockCredentials = [{ id: '1', name: 'My OpenAI Key', credentialName: 'openAIApi' }]
;(mockClient.get as jest.Mock).mockResolvedValue({ data: mockCredentials })
const result = await api.getAllCredentials()
expect(mockClient.get).toHaveBeenCalledWith('/credentials')
expect(result).toEqual(mockCredentials)
})
const result = await api.getAllCredentials()
expect(mockClient.get).toHaveBeenCalledWith('/credentials')
expect(result).toEqual(mockCredentials)
})
describe('getCredentialsByName', () => {
it('should call GET /credentials with credentialName param', async () => {
const mockCredentials = [{ id: '1', name: 'My OpenAI Key', credentialName: 'openAIApi' }]
;(mockClient.get as jest.Mock).mockResolvedValue({ data: mockCredentials })
it('getCredentialsByName calls GET /credentials with credentialName param', async () => {
const mockCredentials = [{ id: '1', name: 'My OpenAI Key', credentialName: 'openAIApi' }]
;(mockClient.get as jest.Mock).mockResolvedValue({ data: mockCredentials })
const result = await api.getCredentialsByName('openAIApi')
expect(mockClient.get).toHaveBeenCalledWith('/credentials', { params: { credentialName: 'openAIApi' } })
expect(result).toEqual(mockCredentials)
})
const result = await api.getCredentialsByName('openAIApi')
expect(mockClient.get).toHaveBeenCalledWith('/credentials', { params: { credentialName: 'openAIApi' } })
expect(result).toEqual(mockCredentials)
})
it('should not expose encryptedData in the response', async () => {
const mockCredentials = [{ id: '1', name: 'My OpenAI Key', credentialName: 'openAIApi' }]
;(mockClient.get as jest.Mock).mockResolvedValue({ data: mockCredentials })
it('should not expose encryptedData in the response', async () => {
const mockCredentials = [{ id: '1', name: 'My OpenAI Key', credentialName: 'openAIApi' }]
;(mockClient.get as jest.Mock).mockResolvedValue({ data: mockCredentials })
const result = await api.getCredentialsByName('openAIApi')
for (const credential of result) {
expect(credential).not.toHaveProperty('encryptedData')
}
})
const result = await api.getCredentialsByName('openAIApi')
for (const credential of result) {
expect(credential).not.toHaveProperty('encryptedData')
}
})
it('getComponentCredentialSchema calls GET /components-credentials/:name', async () => {
const mockSchema = { name: 'openAIApi', label: 'OpenAI API', inputs: [] }
;(mockClient.get as jest.Mock).mockResolvedValue({ data: mockSchema })
const result = await api.getComponentCredentialSchema('openAIApi')
expect(mockClient.get).toHaveBeenCalledWith('/components-credentials/openAIApi')
expect(result).toEqual(mockSchema)
})
it('createCredential calls POST /credentials with body', async () => {
const body = { name: 'My New Key', credentialName: 'openAIApi', plainDataObj: { apiKey: 'sk-test' } }
const mockCreated = { id: '2', ...body }
;(mockClient.post as jest.Mock).mockResolvedValue({ data: mockCreated })
const result = await api.createCredential(body)
expect(mockClient.post).toHaveBeenCalledWith('/credentials', body)
expect(result).toEqual(mockCreated)
})
it('getCredentialById calls GET /credentials/:id', async () => {
const mockCredential = { id: 'cred-1', name: 'My Key', credentialName: 'openAIApi', plainDataObj: { apiKey: 'sk-test' } }
;(mockClient.get as jest.Mock).mockResolvedValue({ data: mockCredential })
const result = await api.getCredentialById('cred-1')
expect(mockClient.get).toHaveBeenCalledWith('/credentials/cred-1')
expect(result).toEqual(mockCredential)
})
it('updateCredential calls PUT /credentials/:id with body', async () => {
const body = { name: 'Updated Key', credentialName: 'openAIApi', plainDataObj: { apiKey: 'sk-new' } }
const mockUpdated = { id: 'cred-1', ...body }
;(mockClient.put as jest.Mock).mockResolvedValue({ data: mockUpdated })
const result = await api.updateCredential('cred-1', body)
expect(mockClient.put).toHaveBeenCalledWith('/credentials/cred-1', body)
expect(result).toEqual(mockUpdated)
})
})
@@ -1,11 +1,11 @@
import type { AxiosInstance } from 'axios'
import type { ComponentCredentialSchema, CreateCredentialBody, Credential } from '@/core/types'
import type { Credential } from '@/core/types'
import type { DeduplicatedClient } from './deduplicatedClient'
/**
* Create credentials API functions bound to a client instance
*/
export function bindCredentialsApi(client: AxiosInstance) {
export function bindCredentialsApi(client: DeduplicatedClient) {
return {
/**
* Get all credentials
@@ -21,6 +21,40 @@ export function bindCredentialsApi(client: AxiosInstance) {
getCredentialsByName: async (credentialName: string | string[]): Promise<Credential[]> => {
const response = await client.get('/credentials', { params: { credentialName } })
return response.data
},
/**
* Fetch the credential schema (field definitions) for a given component credential name.
*/
getComponentCredentialSchema: async (name: string): Promise<ComponentCredentialSchema> => {
const response = await client.get(`/components-credentials/${name}`)
return response.data
},
/**
* Create a new credential.
*/
createCredential: async (body: CreateCredentialBody): Promise<Credential> => {
const response = await client.post('/credentials', body)
client.clearCache()
return response.data
},
/**
* Get a specific credential by ID (includes plainDataObj for editing).
*/
getCredentialById: async (id: string): Promise<Credential & { plainDataObj?: Record<string, unknown> }> => {
const response = await client.get(`/credentials/${id}`)
return response.data
},
/**
* Update an existing credential.
*/
updateCredential: async (id: string, body: CreateCredentialBody): Promise<Credential> => {
const response = await client.put(`/credentials/${id}`, body)
client.clearCache()
return response.data
}
}
}
@@ -20,7 +20,11 @@ const mockApis: ApiServices = {
},
credentialsApi: {
getAllCredentials: jest.fn(),
getCredentialsByName: jest.fn()
getCredentialsByName: jest.fn(),
getComponentCredentialSchema: jest.fn(),
createCredential: jest.fn(),
getCredentialById: jest.fn(),
updateCredential: jest.fn()
},
storesApi: {
getStores: jest.fn(),
@@ -3,6 +3,7 @@ import type { ReactFlowInstance } from 'reactflow'
import { cloneDeep } from 'lodash'
import { getDefaultValueForType } from '@/core/primitives'
import type {
AgentflowAction,
AgentflowState,
@@ -216,7 +217,7 @@ export function AgentflowStateProvider({ children, initialFlow }: AgentflowState
if (isConnectionString(value)) {
// Reset string connections to parameter default
const inputParam = newNode.data.inputs?.find((p) => p.name === inputName)
newNode.data.inputValues[inputName] = inputParam?.default ?? ''
newNode.data.inputValues[inputName] = inputParam ? getDefaultValueForType(inputParam) : ''
} else if (Array.isArray(value)) {
// Filter out connection strings from arrays
newNode.data.inputValues[inputName] = value.filter((item) => !isConnectionString(item))
+1
View File
@@ -46,6 +46,7 @@ export default defineConfig(({ mode }) => {
'react/jsx-runtime': 'jsxRuntime',
'@mui/material': 'MaterialUI',
'@mui/material/styles': 'MaterialUIStyles',
'@mui/icons-material': 'MaterialUIIcons',
'@emotion/react': 'emotionReact',
'@emotion/styled': 'emotionStyled',
reactflow: 'ReactFlow'
+237 -273
View File
File diff suppressed because it is too large Load Diff