From 026ae25397e1db2e7bf0b724129902d45a5aabaa Mon Sep 17 00:00:00 2001 From: j-sanaa Date: Wed, 4 Mar 2026 13:56:25 -0800 Subject: [PATCH 1/6] feat(agentflow): Add array input component with field visibility - Add ArrayInput component for managing lists of structured data - Implement field visibility engine with show/hide conditions - Add minItems constraint support for array inputs - Add comprehensive test coverage for ArrayInput and EditNodeDialog - Update NodeInputHandler to support array type inputs - Update types to include array-related input parameters --- .../examples/src/demos/CustomNodeExample.tsx | 32 ++ packages/agentflow/jest.config.js | 1 + .../agentflow/src/atoms/ArrayInput.test.tsx | 304 ++++++++++++++++++ packages/agentflow/src/atoms/ArrayInput.tsx | 173 ++++++++++ .../agentflow/src/atoms/NodeInputHandler.tsx | 4 + packages/agentflow/src/atoms/index.ts | 1 + packages/agentflow/src/core/types/index.ts | 3 + .../canvas/components/NodeOutputHandles.tsx | 5 +- .../node-editor/EditNodeDialog.test.tsx | 191 ++++++++++- .../features/node-editor/EditNodeDialog.tsx | 22 +- .../ConditionAgent/ConditionAgent.ts | 1 + packages/components/src/Interface.ts | 2 + 12 files changed, 720 insertions(+), 19 deletions(-) create mode 100644 packages/agentflow/src/atoms/ArrayInput.test.tsx create mode 100644 packages/agentflow/src/atoms/ArrayInput.tsx diff --git a/packages/agentflow/examples/src/demos/CustomNodeExample.tsx b/packages/agentflow/examples/src/demos/CustomNodeExample.tsx index d6fc20372e1..2c65ce23985 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: { operation: 'isEmpty' } + } + ] + }, { id: 'outputFormat', name: 'outputFormat', diff --git a/packages/agentflow/jest.config.js b/packages/agentflow/jest.config.js index 55e1e6b2d00..6c462c1d26e 100644 --- a/packages/agentflow/jest.config.js +++ b/packages/agentflow/jest.config.js @@ -37,6 +37,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..db36940a51c --- /dev/null +++ b/packages/agentflow/src/atoms/ArrayInput.test.tsx @@ -0,0 +1,304 @@ +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 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..59d645324c0 --- /dev/null +++ b/packages/agentflow/src/atoms/ArrayInput.tsx @@ -0,0 +1,173 @@ +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 { evaluateFieldVisibility } from '@/core/utils/fieldVisibility' + +import { NodeInputHandler } from './NodeInputHandler' + +export interface ArrayInputProps { + inputParam: InputParam + data: NodeData + disabled?: boolean + onDataChange?: (params: { inputParam: InputParam; newValue: unknown }) => void +} + +/** + * Array input component for managing lists of structured data + * + * @param inputParam - Array field definition with structure + * @param data - Node data containing inputValues + * @param onDataChange - Callback invoked when array is modified + * @param disabled - Whether the input is disabled + */ +export function ArrayInput({ inputParam, data, disabled = false, onDataChange }: 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] + ) + + // Derive item parameters for each array item + const itemParameters = useMemo( + () => arrayItems.map((itemValues, index) => evaluateFieldVisibility(inputParam.array || [], itemValues, index)), + [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 default values + const newItem: Record = {} + + if (inputParam.array) { + for (const field of inputParam.array) { + newItem[field.name] = field.default ?? '' + } + } + + 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] + ) + + // 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, paramIndex) => ( + { + handleItemInputChange(index, changedParam, newValue) + }} + /> + ))} + + ) + })} + + {/* Add item button */} + + + ) +} + +export default ArrayInput diff --git a/packages/agentflow/src/atoms/NodeInputHandler.tsx b/packages/agentflow/src/atoms/NodeInputHandler.tsx index 24e06f99e45..046f09db427 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 @@ -116,6 +118,8 @@ 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 a00f99d4f2b..9a93e11eec0 100644 --- a/packages/agentflow/src/core/types/index.ts +++ b/packages/agentflow/src/core/types/index.ts @@ -131,6 +131,9 @@ export interface InputParam { show?: Record hide?: Record display?: boolean + array?: InputParam[] + minItems?: number + maxItems?: number } export interface EdgeData { 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..bfde6f989ec 100644 --- a/packages/agentflow/src/features/node-editor/EditNodeDialog.test.tsx +++ b/packages/agentflow/src/features/node-editor/EditNodeDialog.test.tsx @@ -26,18 +26,69 @@ jest.mock('@/infrastructure/store', () => ({ jest.mock('@/atoms', () => ({ NodeInputHandler: ({ inputParam, - onDataChange + onDataChange, + data }: { inputParam: InputParam data: NodeData onDataChange: (args: { inputParam: InputParam; newValue: unknown }) => void - }) => ( -
- -
- ) + }) => { + // 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 +303,128 @@ 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 - adds a new item with default values + 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 }, + { host: 'localhost', port: 5432 } + ] + } + }) + + // 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) + }) + }) }) diff --git a/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx b/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx index f730767eb79..990d0c93ade 100644 --- a/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx +++ b/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx @@ -224,16 +224,18 @@ 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 { From e72e92e0b05cbf45f3720cf649efd06578f25765 Mon Sep 17 00:00:00 2001 From: j-sanaa Date: Wed, 4 Mar 2026 14:30:44 -0800 Subject: [PATCH 2/6] Move show/hide field logic into EditNodeDialog --- .../examples/src/demos/CustomNodeExample.tsx | 2 +- .../agentflow/src/atoms/ArrayInput.test.tsx | 26 +++++++++++++ packages/agentflow/src/atoms/ArrayInput.tsx | 21 ++++------ .../agentflow/src/atoms/NodeInputHandler.tsx | 14 ++++++- .../node-editor/EditNodeDialog.test.tsx | 38 ++++++++++++++++++- .../features/node-editor/EditNodeDialog.tsx | 23 ++++++++++- 6 files changed, 103 insertions(+), 21 deletions(-) diff --git a/packages/agentflow/examples/src/demos/CustomNodeExample.tsx b/packages/agentflow/examples/src/demos/CustomNodeExample.tsx index 2c65ce23985..6951b11117a 100644 --- a/packages/agentflow/examples/src/demos/CustomNodeExample.tsx +++ b/packages/agentflow/examples/src/demos/CustomNodeExample.tsx @@ -127,7 +127,7 @@ const customNodeInputParams: InputParam[] = [ name: 'value', label: 'Value', type: 'string', - hide: { operation: 'isEmpty' } + hide: { 'conditions[$index].operation': 'isEmpty' } } ] }, diff --git a/packages/agentflow/src/atoms/ArrayInput.test.tsx b/packages/agentflow/src/atoms/ArrayInput.test.tsx index db36940a51c..f8a88cb0e71 100644 --- a/packages/agentflow/src/atoms/ArrayInput.test.tsx +++ b/packages/agentflow/src/atoms/ArrayInput.test.tsx @@ -281,6 +281,32 @@ describe('ArrayInput', () => { expect(deleteButtons[1]).toBeDisabled() }) + // Test 11: 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 = { diff --git a/packages/agentflow/src/atoms/ArrayInput.tsx b/packages/agentflow/src/atoms/ArrayInput.tsx index 59d645324c0..17f0ad831bb 100644 --- a/packages/agentflow/src/atoms/ArrayInput.tsx +++ b/packages/agentflow/src/atoms/ArrayInput.tsx @@ -5,7 +5,6 @@ import { useTheme } from '@mui/material/styles' import { IconPlus, IconTrash } from '@tabler/icons-react' import type { InputParam, NodeData } from '@/core/types' -import { evaluateFieldVisibility } from '@/core/utils/fieldVisibility' import { NodeInputHandler } from './NodeInputHandler' @@ -14,17 +13,10 @@ export interface ArrayInputProps { data: NodeData disabled?: boolean onDataChange?: (params: { inputParam: InputParam; newValue: unknown }) => void + itemParameters?: InputParam[][] } -/** - * Array input component for managing lists of structured data - * - * @param inputParam - Array field definition with structure - * @param data - Node data containing inputValues - * @param onDataChange - Callback invoked when array is modified - * @param disabled - Whether the input is disabled - */ -export function ArrayInput({ inputParam, data, disabled = false, onDataChange }: ArrayInputProps) { +export function ArrayInput({ inputParam, data, disabled = false, onDataChange, itemParameters: itemParametersProp }: ArrayInputProps) { const theme = useTheme() // Derive array items directly from props (single source of truth) @@ -34,10 +26,11 @@ export function ArrayInput({ inputParam, data, disabled = false, onDataChange }: [data.inputValues, inputParam.name] ) - // Derive item parameters for each array item - const itemParameters = useMemo( - () => arrayItems.map((itemValues, index) => evaluateFieldVisibility(inputParam.array || [], itemValues, index)), - [arrayItems, inputParam.array] + // 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 diff --git a/packages/agentflow/src/atoms/NodeInputHandler.tsx b/packages/agentflow/src/atoms/NodeInputHandler.tsx index 046f09db427..c68b4c082c5 100644 --- a/packages/agentflow/src/atoms/NodeInputHandler.tsx +++ b/packages/agentflow/src/atoms/NodeInputHandler.tsx @@ -24,6 +24,7 @@ export interface NodeInputHandlerProps { isAdditionalParams?: boolean disablePadding?: boolean onDataChange?: (params: { inputParam: InputParam; newValue: unknown }) => void + itemParameters?: InputParam[][] } /** @@ -37,7 +38,8 @@ export function NodeInputHandler({ disabled = false, isAdditionalParams = false, disablePadding = false, - onDataChange + onDataChange, + itemParameters }: NodeInputHandlerProps) { const theme = useTheme() const ref = useRef(null) @@ -119,7 +121,15 @@ export function NodeInputHandler({ ) case 'array': - return + return ( + + ) default: // For unsupported types, render a basic text field diff --git a/packages/agentflow/src/features/node-editor/EditNodeDialog.test.tsx b/packages/agentflow/src/features/node-editor/EditNodeDialog.test.tsx index bfde6f989ec..4bb51b20bb2 100644 --- a/packages/agentflow/src/features/node-editor/EditNodeDialog.test.tsx +++ b/packages/agentflow/src/features/node-editor/EditNodeDialog.test.tsx @@ -27,18 +27,20 @@ jest.mock('@/atoms', () => ({ NodeInputHandler: ({ inputParam, onDataChange, - data + 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 ( -
+