mirror of
https://github.com/FlowiseAI/Flowise.git
synced 2026-10-12 02:02:17 +00:00
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:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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/`
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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' }}> *</span>}
|
||||
{!inputAnchor.optional && <span style={{ color: theme.palette.error.main }}> *</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' }}> *</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 }}> *</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' }}> *</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 }}> *</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' }}> *</span>
|
||||
<span style={{ color: theme.palette.error.main }}> *</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' }}> *</span>
|
||||
<span style={{ color: theme.palette.error.main }}> *</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' }}> *</span>
|
||||
<span style={{ color: theme.palette.error.main }}> *</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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }}> *</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 }}> *</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))
|
||||
|
||||
@@ -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'
|
||||
|
||||
Generated
+237
-273
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user