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 */} + + + ) +} + +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 {