diff --git a/packages/agentflow/examples/src/demos/DarkModeExample.tsx b/packages/agentflow/examples/src/demos/DarkModeExample.tsx index 95f176885..d9cad29d2 100644 --- a/packages/agentflow/examples/src/demos/DarkModeExample.tsx +++ b/packages/agentflow/examples/src/demos/DarkModeExample.tsx @@ -39,7 +39,7 @@ const sampleFlow: FlowData = { label: 'AI Assistant', color: '#4DD0E1', outputAnchors: [{ id: 'agentAgentflow_0-output-0', name: 'output', label: 'Output', type: 'string' }], - inputs: { + inputValues: { agentModel: 'chatAnthropic', agentModelConfig: { modelName: 'claude-3-5-sonnet' diff --git a/packages/agentflow/examples/src/demos/MultiNodeFlow.tsx b/packages/agentflow/examples/src/demos/MultiNodeFlow.tsx index 79f1d00ad..23155fe60 100644 --- a/packages/agentflow/examples/src/demos/MultiNodeFlow.tsx +++ b/packages/agentflow/examples/src/demos/MultiNodeFlow.tsx @@ -38,7 +38,7 @@ const translationFlow: FlowData = { label: 'Translator', color: '#64B5F6', outputAnchors: [{ id: 'llmAgentflow_0-output-0', name: 'output', label: 'Output', type: 'string' }], - inputs: { + inputValues: { llmModel: 'chatGoogleGenerativeAI', llmModelConfig: { modelName: 'gemini-2.0-flash' diff --git a/packages/agentflow/src/Agentflow.test.tsx b/packages/agentflow/src/Agentflow.test.tsx index 0eae49ac1..e0cacf8e5 100644 --- a/packages/agentflow/src/Agentflow.test.tsx +++ b/packages/agentflow/src/Agentflow.test.tsx @@ -274,12 +274,24 @@ describe('Agentflow Component', () => { it('should accept onSave callback', async () => { const onSave = jest.fn() - const { container } = render() + const { container, getByText } = render() await waitFor(() => { expect(container.querySelector('.agentflow-container')).toBeInTheDocument() }) - // Callback should be registered + + // Find and click the save button + const saveButton = getByText('Save') + fireEvent.click(saveButton) + + // Verify the callback was called + expect(onSave).toHaveBeenCalledTimes(1) + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + nodes: expect.any(Array), + edges: expect.any(Array) + }) + ) }) it('should accept onFlowGenerated callback', async () => { diff --git a/packages/agentflow/src/Agentflow.tsx b/packages/agentflow/src/Agentflow.tsx index 783cef842..613f0225e 100644 --- a/packages/agentflow/src/Agentflow.tsx +++ b/packages/agentflow/src/Agentflow.tsx @@ -16,6 +16,7 @@ import { useFlowNodes } from './features/canvas' import { GenerateFlowDialog } from './features/generator' +import { EditNodeDialog } from './features/node-editor' import { AddNodesDrawer, StyledFab } from './features/node-palette' import { useAgentflowContext, useConfigContext } from './infrastructure/store' import { AgentflowProvider } from './AgentflowProvider' @@ -50,7 +51,7 @@ function AgentflowCanvas({ renderHeader?: AgentflowProps['renderHeader'] renderNodePalette?: AgentflowProps['renderNodePalette'] }) { - const { state, setNodes, setEdges, setDirty, setReactFlowInstance } = useAgentflowContext() + const { state, setNodes, setEdges, setDirty, setReactFlowInstance, closeEditDialog } = useAgentflowContext() const { isDarkMode } = useConfigContext() const agentflow = useAgentflow() const reactFlowWrapper = useRef(null) @@ -74,15 +75,71 @@ function AgentflowCanvas({ // Load available nodes const { availableNodes } = useFlowNodes() - // Sync local state with context + // TODO: Refactor bidirectional state sync architecture + // Current implementation uses refs and setTimeout to prevent circular updates between + // ReactFlow's local state (useNodesState/useEdgesState) and AgentflowContext state. + // This approach is brittle and prone to race conditions. + // + // Problem: We have TWO sources of truth: + // 1. ReactFlow local state (nodes/edges) - updated by drag/drop, user interactions + // 2. AgentflowContext state - updated by edit dialog, delete, duplicate operations + // + // Current sync flow: + // - Local ReactFlow → Context (for drag/drop) ✓ + // - Context → Local ReactFlow (for edit dialog updates) ⚠️ Brittle with refs/setTimeout + // + // Better solution (for future refactor): + // Option A: Make context operations (updateNodeData, deleteNode, etc.) also call + // setLocalNodes/setLocalEdges directly, then remove reverse sync entirely. + // Option B: Use deep equality checks (e.g., lodash.isEqual) instead of refs/setTimeout. + // Option C: Consolidate to single source of truth (context only, ReactFlow reads from it). + // + // See: https://github.com/FlowiseAI/Flowise/pull/XXX for discussion + // + // Use refs to prevent circular sync updates + const syncingToContext = useRef(false) + const syncingFromContext = useRef(false) + + // Sync local ReactFlow state to context (when user interacts with canvas) useEffect(() => { + if (syncingFromContext.current) return + syncingToContext.current = true setNodes(nodes as FlowNode[]) + // Use setTimeout to reset the flag after the sync completes + setTimeout(() => { + syncingToContext.current = false + }, 0) }, [nodes, setNodes]) useEffect(() => { + if (syncingFromContext.current) return + syncingToContext.current = true setEdges(edges as FlowEdge[]) + setTimeout(() => { + syncingToContext.current = false + }, 0) }, [edges, setEdges]) + // Sync context state back to local ReactFlow (when updated via edit dialog, etc.) + useEffect(() => { + if (syncingToContext.current) return + syncingFromContext.current = true + setLocalNodes(state.nodes) + setTimeout(() => { + syncingFromContext.current = false + }, 0) + }, [state.nodes, setLocalNodes]) + + useEffect(() => { + if (syncingToContext.current) return + syncingFromContext.current = true + setLocalEdges(state.edges) + setTimeout(() => { + syncingFromContext.current = false + }, 0) + }, [state.edges, setLocalEdges]) + // END TODO: Bidirectional state sync + // Flow handlers const { handleConnect, handleNodesChange, handleEdgesChange, handleAddNode } = useFlowHandlers({ nodes: nodes as FlowNode[], @@ -213,6 +270,9 @@ function AgentflowCanvas({ {/* Generate Flow Dialog */} setShowGenerateDialog(false)} onGenerated={handleFlowGenerated} /> + + {/* Edit Node Dialog */} + ) } diff --git a/packages/agentflow/src/__mocks__/reactflow.tsx b/packages/agentflow/src/__mocks__/reactflow.tsx index 65e6d950b..44a91c9e8 100644 --- a/packages/agentflow/src/__mocks__/reactflow.tsx +++ b/packages/agentflow/src/__mocks__/reactflow.tsx @@ -59,3 +59,22 @@ export const useReactFlow = () => ({ getNode: jest.fn(), getEdge: jest.fn() }) + +// Hook to update node internals (used for dynamic handle positioning) +export const useUpdateNodeInternals = () => jest.fn() + +// Hook to access ReactFlow store state +export const useStore = (selector?: any) => (selector ? selector({}) : {}) + +// Edge utilities +export const addEdge = (connection: any, edges: any[]) => [...edges, connection] + +export const getBezierPath = jest.fn(({ sourceX, sourceY, targetX, targetY }: any) => [ + `M ${sourceX},${sourceY} L ${targetX},${targetY}`, + (sourceX + targetX) / 2, + (sourceY + targetY) / 2 +]) + +export const EdgeLabelRenderer = ({ children }: { children: React.ReactNode }) =>
{children}
+ +export const NodeResizer = () =>
diff --git a/packages/agentflow/src/atoms/NodeInputHandler.tsx b/packages/agentflow/src/atoms/NodeInputHandler.tsx index 04ebf89e2..24e06f99e 100644 --- a/packages/agentflow/src/atoms/NodeInputHandler.tsx +++ b/packages/agentflow/src/atoms/NodeInputHandler.tsx @@ -45,12 +45,11 @@ export function NodeInputHandler({ const handleDataChange = useCallback( (newValue: unknown) => { - if (inputParam && data.inputs) { - data.inputs[inputParam.name] = newValue + if (inputParam) { onDataChange?.({ inputParam, newValue }) } }, - [inputParam, data, onDataChange] + [inputParam, onDataChange] ) useEffect(() => { @@ -67,7 +66,7 @@ export function NodeInputHandler({ const renderInput = () => { if (!inputParam) return null - const value = data.inputs?.[inputParam.name] ?? inputParam.default ?? '' + const value = data.inputValues?.[inputParam.name] ?? inputParam.default ?? '' switch (inputParam.type) { case 'string': diff --git a/packages/agentflow/src/core/types/index.ts b/packages/agentflow/src/core/types/index.ts index 130cb600c..a5f304226 100644 --- a/packages/agentflow/src/core/types/index.ts +++ b/packages/agentflow/src/core/types/index.ts @@ -3,6 +3,8 @@ import type { ReactFlowInstance } from 'reactflow' import type { AxiosInstance } from 'axios' +import { EditNodeDialogProps } from '@/features/node-editor/EditNodeDialog' + // ============================================================================ // Flow Data Types // ============================================================================ @@ -65,11 +67,11 @@ export interface NodeData { description?: string version?: number baseClasses?: string[] - inputs?: Record + inputs?: InputParam[] // Parameter definitions from API + inputValues?: Record // Actual values entered by users outputs?: NodeOutput[] inputAnchors?: InputAnchor[] outputAnchors?: OutputAnchor[] - inputParams?: InputParam[] // Visual properties color?: string icon?: string @@ -268,6 +270,8 @@ export interface AgentflowState { chatflow: FlowConfig | null isDirty: boolean reactFlowInstance: ReactFlowInstance | null + editingNodeId: string | null + editDialogProps: EditNodeDialogProps['dialogProps'] | null } export type AgentflowAction = @@ -276,6 +280,8 @@ export type AgentflowAction = | { type: 'SET_CHATFLOW'; payload: FlowConfig | null } | { type: 'SET_DIRTY'; payload: boolean } | { type: 'SET_REACTFLOW_INSTANCE'; payload: ReactFlowInstance | null } + | { type: 'OPEN_EDIT_DIALOG'; payload: { nodeId: string; dialogProps: EditNodeDialogProps['dialogProps'] } } + | { type: 'CLOSE_EDIT_DIALOG' } | { type: 'RESET' } // ============================================================================ diff --git a/packages/agentflow/src/core/utils/flowExport.test.ts b/packages/agentflow/src/core/utils/flowExport.test.ts index 6b0914f34..f75a54c11 100644 --- a/packages/agentflow/src/core/utils/flowExport.test.ts +++ b/packages/agentflow/src/core/utils/flowExport.test.ts @@ -38,14 +38,14 @@ describe('generateExportFlowData', () => { const flowData = { nodes: [ makeNode('a', { - data: { id: 'a', name: 'llm', label: 'LLM', inputs: { model: 'gpt-4' } } + data: { id: 'a', name: 'llm', label: 'LLM', inputValues: { model: 'gpt-4' } } }) ], edges: [] } const result = generateExportFlowData(flowData) expect(result.nodes[0].data.name).toBe('llm') - expect(result.nodes[0].data.inputs).toEqual({ model: 'gpt-4' }) + expect(result.nodes[0].data.inputValues).toEqual({ model: 'gpt-4' }) expect(result.nodes[0].position).toEqual({ x: 0, y: 0 }) }) diff --git a/packages/agentflow/src/core/utils/nodeFactory.test.ts b/packages/agentflow/src/core/utils/nodeFactory.test.ts index b4fb3352f..dbb4399ad 100644 --- a/packages/agentflow/src/core/utils/nodeFactory.test.ts +++ b/packages/agentflow/src/core/utils/nodeFactory.test.ts @@ -54,71 +54,72 @@ describe('initNode', () => { expect(result.id).toBe('node_0') }) - it('should classify whitelisted input types as inputParams', () => { + it('should classify whitelisted input types as inputs (definitions)', () => { const nodeData = makeNodeData({ - inputParams: [ + inputs: [ { id: '', name: 'temp', label: 'Temperature', type: 'number' }, { id: '', name: 'model', label: 'Model', type: 'options', default: 'gpt-4' }, { id: '', name: 'code', label: 'Code', type: 'code' } - ] as NodeData['inputParams'] + ] as NodeData['inputs'] }) const result = initNode(nodeData, 'n1') - expect(result.inputParams).toHaveLength(3) - result.inputParams!.forEach((p) => { + expect(result.inputs).toHaveLength(3) + result.inputs!.forEach((p) => { expect(p.id).toMatch(/^n1-input-/) }) }) it('should classify non-whitelisted input types as inputAnchors', () => { const nodeData = makeNodeData({ - inputParams: [ + inputs: [ { id: '', name: 'llm', label: 'LLM', type: 'BaseChatModel' }, { id: '', name: 'memory', label: 'Memory', type: 'BaseMemory' } - ] as NodeData['inputParams'] + ] as NodeData['inputs'] }) const result = initNode(nodeData, 'n1') expect(result.inputAnchors).toHaveLength(2) - expect(result.inputParams).toHaveLength(0) + expect(result.inputs).toHaveLength(0) }) it('should split mixed input types between params and anchors', () => { const nodeData = makeNodeData({ - inputParams: [ + inputs: [ { id: '', name: 'temp', label: 'Temperature', type: 'number' }, { id: '', name: 'llm', label: 'LLM', type: 'BaseChatModel' }, { id: '', name: 'prompt', label: 'Prompt', type: 'string' } - ] as NodeData['inputParams'] + ] as NodeData['inputs'] }) const result = initNode(nodeData, 'n1') - expect(result.inputParams).toHaveLength(2) + expect(result.inputs).toHaveLength(2) expect(result.inputAnchors).toHaveLength(1) expect(result.inputAnchors![0].name).toBe('llm') }) it('should initialize default values for params', () => { const nodeData = makeNodeData({ - inputParams: [ + inputs: [ { id: '', name: 'temp', label: 'Temperature', type: 'number', default: 0.7 }, { id: '', name: 'model', label: 'Model', type: 'string' } - ] as NodeData['inputParams'] + ] as NodeData['inputs'] }) const result = initNode(nodeData, 'n1') - expect(result.inputs!['temp']).toBe(0.7) - // Params without a default fall back to '' (matching agentflow v2 behavior). - // This ensures show/hide conditions can evaluate against a defined value. - expect(result.inputs!['model']).toBe('') + expect(result.inputValues!['temp']).toBe(0.7) + // initNode uses initializeDefaultNodeData which falls back to '' for params + // without an explicit default value. This ensures all params have a value + // for show/hide condition evaluation. + expect(result.inputValues!['model']).toBe('') }) - it('should preserve existing inputs over defaults', () => { + it('should preserve existing inputValues over defaults', () => { const nodeData = makeNodeData({ - inputs: { temp: 0.9 }, - inputParams: [{ id: '', name: 'temp', label: 'Temperature', type: 'number', default: 0.7 }] as NodeData['inputParams'] + inputValues: { temp: 0.9 }, + inputs: [{ id: '', name: 'temp', label: 'Temperature', type: 'number', default: 0.7 }] as NodeData['inputs'] }) const result = initNode(nodeData, 'n1') - expect(result.inputs!['temp']).toBe(0.9) + expect(result.inputValues!['temp']).toBe(0.9) }) - it('should fall back to inputAnchors when inputParams is absent', () => { + it('should fall back to inputAnchors when inputs is absent', () => { const nodeData = makeNodeData({ inputAnchors: [{ id: '', name: 'llm', label: 'LLM', type: 'BaseChatModel' }] as NodeData['inputAnchors'] }) diff --git a/packages/agentflow/src/core/utils/nodeFactory.ts b/packages/agentflow/src/core/utils/nodeFactory.ts index 189f34424..b84de43eb 100644 --- a/packages/agentflow/src/core/utils/nodeFactory.ts +++ b/packages/agentflow/src/core/utils/nodeFactory.ts @@ -72,13 +72,14 @@ function createAgentFlowOutputs(nodeData: NodeData, newNodeId: string): Array<{ /** * Initialize a node with proper anchors and default values + * Converts API response (with inputs as definitions) to canvas node format */ export function initNode(nodeData: NodeData, newNodeId: string, isAgentflow = true): NodeData { const inputAnchors: Array<{ id: string; name: string; label: string; type: string }> = [] - const inputParams: Array<{ id: string; name: string; label: string; type: string; default?: unknown; optional?: boolean }> = [] + const inputDefinitions: Array<{ id: string; name: string; label: string; type: string; default?: unknown; optional?: boolean }> = [] - // Get input definitions from inputParams if available, otherwise use inputAnchors - const inputDefs = nodeData.inputParams || nodeData.inputAnchors || [] + // Get input definitions from API response (nodeData.inputs contains InputParam[] from API) + const inputDefs = nodeData.inputs || nodeData.inputAnchors || [] const whitelistTypes = [ 'asyncOptions', @@ -100,14 +101,14 @@ export function initNode(nodeData: NodeData, newNodeId: string, isAgentflow = tr 'conditionFunction' ] - // Process inputs + // Process input definitions - separate into anchors vs parameters for (const input of inputDefs) { const newInput = { ...input, id: `${newNodeId}-input-${input.name}-${input.type}` } if (whitelistTypes.includes(input.type)) { - inputParams.push(newInput) + inputDefinitions.push(newInput) } else { inputAnchors.push(newInput) } @@ -116,16 +117,16 @@ export function initNode(nodeData: NodeData, newNodeId: string, isAgentflow = tr // Initialize outputs const outputAnchors = isAgentflow ? createAgentFlowOutputs(nodeData, newNodeId) : [] - // Initialize default input values (matches agentflow v2 — falls back to '' for show/hide conditions) - const initialInputs = initializeDefaultNodeData(inputParams) + // Initialize default input values from definitions using initializeDefaultNodeData + const initialInputValues = initializeDefaultNodeData(inputDefinitions) // Create initialized node data const initializedData: NodeData = { ...nodeData, id: newNodeId, - inputs: { ...initialInputs, ...(nodeData.inputs || {}) }, + inputs: inputDefinitions, // Keep parameter definitions + inputValues: { ...initialInputValues, ...(nodeData.inputValues || {}) }, // Merge defaults with existing values inputAnchors: inputAnchors as NodeData['inputAnchors'], - inputParams: inputParams as NodeData['inputParams'], outputAnchors: outputAnchors as NodeData['outputAnchors'] } diff --git a/packages/agentflow/src/core/validation/flowValidation.test.ts b/packages/agentflow/src/core/validation/flowValidation.test.ts index cd30610e1..3389aa053 100644 --- a/packages/agentflow/src/core/validation/flowValidation.test.ts +++ b/packages/agentflow/src/core/validation/flowValidation.test.ts @@ -108,8 +108,8 @@ describe('validateNode', () => { id: 'a', name: 'llmAgentflow', label: 'LLM', - inputParams: [{ id: 'p1', name: 'model', label: 'Model', type: 'string', optional: false }], - inputs: {} + inputs: [{ id: 'p1', name: 'model', label: 'Model', type: 'string', optional: false }], + inputValues: {} } } const errors = validateNode(node, []) @@ -123,8 +123,8 @@ describe('validateNode', () => { id: 'a', name: 'llmAgentflow', label: 'LLM', - inputParams: [{ id: 'p1', name: 'apiKey', label: 'API Key', type: 'string', optional: true }], - inputs: {} + inputs: [{ id: 'p1', name: 'apiKey', label: 'API Key', type: 'string', optional: true }], + inputValues: {} } } const errors = validateNode(node, []) diff --git a/packages/agentflow/src/core/validation/flowValidation.ts b/packages/agentflow/src/core/validation/flowValidation.ts index f1641da7c..4fea52eb7 100644 --- a/packages/agentflow/src/core/validation/flowValidation.ts +++ b/packages/agentflow/src/core/validation/flowValidation.ts @@ -152,9 +152,9 @@ export function validateNode(node: FlowNode, _edges: FlowEdge[]): ValidationErro } // Check required inputs (if defined) - const inputParams = node.data.inputParams || [] + const inputParams = node.data.inputs || [] for (const param of inputParams) { - if (!param.optional && !node.data.inputs?.[param.name]) { + if (!param.optional && !node.data.inputValues?.[param.name]) { errors.push({ nodeId: node.id, message: `Required input "${param.label || param.name}" is missing`, diff --git a/packages/agentflow/src/features/canvas/components/NodeToolbarActions.tsx b/packages/agentflow/src/features/canvas/components/NodeToolbarActions.tsx index 408ccbb36..78256202b 100644 --- a/packages/agentflow/src/features/canvas/components/NodeToolbarActions.tsx +++ b/packages/agentflow/src/features/canvas/components/NodeToolbarActions.tsx @@ -3,10 +3,11 @@ import { Position } from 'reactflow' import { ButtonGroup, IconButton } from '@mui/material' import { useTheme } from '@mui/material/styles' -import { IconCopy, IconInfoCircle, IconTrash } from '@tabler/icons-react' +import { IconCopy, IconEdit, IconInfoCircle, IconTrash } from '@tabler/icons-react' import { useAgentflowContext, useConfigContext } from '@/infrastructure/store' +import { useOpenNodeEditor } from '../hooks' import { StyledNodeToolbar } from '../styled' export interface NodeToolbarActionsProps { @@ -23,6 +24,11 @@ function NodeToolbarActionsComponent({ nodeId, nodeName, isVisible, onInfoClick const theme = useTheme() const { isDarkMode } = useConfigContext() const { deleteNode, duplicateNode } = useAgentflowContext() + const { openNodeEditor } = useOpenNodeEditor() + + const handleEditClick = () => { + openNodeEditor(nodeId) + } // ReactFlow's NodeToolbar treats `isVisible={false}` differently from `isVisible={undefined}`. // When `false`, the toolbar is force-hidden; when `undefined`, it falls back to ReactFlow's @@ -43,6 +49,17 @@ function NodeToolbarActionsComponent({ nodeId, nodeName, isVisible, onInfoClick )} + + + (null) const updateNodeInternals = useUpdateNodeInternals() + const { openNodeEditor } = useOpenNodeEditor() const [isHovered, setIsHovered] = useState(false) const [warningMessage, setWarningMessage] = useState('') @@ -43,6 +45,10 @@ function AgentFlowNodeComponent({ data }: AgentFlowNodeProps) { isHovered }) + const handleDoubleClick = () => { + openNodeEditor(data.id) + } + const outputAnchors = data.outputAnchors ?? [] const minHeight = getMinimumNodeHeight(outputAnchors.length) @@ -63,7 +69,7 @@ function AgentFlowNodeComponent({ data }: AgentFlowNodeProps) { }, [data.name, data.version, data.warning]) return ( -
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}> +
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} onDoubleClick={handleDoubleClick}> {data.label} - + diff --git a/packages/agentflow/src/features/canvas/containers/StickyNote.tsx b/packages/agentflow/src/features/canvas/containers/StickyNote.tsx index ab538fdc3..e60a31e27 100644 --- a/packages/agentflow/src/features/canvas/containers/StickyNote.tsx +++ b/packages/agentflow/src/features/canvas/containers/StickyNote.tsx @@ -3,7 +3,7 @@ import { memo, useRef, useState } from 'react' import { Box, TextField } from '@mui/material' import type { NodeData } from '@/core/types' -import { useConfigContext } from '@/infrastructure/store' +import { useAgentflowContext, useConfigContext } from '@/infrastructure/store' import { NodeToolbarActions } from '../components/NodeToolbarActions' import { useNodeColors } from '../hooks/useNodeColors' @@ -18,9 +18,10 @@ export interface StickyNoteProps { */ function StickyNoteComponent({ data }: StickyNoteProps) { const { isDarkMode } = useConfigContext() + const { updateNodeData } = useAgentflowContext() const ref = useRef(null) - const [inputParam] = data.inputParams || [] + const [inputParam] = data.inputs || [] const [isHovered, setIsHovered] = useState(false) const { stateColor, backgroundColor } = useNodeColors({ @@ -56,10 +57,14 @@ function StickyNoteComponent({ data }: StickyNoteProps) { multiline rows={3} placeholder={inputParam?.placeholder || 'Add a note...'} - value={data.inputs?.[inputParam?.name || 'note'] ?? inputParam?.default ?? ''} + value={data.inputValues?.[inputParam?.name || 'note'] ?? inputParam?.default ?? ''} onChange={(e) => { - if (data.inputs && inputParam) { - data.inputs[inputParam.name] = e.target.value + if (inputParam) { + const updatedInputValues = { + ...data.inputValues, + [inputParam.name]: e.target.value + } + updateNodeData(data.id, { inputValues: updatedInputValues }) } }} sx={{ diff --git a/packages/agentflow/src/features/canvas/hooks/index.ts b/packages/agentflow/src/features/canvas/hooks/index.ts index 28e13263e..e49295d9b 100644 --- a/packages/agentflow/src/features/canvas/hooks/index.ts +++ b/packages/agentflow/src/features/canvas/hooks/index.ts @@ -2,3 +2,4 @@ export { useDragAndDrop } from './useDragAndDrop' export { useFlowHandlers } from './useFlowHandlers' export { useFlowNodes } from './useFlowNodes' export { useNodeColors } from './useNodeColors' +export { useOpenNodeEditor } from './useOpenNodeEditor' diff --git a/packages/agentflow/src/features/canvas/hooks/useOpenNodeEditor.ts b/packages/agentflow/src/features/canvas/hooks/useOpenNodeEditor.ts new file mode 100644 index 000000000..12c45bb48 --- /dev/null +++ b/packages/agentflow/src/features/canvas/hooks/useOpenNodeEditor.ts @@ -0,0 +1,42 @@ +import { useCallback } from 'react' + +import type { NodeData } from '@/core/types' +import { useAgentflowContext } from '@/infrastructure/store' + +import { useFlowNodes } from './useFlowNodes' + +/** + * Hook to open the node editor dialog + * Handles fetching node data and schema, then opens the edit dialog + */ +export function useOpenNodeEditor() { + const { state, openEditDialog } = useAgentflowContext() + const { availableNodes } = useFlowNodes() + + const openNodeEditor = useCallback( + (nodeId: string) => { + // Find the node data + const node = state.nodes.find((n) => n.id === nodeId) + if (!node) return + + // Find the node schema from available nodes (contains InputParam[] definitions) + const nodeSchema = availableNodes.find((n) => n.name === node.data.name) + if (!nodeSchema) return + + // Get inputParams from schema (API returns 'inputs' property as InputParam[]) + const inputParams = nodeSchema.inputs || [] + + // Ensure inputValues object exists for storing user input + const nodeDataWithInputValues: NodeData = { + ...node.data, + inputValues: node.data.inputValues || {} + } + + // Open the dialog + openEditDialog(nodeId, nodeDataWithInputValues, inputParams) + }, + [state.nodes, availableNodes, openEditDialog] + ) + + return { openNodeEditor } +} diff --git a/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx b/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx index 0777bf895..0abc0f3e1 100644 --- a/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx +++ b/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx @@ -47,13 +47,13 @@ function EditNodeDialogComponent({ show, dialogProps, onCancel }: EditNodeDialog const onCustomDataChange = ({ inputParam, newValue }: { inputParam: InputParam; newValue: unknown }) => { if (!data) return - const updatedInputs = { - ...data.inputs, + const updatedInputValues = { + ...data.inputValues, [inputParam.name]: newValue } - updateNodeData(data.id, { inputs: updatedInputs }) - setData({ ...data, inputs: updatedInputs }) + updateNodeData(data.id, { inputValues: updatedInputValues }) + setData({ ...data, inputValues: updatedInputValues }) } useEffect(() => { @@ -64,11 +64,6 @@ function EditNodeDialogComponent({ show, dialogProps, onCancel }: EditNodeDialog setData(dialogProps.data) if (dialogProps.data.label) setNodeName(dialogProps.data.label) } - - return () => { - setInputParams([]) - setData(null) - } }, [dialogProps]) if (!show) return null diff --git a/packages/agentflow/src/infrastructure/store/AgentflowContext.tsx b/packages/agentflow/src/infrastructure/store/AgentflowContext.tsx index ecf8c654c..ccf102d31 100644 --- a/packages/agentflow/src/infrastructure/store/AgentflowContext.tsx +++ b/packages/agentflow/src/infrastructure/store/AgentflowContext.tsx @@ -1,7 +1,7 @@ import { createContext, Dispatch, ReactNode, useCallback, useContext, useReducer } from 'react' import type { ReactFlowInstance } from 'reactflow' -import type { AgentflowAction, AgentflowState, FlowConfig, FlowData, FlowEdge, FlowNode } from '@/core/types' +import type { AgentflowAction, AgentflowState, FlowConfig, FlowData, FlowEdge, FlowNode, InputParam, NodeData } from '@/core/types' import { agentflowReducer, initialState, normalizeNodes } from './agentflowReducer' @@ -28,6 +28,10 @@ export interface AgentflowContextValue { // Flow operations getFlowData: () => FlowData reset: () => void + + //Dialog operations + openEditDialog: (nodeId: string, data: NodeData, inputParams: InputParam[]) => void + closeEditDialog: () => void } const AgentflowContext = createContext(null) @@ -125,6 +129,26 @@ export function AgentflowStateProvider({ children, initialFlow }: AgentflowState [state.edges] ) + // Dialog operations + const openEditDialog = useCallback((nodeId: string, data: NodeData, inputParams: InputParam[]) => { + const dialogProps = { + inputParams: inputParams, + data: data, + disabled: false + } + dispatch({ + type: 'OPEN_EDIT_DIALOG', + payload: { + nodeId, + dialogProps + } + }) + }, []) + + const closeEditDialog = useCallback(() => { + dispatch({ type: 'CLOSE_EDIT_DIALOG' }) + }, []) + // Get flow data const getFlowData = useCallback((): FlowData => { const viewport = state.reactFlowInstance?.getViewport() || { x: 0, y: 0, zoom: 1 } @@ -152,6 +176,8 @@ export function AgentflowStateProvider({ children, initialFlow }: AgentflowState duplicateNode, updateNodeData, deleteEdge, + openEditDialog, + closeEditDialog, getFlowData, reset } diff --git a/packages/agentflow/src/infrastructure/store/agentflowReducer.test.ts b/packages/agentflow/src/infrastructure/store/agentflowReducer.test.ts index df84b279b..5efe2398c 100644 --- a/packages/agentflow/src/infrastructure/store/agentflowReducer.test.ts +++ b/packages/agentflow/src/infrastructure/store/agentflowReducer.test.ts @@ -13,7 +13,9 @@ const initialState: AgentflowState = { edges: [], chatflow: null, isDirty: false, - reactFlowInstance: null + reactFlowInstance: null, + editingNodeId: null, + editDialogProps: null } describe('normalizeNodes', () => { @@ -77,6 +79,39 @@ describe('agentflowReducer', () => { expect(result.chatflow).toBeNull() }) + it('should open edit dialog with nodeId and dialogProps', () => { + const state = initialState + const action: AgentflowAction = { + type: 'OPEN_EDIT_DIALOG', + payload: { + nodeId: 'node-123', + dialogProps: { + inputParams: [], + data: { id: 'node-123', name: 'sendResponse', label: 'Send Response 1' }, + disabled: false + } + } + } + const newState = agentflowReducer(state, action) + + expect(newState.editingNodeId).toBe('node-123') + expect(newState.editDialogProps).toEqual(action.payload.dialogProps) + }) + + it('should close edit dialog and clear state', () => { + const state = { + ...initialState, + editingNodeId: 'node-123', + editDialogProps: { + /* ... */ + } + } + const newState = agentflowReducer(state, { type: 'CLOSE_EDIT_DIALOG' }) + + expect(newState.editingNodeId).toBeNull() + expect(newState.editDialogProps).toBeNull() + }) + it('should handle SET_DIRTY', () => { const result = agentflowReducer(initialState, { type: 'SET_DIRTY', payload: true }) expect(result.isDirty).toBe(true) @@ -94,7 +129,9 @@ describe('agentflowReducer', () => { edges: [makeEdge('a', 'b')], chatflow: { id: '1', name: 'Test' }, isDirty: true, - reactFlowInstance: null + reactFlowInstance: null, + editingNodeId: null, + editDialogProps: null } const result = agentflowReducer(dirtyState, { type: 'RESET' }) expect(result.nodes).toEqual([]) diff --git a/packages/agentflow/src/infrastructure/store/agentflowReducer.ts b/packages/agentflow/src/infrastructure/store/agentflowReducer.ts index 716f7b2dc..b3cc93871 100644 --- a/packages/agentflow/src/infrastructure/store/agentflowReducer.ts +++ b/packages/agentflow/src/infrastructure/store/agentflowReducer.ts @@ -18,7 +18,9 @@ export const initialState: AgentflowState = { edges: [], chatflow: null, isDirty: false, - reactFlowInstance: null + reactFlowInstance: null, + editingNodeId: null, + editDialogProps: null } export function agentflowReducer(state: AgentflowState, action: AgentflowAction): AgentflowState { @@ -33,6 +35,18 @@ export function agentflowReducer(state: AgentflowState, action: AgentflowAction) return { ...state, isDirty: action.payload } case 'SET_REACTFLOW_INSTANCE': return { ...state, reactFlowInstance: action.payload } + case 'OPEN_EDIT_DIALOG': + return { + ...state, + editingNodeId: action.payload.nodeId, + editDialogProps: action.payload.dialogProps + } + case 'CLOSE_EDIT_DIALOG': + return { + ...state, + editingNodeId: null, + editDialogProps: null + } case 'RESET': return initialState default: