mirror of
https://github.com/FlowiseAI/Flowise.git
synced 2026-10-12 02:02:17 +00:00
Fix/agentflow Integration and verification test fixes (#6175)
* Fix JSON inout button not appearing * Fix to display icons for vector embeddings and tools in Agent node * Add variable syntax support to condition node * Clear out existing values when operation is changed in Condition node * Loop node to show ancestral nodes * Fix position and styling of "See Example" * Refactor AsyncInput to remove duplicate logic * Fix gemini comments * Addressed review comments
This commit is contained in:
@@ -3,6 +3,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { InputParam, NodeData } from '@/core/types'
|
||||
|
||||
import { ConditionBuilder } from './ConditionBuilder'
|
||||
import type { VariableItem } from './VariablePicker'
|
||||
|
||||
// --- Mocks ---
|
||||
const mockOnDataChange = jest.fn()
|
||||
@@ -10,13 +11,15 @@ const mockOnDataChange = jest.fn()
|
||||
jest.mock('./NodeInputHandler', () => ({
|
||||
NodeInputHandler: ({
|
||||
inputParam,
|
||||
onDataChange
|
||||
onDataChange,
|
||||
variableItems
|
||||
}: {
|
||||
inputParam: InputParam
|
||||
data: NodeData
|
||||
onDataChange: (args: { inputParam: InputParam; newValue: unknown }) => void
|
||||
variableItems?: unknown[]
|
||||
}) => (
|
||||
<div data-testid={`input-handler-${inputParam.name}`}>
|
||||
<div data-testid={`input-handler-${inputParam.name}`} data-has-variables={variableItems !== undefined ? 'true' : 'false'}>
|
||||
<label>{inputParam.label}</label>
|
||||
<input data-testid={`input-${inputParam.name}`} onChange={(e) => onDataChange({ inputParam, newValue: e.target.value })} />
|
||||
</div>
|
||||
@@ -146,6 +149,25 @@ describe('ConditionBuilder', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear value1 and value2 when operation changes', () => {
|
||||
const data: NodeData = {
|
||||
...mockNodeData,
|
||||
inputs: {
|
||||
conditions: [{ type: 'string', value1: 'hello', operation: 'equal', value2: 'world' }]
|
||||
}
|
||||
} as NodeData
|
||||
|
||||
render(<ConditionBuilder inputParam={conditionInputParam} data={data} onDataChange={mockOnDataChange} />)
|
||||
|
||||
const operationInput = screen.getByTestId('input-operation')
|
||||
fireEvent.change(operationInput, { target: { value: 'contains' } })
|
||||
|
||||
expect(mockOnDataChange).toHaveBeenCalledWith({
|
||||
inputParam: conditionInputParam,
|
||||
newValue: [{ type: 'string', value1: '', operation: 'contains', value2: '' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable buttons when disabled prop is true', () => {
|
||||
const data: NodeData = {
|
||||
...mockNodeData,
|
||||
@@ -207,6 +229,35 @@ describe('ConditionBuilder', () => {
|
||||
expect(screen.queryByTestId('input-handler-value2')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should forward variableItems to NodeInputHandler only for params with acceptVariable', () => {
|
||||
const inputParamWithAcceptVariable: InputParam = {
|
||||
...conditionInputParam,
|
||||
array: [
|
||||
{ id: 'type', name: 'type', label: 'Type', type: 'options', default: 'string' } as InputParam,
|
||||
{ id: 'value1', name: 'value1', label: 'Value 1', type: 'string', default: '', acceptVariable: true } as InputParam,
|
||||
{ id: 'value2', name: 'value2', label: 'Value 2', type: 'string', default: '', acceptVariable: true } as InputParam
|
||||
]
|
||||
}
|
||||
const data: NodeData = {
|
||||
...mockNodeData,
|
||||
inputs: { conditions: [{ type: 'string', value1: '', value2: '' }] }
|
||||
} as NodeData
|
||||
const variableItems: VariableItem[] = [{ label: 'question', value: '{{question}}', category: 'Chat Context' }]
|
||||
|
||||
render(
|
||||
<ConditionBuilder
|
||||
inputParam={inputParamWithAcceptVariable}
|
||||
data={data}
|
||||
onDataChange={mockOnDataChange}
|
||||
variableItems={variableItems}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('input-handler-type')).toHaveAttribute('data-has-variables', 'false')
|
||||
expect(screen.getByTestId('input-handler-value1')).toHaveAttribute('data-has-variables', 'true')
|
||||
expect(screen.getByTestId('input-handler-value2')).toHaveAttribute('data-has-variables', 'true')
|
||||
})
|
||||
|
||||
it('should render fields for each condition item', () => {
|
||||
const data: NodeData = {
|
||||
...mockNodeData,
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { InputParam, NodeData } from '@/core/types'
|
||||
|
||||
import { NodeInputHandler } from './NodeInputHandler'
|
||||
import { useStableKeys } from './useStableKeys'
|
||||
import type { VariableItem } from './VariablePicker'
|
||||
|
||||
export interface ConditionBuilderProps {
|
||||
inputParam: InputParam
|
||||
@@ -16,6 +17,7 @@ export interface ConditionBuilderProps {
|
||||
disabled?: boolean
|
||||
onDataChange?: (params: { inputParam: InputParam; newValue: unknown }) => void
|
||||
itemParameters?: InputParam[][]
|
||||
variableItems?: VariableItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,7 +30,8 @@ export function ConditionBuilder({
|
||||
data,
|
||||
disabled = false,
|
||||
onDataChange,
|
||||
itemParameters: itemParametersProp
|
||||
itemParameters: itemParametersProp,
|
||||
variableItems
|
||||
}: ConditionBuilderProps) {
|
||||
const theme = useTheme()
|
||||
|
||||
@@ -49,6 +52,10 @@ export function ConditionBuilder({
|
||||
const updatedArrayItems = [...arrayItems]
|
||||
const updatedItem = { ...updatedArrayItems[itemIndex] }
|
||||
updatedItem[changedParam.name] = newValue
|
||||
if (changedParam.name === 'operation') {
|
||||
updatedItem.value1 = ''
|
||||
updatedItem.value2 = ''
|
||||
}
|
||||
updatedArrayItems[itemIndex] = updatedItem
|
||||
onDataChange?.({ inputParam, newValue: updatedArrayItems })
|
||||
},
|
||||
@@ -137,6 +144,7 @@ export function ConditionBuilder({
|
||||
isAdditionalParams={true}
|
||||
disablePadding={false}
|
||||
onDataChange={itemHandlers[index]}
|
||||
variableItems={param.acceptVariable ? variableItems : undefined}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -379,17 +379,18 @@ describe('NodeInputHandler – json type', () => {
|
||||
expect(screen.queryByTestId('json-input')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders inline JsonInput for json with acceptVariable but no variableItems', () => {
|
||||
it('renders a button for json with acceptVariable even when no variableItems are provided', () => {
|
||||
render(
|
||||
<NodeInputHandler
|
||||
inputParam={makeParam({ type: 'json', acceptVariable: true })}
|
||||
inputParam={makeParam({ type: 'json', acceptVariable: true, label: 'My Field' })}
|
||||
data={baseNodeData}
|
||||
isAdditionalParams
|
||||
onDataChange={mockOnDataChange}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('json-input')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'My Field' })).toBeTruthy()
|
||||
expect(screen.queryByTestId('json-input')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface AsyncInputProps {
|
||||
disabled: boolean
|
||||
onChange: (newValue: string) => void
|
||||
nodeName?: string
|
||||
nodeId?: string
|
||||
inputValues?: Record<string, unknown>
|
||||
}
|
||||
|
||||
@@ -303,7 +304,7 @@ export function NodeInputHandler({
|
||||
|
||||
case 'json': {
|
||||
const jsonStr = typeof value === 'string' ? value : JSON.stringify(value || {})
|
||||
if (inputParam.acceptVariable && variableItems && variableItems.length > 0) {
|
||||
if (inputParam.acceptVariable) {
|
||||
// acceptVariable: show a button that opens a dialog with JsonInput + variable support
|
||||
return (
|
||||
<Button
|
||||
@@ -323,22 +324,22 @@ export function NodeInputHandler({
|
||||
case 'code':
|
||||
return (
|
||||
<>
|
||||
{inputParam.codeExample && !disabled && (
|
||||
<Button
|
||||
size='small'
|
||||
variant='outlined'
|
||||
sx={{ mb: 1, textTransform: 'none' }}
|
||||
onClick={() => handleDataChange(inputParam.codeExample)}
|
||||
>
|
||||
See Example
|
||||
</Button>
|
||||
)}
|
||||
<CodeInput
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(code) => handleDataChange(code)}
|
||||
language={inputParam.codeLanguage}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{inputParam.codeExample && !disabled && (
|
||||
<Button
|
||||
size='small'
|
||||
variant='text'
|
||||
sx={{ mt: 0.5, textTransform: 'none' }}
|
||||
onClick={() => handleDataChange(inputParam.codeExample)}
|
||||
>
|
||||
See Example
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -378,6 +379,7 @@ export function NodeInputHandler({
|
||||
disabled={disabled}
|
||||
onChange={(v) => handleDataChange(v)}
|
||||
nodeName={data.name}
|
||||
nodeId={data.id}
|
||||
inputValues={data.inputs as Record<string, unknown> | undefined}
|
||||
/>
|
||||
{inputParam.loadConfig && ConfigInputComponent && value && onConfigChange && (
|
||||
@@ -403,6 +405,7 @@ export function NodeInputHandler({
|
||||
disabled={disabled}
|
||||
onChange={(v) => handleDataChange(v)}
|
||||
nodeName={data.name}
|
||||
nodeId={data.id}
|
||||
inputValues={data.inputs as Record<string, unknown> | undefined}
|
||||
/>
|
||||
)
|
||||
@@ -541,7 +544,7 @@ export function NodeInputHandler({
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{inputParam?.type === 'json' && inputParam.acceptVariable && variableItems && variableItems.length > 0 && (
|
||||
{inputParam?.type === 'json' && inputParam.acceptVariable && (
|
||||
<Dialog open={jsonDialogOpen} onClose={() => setJsonDialogOpen(false)} fullWidth maxWidth='sm'>
|
||||
<DialogTitle>{inputParam.label}</DialogTitle>
|
||||
<DialogContent>
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { darken } from '@mui/material/styles'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Mock } from 'jest-mock'
|
||||
|
||||
import { useConfigContext } from '@/infrastructure/store'
|
||||
|
||||
import { NodeToolIcons } from './NodeToolIcons'
|
||||
|
||||
// --- Mocks ---
|
||||
jest.mock('@/infrastructure/store', () => ({
|
||||
useApiContext: () => ({ apiBaseUrl: 'http://localhost:3000' }),
|
||||
useConfigContext: jest.fn(() => ({ isDarkMode: false }))
|
||||
}))
|
||||
|
||||
jest.mock('@mui/material/styles', () => ({
|
||||
...jest.requireActual('@mui/material/styles'),
|
||||
darken: jest.fn((color: string, coefficient: number) => `darken(${color},${coefficient})`)
|
||||
}))
|
||||
|
||||
jest.mock('../nodeIcons', () => ({
|
||||
getBuiltInOpenAIToolIcon: (toolName: string) => {
|
||||
const icons: Record<string, React.ReactElement> = {
|
||||
web_search_preview: <span data-testid='openai-web-search' />,
|
||||
code_interpreter: <span data-testid='openai-code-interpreter' />,
|
||||
image_generation: <span data-testid='openai-image-generation' />
|
||||
}
|
||||
return icons[toolName] ?? null
|
||||
},
|
||||
getBuiltInGeminiToolIcon: (toolName: string) => {
|
||||
const icons: Record<string, React.ReactElement> = {
|
||||
urlContext: <span data-testid='gemini-url-context' />,
|
||||
googleSearch: <span data-testid='gemini-google-search' />,
|
||||
codeExecution: <span data-testid='gemini-code-execution' />
|
||||
}
|
||||
return icons[toolName] ?? null
|
||||
},
|
||||
getBuiltInAnthropicToolIcon: (toolName: string) => {
|
||||
const icons: Record<string, React.ReactElement> = {
|
||||
web_search_20250305: <span data-testid='anthropic-web-search' />,
|
||||
web_fetch_20250910: <span data-testid='anthropic-web-fetch' />
|
||||
}
|
||||
return icons[toolName] ?? null
|
||||
}
|
||||
}))
|
||||
|
||||
// --- Helpers ---
|
||||
function renderToolIcons(inputs: Record<string, unknown>, nodeColor = '#4A90D9') {
|
||||
return render(<NodeToolIcons inputs={inputs} nodeColor={nodeColor} />)
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('NodeToolIcons', () => {
|
||||
describe('renders nothing', () => {
|
||||
it('returns null when inputs is undefined', () => {
|
||||
const { container } = render(<NodeToolIcons />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when inputs has no tool fields', () => {
|
||||
const { container } = renderToolIcons({ llmModel: 'gpt-4', someOtherField: 'value' })
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when tool arrays are empty', () => {
|
||||
const { container } = renderToolIcons({ llmTools: [], agentTools: [] })
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when llmTools is a non-array (JSON string guard)', () => {
|
||||
// Non-array values should be ignored, not crash
|
||||
const { container } = renderToolIcons({ llmTools: '[{"llmSelectedTool":"customTool"}]' })
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when agentKnowledgeVSEmbeddings is a non-array', () => {
|
||||
const { container } = renderToolIcons({ agentKnowledgeVSEmbeddings: 'not-an-array' })
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('llmTools', () => {
|
||||
it('renders icon for each tool in llmTools', () => {
|
||||
renderToolIcons({
|
||||
llmTools: [{ llmSelectedTool: 'customTool1' }, { llmSelectedTool: 'customTool2' }]
|
||||
})
|
||||
const imgs = screen.getAllByRole('img')
|
||||
expect(imgs).toHaveLength(2)
|
||||
expect(imgs[0]).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/customTool1')
|
||||
expect(imgs[1]).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/customTool2')
|
||||
})
|
||||
|
||||
it('skips entries without llmSelectedTool property', () => {
|
||||
renderToolIcons({
|
||||
llmTools: [{ llmSelectedTool: 'toolA' }, { someOtherProp: 'value' }]
|
||||
})
|
||||
const imgs = screen.getAllByRole('img')
|
||||
expect(imgs).toHaveLength(1)
|
||||
expect(imgs[0]).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/toolA')
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentTools', () => {
|
||||
it('renders icon for each tool in agentTools', () => {
|
||||
renderToolIcons({
|
||||
agentTools: [{ agentSelectedTool: 'agentToolX' }]
|
||||
})
|
||||
const imgs = screen.getAllByRole('img')
|
||||
expect(imgs).toHaveLength(1)
|
||||
expect(imgs[0]).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/agentToolX')
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectedTool / toolAgentflowSelectedTool', () => {
|
||||
it('renders icon for selectedTool', () => {
|
||||
renderToolIcons({ selectedTool: 'myTool' })
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/myTool')
|
||||
})
|
||||
|
||||
it('falls back to toolAgentflowSelectedTool when selectedTool is absent', () => {
|
||||
renderToolIcons({ toolAgentflowSelectedTool: 'fallbackTool' })
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/fallbackTool')
|
||||
})
|
||||
|
||||
it('prefers selectedTool over toolAgentflowSelectedTool', () => {
|
||||
renderToolIcons({ selectedTool: 'primary', toolAgentflowSelectedTool: 'secondary' })
|
||||
const imgs = screen.getAllByRole('img')
|
||||
// Only one icon should render (the resolved single tool)
|
||||
expect(imgs).toHaveLength(1)
|
||||
expect(imgs[0]).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/primary')
|
||||
})
|
||||
|
||||
it('renders nothing when both selectedTool and toolAgentflowSelectedTool are absent', () => {
|
||||
const { container } = renderToolIcons({ llmModel: 'gpt-4' })
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentKnowledgeVSEmbeddings', () => {
|
||||
it('renders vectorStore and embeddingModel icons per entry', () => {
|
||||
renderToolIcons({
|
||||
agentKnowledgeVSEmbeddings: [{ vectorStore: 'pinecone', embeddingModel: 'openaiEmbeddings' }]
|
||||
})
|
||||
const imgs = screen.getAllByRole('img')
|
||||
expect(imgs).toHaveLength(2)
|
||||
expect(imgs[0]).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/pinecone')
|
||||
expect(imgs[1]).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/openaiEmbeddings')
|
||||
})
|
||||
|
||||
it('renders only vectorStore icon when embeddingModel is absent', () => {
|
||||
renderToolIcons({
|
||||
agentKnowledgeVSEmbeddings: [{ vectorStore: 'pinecone' }]
|
||||
})
|
||||
const imgs = screen.getAllByRole('img')
|
||||
expect(imgs).toHaveLength(1)
|
||||
expect(imgs[0]).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/pinecone')
|
||||
})
|
||||
|
||||
it('renders icons for multiple knowledge entries', () => {
|
||||
renderToolIcons({
|
||||
agentKnowledgeVSEmbeddings: [
|
||||
{ vectorStore: 'pinecone', embeddingModel: 'openaiEmbeddings' },
|
||||
{ vectorStore: 'weaviate', embeddingModel: 'cohereEmbeddings' }
|
||||
]
|
||||
})
|
||||
const imgs = screen.getAllByRole('img')
|
||||
expect(imgs).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('built-in OpenAI tools', () => {
|
||||
it('renders icon for web_search_preview as array', () => {
|
||||
renderToolIcons({ agentToolsBuiltInOpenAI: ['web_search_preview'] })
|
||||
expect(screen.getByTestId('openai-web-search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders icon for code_interpreter', () => {
|
||||
renderToolIcons({ agentToolsBuiltInOpenAI: ['code_interpreter'] })
|
||||
expect(screen.getByTestId('openai-code-interpreter')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders icon for image_generation', () => {
|
||||
renderToolIcons({ agentToolsBuiltInOpenAI: ['image_generation'] })
|
||||
expect(screen.getByTestId('openai-image-generation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('accepts JSON string input', () => {
|
||||
renderToolIcons({ agentToolsBuiltInOpenAI: '["web_search_preview","code_interpreter"]' })
|
||||
expect(screen.getByTestId('openai-web-search')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('openai-code-interpreter')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders no icons for unknown built-in tool names', () => {
|
||||
renderToolIcons({ agentToolsBuiltInOpenAI: ['unknown_tool'] })
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId(/^openai-/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('built-in Gemini tools', () => {
|
||||
it('renders icon for urlContext', () => {
|
||||
renderToolIcons({ agentToolsBuiltInGemini: ['urlContext'] })
|
||||
expect(screen.getByTestId('gemini-url-context')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders icon for googleSearch', () => {
|
||||
renderToolIcons({ agentToolsBuiltInGemini: ['googleSearch'] })
|
||||
expect(screen.getByTestId('gemini-google-search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders icon for codeExecution', () => {
|
||||
renderToolIcons({ agentToolsBuiltInGemini: ['codeExecution'] })
|
||||
expect(screen.getByTestId('gemini-code-execution')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('accepts JSON string input', () => {
|
||||
renderToolIcons({ agentToolsBuiltInGemini: '["googleSearch"]' })
|
||||
expect(screen.getByTestId('gemini-google-search')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('built-in Anthropic tools', () => {
|
||||
it('renders icon for web_search_20250305', () => {
|
||||
renderToolIcons({ agentToolsBuiltInAnthropic: ['web_search_20250305'] })
|
||||
expect(screen.getByTestId('anthropic-web-search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders icon for web_fetch_20250910', () => {
|
||||
renderToolIcons({ agentToolsBuiltInAnthropic: ['web_fetch_20250910'] })
|
||||
expect(screen.getByTestId('anthropic-web-fetch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('accepts JSON string input', () => {
|
||||
renderToolIcons({ agentToolsBuiltInAnthropic: '["web_search_20250305","web_fetch_20250910"]' })
|
||||
expect(screen.getByTestId('anthropic-web-search')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('anthropic-web-fetch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders no icons for unknown built-in tool names', () => {
|
||||
renderToolIcons({ agentToolsBuiltInAnthropic: ['unknown_anthropic_tool'] })
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId(/^anthropic-/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiple tool groups', () => {
|
||||
it('renders icons from multiple tool groups simultaneously', () => {
|
||||
renderToolIcons({
|
||||
llmTools: [{ llmSelectedTool: 'customTool' }],
|
||||
agentToolsBuiltInOpenAI: ['web_search_preview'],
|
||||
agentToolsBuiltInGemini: ['googleSearch']
|
||||
})
|
||||
expect(screen.getByRole('img')).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/customTool')
|
||||
expect(screen.getByTestId('openai-web-search')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('gemini-google-search')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dark mode', () => {
|
||||
afterEach(() => {
|
||||
;(useConfigContext as Mock).mockReturnValue({ isDarkMode: false })
|
||||
})
|
||||
|
||||
it('renders built-in tool icons in dark mode without crashing', () => {
|
||||
;(useConfigContext as Mock).mockReturnValue({ isDarkMode: true })
|
||||
expect(() => renderToolIcons({ agentToolsBuiltInOpenAI: ['web_search_preview'] })).not.toThrow()
|
||||
})
|
||||
|
||||
it('uses a higher darken coefficient in dark mode than in light mode', () => {
|
||||
const nodeColor = '#4A90D9'
|
||||
|
||||
;(useConfigContext as Mock).mockReturnValue({ isDarkMode: true })
|
||||
renderToolIcons({ agentToolsBuiltInOpenAI: ['web_search_preview'] }, nodeColor)
|
||||
expect(darken).toHaveBeenCalledWith(nodeColor, 0.5)
|
||||
;(useConfigContext as Mock).mockReturnValue({ isDarkMode: false })
|
||||
renderToolIcons({ agentToolsBuiltInOpenAI: ['web_search_preview'] }, nodeColor)
|
||||
expect(darken).toHaveBeenCalledWith(nodeColor, 0.2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles invalid JSON string for built-in tools gracefully', () => {
|
||||
const { container } = renderToolIcons({ agentToolsBuiltInOpenAI: 'not-valid-json' })
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('handles empty JSON array string', () => {
|
||||
const { container } = renderToolIcons({ agentToolsBuiltInOpenAI: '[]' })
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('does not crash when nodeColor is undefined', () => {
|
||||
expect(() => render(<NodeToolIcons inputs={{ agentToolsBuiltInOpenAI: ['web_search_preview'] }} />)).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { memo } from 'react'
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { darken } from '@mui/material/styles'
|
||||
|
||||
import { useApiContext, useConfigContext } from '@/infrastructure/store'
|
||||
|
||||
import { getBuiltInAnthropicToolIcon, getBuiltInGeminiToolIcon, getBuiltInOpenAIToolIcon } from '../nodeIcons'
|
||||
|
||||
interface ToolEntry {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type BuiltInProvider = 'openai' | 'gemini' | 'anthropic'
|
||||
|
||||
interface ToolConfig {
|
||||
tools: ToolEntry[]
|
||||
toolProperty: string | string[]
|
||||
builtInProvider?: BuiltInProvider
|
||||
}
|
||||
|
||||
const builtInIconGetters: Record<BuiltInProvider, (_name: string) => React.ReactElement | null> = {
|
||||
openai: getBuiltInOpenAIToolIcon,
|
||||
gemini: getBuiltInGeminiToolIcon,
|
||||
anthropic: getBuiltInAnthropicToolIcon
|
||||
}
|
||||
|
||||
export interface NodeToolIconsProps {
|
||||
inputs?: Record<string, unknown>
|
||||
nodeColor?: string
|
||||
}
|
||||
|
||||
function parseToolArray(value: unknown): ToolEntry[] {
|
||||
if (!value) return []
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return Array.isArray(parsed) ? parsed.map((tool: unknown) => ({ builtInTool: tool })) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((tool) => ({ builtInTool: tool }))
|
||||
return []
|
||||
}
|
||||
|
||||
function NodeToolIconsComponent({ inputs, nodeColor = '#4A90D9' }: NodeToolIconsProps) {
|
||||
const { apiBaseUrl } = useApiContext()
|
||||
const { isDarkMode } = useConfigContext()
|
||||
|
||||
if (!inputs) return null
|
||||
|
||||
const singleTool = inputs.selectedTool ?? inputs.toolAgentflowSelectedTool
|
||||
|
||||
const toolConfigs: ToolConfig[] = [
|
||||
{ tools: Array.isArray(inputs.llmTools) ? (inputs.llmTools as ToolEntry[]) : [], toolProperty: 'llmSelectedTool' },
|
||||
{ tools: Array.isArray(inputs.agentTools) ? (inputs.agentTools as ToolEntry[]) : [], toolProperty: 'agentSelectedTool' },
|
||||
{
|
||||
tools: singleTool ? [{ selectedTool: singleTool }] : [],
|
||||
toolProperty: ['selectedTool', 'toolAgentflowSelectedTool']
|
||||
},
|
||||
{
|
||||
tools: Array.isArray(inputs.agentKnowledgeVSEmbeddings) ? (inputs.agentKnowledgeVSEmbeddings as ToolEntry[]) : [],
|
||||
toolProperty: ['vectorStore', 'embeddingModel']
|
||||
},
|
||||
{
|
||||
tools: parseToolArray(inputs.agentToolsBuiltInOpenAI),
|
||||
toolProperty: 'builtInTool',
|
||||
builtInProvider: 'openai' as const
|
||||
},
|
||||
{
|
||||
tools: parseToolArray(inputs.agentToolsBuiltInGemini),
|
||||
toolProperty: 'builtInTool',
|
||||
builtInProvider: 'gemini' as const
|
||||
},
|
||||
{
|
||||
tools: parseToolArray(inputs.agentToolsBuiltInAnthropic),
|
||||
toolProperty: 'builtInTool',
|
||||
builtInProvider: 'anthropic' as const
|
||||
}
|
||||
]
|
||||
|
||||
const validConfigs = toolConfigs.filter((config) => config.tools.length > 0)
|
||||
if (validConfigs.length === 0) return null
|
||||
|
||||
const builtInBg = isDarkMode ? darken(nodeColor, 0.5) : darken(nodeColor, 0.2)
|
||||
|
||||
return (
|
||||
<>
|
||||
{validConfigs.map((config, configIndex) => (
|
||||
<Box key={`tools-${configIndex}`} sx={{ display: 'flex', gap: 1, mt: 1 }}>
|
||||
{config.tools.flatMap((tool, toolIndex) => {
|
||||
if (Array.isArray(config.toolProperty)) {
|
||||
return config.toolProperty
|
||||
.filter((prop) => tool[prop])
|
||||
.map((prop, propIndex) => {
|
||||
const toolName = tool[prop] as string
|
||||
return (
|
||||
<Box
|
||||
key={`tool-${configIndex}-${toolIndex}-${propIndex}`}
|
||||
component='img'
|
||||
src={`${apiBaseUrl}/api/v1/node-icon/${encodeURIComponent(toolName)}`}
|
||||
alt={toolName}
|
||||
sx={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
padding: 0.3,
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const toolName = tool[config.toolProperty as string] as string
|
||||
if (!toolName) return []
|
||||
|
||||
if (config.builtInProvider) {
|
||||
const icon = builtInIconGetters[config.builtInProvider](toolName)
|
||||
if (!icon) return []
|
||||
return [
|
||||
<Box
|
||||
key={`tool-${configIndex}-${toolIndex}`}
|
||||
sx={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: builtInBg,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 0.2
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
<Box
|
||||
key={`tool-${configIndex}-${toolIndex}`}
|
||||
component='img'
|
||||
src={`${apiBaseUrl}/api/v1/node-icon/${encodeURIComponent(toolName)}`}
|
||||
alt={toolName}
|
||||
sx={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
padding: 0.3,
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
]
|
||||
})}
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const NodeToolIcons = memo(NodeToolIconsComponent)
|
||||
@@ -12,6 +12,7 @@ import { NodeModelConfigs } from '../components/NodeModelConfigs'
|
||||
import { getMinimumNodeHeight, NodeOutputHandles } from '../components/NodeOutputHandles'
|
||||
import { NodeStatusIndicator, NodeWarningIndicator } from '../components/NodeStatusIndicator'
|
||||
import { NodeToolbarActions } from '../components/NodeToolbarActions'
|
||||
import { NodeToolIcons } from '../components/NodeToolIcons'
|
||||
import { useOpenNodeEditor } from '../hooks'
|
||||
import { useNodeColors } from '../hooks/useNodeColors'
|
||||
import { CardWrapper } from '../styled'
|
||||
@@ -106,6 +107,7 @@ function AgentFlowNodeComponent({ data }: AgentFlowNodeProps) {
|
||||
{data.label}
|
||||
</Typography>
|
||||
<NodeModelConfigs inputs={data.inputs} />
|
||||
<NodeToolIcons inputs={data.inputs} nodeColor={data.color} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -63,10 +63,11 @@ jest.mock('@/infrastructure/api/hooks', () => ({
|
||||
}))
|
||||
|
||||
let mockNodes: Array<{ id: string; type: string; position: { x: number; y: number }; data: Record<string, unknown> }> = []
|
||||
let mockEdges: Array<{ id: string; source: string; target: string; targetHandle?: string; type: string }> = []
|
||||
|
||||
jest.mock('@/infrastructure/store', () => ({
|
||||
useAgentflowContext: () => ({
|
||||
state: { nodes: mockNodes, edges: [] }
|
||||
state: { nodes: mockNodes, edges: mockEdges }
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -96,6 +97,7 @@ const idleResult = (): MockAsyncResult => ({ options: [], loading: false, error:
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockNodes = []
|
||||
mockEdges = []
|
||||
mockUseAsyncOptions.mockReturnValue({ options: [], loading: true, error: null, refetch: mockRefetch })
|
||||
})
|
||||
|
||||
@@ -731,6 +733,101 @@ describe('AsyncInput', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('AsyncInput – listPreviousNodes', () => {
|
||||
const makeFlowNode = (id: string, name: string, label: string) => ({
|
||||
id,
|
||||
type: 'agentflowNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { name, label, inputs: {} }
|
||||
})
|
||||
|
||||
const makeEdge = (source: string, target: string) => ({
|
||||
id: `${source}-${target}`,
|
||||
source,
|
||||
target,
|
||||
targetHandle: target,
|
||||
type: 'agentflowEdge'
|
||||
})
|
||||
|
||||
it('does not call useAsyncOptions — options come from flow state only', () => {
|
||||
mockNodes = [makeFlowNode('loop_0', 'loopAgentflow', 'Loop 0')]
|
||||
|
||||
render(
|
||||
<AsyncInput
|
||||
inputParam={makeParam({ type: 'asyncOptions', loadMethod: 'listPreviousNodes' })}
|
||||
value=''
|
||||
disabled={false}
|
||||
onChange={jest.fn()}
|
||||
nodeId='loop_0'
|
||||
/>
|
||||
)
|
||||
|
||||
expect(mockUseAsyncOptions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders ancestor nodes from flow state as dropdown options', async () => {
|
||||
mockNodes = [
|
||||
makeFlowNode('start_0', 'startAgentflow', 'Start'),
|
||||
makeFlowNode('agent_0', 'agentAgentflow', 'Agent 0'),
|
||||
makeFlowNode('loop_0', 'loopAgentflow', 'Loop 0')
|
||||
]
|
||||
mockEdges = [makeEdge('start_0', 'agent_0'), makeEdge('agent_0', 'loop_0')]
|
||||
|
||||
render(
|
||||
<AsyncInput
|
||||
inputParam={makeParam({ type: 'asyncOptions', loadMethod: 'listPreviousNodes' })}
|
||||
value=''
|
||||
disabled={false}
|
||||
onChange={jest.fn()}
|
||||
nodeId='loop_0'
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole('combobox'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Start')).toBeTruthy()
|
||||
expect(screen.getByText('Agent 0')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows no options when the loop node has no ancestors', async () => {
|
||||
mockNodes = [makeFlowNode('loop_0', 'loopAgentflow', 'Loop 0')]
|
||||
mockEdges = []
|
||||
|
||||
render(
|
||||
<AsyncInput
|
||||
inputParam={makeParam({ type: 'asyncOptions', loadMethod: 'listPreviousNodes' })}
|
||||
value=''
|
||||
disabled={false}
|
||||
onChange={jest.fn()}
|
||||
nodeId='loop_0'
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole('combobox'))
|
||||
await waitFor(() => expect(screen.getByText('No options')).toBeTruthy())
|
||||
})
|
||||
|
||||
it('clears stored value when the selected ancestor node is removed from the flow', async () => {
|
||||
const mockChange = jest.fn()
|
||||
mockNodes = [makeFlowNode('loop_0', 'loopAgentflow', 'Loop 0')]
|
||||
mockEdges = []
|
||||
|
||||
// Value points to a node that is no longer in the flow / no longer an ancestor
|
||||
render(
|
||||
<AsyncInput
|
||||
inputParam={makeParam({ type: 'asyncOptions', loadMethod: 'listPreviousNodes' })}
|
||||
value='agent_0-Agent 0'
|
||||
disabled={false}
|
||||
onChange={mockChange}
|
||||
nodeId='loop_0'
|
||||
/>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mockChange).toHaveBeenCalledWith(''))
|
||||
})
|
||||
})
|
||||
|
||||
describe('AsyncInput – Refresh button', () => {
|
||||
it('does NOT show refresh button when inputParam.refresh is falsy', () => {
|
||||
mockUseAsyncOptions.mockReturnValue({
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Fragment, useCallback, useEffect, useState } from 'react'
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Box, CircularProgress, IconButton, TextField, Typography } from '@mui/material'
|
||||
import Autocomplete from '@mui/material/Autocomplete'
|
||||
import { IconEdit, IconRefresh } from '@tabler/icons-react'
|
||||
|
||||
import type { AsyncInputProps } from '@/atoms'
|
||||
import { Dropdown } from '@/atoms'
|
||||
import type { FlowNode, NodeOption } from '@/core/types'
|
||||
import { getDefinedStateKeys } from '@/core/utils'
|
||||
import { getDefinedStateKeys, getUpstreamNodes } from '@/core/utils'
|
||||
import { useAsyncOptions } from '@/infrastructure/api/hooks'
|
||||
import { useAgentflowContext } from '@/infrastructure/store'
|
||||
|
||||
@@ -36,6 +37,21 @@ function buildAsyncParams(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all ancestor nodes for the given node ID as dropdown options.
|
||||
* Used directly by AsyncOptionsDropdown when loadMethod === 'listPreviousNodes',
|
||||
* bypassing useAsyncOptions entirely — no server call needed.
|
||||
*/
|
||||
function useFlowAncestorNodeOptions(nodeId?: string): NodeOption[] {
|
||||
const { state } = useAgentflowContext()
|
||||
if (!nodeId) return []
|
||||
return getUpstreamNodes(nodeId, state.nodes as FlowNode[], state.edges, /* includeStart */ true).map((n) => ({
|
||||
label: n.data.label,
|
||||
name: `${n.id}-${n.data.label}`,
|
||||
description: n.id
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract state keys from all nodes except the given node.
|
||||
* Excludes the current node to avoid a circular reference where a node's own
|
||||
@@ -47,7 +63,87 @@ function useFlowStateKeys(excludeNodeName?: string): string[] {
|
||||
return getDefinedStateKeys(nodes as FlowNode[])
|
||||
}
|
||||
|
||||
function AsyncOptionsInput({ inputParam, value, disabled, onChange, nodeName, inputValues }: AsyncInputProps) {
|
||||
/** Wraps the three-step async option fetching setup into a single hook. */
|
||||
function useAsyncOptionData(
|
||||
inputParam: AsyncInputProps['inputParam'],
|
||||
nodeName: string | undefined,
|
||||
inputValues: Record<string, unknown> | undefined
|
||||
) {
|
||||
const stateKeys = useFlowStateKeys(nodeName)
|
||||
const params = buildAsyncParams(inputParam.loadMethod, nodeName, inputValues, stateKeys)
|
||||
return useAsyncOptions({
|
||||
loadMethod: inputParam.loadMethod,
|
||||
credentialNames: inputParam.credentialNames,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/** Error state with a retry button. Shared between single- and multi-select dropdowns. */
|
||||
function AsyncFetchError({ error, onRetry }: { error: string; onRetry: () => void }) {
|
||||
return (
|
||||
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant='caption' color='error' sx={{ flexGrow: 1 }}>
|
||||
{error}
|
||||
</Typography>
|
||||
<IconButton size='small' onClick={onRetry} title='Retry' aria-label='retry'>
|
||||
<IconRefresh size={16} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/** Renders the image + label + description block for a single option. */
|
||||
function OptionContent({ option }: { option: NodeOption }) {
|
||||
return (
|
||||
<>
|
||||
{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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Loading spinner end adornment. Shared between single- and multi-select inputs. */
|
||||
function LoadingEndAdornment({ loading, original }: { loading: boolean; original: React.ReactNode }) {
|
||||
return (
|
||||
<Fragment>
|
||||
{loading ? <CircularProgress color='inherit' size={20} /> : null}
|
||||
{original}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
/** Dropdown for listPreviousNodes — reads ancestor nodes from flow state, no server call. */
|
||||
function PreviousNodesDropdown({ value, disabled, onChange, nodeId }: Pick<AsyncInputProps, 'value' | 'disabled' | 'onChange' | 'nodeId'>) {
|
||||
const options = useFlowAncestorNodeOptions(nodeId)
|
||||
const stringValue = typeof value === 'string' ? value : ''
|
||||
|
||||
const onChangeRef = useRef(onChange)
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange
|
||||
})
|
||||
|
||||
// Clear stored value if the selected node no longer exists in the flow.
|
||||
// onChange is accessed via ref so an unstable parent callback can't retrigger this effect.
|
||||
useEffect(() => {
|
||||
if (stringValue && !options.some((o) => o.name === stringValue)) {
|
||||
onChangeRef.current('')
|
||||
}
|
||||
}, [stringValue, options])
|
||||
|
||||
return <Dropdown value={stringValue} options={options} onSelect={onChange} disabled={disabled} />
|
||||
}
|
||||
|
||||
function AsyncOptionsInput({ inputParam, value, disabled, onChange, nodeName, nodeId, inputValues }: AsyncInputProps) {
|
||||
const isCredential = !!inputParam.credentialNames?.length
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||
@@ -83,20 +179,32 @@ function AsyncOptionsInput({ inputParam, value, disabled, onChange, nodeName, in
|
||||
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)}
|
||||
/>
|
||||
{inputParam.loadMethod === 'listPreviousNodes' ? (
|
||||
<PreviousNodesDropdown
|
||||
value={pendingValue ?? value}
|
||||
disabled={disabled}
|
||||
onChange={(v) => {
|
||||
setPendingValue(null)
|
||||
onChange(v)
|
||||
}}
|
||||
nodeId={nodeId}
|
||||
/>
|
||||
) : (
|
||||
<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} />
|
||||
@@ -146,13 +254,7 @@ function AsyncOptionsDropdown({
|
||||
isCredential,
|
||||
onCreateNew
|
||||
}: AsyncInputProps & { isCredential: boolean; onCreateNew: () => void }) {
|
||||
const stateKeys = useFlowStateKeys(nodeName)
|
||||
const params = buildAsyncParams(inputParam.loadMethod, nodeName, inputValues, stateKeys)
|
||||
const { options, loading, error, refetch } = useAsyncOptions({
|
||||
loadMethod: inputParam.loadMethod,
|
||||
credentialNames: inputParam.credentialNames,
|
||||
params
|
||||
})
|
||||
const { options, loading, error, refetch } = useAsyncOptionData(inputParam, nodeName, inputValues)
|
||||
|
||||
// Append "- Create New -" sentinel for credential dropdowns
|
||||
const displayOptions = isCredential ? [...options, { label: '- Create New -', name: CREATE_NEW_SENTINEL }] : options
|
||||
@@ -165,24 +267,21 @@ function AsyncOptionsDropdown({
|
||||
setInputText(matchedValue?.label ?? '')
|
||||
}, [matchedValue?.label])
|
||||
|
||||
// Clear the stored value if it no longer matches any available option
|
||||
const onChangeRef = useRef(onChange)
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange
|
||||
})
|
||||
|
||||
// Clear the stored value if it no longer matches any available option.
|
||||
// onChange is accessed via ref so an unstable parent callback can't retrigger this effect.
|
||||
useEffect(() => {
|
||||
if (!loading && value && typeof value === 'string' && !options.some((o) => o.name === value)) {
|
||||
onChange('')
|
||||
onChangeRef.current('')
|
||||
}
|
||||
}, [loading, value, options, onChange])
|
||||
}, [loading, value, options])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant='caption' color='error' sx={{ flexGrow: 1 }}>
|
||||
{error}
|
||||
</Typography>
|
||||
<IconButton size='small' onClick={refetch} title='Retry' aria-label='retry'>
|
||||
<IconRefresh size={16} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)
|
||||
return <AsyncFetchError error={error} onRetry={refetch} />
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -217,20 +316,7 @@ function AsyncOptionsDropdown({
|
||||
{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>
|
||||
</>
|
||||
<OptionContent option={option} />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
@@ -252,12 +338,7 @@ function AsyncOptionsDropdown({
|
||||
{params.InputProps.startAdornment}
|
||||
</>
|
||||
),
|
||||
endAdornment: (
|
||||
<Fragment>
|
||||
{loading ? <CircularProgress color='inherit' size={20} /> : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</Fragment>
|
||||
)
|
||||
endAdornment: <LoadingEndAdornment loading={loading} original={params.InputProps.endAdornment} />
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -290,25 +371,10 @@ function AsyncMultiOptionsInput({ inputParam, value, disabled, onChange, nodeNam
|
||||
|
||||
/** Inner multi-select component. Remounted via key to force a fresh fetch. */
|
||||
function AsyncMultiOptionsDropdown({ inputParam, value, disabled, onChange, nodeName, inputValues }: AsyncInputProps) {
|
||||
const stateKeys = useFlowStateKeys(nodeName)
|
||||
const params = buildAsyncParams(inputParam.loadMethod, nodeName, inputValues, stateKeys)
|
||||
const { options, loading, error, refetch } = useAsyncOptions({
|
||||
loadMethod: inputParam.loadMethod,
|
||||
credentialNames: inputParam.credentialNames,
|
||||
params
|
||||
})
|
||||
const { options, loading, error, refetch } = useAsyncOptionData(inputParam, nodeName, inputValues)
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant='caption' color='error' sx={{ flexGrow: 1 }}>
|
||||
{error}
|
||||
</Typography>
|
||||
<IconButton size='small' onClick={refetch} title='Retry' aria-label='retry'>
|
||||
<IconRefresh size={16} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)
|
||||
return <AsyncFetchError error={error} onRetry={refetch} />
|
||||
}
|
||||
|
||||
// Stored as JSON-serialized array of names, e.g. '["option1","option2"]'
|
||||
@@ -347,18 +413,7 @@ function AsyncMultiOptionsDropdown({ inputParam, value, disabled, onChange, node
|
||||
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 }}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography variant='h5'>{option.label}</Typography>
|
||||
{option.description && <Typography variant='caption'>{option.description}</Typography>}
|
||||
</Box>
|
||||
<OptionContent option={option} />
|
||||
</Box>
|
||||
)}
|
||||
renderInput={(params) => (
|
||||
@@ -366,12 +421,7 @@ function AsyncMultiOptionsDropdown({ inputParam, value, disabled, onChange, node
|
||||
{...params}
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
endAdornment: (
|
||||
<Fragment>
|
||||
{loading ? <CircularProgress color='inherit' size={20} /> : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</Fragment>
|
||||
)
|
||||
endAdornment: <LoadingEndAdornment loading={loading} original={params.InputProps.endAdornment} />
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -342,6 +342,7 @@ function EditNodeDialogComponent({ show, dialogProps, onCancel }: EditNodeDialog
|
||||
disabled={dialogProps.disabled}
|
||||
onDataChange={onCustomDataChange}
|
||||
itemParameters={arrayItemParameters[inputParam.name]}
|
||||
variableItems={variableItems}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user