diff --git a/packages/agentflow/TESTS.md b/packages/agentflow/TESTS.md index c9c333a8a..bf8aae2ec 100644 --- a/packages/agentflow/TESTS.md +++ b/packages/agentflow/TESTS.md @@ -34,7 +34,7 @@ These modules carry the highest risk. Test in the same PR when modifying. | `src/infrastructure/api/client.ts` | `createApiClient` — headers, auth token, 401 interceptor | ✅ Done | | `src/infrastructure/api/chatflows.ts` | All CRUD + `generateAgentflow` + `getChatModels`, FlowData serialization | ✅ Done | | `src/infrastructure/api/nodes.ts` | `getAllNodes`, `getNodeByName`, `getNodeIconUrl` | ✅ Done | -| `src/infrastructure/store/AgentflowContext.tsx` | `agentflowReducer` (all actions), `normalizeNodes`. Remaining: `deleteNode()`, `duplicateNode()`, `updateNodeData()`, `getFlowData()` | 🟡 Partial | +| `src/infrastructure/store/AgentflowContext.tsx` | `agentflowReducer` (all actions), `normalizeNodes`, `deleteNode()`, `duplicateNode()`. Remaining: `updateNodeData()`, `getFlowData()` | 🟡 Partial| | `src/useAgentflow.ts` | `getFlow()`, `toJSON()`, `validate()`, `addNode()`, `clear()` | ⬜ Not yet — thin wrapper | | `src/features/canvas/hooks/useFlowHandlers.ts` | `handleConnect`, `handleNodesChange`, `handleEdgesChange`, `handleAddNode` — synchronous `onFlowChange` callbacks, dirty tracking, viewport resolution, change filtering | ✅ Done | @@ -130,6 +130,7 @@ Key features: - `./src/features/canvas/hooks/useFlowHandlers.ts` - `./src/features/node-palette/search.ts` - `./src/infrastructure/api/` + - ⏳ `./src/infrastructure/store/AgentflowContext.tsx` — will be added when coverage reaches 80% - **Coverage exclusions**: - `src/__test_utils__/**` — test utilities - `src/__mocks__/**` — module mocks diff --git a/packages/agentflow/jest.config.js b/packages/agentflow/jest.config.js index 1030ed450..644e67b63 100644 --- a/packages/agentflow/jest.config.js +++ b/packages/agentflow/jest.config.js @@ -40,7 +40,8 @@ module.exports = { './src/core/': { branches: 80, functions: 80, lines: 80, statements: 80 }, './src/features/canvas/hooks/useFlowHandlers.ts': { branches: 80, functions: 80, lines: 80, statements: 80 }, './src/features/node-palette/search.ts': { branches: 80, functions: 80, lines: 80, statements: 80 }, - './src/infrastructure/api/': { branches: 80, functions: 80, lines: 80, statements: 80 } + './src/infrastructure/api/': { branches: 80, functions: 80, lines: 80, statements: 80 }, + './src/infrastructure/store/AgentflowContext.tsx': { branches: 80, functions: 80, lines: 80, statements: 80 } }, projects: [ // .test.ts → node (fast, no DOM) diff --git a/packages/agentflow/src/Agentflow.tsx b/packages/agentflow/src/Agentflow.tsx index c44521df5..c357010c7 100644 --- a/packages/agentflow/src/Agentflow.tsx +++ b/packages/agentflow/src/Agentflow.tsx @@ -51,7 +51,15 @@ function AgentflowCanvas({ renderHeader?: AgentflowProps['renderHeader'] renderNodePalette?: AgentflowProps['renderNodePalette'] }) { - const { state, setNodes, setEdges, setDirty, setReactFlowInstance, closeEditDialog, registerLocalStateSetters } = useAgentflowContext() + const { + state, + syncNodesFromReactFlow, + syncEdgesFromReactFlow, + setDirty, + setReactFlowInstance, + closeEditDialog, + registerLocalStateSetters + } = useAgentflowContext() const { isDarkMode } = useConfigContext() const agentflow = useAgentflow() const reactFlowWrapper = useRef(null) @@ -82,12 +90,12 @@ function AgentflowCanvas({ // Sync local ReactFlow state to context (when user interacts with canvas) useEffect(() => { - setNodes(nodes as FlowNode[]) - }, [nodes, setNodes]) + syncNodesFromReactFlow(nodes as FlowNode[]) + }, [nodes, syncNodesFromReactFlow]) useEffect(() => { - setEdges(edges as FlowEdge[]) - }, [edges, setEdges]) + syncEdgesFromReactFlow(edges as FlowEdge[]) + }, [edges, syncEdgesFromReactFlow]) // Flow handlers const { handleConnect, handleNodesChange, handleNodeDragStop, handleEdgesChange, handleAddNode } = useFlowHandlers({ diff --git a/packages/agentflow/src/infrastructure/store/AgentflowContext.test.tsx b/packages/agentflow/src/infrastructure/store/AgentflowContext.test.tsx new file mode 100644 index 000000000..bb90d9bb9 --- /dev/null +++ b/packages/agentflow/src/infrastructure/store/AgentflowContext.test.tsx @@ -0,0 +1,1060 @@ +import { ReactNode } from 'react' + +import { makeFlowEdge, makeFlowNode } from '@test-utils/factories' +import { act, renderHook } from '@testing-library/react' + +import type { FlowData, FlowEdge, FlowNode } from '@/core/types' + +import { AgentflowStateProvider, useAgentflowContext } from './AgentflowContext' + +const makeNode = (id: string, type = 'agentFlow') => makeFlowNode(id, { type }) +const makeEdge = makeFlowEdge + +// Wrapper component for testing +const createWrapper = (initialFlow?: FlowData) => { + function Wrapper({ children }: { children: ReactNode }) { + return {children} + } + return Wrapper +} + +describe('AgentflowContext - deleteNode', () => { + it('should remove node from the nodes array', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1'), makeNode('node-2'), makeNode('node-3')], + edges: [] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + // Initial state should have 3 nodes + expect(result.current.state.nodes).toHaveLength(3) + expect(result.current.state.nodes.map((n) => n.id)).toEqual(['node-1', 'node-2', 'node-3']) + + // Delete node-2 + act(() => { + result.current.deleteNode('node-2') + }) + + // Should have 2 nodes remaining + expect(result.current.state.nodes).toHaveLength(2) + expect(result.current.state.nodes.map((n) => n.id)).toEqual(['node-1', 'node-3']) + expect(result.current.state.nodes.find((n) => n.id === 'node-2')).toBeUndefined() + }) + + it('should remove connected edges when node is deleted', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1'), makeNode('node-2'), makeNode('node-3'), makeNode('node-4')], + edges: [ + makeEdge('node-1', 'node-2', { id: 'edge-1-2' }), + makeEdge('node-2', 'node-3', { id: 'edge-2-3' }), + makeEdge('node-3', 'node-4', { id: 'edge-3-4' }) + ] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + // Initial state should have 3 edges + expect(result.current.state.edges).toHaveLength(3) + + // Delete node-2 (which is connected to node-1 and node-3) + act(() => { + result.current.deleteNode('node-2') + }) + + // Should remove edges where node-2 is source or target + expect(result.current.state.edges).toHaveLength(1) + expect(result.current.state.edges[0].id).toBe('edge-3-4') + expect(result.current.state.edges.find((e) => e.id === 'edge-1-2')).toBeUndefined() + expect(result.current.state.edges.find((e) => e.id === 'edge-2-3')).toBeUndefined() + }) + + it('should remove edges where deleted node is the source', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1'), makeNode('node-2'), makeNode('node-3')], + edges: [makeEdge('node-1', 'node-2', { id: 'edge-1-2' }), makeEdge('node-1', 'node-3', { id: 'edge-1-3' })] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + expect(result.current.state.edges).toHaveLength(2) + + // Delete node-1 (which is the source for both edges) + act(() => { + result.current.deleteNode('node-1') + }) + + // All edges from node-1 should be removed + expect(result.current.state.edges).toHaveLength(0) + }) + + it('should remove edges where deleted node is the target', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1'), makeNode('node-2'), makeNode('node-3')], + edges: [makeEdge('node-1', 'node-3', { id: 'edge-1-3' }), makeEdge('node-2', 'node-3', { id: 'edge-2-3' })] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + expect(result.current.state.edges).toHaveLength(2) + + // Delete node-3 (which is the target for both edges) + act(() => { + result.current.deleteNode('node-3') + }) + + // All edges to node-3 should be removed + expect(result.current.state.edges).toHaveLength(0) + }) + + it('should mark state as dirty when node is deleted', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1'), makeNode('node-2')], + edges: [] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + // Initial state should not be dirty + expect(result.current.state.isDirty).toBe(false) + + // Delete a node + act(() => { + result.current.deleteNode('node-1') + }) + + // State should be marked as dirty + expect(result.current.state.isDirty).toBe(true) + }) + + it('should call local state setters when registered', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1'), makeNode('node-2')], + edges: [makeEdge('node-1', 'node-2', { id: 'edge-1-2' })] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + // Create mock local state setters + const mockSetLocalNodes = jest.fn() + const mockSetLocalEdges = jest.fn() + + // Register the local state setters + act(() => { + result.current.registerLocalStateSetters(mockSetLocalNodes, mockSetLocalEdges) + }) + + // Delete a node + act(() => { + result.current.deleteNode('node-1') + }) + + // Local state setters should be called with updated nodes and edges + expect(mockSetLocalNodes).toHaveBeenCalledTimes(1) + expect(mockSetLocalNodes).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ id: 'node-2' })])) + expect(mockSetLocalNodes).toHaveBeenCalledWith(expect.not.arrayContaining([expect.objectContaining({ id: 'node-1' })])) + + expect(mockSetLocalEdges).toHaveBeenCalledTimes(1) + expect(mockSetLocalEdges).toHaveBeenCalledWith([]) + }) + + it('should handle deleting multiple nodes sequentially', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1'), makeNode('node-2'), makeNode('node-3')], + edges: [makeEdge('node-1', 'node-2', { id: 'edge-1-2' }), makeEdge('node-2', 'node-3', { id: 'edge-2-3' })] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + // Delete first node + act(() => { + result.current.deleteNode('node-1') + }) + + expect(result.current.state.nodes).toHaveLength(2) + expect(result.current.state.edges).toHaveLength(1) + expect(result.current.state.edges[0].id).toBe('edge-2-3') + + // Delete second node + act(() => { + result.current.deleteNode('node-2') + }) + + expect(result.current.state.nodes).toHaveLength(1) + expect(result.current.state.nodes[0].id).toBe('node-3') + expect(result.current.state.edges).toHaveLength(0) + }) + + it('should preserve other nodes and edges when deleting a node', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1'), makeNode('node-2'), makeNode('node-3'), makeNode('node-4')], + edges: [makeEdge('node-1', 'node-2', { id: 'edge-1-2' }), makeEdge('node-3', 'node-4', { id: 'edge-3-4' })] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + // Delete node-2 (connected to node-1) + act(() => { + result.current.deleteNode('node-2') + }) + + // Should preserve node-3, node-4 and their edge + expect(result.current.state.nodes).toHaveLength(3) + expect(result.current.state.nodes.map((n) => n.id)).toEqual(['node-1', 'node-3', 'node-4']) + + expect(result.current.state.edges).toHaveLength(1) + expect(result.current.state.edges[0].id).toBe('edge-3-4') + }) +}) + +describe('AgentflowContext - duplicateNode', () => { + it('should create a duplicate node with unique ID', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1'), makeNode('node-2')], + edges: [] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + // Initial state should have 2 nodes + expect(result.current.state.nodes).toHaveLength(2) + + // Duplicate node-1 + act(() => { + result.current.duplicateNode('node-1') + }) + + // Should have 3 nodes + expect(result.current.state.nodes).toHaveLength(3) + + // Find the duplicated node + const duplicatedNode = result.current.state.nodes.find((n) => n.id.startsWith('node-1_copy_')) + + expect(duplicatedNode).toBeDefined() + expect(duplicatedNode?.id).toBe('node-1_copy_1') + }) + + it('should position duplicate with +50 offset', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1')], + edges: [] + } + + const originalNode = initialFlow.nodes[0] + originalNode.position = { x: 100, y: 200 } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + // Duplicate the node + act(() => { + result.current.duplicateNode('node-1') + }) + + // Find the duplicated node + const duplicatedNode = result.current.state.nodes.find((n) => n.id.startsWith('node-1_copy_')) + + expect(duplicatedNode?.position.x).toBe(150) + expect(duplicatedNode?.position.y).toBe(250) + }) + + it('should preserve all node data properties', () => { + const initialFlow: FlowData = { + nodes: [ + makeFlowNode('node-1', { + type: 'customType', + data: { + id: 'node-1', + name: 'testNode', + label: 'Test Node', + color: '#FF0000', + category: 'test', + description: 'Test description' + } + }) + ], + edges: [] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + // Duplicate the node + act(() => { + result.current.duplicateNode('node-1') + }) + + // Find the duplicated node + const duplicatedNode = result.current.state.nodes.find((n) => n.id.startsWith('node-1_copy_')) + + // Should preserve data properties (except id) + expect(duplicatedNode?.data.name).toBe('testNode') + expect(duplicatedNode?.data.label).toBe('Test Node') + expect(duplicatedNode?.data.color).toBe('#FF0000') + expect(duplicatedNode?.data.category).toBe('test') + expect(duplicatedNode?.data.description).toBe('Test description') + }) + + it('should preserve node type', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1', 'stickyNote')], + edges: [] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + // Duplicate the node + act(() => { + result.current.duplicateNode('node-1') + }) + + // Find the duplicated node + const duplicatedNode = result.current.state.nodes.find((n) => n.id.startsWith('node-1_copy_')) + + expect(duplicatedNode?.type).toBe('stickyNote') + }) + + it('should mark state as dirty after duplication', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1')], + edges: [] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + // Initial state should not be dirty + expect(result.current.state.isDirty).toBe(false) + + // Duplicate a node + act(() => { + result.current.duplicateNode('node-1') + }) + + // State should be marked as dirty + expect(result.current.state.isDirty).toBe(true) + }) + + it('should preserve original node unchanged', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1')], + edges: [] + } + + const originalNode = initialFlow.nodes[0] + originalNode.position = { x: 100, y: 200 } + originalNode.data.label = 'Original Label' + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + // Duplicate the node + act(() => { + result.current.duplicateNode('node-1') + }) + + // Find the original node + const originalNodeAfter = result.current.state.nodes.find((n) => n.id === 'node-1') + + // Original should be unchanged + expect(originalNodeAfter?.position.x).toBe(100) + expect(originalNodeAfter?.position.y).toBe(200) + expect(originalNodeAfter?.data.label).toBe('Original Label') + expect(originalNodeAfter?.data.id).toBe('node-1') + }) + + it('should handle multiple sequential duplications with unique IDs', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1'), makeNode('node-2')], + edges: [] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + act(() => { + result.current.duplicateNode('node-1') + }) + + act(() => { + result.current.duplicateNode('node-2') + }) + + // Should have 4 nodes (2 originals + 2 duplicates) + expect(result.current.state.nodes).toHaveLength(4) + + // All IDs should be unique + const ids = result.current.state.nodes.map((n) => n.id) + const uniqueIds = new Set(ids) + expect(uniqueIds.size).toBe(4) + + // Should have one duplicate of each original + const node1Duplicates = result.current.state.nodes.filter((n) => n.id.startsWith('node-1_copy_')) + const node2Duplicates = result.current.state.nodes.filter((n) => n.id.startsWith('node-2_copy_')) + + expect(node1Duplicates).toHaveLength(1) + expect(node2Duplicates).toHaveLength(1) + + // Each duplicate should have matching node.id and data.id + expect(node1Duplicates[0].id).toBe(node1Duplicates[0].data.id) + expect(node2Duplicates[0].id).toBe(node2Duplicates[0].data.id) + }) + + it('should NOT duplicate connected edges', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1'), makeNode('node-2'), makeNode('node-3')], + edges: [makeEdge('node-1', 'node-2', { id: 'edge-1-2' }), makeEdge('node-2', 'node-3', { id: 'edge-2-3' })] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + // Initial state should have 2 edges + expect(result.current.state.edges).toHaveLength(2) + + // Duplicate node-2 (which has incoming and outgoing edges) + act(() => { + result.current.duplicateNode('node-2') + }) + + // Should still have only 2 edges (edges not duplicated) + expect(result.current.state.edges).toHaveLength(2) + expect(result.current.state.edges[0].id).toBe('edge-1-2') + expect(result.current.state.edges[1].id).toBe('edge-2-3') + }) + + it('should generate sequential unique IDs for duplicates', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1')], + edges: [] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + // Duplicate the node once + act(() => { + result.current.duplicateNode('node-1') + }) + + // Find the first duplicated node + const firstDuplicate = result.current.state.nodes.find((n) => n.id === 'node-1_copy_1') + expect(firstDuplicate).toBeDefined() + + // Duplicate the original node again + act(() => { + result.current.duplicateNode('node-1') + }) + + // Find the second duplicated node + const secondDuplicate = result.current.state.nodes.find((n) => n.id === 'node-1_copy_2') + expect(secondDuplicate).toBeDefined() + + // Should have 3 nodes total (original + 2 duplicates) + expect(result.current.state.nodes).toHaveLength(3) + }) +}) + +describe('AgentflowContext - openEditDialog & closeEditDialog', () => { + it('should open edit dialog with node data and input params', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1')], + edges: [] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + // Initial state should have no editing node + expect(result.current.state.editingNodeId).toBeNull() + expect(result.current.state.editDialogProps).toBeNull() + + const nodeData = { + id: 'node-1', + name: 'testNode', + label: 'Test Node', + outputAnchors: [] + } + + const inputParams = [ + { + id: 'param-1', + name: 'param1', + label: 'Parameter 1', + type: 'string' + } + ] + + // Open edit dialog + act(() => { + result.current.openEditDialog('node-1', nodeData, inputParams) + }) + + // Should set editingNodeId + expect(result.current.state.editingNodeId).toBe('node-1') + + // Should set editDialogProps + expect(result.current.state.editDialogProps).toEqual({ + inputParams: inputParams, + data: nodeData, + disabled: false + }) + }) + + it('should close edit dialog and clear state', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1')], + edges: [] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + const nodeData = { + id: 'node-1', + name: 'testNode', + label: 'Test Node', + outputAnchors: [] + } + + const inputParams = [ + { + id: 'param-1', + name: 'param1', + label: 'Parameter 1', + type: 'string' + } + ] + + // First open the dialog + act(() => { + result.current.openEditDialog('node-1', nodeData, inputParams) + }) + + // Verify dialog is open + expect(result.current.state.editingNodeId).toBe('node-1') + expect(result.current.state.editDialogProps).not.toBeNull() + + // Close the dialog + act(() => { + result.current.closeEditDialog() + }) + + // Should clear editingNodeId + expect(result.current.state.editingNodeId).toBeNull() + + // Should clear editDialogProps + expect(result.current.state.editDialogProps).toBeNull() + }) + + it('should handle opening dialog for different nodes', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1'), makeNode('node-2')], + edges: [] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + const nodeData1 = { + id: 'node-1', + name: 'testNode1', + label: 'Test Node 1', + outputAnchors: [] + } + + const nodeData2 = { + id: 'node-2', + name: 'testNode2', + label: 'Test Node 2', + outputAnchors: [] + } + + const inputParams = [ + { + id: 'param-1', + name: 'param1', + label: 'Parameter 1', + type: 'string' + } + ] + + // Open dialog for node-1 + act(() => { + result.current.openEditDialog('node-1', nodeData1, inputParams) + }) + + expect(result.current.state.editingNodeId).toBe('node-1') + expect(result.current.state.editDialogProps).not.toBeNull() + expect(result.current.state.editDialogProps!.data).toBeDefined() + expect(result.current.state.editDialogProps!.data!.label).toBe('Test Node 1') + + // Open dialog for node-2 (should replace node-1) + act(() => { + result.current.openEditDialog('node-2', nodeData2, inputParams) + }) + + expect(result.current.state.editingNodeId).toBe('node-2') + expect(result.current.state.editDialogProps).not.toBeNull() + expect(result.current.state.editDialogProps!.data).toBeDefined() + expect(result.current.state.editDialogProps!.data!.label).toBe('Test Node 2') + }) + + it('should set disabled to false in dialog props', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1')], + edges: [] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + const nodeData = { + id: 'node-1', + name: 'testNode', + label: 'Test Node', + outputAnchors: [] + } + + act(() => { + result.current.openEditDialog('node-1', nodeData, []) + }) + + // disabled should always be false + expect(result.current.state.editDialogProps?.disabled).toBe(false) + }) + + it('should preserve inputParams in dialog props', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1')], + edges: [] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + const nodeData = { + id: 'node-1', + name: 'testNode', + label: 'Test Node', + outputAnchors: [] + } + + const inputParams = [ + { + id: 'param-1', + name: 'param1', + label: 'Parameter 1', + type: 'string', + optional: true + }, + { + id: 'param-2', + name: 'param2', + label: 'Parameter 2', + type: 'number', + default: 42 + } + ] + + act(() => { + result.current.openEditDialog('node-1', nodeData, inputParams) + }) + + // Should preserve all input params with their properties + expect(result.current.state.editDialogProps).not.toBeNull() + expect(result.current.state.editDialogProps!.inputParams).toEqual(inputParams) + expect(result.current.state.editDialogProps!.inputParams).toHaveLength(2) + + const params = result.current.state.editDialogProps!.inputParams! + expect(params[0]).toBeDefined() + expect(params[0]!.optional).toBe(true) + expect(params[1]).toBeDefined() + expect(params[1]!.default).toBe(42) + }) +}) + +describe('AgentflowContext - state synchronization', () => { + it('should call local state setters for setNodes', () => { + const initialFlow: FlowData = { + nodes: [ + { + id: 'node-1', + type: 'agentflowNode', + position: { x: 100, y: 100 }, + data: { + id: 'node-1', + name: 'Node 1', + label: 'Node 1', + outputAnchors: [] + } + } + ], + edges: [] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + const mockSetLocalNodes = jest.fn() + const mockSetLocalEdges = jest.fn() + + act(() => { + result.current.registerLocalStateSetters(mockSetLocalNodes, mockSetLocalEdges) + }) + + const newNodes: FlowNode[] = [ + { + id: 'node-2', + type: 'agentflowNode', + position: { x: 200, y: 200 }, + data: { + id: 'node-2', + name: 'Node 2', + label: 'Node 2', + outputAnchors: [] + } + }, + { + id: 'node-3', + type: 'agentflowNode', + position: { x: 300, y: 300 }, + data: { + id: 'node-3', + name: 'Node 3', + label: 'Node 3', + outputAnchors: [] + } + } + ] + + act(() => { + result.current.setNodes(newNodes) + }) + + // Verify local state setter was called + expect(mockSetLocalNodes).toHaveBeenCalledTimes(1) + expect(mockSetLocalNodes).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ id: 'node-2' }), expect.objectContaining({ id: 'node-3' })]) + ) + + // Verify context state was updated + expect(result.current.state.nodes).toHaveLength(2) + expect(result.current.state.nodes[0].id).toBe('node-2') + expect(result.current.state.nodes[1].id).toBe('node-3') + }) + + it('should call local state setters for setEdges', () => { + const initialFlow: FlowData = { + nodes: [ + { + id: 'node-1', + type: 'agentflowNode', + position: { x: 100, y: 100 }, + data: { + id: 'node-1', + name: 'Node 1', + label: 'Node 1', + outputAnchors: [] + } + }, + { + id: 'node-2', + type: 'agentflowNode', + position: { x: 200, y: 200 }, + data: { + id: 'node-2', + name: 'Node 2', + label: 'Node 2', + outputAnchors: [] + } + } + ], + edges: [] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + const mockSetLocalNodes = jest.fn() + const mockSetLocalEdges = jest.fn() + + act(() => { + result.current.registerLocalStateSetters(mockSetLocalNodes, mockSetLocalEdges) + }) + + const newEdges: FlowEdge[] = [ + { + id: 'edge-1', + source: 'node-1', + target: 'node-2', + type: 'agentflowEdge' + }, + { + id: 'edge-2', + source: 'node-2', + target: 'node-1', + type: 'agentflowEdge' + } + ] + + act(() => { + result.current.setEdges(newEdges) + }) + + // Verify local state setter was called + expect(mockSetLocalEdges).toHaveBeenCalledTimes(1) + expect(mockSetLocalEdges).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ id: 'edge-1' }), expect.objectContaining({ id: 'edge-2' })]) + ) + + // Verify context state was updated + expect(result.current.state.edges).toHaveLength(2) + expect(result.current.state.edges[0].id).toBe('edge-1') + expect(result.current.state.edges[1].id).toBe('edge-2') + }) + + it('should call local state setters for updateNodeData', () => { + const initialFlow: FlowData = { + nodes: [ + { + id: 'node-1', + type: 'agentflowNode', + position: { x: 100, y: 100 }, + data: { + id: 'node-1', + name: 'Node 1', + label: 'Node 1', + outputAnchors: [] + } + }, + { + id: 'node-2', + type: 'agentflowNode', + position: { x: 200, y: 200 }, + data: { + id: 'node-2', + name: 'Node 2', + label: 'Node 2', + outputAnchors: [] + } + } + ], + edges: [] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + const mockSetLocalNodes = jest.fn() + const mockSetLocalEdges = jest.fn() + + act(() => { + result.current.registerLocalStateSetters(mockSetLocalNodes, mockSetLocalEdges) + }) + + const updatedData = { + label: 'Updated Node 1', + name: 'updated-node-1' + } + + act(() => { + result.current.updateNodeData('node-1', updatedData) + }) + + // Verify local state setter was called + expect(mockSetLocalNodes).toHaveBeenCalledTimes(1) + expect(mockSetLocalNodes).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + id: 'node-1', + data: expect.objectContaining({ + label: 'Updated Node 1', + name: 'updated-node-1' + }) + }), + expect.objectContaining({ id: 'node-2' }) + ]) + ) + + // Verify context state was updated + const updatedNode = result.current.state.nodes.find((n) => n.id === 'node-1') + expect(updatedNode?.data.label).toBe('Updated Node 1') + expect(updatedNode?.data.name).toBe('updated-node-1') + + // Verify other node was not affected + const otherNode = result.current.state.nodes.find((n) => n.id === 'node-2') + expect(otherNode?.data.label).toBe('Node 2') + }) + + it('should call local state setters for deleteEdge', () => { + const initialFlow: FlowData = { + nodes: [ + { + id: 'node-1', + type: 'agentflowNode', + position: { x: 100, y: 100 }, + data: { + id: 'node-1', + name: 'Node 1', + label: 'Node 1', + outputAnchors: [] + } + }, + { + id: 'node-2', + type: 'agentflowNode', + position: { x: 200, y: 200 }, + data: { + id: 'node-2', + name: 'Node 2', + label: 'Node 2', + outputAnchors: [] + } + } + ], + edges: [ + { + id: 'edge-1', + source: 'node-1', + target: 'node-2', + type: 'agentflowEdge' + }, + { + id: 'edge-2', + source: 'node-2', + target: 'node-1', + type: 'agentflowEdge' + } + ] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + const mockSetLocalNodes = jest.fn() + const mockSetLocalEdges = jest.fn() + + act(() => { + result.current.registerLocalStateSetters(mockSetLocalNodes, mockSetLocalEdges) + }) + + act(() => { + result.current.deleteEdge('edge-1') + }) + + // Verify local state setter was called + expect(mockSetLocalEdges).toHaveBeenCalledTimes(1) + expect(mockSetLocalEdges).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ id: 'edge-2' })])) + + // Verify context state was updated + expect(result.current.state.edges).toHaveLength(1) + expect(result.current.state.edges[0].id).toBe('edge-2') + }) + + it('should synchronize state for combined operations', () => { + const initialFlow: FlowData = { + nodes: [ + { + id: 'node-1', + type: 'agentflowNode', + position: { x: 100, y: 100 }, + data: { + id: 'node-1', + name: 'Node 1', + label: 'Node 1', + outputAnchors: [] + } + } + ], + edges: [] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + const mockSetLocalNodes = jest.fn() + const mockSetLocalEdges = jest.fn() + + act(() => { + result.current.registerLocalStateSetters(mockSetLocalNodes, mockSetLocalEdges) + }) + + // 1. Add a new node via setNodes + act(() => { + result.current.setNodes([ + ...result.current.state.nodes, + { + id: 'node-2', + type: 'agentflowNode', + position: { x: 200, y: 200 }, + data: { + id: 'node-2', + name: 'Node 2', + label: 'Node 2', + outputAnchors: [] + } + } + ]) + }) + + expect(mockSetLocalNodes).toHaveBeenCalledTimes(1) + + // 2. Duplicate node-1 + act(() => { + result.current.duplicateNode('node-1') + }) + + expect(mockSetLocalNodes).toHaveBeenCalledTimes(2) + + // 3. Update node-2 data + act(() => { + result.current.updateNodeData('node-2', { label: 'Updated Node 2' }) + }) + + expect(mockSetLocalNodes).toHaveBeenCalledTimes(3) + + // Verify final state + expect(result.current.state.nodes).toHaveLength(3) + expect(result.current.state.nodes.find((n) => n.id === 'node-1')).toBeDefined() + expect(result.current.state.nodes.find((n) => n.id === 'node-2')?.data.label).toBe('Updated Node 2') + expect(result.current.state.nodes.find((n) => n.id === 'node-1_copy_1')).toBeDefined() + }) +}) diff --git a/packages/agentflow/src/infrastructure/store/AgentflowContext.tsx b/packages/agentflow/src/infrastructure/store/AgentflowContext.tsx index 0622d0e00..6742492c7 100644 --- a/packages/agentflow/src/infrastructure/store/AgentflowContext.tsx +++ b/packages/agentflow/src/infrastructure/store/AgentflowContext.tsx @@ -5,14 +5,20 @@ import type { AgentflowAction, AgentflowState, FlowConfig, FlowData, FlowEdge, F import { agentflowReducer, initialState, normalizeNodes } from './agentflowReducer' +// Local state setter types +type NodesSetter = (nodes: FlowNode[]) => void +type EdgesSetter = (edges: FlowEdge[]) => void + // Context value interface export interface AgentflowContextValue { state: AgentflowState dispatch: Dispatch // Convenience methods - setNodes: (nodes: FlowNode[]) => void - setEdges: (edges: FlowEdge[]) => void + setNodes: NodesSetter + setEdges: EdgesSetter + syncNodesFromReactFlow: NodesSetter + syncEdgesFromReactFlow: EdgesSetter setChatflow: (chatflow: FlowConfig | null) => void setDirty: (dirty: boolean) => void setReactFlowInstance: (instance: ReactFlowInstance | null) => void @@ -34,7 +40,7 @@ export interface AgentflowContextValue { closeEditDialog: () => void // Register ReactFlow local state setters - registerLocalStateSetters: (setLocalNodes: (nodes: FlowNode[]) => void, setLocalEdges: (edges: FlowEdge[]) => void) => void + registerLocalStateSetters: (setLocalNodes: NodesSetter, setLocalEdges: EdgesSetter) => void } const AgentflowContext = createContext(null) @@ -52,16 +58,26 @@ export function AgentflowStateProvider({ children, initialFlow }: AgentflowState }) // Store ReactFlow local state setters in refs which are populated by AgentflowCanvas - const localNodesSetterRef = useRef<((nodes: FlowNode[]) => void) | null>(null) - const localEdgesSetterRef = useRef<((edges: FlowEdge[]) => void) | null>(null) + const localNodesSetterRef = useRef(null) + const localEdgesSetterRef = useRef(null) - const registerLocalStateSetters = useCallback( - (setLocalNodes: (nodes: FlowNode[]) => void, setLocalEdges: (edges: FlowEdge[]) => void) => { - localNodesSetterRef.current = setLocalNodes - localEdgesSetterRef.current = setLocalEdges - }, - [] - ) + const registerLocalStateSetters = useCallback((setLocalNodes: NodesSetter, setLocalEdges: EdgesSetter) => { + localNodesSetterRef.current = setLocalNodes + localEdgesSetterRef.current = setLocalEdges + }, []) + + // Helper function to generate unique copy IDs + const getUniqueCopyId = useCallback((baseId: string, nodes: FlowNode[]): string => { + const existingIds = new Set(nodes.map((node) => node.id)) + for (let i = 1; i < Number.MAX_SAFE_INTEGER; i++) { + const newId = `${baseId}_copy_${i}` + if (!existingIds.has(newId)) { + return newId + } + } + //Fallback + return `${baseId}_copy_${Date.now()}` + }, []) // Helper function to synchronize state updates between context and ReactFlow const syncStateUpdate = useCallback(({ nodes, edges }: { nodes?: FlowNode[]; edges?: FlowEdge[] }) => { @@ -80,11 +96,25 @@ export function AgentflowStateProvider({ children, initialFlow }: AgentflowState }, []) // Convenience setters - const setNodes = useCallback((nodes: FlowNode[]) => { - dispatch({ type: 'SET_NODES', payload: nodes }) + const setNodes = useCallback( + (nodes) => { + syncStateUpdate({ nodes: nodes }) + }, + [syncStateUpdate] + ) + + const setEdges = useCallback( + (edges) => { + syncStateUpdate({ edges: edges }) + }, + [syncStateUpdate] + ) + + const syncNodesFromReactFlow = useCallback((nodes) => { + dispatch({ type: 'SET_NODES', payload: normalizeNodes(nodes) }) }, []) - const setEdges = useCallback((edges: FlowEdge[]) => { + const syncEdgesFromReactFlow = useCallback((edges) => { dispatch({ type: 'SET_EDGES', payload: edges }) }, []) @@ -116,20 +146,24 @@ export function AgentflowStateProvider({ children, initialFlow }: AgentflowState const nodeToDuplicate = state.nodes.find((node) => node.id === nodeId) if (!nodeToDuplicate) return + const newNodeId = getUniqueCopyId(nodeToDuplicate.id, state.nodes) const newNode: FlowNode = { ...nodeToDuplicate, - id: `${nodeToDuplicate.id}_copy_${Date.now()}`, + id: newNodeId, position: { x: nodeToDuplicate.position.x + 50, y: nodeToDuplicate.position.y + 50 }, - data: { ...nodeToDuplicate.data } + data: { + ...nodeToDuplicate.data, + id: newNodeId + } } const newNodes = [...state.nodes, newNode] syncStateUpdate({ nodes: newNodes }) }, - [state.nodes, syncStateUpdate] + [state.nodes, syncStateUpdate, getUniqueCopyId] ) const updateNodeData = useCallback( @@ -198,6 +232,8 @@ export function AgentflowStateProvider({ children, initialFlow }: AgentflowState dispatch, setNodes, setEdges, + syncNodesFromReactFlow, + syncEdgesFromReactFlow, setChatflow, setDirty, setReactFlowInstance,