Skip to content

Commit 1473702

Browse files
feat(agentflow): update behaviour for Start node, Http Node, and validation fixes (FLOWISE-265, FLOWISE-264) (#6027)
* feat(agentflow): update behaviour for Start node, Http Node, and validation fixes (FLOWISE-265, FLOWISE-264) - Updated behaviors for Start and HTTP nodes - Fix computeArrayItemParameters crash when non-array field changes - Fix validateNode to use availableNodes schema when node.data.inputs is missing - Fix validateNode to respect param.default for required field checks - Add option.description rendering in dropdown to match legacy UI Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix dropdown display to match agentflow v2 * add missing credential field on start node * remove HttpBodyInput component and associated tests (not referenced) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5daf8a3 commit 1473702

10 files changed

Lines changed: 524 additions & 16 deletions

File tree

packages/agentflow/src/atoms/ArrayInput.test.tsx

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,140 @@ describe('ArrayInput', () => {
306306
})
307307
})
308308

309-
// Test 12: itemParameters prop overrides inputParam.array display flags
309+
// Test 12: Nested array sub-fields render recursively
310+
it('should render nested array sub-fields (e.g., addOptions inside formInputTypes)', () => {
311+
const nestedArrayParam: InputParam = {
312+
id: 'formInputTypes',
313+
name: 'formInputTypes',
314+
label: 'Form Input Type',
315+
type: 'array',
316+
array: [
317+
{ id: 'type', name: 'type', label: 'Type', type: 'options', default: 'string' } as InputParam,
318+
{ id: 'label', name: 'label', label: 'Label', type: 'string' } as InputParam,
319+
{
320+
id: 'addOptions',
321+
name: 'addOptions',
322+
label: 'Add Options',
323+
type: 'array',
324+
display: true,
325+
array: [{ id: 'option', name: 'option', label: 'Option', type: 'string' } as InputParam]
326+
} as InputParam
327+
]
328+
}
329+
330+
const dataWithNestedArray: NodeData = {
331+
...mockNodeData,
332+
inputValues: {
333+
formInputTypes: [
334+
{
335+
type: 'options',
336+
label: 'Color',
337+
addOptions: [{ option: 'Red' }, { option: 'Blue' }]
338+
}
339+
]
340+
}
341+
} as NodeData
342+
343+
render(<ArrayInput inputParam={nestedArrayParam} data={dataWithNestedArray} onDataChange={mockOnDataChange} />)
344+
345+
// The parent array item renders
346+
expect(screen.getByText('0')).toBeInTheDocument()
347+
348+
// The nested array sub-field (addOptions) is rendered via NodeInputHandler
349+
// Since our mock NodeInputHandler renders a div with data-testid, the addOptions field should appear
350+
expect(screen.getByTestId('input-handler-addOptions')).toBeInTheDocument()
351+
})
352+
353+
it('should hide nested array sub-fields when display is false', () => {
354+
const nestedArrayParam: InputParam = {
355+
id: 'formInputTypes',
356+
name: 'formInputTypes',
357+
label: 'Form Input Type',
358+
type: 'array',
359+
array: [
360+
{ id: 'type', name: 'type', label: 'Type', type: 'options' } as InputParam,
361+
{
362+
id: 'addOptions',
363+
name: 'addOptions',
364+
label: 'Add Options',
365+
type: 'array',
366+
display: false,
367+
array: [{ id: 'option', name: 'option', label: 'Option', type: 'string' } as InputParam]
368+
} as InputParam
369+
]
370+
}
371+
372+
const dataWithNestedArray: NodeData = {
373+
...mockNodeData,
374+
inputValues: {
375+
formInputTypes: [{ type: 'string', label: 'Name' }]
376+
}
377+
} as NodeData
378+
379+
render(<ArrayInput inputParam={nestedArrayParam} data={dataWithNestedArray} onDataChange={mockOnDataChange} />)
380+
381+
expect(screen.getByTestId('input-handler-type')).toBeInTheDocument()
382+
expect(screen.queryByTestId('input-handler-addOptions')).not.toBeInTheDocument()
383+
})
384+
385+
it('should use itemParameters to control nested array visibility per row', () => {
386+
const nestedArrayParam: InputParam = {
387+
id: 'formInputTypes',
388+
name: 'formInputTypes',
389+
label: 'Form Input Type',
390+
type: 'array',
391+
array: [
392+
{ id: 'type', name: 'type', label: 'Type', type: 'options' } as InputParam,
393+
{
394+
id: 'addOptions',
395+
name: 'addOptions',
396+
label: 'Add Options',
397+
type: 'array',
398+
array: [{ id: 'option', name: 'option', label: 'Option', type: 'string' } as InputParam]
399+
} as InputParam
400+
]
401+
}
402+
403+
const dataWithTwoRows: NodeData = {
404+
...mockNodeData,
405+
inputValues: {
406+
formInputTypes: [
407+
{ type: 'options', label: 'Color', addOptions: [{ option: 'Red' }] },
408+
{ type: 'string', label: 'Name' }
409+
]
410+
}
411+
} as NodeData
412+
413+
// Row 0: addOptions visible (type = options)
414+
// Row 1: addOptions hidden (type = string)
415+
const itemParameters: InputParam[][] = [
416+
[
417+
{ id: 'type', name: 'type', label: 'Type', type: 'options', display: true } as InputParam,
418+
{ id: 'addOptions', name: 'addOptions', label: 'Add Options', type: 'array', display: true } as InputParam
419+
],
420+
[
421+
{ id: 'type', name: 'type', label: 'Type', type: 'options', display: true } as InputParam,
422+
{ id: 'addOptions', name: 'addOptions', label: 'Add Options', type: 'array', display: false } as InputParam
423+
]
424+
]
425+
426+
render(
427+
<ArrayInput
428+
inputParam={nestedArrayParam}
429+
data={dataWithTwoRows}
430+
onDataChange={mockOnDataChange}
431+
itemParameters={itemParameters}
432+
/>
433+
)
434+
435+
// Both rows show their Type field
436+
expect(screen.getAllByTestId('input-handler-type')).toHaveLength(2)
437+
438+
// Only row 0 shows addOptions (row 1 has display: false)
439+
expect(screen.getAllByTestId('input-handler-addOptions')).toHaveLength(1)
440+
})
441+
442+
// Test 13: itemParameters prop overrides inputParam.array display flags
310443
it('should use itemParameters prop for field visibility when provided, ignoring inputParam.array display flags', () => {
311444
// inputParam.array has both fields with no display flag (both would show)
312445
const dataWithItem: NodeData = {

packages/agentflow/src/atoms/NodeInputHandler.tsx

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -241,15 +241,31 @@ export function NodeInputHandler({
241241
value={value || ''}
242242
onChange={(e) => handleDataChange(e.target.value)}
243243
sx={{ mt: 1 }}
244+
renderValue={(selected) => {
245+
const match = inputParam.options?.find((o) => (typeof o === 'string' ? o : o.name) === selected)
246+
return match ? (typeof match === 'string' ? match : match.label) : String(selected)
247+
}}
244248
>
245-
{inputParam.options?.map((option) => (
246-
<MenuItem
247-
key={typeof option === 'string' ? option : option.name}
248-
value={typeof option === 'string' ? option : option.name}
249-
>
250-
{typeof option === 'string' ? option : option.label}
251-
</MenuItem>
252-
))}
249+
{inputParam.options?.map((option) => {
250+
const isObj = typeof option !== 'string'
251+
const key = isObj ? option.name : option
252+
const label = isObj ? option.label : option
253+
const desc = isObj ? option.description : undefined
254+
return (
255+
<MenuItem key={key} value={key}>
256+
{desc ? (
257+
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
258+
<Typography variant='body2'>{label}</Typography>
259+
<Typography variant='caption' color='text.secondary'>
260+
{desc}
261+
</Typography>
262+
</Box>
263+
) : (
264+
label
265+
)}
266+
</MenuItem>
267+
)
268+
})}
253269
</Select>
254270
)
255271

packages/agentflow/src/core/types/node.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export interface InputParam {
6767
type: string
6868
default?: unknown
6969
optional?: boolean
70-
options?: Array<{ label: string; name: string } | string>
70+
options?: Array<{ label: string; name: string; description?: string } | string>
7171
placeholder?: string
7272
rows?: number
7373
description?: string

packages/agentflow/src/core/utils/fieldVisibility.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,54 @@ describe('evaluateFieldVisibility', () => {
196196
})
197197
})
198198

199+
describe('evaluateFieldVisibility – nested array $index pattern (Start node formInputTypes)', () => {
200+
// Mirrors the Start node's formInputTypes definition:
201+
// addOptions has show: { 'formInputTypes[$index].type': 'options' }
202+
const addOptionsParam = makeParam({
203+
name: 'addOptions',
204+
label: 'Add Options',
205+
type: 'array',
206+
show: { 'formInputTypes[$index].type': 'options' }
207+
})
208+
209+
const typeParam = makeParam({ name: 'type', label: 'Type', type: 'options' })
210+
const labelParam = makeParam({ name: 'label', label: 'Label', type: 'string' })
211+
212+
const arraySubFields = [typeParam, labelParam, addOptionsParam]
213+
214+
it('shows addOptions when type is "options" at given index', () => {
215+
const inputValues = {
216+
formInputTypes: [
217+
{ type: 'options', label: 'Pick one' },
218+
{ type: 'string', label: 'Name' }
219+
]
220+
}
221+
222+
const row0 = evaluateFieldVisibility(arraySubFields, inputValues, 0)
223+
expect(row0.find((p) => p.name === 'addOptions')!.display).toBe(true)
224+
expect(row0.find((p) => p.name === 'type')!.display).toBe(true)
225+
226+
const row1 = evaluateFieldVisibility(arraySubFields, inputValues, 1)
227+
expect(row1.find((p) => p.name === 'addOptions')!.display).toBe(false)
228+
expect(row1.find((p) => p.name === 'type')!.display).toBe(true)
229+
})
230+
231+
it('hides addOptions when type changes from "options" to "string"', () => {
232+
const inputValues = {
233+
formInputTypes: [{ type: 'string', label: 'Name' }]
234+
}
235+
236+
const row0 = evaluateFieldVisibility(arraySubFields, inputValues, 0)
237+
expect(row0.find((p) => p.name === 'addOptions')!.display).toBe(false)
238+
})
239+
240+
it('handles missing array gracefully (defaults to empty)', () => {
241+
const inputValues = {} // formInputTypes not yet set
242+
const row0 = evaluateFieldVisibility(arraySubFields, inputValues, 0)
243+
expect(row0.find((p) => p.name === 'addOptions')!.display).toBe(false)
244+
})
245+
})
246+
199247
describe('stripHiddenFieldValues', () => {
200248
it('removes hidden keys and retains visible ones', () => {
201249
const params = [makeParam({ name: 'visible', show: { mode: 'api' } }), makeParam({ name: 'hidden', hide: { mode: 'api' } })]

packages/agentflow/src/core/validation/flowValidation.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,31 @@ describe('validateNode', () => {
144144
expect(errors).toHaveLength(0)
145145
})
146146

147+
it('should not warn when required field has a default value and no explicit inputValue', () => {
148+
const node: FlowNode = {
149+
...makeNode('a', 'agentAgentflow'),
150+
data: {
151+
id: 'a',
152+
name: 'agentAgentflow',
153+
label: 'Agent',
154+
inputs: [
155+
{
156+
id: 'p1',
157+
name: 'agentReturnResponseAs',
158+
label: 'Return Response As',
159+
type: 'options',
160+
optional: false,
161+
default: 'userMessage'
162+
}
163+
],
164+
inputValues: {}
165+
}
166+
}
167+
const errors = validateNode(node)
168+
const responseErrors = errors.filter((e) => e.message.includes('Return Response As'))
169+
expect(responseErrors).toHaveLength(0)
170+
})
171+
147172
it('should skip hidden fields (show condition not met)', () => {
148173
const node: FlowNode = {
149174
...makeNode('a', 'llmAgentflow'),
@@ -322,6 +347,54 @@ describe('validateNode', () => {
322347
expect(errors).toContainEqual(expect.objectContaining({ message: 'Calculator Config is required' }))
323348
})
324349

350+
// --- availableNodes schema fallback ---
351+
it('should use availableNodes input definitions when node.data.inputs is missing', () => {
352+
const availableNodes = [
353+
makeNodeData({
354+
name: 'llmAgentflow',
355+
inputs: [{ id: 'p1', name: 'model', label: 'Model', type: 'string', optional: false }]
356+
})
357+
]
358+
const node: FlowNode = {
359+
...makeNode('a', 'llmAgentflow'),
360+
data: {
361+
id: 'a',
362+
name: 'llmAgentflow',
363+
label: 'LLM',
364+
// No inputs on node — schema comes from availableNodes
365+
inputValues: {}
366+
}
367+
}
368+
const errors = validateNode(node, availableNodes)
369+
expect(errors).toContainEqual(expect.objectContaining({ type: 'warning', message: 'Model is required' }))
370+
})
371+
372+
it('should prefer availableNodes schema over node.data.inputs', () => {
373+
const availableNodes = [
374+
makeNodeData({
375+
name: 'llmAgentflow',
376+
inputs: [
377+
{ id: 'p1', name: 'model', label: 'Model', type: 'string', optional: false },
378+
{ id: 'p2', name: 'temperature', label: 'Temperature', type: 'number', optional: false }
379+
]
380+
})
381+
]
382+
const node: FlowNode = {
383+
...makeNode('a', 'llmAgentflow'),
384+
data: {
385+
id: 'a',
386+
name: 'llmAgentflow',
387+
label: 'LLM',
388+
// Stale/partial inputs on node — availableNodes has the full schema
389+
inputs: [{ id: 'p1', name: 'model', label: 'Model', type: 'string', optional: false }],
390+
inputValues: {}
391+
}
392+
}
393+
const errors = validateNode(node, availableNodes)
394+
expect(errors).toContainEqual(expect.objectContaining({ message: 'Model is required' }))
395+
expect(errors).toContainEqual(expect.objectContaining({ message: 'Temperature is required' }))
396+
})
397+
325398
// --- Nested config validation ---
326399
it('should validate nested component config required fields', () => {
327400
const availableNodes = [

packages/agentflow/src/core/validation/flowValidation.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,8 @@ export function validateNode(node: FlowNode, availableNodes?: NodeData[]): Valid
184184
})
185185
}
186186

187-
const inputParams = node.data.inputs || []
187+
const schemaFromAvailable = availableNodes?.find((n) => n.name === node.data.name)
188+
const inputParams = schemaFromAvailable?.inputs || node.data.inputs || []
188189
const inputValues = node.data.inputValues || {}
189190

190191
for (const param of inputParams) {
@@ -204,7 +205,7 @@ export function validateNode(node: FlowNode, availableNodes?: NodeData[]): Valid
204205
// asyncOptions and asyncMultiOptions values are stored in inputValues just like options;
205206
// evaluateParamVisibility correctly uses those values to resolve show/hide conditions on
206207
// dependent fields, so async-driven visibility is handled automatically here.
207-
if (!param.optional && evaluateParamVisibility(param, inputValues) && isEmptyValue(inputValues[param.name])) {
208+
if (!param.optional && evaluateParamVisibility(param, inputValues) && isEmptyValue(inputValues[param.name] ?? param.default)) {
208209
errors.push({
209210
nodeId: node.id,
210211
message: `${param.label || param.name} is required`,

packages/agentflow/src/features/canvas/hooks/useOpenNodeEditor.test.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jest.mock('./useFlowNodes', () => ({
2020
}))
2121

2222
let mockNodes: ReturnType<typeof makeFlowNode>[] = []
23-
let mockAvailableNodes: { name: string; inputs?: { name: string }[] }[] = []
23+
let mockAvailableNodes: { name: string; inputs?: { name: string }[]; credential?: { name: string; type: string } }[] = []
2424

2525
describe('useOpenNodeEditor', () => {
2626
beforeEach(() => {
@@ -112,6 +112,31 @@ describe('useOpenNodeEditor', () => {
112112
)
113113
})
114114

115+
it('should prepend credential param to inputParams when schema has credential', () => {
116+
mockAvailableNodes = [
117+
{
118+
name: 'llmAgentflow',
119+
inputs: [{ name: 'model' }],
120+
credential: { name: 'credential', type: 'credential' }
121+
}
122+
]
123+
const { result } = renderHook(() => useOpenNodeEditor())
124+
result.current.openNodeEditor('node-1')
125+
126+
expect(mockOpenEditDialog).toHaveBeenCalledWith('node-1', expect.objectContaining({ name: 'llmAgentflow' }), [
127+
{ name: 'credential', type: 'credential' },
128+
{ name: 'model' }
129+
])
130+
})
131+
132+
it('should not prepend credential when schema has no credential', () => {
133+
mockAvailableNodes = [{ name: 'llmAgentflow', inputs: [{ name: 'model' }] }]
134+
const { result } = renderHook(() => useOpenNodeEditor())
135+
result.current.openNodeEditor('node-1')
136+
137+
expect(mockOpenEditDialog).toHaveBeenCalledWith('node-1', expect.objectContaining({ name: 'llmAgentflow' }), [{ name: 'model' }])
138+
})
139+
115140
it('should open dialog with empty inputs when schema has no inputs', () => {
116141
mockAvailableNodes = [{ name: 'llmAgentflow' }] // no inputs property
117142
const { result } = renderHook(() => useOpenNodeEditor())

0 commit comments

Comments
 (0)