diff --git a/packages/agentflow/examples/src/demos/CustomNodeExample.tsx b/packages/agentflow/examples/src/demos/CustomNodeExample.tsx
index d6fc20372e1..6951b11117a 100644
--- a/packages/agentflow/examples/src/demos/CustomNodeExample.tsx
+++ b/packages/agentflow/examples/src/demos/CustomNodeExample.tsx
@@ -99,6 +99,38 @@ const customNodeInputParams: InputParam[] = [
default: 2000,
show: { enableMemory: true, memoryType: 'tokenBuffer' }
},
+ {
+ id: 'conditions',
+ name: 'conditions',
+ label: 'Condition',
+ type: 'array',
+ array: [
+ {
+ id: 'variable',
+ name: 'variable',
+ label: 'Variable',
+ type: 'string'
+ },
+ {
+ id: 'operation',
+ name: 'operation',
+ label: 'Operation',
+ type: 'options',
+ options: [
+ { label: 'Equals', name: 'equals' },
+ { label: 'Contains', name: 'contains' },
+ { label: 'Is Empty', name: 'isEmpty' }
+ ]
+ },
+ {
+ id: 'value',
+ name: 'value',
+ label: 'Value',
+ type: 'string',
+ hide: { 'conditions[$index].operation': 'isEmpty' }
+ }
+ ]
+ },
{
id: 'outputFormat',
name: 'outputFormat',
diff --git a/packages/agentflow/jest.config.js b/packages/agentflow/jest.config.js
index 822f10e04fa..8279cc5de63 100644
--- a/packages/agentflow/jest.config.js
+++ b/packages/agentflow/jest.config.js
@@ -38,6 +38,7 @@ module.exports = {
coverageThreshold: {
'./src/*.ts': { branches: 80, functions: 80, lines: 80, statements: 80 },
'./src/Agentflow.tsx': { branches: 80, functions: 80, lines: 80, statements: 80 },
+ './src/atoms/ArrayInput.tsx': { branches: 80, functions: 80, lines: 80, statements: 80 },
'./src/core/': { branches: 80, functions: 80, lines: 80, statements: 80 },
'./src/features/canvas/components/ConnectionLine.tsx': { branches: 80, functions: 80, lines: 80, statements: 80 },
// Only getMinimumNodeHeight() is tested; the component is Tier 3 UI with no business logic
diff --git a/packages/agentflow/src/atoms/ArrayInput.test.tsx b/packages/agentflow/src/atoms/ArrayInput.test.tsx
new file mode 100644
index 00000000000..fdcfedfdf79
--- /dev/null
+++ b/packages/agentflow/src/atoms/ArrayInput.test.tsx
@@ -0,0 +1,355 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+
+import type { InputParam, NodeData } from '@/core/types'
+
+import { ArrayInput } from './ArrayInput'
+
+// --- Mocks ---
+const mockOnDataChange = jest.fn()
+
+jest.mock('./NodeInputHandler', () => ({
+ NodeInputHandler: ({
+ inputParam,
+ onDataChange
+ }: {
+ inputParam: InputParam
+ data: NodeData
+ onDataChange: (args: { inputParam: InputParam; newValue: unknown }) => void
+ }) => (
+
+
+ onDataChange({ inputParam, newValue: e.target.value })} />
+
+ )
+}))
+
+jest.mock('@tabler/icons-react', () => ({
+ IconPlus: () => ,
+ IconTrash: () =>
+}))
+
+describe('ArrayInput', () => {
+ const mockInputParam: InputParam = {
+ id: 'test-array',
+ name: 'testArray',
+ label: 'Test Item',
+ type: 'array',
+ array: [
+ { id: 'field1', name: 'field1', label: 'Field 1', type: 'string', default: '' } as InputParam,
+ { id: 'field2', name: 'field2', label: 'Field 2', type: 'number', default: 0 } as InputParam
+ ]
+ }
+
+ const mockNodeData: NodeData = {
+ id: 'node-1',
+ name: 'testNode',
+ label: 'Test Node',
+ inputValues: {}
+ } as NodeData
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ // Test 1: Render existing items
+ it('should render existing items correctly', () => {
+ const dataWithItems: NodeData = {
+ ...mockNodeData,
+ inputValues: {
+ testArray: [
+ { field1: 'value1', field2: 10 },
+ { field1: 'value2', field2: 20 }
+ ]
+ }
+ } as NodeData
+
+ render()
+
+ // Verify both items are rendered
+ expect(screen.getByText('0')).toBeInTheDocument()
+ expect(screen.getByText('1')).toBeInTheDocument()
+
+ // Verify field handlers are rendered for both items
+ expect(screen.getAllByTestId('input-handler-field1')).toHaveLength(2)
+ expect(screen.getAllByTestId('input-handler-field2')).toHaveLength(2)
+ })
+
+ // Test 2: Render Add button
+ it('should render Add button with correct label', () => {
+ render()
+
+ const addButton = screen.getByRole('button', { name: /Add Test Item/i })
+ expect(addButton).toBeInTheDocument()
+ expect(screen.getByTestId('icon-plus')).toBeInTheDocument()
+ })
+
+ // Test 3: Add new item
+ it('should add new item and call onDataChange with new array', () => {
+ render()
+
+ const addButton = screen.getByRole('button', { name: /Add Test Item/i })
+ fireEvent.click(addButton)
+
+ // Verify onDataChange was called with new array containing default values
+ expect(mockOnDataChange).toHaveBeenCalledWith({
+ inputParam: mockInputParam,
+ newValue: [{ field1: '', field2: 0 }]
+ })
+ })
+
+ // Test 4: Delete item
+ it('should delete item and verify item removed from array', () => {
+ const dataWithItems: NodeData = {
+ ...mockNodeData,
+ inputValues: {
+ testArray: [
+ { field1: 'value1', field2: 10 },
+ { field1: 'value2', field2: 20 }
+ ]
+ }
+ } as NodeData
+
+ render()
+
+ // Get all delete buttons (IconTrash buttons)
+ const deleteButtons = screen.getAllByTitle('Delete')
+
+ // Click the first delete button
+ fireEvent.click(deleteButtons[0])
+
+ // Verify onDataChange was called with updated array (first item removed)
+ expect(mockOnDataChange).toHaveBeenCalledWith({
+ inputParam: mockInputParam,
+ newValue: [{ field1: 'value2', field2: 20 }]
+ })
+ })
+
+ // Test 5: Handle field changes
+ it('should handle nested field changes and update parent array', () => {
+ const dataWithItems: NodeData = {
+ ...mockNodeData,
+ inputValues: {
+ testArray: [{ field1: 'initial', field2: 5 }]
+ }
+ } as NodeData
+
+ render()
+
+ // Change field1 value
+ const field1Input = screen.getByTestId('input-field1')
+ fireEvent.change(field1Input, { target: { value: 'updated' } })
+
+ // Verify parent array was updated
+ expect(mockOnDataChange).toHaveBeenCalledWith({
+ inputParam: mockInputParam,
+ newValue: [{ field1: 'updated', field2: 5 }]
+ })
+ })
+
+ // Test 6: Empty array initialization
+ it('should render with empty array and only show Add button', () => {
+ render()
+
+ // Verify no items are rendered
+ expect(screen.queryByText('0')).not.toBeInTheDocument()
+
+ // Verify Add button is present
+ expect(screen.getByRole('button', { name: /Add Test Item/i })).toBeInTheDocument()
+ })
+
+ // Test 7: Respect disabled prop
+ it('should disable buttons when disabled prop is true', () => {
+ const dataWithItems: NodeData = {
+ ...mockNodeData,
+ inputValues: {
+ testArray: [{ field1: 'value1', field2: 10 }]
+ }
+ } as NodeData
+
+ render()
+
+ // Verify Add button is disabled
+ const addButton = screen.getByRole('button', { name: /Add Test Item/i })
+ expect(addButton).toBeDisabled()
+
+ // Verify Delete button is disabled
+ const deleteButton = screen.getByTitle('Delete')
+ expect(deleteButton).toBeDisabled()
+ })
+
+ // Test 8: Filter hidden fields
+ it('should not render fields with display set to false', () => {
+ const inputParamWithHiddenField: InputParam = {
+ ...mockInputParam,
+ array: [
+ { id: 'visible', name: 'visible', label: 'Visible Field', type: 'string', display: true } as InputParam,
+ { id: 'hidden', name: 'hidden', label: 'Hidden Field', type: 'string', display: false } as InputParam
+ ]
+ }
+
+ const dataWithItems: NodeData = {
+ ...mockNodeData,
+ inputValues: {
+ testArray: [{ visible: 'test', hidden: 'should-not-show' }]
+ }
+ } as NodeData
+
+ render()
+
+ // Verify visible field is rendered
+ expect(screen.getByTestId('input-handler-visible')).toBeInTheDocument()
+
+ // Verify hidden field is NOT rendered
+ expect(screen.queryByTestId('input-handler-hidden')).not.toBeInTheDocument()
+ })
+
+ // Test 9: Multiple items
+ it('should render multiple items with correct indices', () => {
+ const dataWithMultipleItems: NodeData = {
+ ...mockNodeData,
+ inputValues: {
+ testArray: [
+ { field1: 'item1', field2: 1 },
+ { field1: 'item2', field2: 2 },
+ { field1: 'item3', field2: 3 },
+ { field1: 'item4', field2: 4 }
+ ]
+ }
+ } as NodeData
+
+ render()
+
+ // Verify all indices are shown
+ expect(screen.getByText('0')).toBeInTheDocument()
+ expect(screen.getByText('1')).toBeInTheDocument()
+ expect(screen.getByText('2')).toBeInTheDocument()
+ expect(screen.getByText('3')).toBeInTheDocument()
+
+ // Verify all field handlers are rendered (4 items * 2 fields each = 8 handlers)
+ expect(screen.getAllByTestId('input-handler-field1')).toHaveLength(4)
+ expect(screen.getAllByTestId('input-handler-field2')).toHaveLength(4)
+ })
+
+ // Test 10: Default values
+ it('should initialize new items with field default values', () => {
+ const inputParamWithDefaults: InputParam = {
+ id: 'test-array',
+ name: 'testArray',
+ label: 'Test Item',
+ type: 'array',
+ array: [
+ { id: 'name', name: 'name', label: 'Name', type: 'string', default: 'John Doe' } as InputParam,
+ { id: 'age', name: 'age', label: 'Age', type: 'number', default: 25 } as InputParam,
+ { id: 'active', name: 'active', label: 'Active', type: 'boolean', default: true } as InputParam
+ ]
+ }
+
+ render()
+
+ const addButton = screen.getByRole('button', { name: /Add Test Item/i })
+ fireEvent.click(addButton)
+
+ // Verify new item initialized with correct default values
+ expect(mockOnDataChange).toHaveBeenCalledWith({
+ inputParam: inputParamWithDefaults,
+ newValue: [{ name: 'John Doe', age: 25, active: true }]
+ })
+ })
+
+ // minItems constraint
+ it('should respect minItems constraint and disable delete when minimum reached', () => {
+ const inputParamWithMinItems: InputParam = {
+ ...mockInputParam,
+ minItems: 2
+ }
+
+ const dataWithItems: NodeData = {
+ ...mockNodeData,
+ inputValues: {
+ testArray: [
+ { field1: 'value1', field2: 10 },
+ { field1: 'value2', field2: 20 }
+ ]
+ }
+ } as NodeData
+
+ render()
+
+ // Both delete buttons should be disabled when at minItems limit
+ const deleteButtons = screen.getAllByTitle('Delete')
+ expect(deleteButtons[0]).toBeDisabled()
+ expect(deleteButtons[1]).toBeDisabled()
+ })
+
+ // Test 11: Type-specific defaults when no explicit default is provided
+ it('should initialize new items with type-appropriate defaults when no default is specified', () => {
+ const inputParamWithTypes: InputParam = {
+ id: 'typed-array',
+ name: 'testArray',
+ label: 'Test Item',
+ type: 'array',
+ array: [
+ { id: 'str', name: 'str', label: 'String', type: 'string' } as InputParam,
+ { id: 'num', name: 'num', label: 'Number', type: 'number' } as InputParam,
+ { id: 'bool', name: 'bool', label: 'Boolean', type: 'boolean' } as InputParam,
+ { id: 'arr', name: 'arr', label: 'Array', type: 'array' } as InputParam
+ ]
+ }
+
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: /Add Test Item/i }))
+
+ expect(mockOnDataChange).toHaveBeenCalledWith({
+ inputParam: inputParamWithTypes,
+ newValue: [{ str: '', num: 0, bool: false, arr: [] }]
+ })
+ })
+
+ // Test 12: itemParameters prop overrides inputParam.array display flags
+ it('should use itemParameters prop for field visibility when provided, ignoring inputParam.array display flags', () => {
+ // inputParam.array has both fields with no display flag (both would show)
+ const dataWithItem: NodeData = {
+ ...mockNodeData,
+ inputValues: { testArray: [{ field1: 'value', field2: 10 }] }
+ } as NodeData
+
+ // Parent (EditNodeDialog) has evaluated field2 as hidden
+ const itemParameters: InputParam[][] = [
+ [
+ { id: 'field1', name: 'field1', label: 'Field 1', type: 'string', display: true } as InputParam,
+ { id: 'field2', name: 'field2', label: 'Field 2', type: 'number', display: false } as InputParam
+ ]
+ ]
+
+ render(
+
+ )
+
+ // field1 visible per itemParameters
+ expect(screen.getByTestId('input-handler-field1')).toBeInTheDocument()
+ // field2 hidden per itemParameters even though inputParam.array has no display flag
+ expect(screen.queryByTestId('input-handler-field2')).not.toBeInTheDocument()
+ })
+
+ // Test reading minItems from inputParam
+ it('should read minItems from inputParam', () => {
+ const inputParamWithMinItems: InputParam = {
+ ...mockInputParam,
+ minItems: 1
+ }
+
+ const dataWithOneItem: NodeData = {
+ ...mockNodeData,
+ inputValues: {
+ testArray: [{ field1: 'value1', field2: 10 }]
+ }
+ } as NodeData
+
+ render()
+
+ // Delete button should be disabled when at minItems limit
+ const deleteButton = screen.getByTitle('Delete')
+ expect(deleteButton).toBeDisabled()
+ })
+})
diff --git a/packages/agentflow/src/atoms/ArrayInput.tsx b/packages/agentflow/src/atoms/ArrayInput.tsx
new file mode 100644
index 00000000000..955a287e26a
--- /dev/null
+++ b/packages/agentflow/src/atoms/ArrayInput.tsx
@@ -0,0 +1,189 @@
+import { useCallback, useMemo } from 'react'
+
+import { Box, Button, Chip, IconButton } from '@mui/material'
+import { useTheme } from '@mui/material/styles'
+import { IconPlus, IconTrash } from '@tabler/icons-react'
+
+import type { InputParam, NodeData } from '@/core/types'
+
+import { NodeInputHandler } from './NodeInputHandler'
+
+export interface ArrayInputProps {
+ inputParam: InputParam
+ data: NodeData
+ disabled?: boolean
+ onDataChange?: (params: { inputParam: InputParam; newValue: unknown }) => void
+ itemParameters?: InputParam[][]
+}
+
+export function ArrayInput({ inputParam, data, disabled = false, onDataChange, itemParameters: itemParametersProp }: ArrayInputProps) {
+ const theme = useTheme()
+
+ // Derive array items directly from props (single source of truth)
+ // Memoized to prevent unnecessary re-renders of child hooks
+ const arrayItems = useMemo(
+ () => (Array.isArray(data.inputValues?.[inputParam.name]) ? (data.inputValues[inputParam.name] as Record[]) : []),
+ [data.inputValues, inputParam.name]
+ )
+
+ // Use pre-computed itemParameters
+ // Falls back to raw field definitions for nested arrays without show/hide conditions.
+ const itemParameters = useMemo(
+ () => itemParametersProp ?? arrayItems.map(() => inputParam.array || []),
+ [itemParametersProp, arrayItems, inputParam.array]
+ )
+
+ // Handle changes to individual fields within array items
+ const handleItemInputChange = useCallback(
+ (itemIndex: number, changedParam: InputParam, newValue: unknown) => {
+ const updatedArrayItems = [...arrayItems]
+ const updatedItem = { ...updatedArrayItems[itemIndex] }
+
+ // Update the specific field
+ updatedItem[changedParam.name] = newValue
+ updatedArrayItems[itemIndex] = updatedItem
+
+ // Notify parent of change (parent will update props, causing re-render)
+ onDataChange?.({ inputParam, newValue: updatedArrayItems })
+ },
+ [arrayItems, inputParam, onDataChange]
+ )
+
+ // Add new array item
+ const handleAddItem = useCallback(() => {
+ // Initialize new item with type-appropriate default values
+ const newItem: Record = {}
+
+ 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] = ''
+ }
+ }
+ }
+ }
+
+ const updatedArrayItems = [...arrayItems, newItem]
+
+ // Notify parent of change (parent will update props, causing re-render)
+ onDataChange?.({ inputParam, newValue: updatedArrayItems })
+ }, [arrayItems, inputParam, onDataChange])
+
+ // Delete array item
+ const handleDeleteItem = useCallback(
+ (indexToDelete: number) => {
+ const updatedArrayItems = arrayItems.filter((_, i) => i !== indexToDelete)
+
+ // Notify parent of change (parent will update props, causing re-render)
+ onDataChange?.({ inputParam, newValue: updatedArrayItems })
+ },
+ [arrayItems, inputParam, onDataChange]
+ )
+
+ // Pre-compute stable per-item onDataChange handlers to avoid new closures on every render
+ const itemHandlers = useMemo(
+ () =>
+ arrayItems.map((_, index) => ({ inputParam: changedParam, newValue }: { inputParam: InputParam; newValue: unknown }) => {
+ handleItemInputChange(index, changedParam, newValue)
+ }),
+ [arrayItems, handleItemInputChange]
+ )
+
+ // Check if item can be deleted based on minItems constraint
+ const canDeleteItem = !inputParam.minItems || arrayItems.length > inputParam.minItems
+
+ return (
+ <>
+ {/* Render each array item */}
+ {arrayItems.map((itemValues, index) => {
+ // Create item-specific data context for nested NodeInputHandler
+ const itemData: NodeData = {
+ ...data,
+ inputValues: itemValues
+ }
+
+ return (
+
+ {/* Delete button */}
+ handleDeleteItem(index)}
+ disabled={disabled || !canDeleteItem}
+ sx={{
+ position: 'absolute',
+ height: 35,
+ width: 35,
+ right: 10,
+ top: 10,
+ '&:hover': { color: theme.palette.error.main },
+ ...(!canDeleteItem && {
+ opacity: 0.3,
+ cursor: 'not-allowed'
+ })
+ }}
+ >
+
+
+
+ {/* Index chip */}
+
+
+ {/* Render input fields for array item */}
+ {itemParameters[index]
+ ?.filter((param) => param.display !== false)
+ .map((param, _) => (
+
+ ))}
+
+ )
+ })}
+
+ {/* Add item button */}
+ }
+ onClick={handleAddItem}
+ >
+ Add {inputParam.label}
+
+ >
+ )
+}
+
+export default ArrayInput
diff --git a/packages/agentflow/src/atoms/NodeInputHandler.tsx b/packages/agentflow/src/atoms/NodeInputHandler.tsx
index 24e06f99e45..c68b4c082c5 100644
--- a/packages/agentflow/src/atoms/NodeInputHandler.tsx
+++ b/packages/agentflow/src/atoms/NodeInputHandler.tsx
@@ -8,6 +8,8 @@ import { IconArrowsMaximize, IconVariable } from '@tabler/icons-react'
import type { InputAnchor, InputParam, NodeData } from '@/core/types'
+import ArrayInput from './ArrayInput'
+
const CustomWidthTooltip = styled(({ className, ...props }: TooltipProps) => )({
[`& .${tooltipClasses.tooltip}`]: {
maxWidth: 500
@@ -22,6 +24,7 @@ export interface NodeInputHandlerProps {
isAdditionalParams?: boolean
disablePadding?: boolean
onDataChange?: (params: { inputParam: InputParam; newValue: unknown }) => void
+ itemParameters?: InputParam[][]
}
/**
@@ -35,7 +38,8 @@ export function NodeInputHandler({
disabled = false,
isAdditionalParams = false,
disablePadding = false,
- onDataChange
+ onDataChange,
+ itemParameters
}: NodeInputHandlerProps) {
const theme = useTheme()
const ref = useRef(null)
@@ -116,6 +120,16 @@ export function NodeInputHandler({
))}
)
+ case 'array':
+ return (
+
+ )
default:
// For unsupported types, render a basic text field
diff --git a/packages/agentflow/src/atoms/index.ts b/packages/agentflow/src/atoms/index.ts
index 01ebbabbb2d..aa5cee342b9 100644
--- a/packages/agentflow/src/atoms/index.ts
+++ b/packages/agentflow/src/atoms/index.ts
@@ -1,3 +1,4 @@
// UI Components - Internal design system
+export { ArrayInput, type ArrayInputProps } from './ArrayInput'
export { MainCard, type MainCardProps } from './MainCard'
export { NodeInputHandler } from './NodeInputHandler'
diff --git a/packages/agentflow/src/core/types/index.ts b/packages/agentflow/src/core/types/index.ts
index adb7305fa81..5301dbb9cdb 100644
--- a/packages/agentflow/src/core/types/index.ts
+++ b/packages/agentflow/src/core/types/index.ts
@@ -132,6 +132,8 @@ export interface InputParam {
show?: Record
hide?: Record
display?: boolean
+ minItems?: number
+ maxItems?: number
array?: InputParam[] // Sub-field definitions for array-type params
}
diff --git a/packages/agentflow/src/features/canvas/components/NodeOutputHandles.tsx b/packages/agentflow/src/features/canvas/components/NodeOutputHandles.tsx
index bd16607ae06..b7674d4e1cc 100644
--- a/packages/agentflow/src/features/canvas/components/NodeOutputHandles.tsx
+++ b/packages/agentflow/src/features/canvas/components/NodeOutputHandles.tsx
@@ -61,7 +61,10 @@ function NodeOutputHandlesComponent({ outputAnchors, nodeColor, isHovered, nodeR
}, [nodeRef, nodeId, updateNodeInternals])
const getAnchorPosition = (index: number) => {
- const spacing = nodeHeight / (outputAnchors.length + 1)
+ // Use measured nodeHeight if available, otherwise fallback to minimum calculated height
+ // This ensures handles are positioned correctly even before ResizeObserver fires
+ const effectiveHeight = nodeHeight > 0 ? nodeHeight : getMinimumNodeHeight(outputAnchors.length)
+ const spacing = effectiveHeight / (outputAnchors.length + 1)
return spacing * (index + 1)
}
diff --git a/packages/agentflow/src/features/node-editor/EditNodeDialog.test.tsx b/packages/agentflow/src/features/node-editor/EditNodeDialog.test.tsx
index e3fd51c87c5..52f3c6a4ba2 100644
--- a/packages/agentflow/src/features/node-editor/EditNodeDialog.test.tsx
+++ b/packages/agentflow/src/features/node-editor/EditNodeDialog.test.tsx
@@ -26,18 +26,67 @@ jest.mock('@/infrastructure/store', () => ({
jest.mock('@/atoms', () => ({
NodeInputHandler: ({
inputParam,
- onDataChange
+ onDataChange,
+ data,
+ itemParameters
}: {
inputParam: InputParam
data: NodeData
onDataChange: (args: { inputParam: InputParam; newValue: unknown }) => void
- }) => (
-
-
-
- )
+ itemParameters?: InputParam[][]
+ }) => {
+ // Handle array type inputs differently
+ if (inputParam.type === 'array') {
+ const currentArray = (data.inputValues?.[inputParam.name] as Record[]) || []
+
+ return (
+
+
+ {currentArray.map((_, index) => (
+
+ ))}
+ {currentArray.map((_, index) => (
+
+ ))}
+
+ )
+ }
+
+ // Default handler for other types
+ return (
+
+
+
+ )
+ }
}))
jest.mock('@tabler/icons-react', () => ({
@@ -252,4 +301,162 @@ describe('EditNodeDialog', () => {
})
)
})
+
+ // ========================================================================
+ // Integration Tests - Array Input
+ // ========================================================================
+
+ describe('Array input integration', () => {
+ it('should render ArrayInput component via NodeInputHandler for array type inputs', () => {
+ const arrayInputParams: InputParam[] = [
+ {
+ name: 'items',
+ label: 'Item',
+ type: 'array',
+ array: [
+ { name: 'name', label: 'Name', type: 'string' } as InputParam,
+ { name: 'value', label: 'Value', type: 'number' } as InputParam
+ ]
+ } as InputParam
+ ]
+
+ const propsWithArrayInput = {
+ ...defaultProps,
+ dialogProps: {
+ ...defaultProps.dialogProps,
+ inputParams: arrayInputParams,
+ data: {
+ ...nodeData,
+ inputValues: { items: [] }
+ }
+ }
+ }
+
+ render()
+
+ // Verify ArrayInput is rendered by checking for the "Add {label}" button
+ expect(screen.getByTestId('add-items')).toBeInTheDocument()
+ expect(screen.getByText('Add Item')).toBeInTheDocument()
+ })
+
+ it('should handle array data updates flowing through EditNodeDialog', () => {
+ const arrayInputParams: InputParam[] = [
+ {
+ name: 'connections',
+ label: 'Connection',
+ type: 'array',
+ array: [
+ { name: 'host', label: 'Host', type: 'string', default: 'localhost' } as InputParam,
+ { name: 'port', label: 'Port', type: 'number', default: 5432 } as InputParam
+ ]
+ } as InputParam
+ ]
+
+ const initialArrayData = [
+ { host: 'server1.com', port: 3000 },
+ { host: 'server2.com', port: 8080 }
+ ]
+
+ const propsWithArrayData = {
+ ...defaultProps,
+ dialogProps: {
+ ...defaultProps.dialogProps,
+ inputParams: arrayInputParams,
+ data: {
+ ...nodeData,
+ inputValues: { connections: initialArrayData }
+ }
+ }
+ }
+
+ render()
+
+ // Verify initial state has delete and change buttons for existing items
+ expect(screen.getByTestId('delete-connections-0')).toBeInTheDocument()
+ expect(screen.getByTestId('delete-connections-1')).toBeInTheDocument()
+ expect(screen.getByTestId('change-connections-0')).toBeInTheDocument()
+ expect(screen.getByTestId('change-connections-1')).toBeInTheDocument()
+
+ // Test Add operation - appends a new item to the array
+ const addButton = screen.getByTestId('add-connections')
+ fireEvent.click(addButton)
+
+ expect(mockUpdateNodeData).toHaveBeenCalledWith('node-1', {
+ inputValues: {
+ connections: [{ host: 'server1.com', port: 3000 }, { host: 'server2.com', port: 8080 }, { _mockAdded: true }]
+ }
+ })
+
+ // Clear mock calls for next test
+ mockUpdateNodeData.mockClear()
+
+ // Test Delete operation - removes first item
+ const deleteButton = screen.getByTestId('delete-connections-0')
+ fireEvent.click(deleteButton)
+
+ // Should be called with updated array (first item removed)
+ expect(mockUpdateNodeData).toHaveBeenCalledTimes(1)
+ expect(mockUpdateNodeData).toHaveBeenCalledWith(
+ 'node-1',
+ expect.objectContaining({
+ inputValues: expect.objectContaining({
+ connections: expect.arrayContaining([{ host: 'server2.com', port: 8080 }])
+ })
+ })
+ )
+
+ // Clear mock calls for next test
+ mockUpdateNodeData.mockClear()
+
+ // Test Change operation - modifies an item
+ const changeButton = screen.getByTestId('change-connections-0')
+ fireEvent.click(changeButton)
+
+ // Verify updateNodeData was called with array update
+ expect(mockUpdateNodeData).toHaveBeenCalledTimes(1)
+ const lastCall = mockUpdateNodeData.mock.calls[0]
+ expect(lastCall[0]).toBe('node-1')
+ expect(lastCall[1]).toHaveProperty('inputValues')
+ expect(lastCall[1].inputValues).toHaveProperty('connections')
+ expect(Array.isArray(lastCall[1].inputValues.connections)).toBe(true)
+ })
+
+ it('should compute and pass itemParameters to NodeInputHandler matching array item count', () => {
+ const arrayParams: InputParam[] = [
+ {
+ name: 'items',
+ label: 'Item',
+ type: 'array',
+ array: [
+ { id: 'type', name: 'type', label: 'Type', type: 'string' } as InputParam,
+ {
+ id: 'detail',
+ name: 'detail',
+ label: 'Detail',
+ type: 'string',
+ show: { 'items[$index].type': 'special' }
+ } as InputParam
+ ]
+ } as InputParam
+ ]
+
+ const propsWithArrayData = {
+ ...defaultProps,
+ dialogProps: {
+ ...defaultProps.dialogProps,
+ inputParams: arrayParams,
+ data: {
+ ...nodeData,
+ inputValues: { items: [{ type: 'normal' }, { type: 'special' }] }
+ }
+ }
+ }
+
+ render()
+
+ // itemParameters should have one entry per array item (2 items → count = 2)
+ const handler = screen.getByTestId('input-handler-items')
+ expect(handler).toHaveAttribute('data-item-params-count', '2')
+ })
+ })
})
diff --git a/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx b/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx
index f730767eb79..e03c0e68c36 100644
--- a/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx
+++ b/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx
@@ -16,6 +16,17 @@ export interface EditNodeDialogProps {
onCancel: () => void
}
+function computeArrayItemParameters(params: InputParam[], inputValues: Record): Record {
+ const result: Record = {}
+ for (const param of params) {
+ if (param.type === 'array' && param.array) {
+ const items = (inputValues[param.name] as Record[]) || []
+ result[param.name] = items.map((_, index) => evaluateFieldVisibility(param.array!, inputValues, index))
+ }
+ }
+ return result
+}
+
/**
* Dialog for editing node properties
*/
@@ -30,6 +41,7 @@ function EditNodeDialogComponent({ show, dialogProps, onCancel }: EditNodeDialog
const [data, setData] = useState(null)
const [isEditingNodeName, setEditingNodeName] = useState(false)
const [nodeName, setNodeName] = useState('')
+ const [arrayItemParameters, setArrayItemParameters] = useState>({})
const onNodeLabelChange = () => {
if (!data || !nodeNameRef.current) return
@@ -50,6 +62,7 @@ function EditNodeDialogComponent({ show, dialogProps, onCancel }: EditNodeDialog
const updatedParams = evaluateFieldVisibility(inputParams, updatedInputValues)
setInputParams(updatedParams)
+ setArrayItemParameters(computeArrayItemParameters(inputParams, updatedInputValues))
// Keep full inputValues in state — hidden field values are preserved so they
// can be restored when visibility conditions change (e.g. toggling provider back).
// Stripping should only happen on save/export, not on every keystroke.
@@ -62,6 +75,7 @@ function EditNodeDialogComponent({ show, dialogProps, onCancel }: EditNodeDialog
const initialValues = dialogProps.data?.inputValues || {}
const evaluatedParams = evaluateFieldVisibility(dialogProps.inputParams, initialValues)
setInputParams(evaluatedParams)
+ setArrayItemParameters(computeArrayItemParameters(dialogProps.inputParams, initialValues))
}
if (dialogProps.data) {
setData(dialogProps.data)
@@ -224,16 +238,19 @@ function EditNodeDialogComponent({ show, dialogProps, onCancel }: EditNodeDialog
{data &&
inputParams
.filter((inputParam) => inputParam.display !== false)
- .map((inputParam, index) => (
-
- ))}
+ .map((inputParam, index) => {
+ return (
+
+ )
+ })}
)
diff --git a/packages/components/nodes/agentflow/ConditionAgent/ConditionAgent.ts b/packages/components/nodes/agentflow/ConditionAgent/ConditionAgent.ts
index 168aa30cb18..12eb5f3ec7a 100644
--- a/packages/components/nodes/agentflow/ConditionAgent/ConditionAgent.ts
+++ b/packages/components/nodes/agentflow/ConditionAgent/ConditionAgent.ts
@@ -73,6 +73,7 @@ class ConditionAgent_Agentflow implements INode {
placeholder: 'User is asking for a pizza'
}
],
+ minItems: 1,
default: [
{
scenario: ''
diff --git a/packages/components/src/Interface.ts b/packages/components/src/Interface.ts
index 92e76de8fc9..c64d601d7e1 100644
--- a/packages/components/src/Interface.ts
+++ b/packages/components/src/Interface.ts
@@ -105,6 +105,8 @@ export interface INodeParams {
hide?: INodeDisplay
generateDocStoreDescription?: boolean
generateInstruction?: boolean
+ minItems?: number
+ maxItems?: number
}
export interface INodeExecutionData {