feat(agentflow): wire functionality to edit nodes (#5786)

* WIP: wiring editnode

* End to end flow

* Fixed comments

* test case added for agent flow reducer

* FIxed changes after syncing with main

* Changes to uodate interface NodeData to be in line with restapi response api/v1/nodes

* Fixed es-lint errors

* Clean code

* Fix for making failed testcase

* eslint error fixed

* fix test coverage

* Revert config.ts and vite.config.ts to main

* Fix gemini comments

* Update imports to use alias

* fix alias
This commit is contained in:
j-sanaa
2026-02-20 17:17:48 -08:00
committed by GitHub
parent 5c82c42e5f
commit d09387bc4f
21 changed files with 313 additions and 72 deletions
@@ -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'
@@ -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'
+14 -2
View File
@@ -274,12 +274,24 @@ describe('Agentflow Component', () => {
it('should accept onSave callback', async () => {
const onSave = jest.fn()
const { container } = render(<Agentflow {...defaultProps} onSave={onSave} />)
const { container, getByText } = render(<Agentflow {...defaultProps} onSave={onSave} showDefaultHeader={true} />)
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 () => {
+62 -2
View File
@@ -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<HTMLDivElement>(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 */}
<GenerateFlowDialog open={showGenerateDialog} onClose={() => setShowGenerateDialog(false)} onGenerated={handleFlowGenerated} />
{/* Edit Node Dialog */}
<EditNodeDialog show={state.editingNodeId !== null} dialogProps={state.editDialogProps || {}} onCancel={closeEditDialog} />
</div>
)
}
@@ -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 }) => <div data-testid='edge-label-renderer'>{children}</div>
export const NodeResizer = () => <div data-testid='node-resizer' />
@@ -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':
+8 -2
View File
@@ -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<string, unknown>
inputs?: InputParam[] // Parameter definitions from API
inputValues?: Record<string, unknown> // 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' }
// ============================================================================
@@ -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 })
})
@@ -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']
})
@@ -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']
}
@@ -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, [])
@@ -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`,
@@ -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
<IconCopy size={20} />
</IconButton>
)}
<IconButton
size='small'
title='Edit'
onClick={handleEditClick}
sx={{
color: isDarkMode ? 'white' : theme.palette.grey[600],
'&:hover': { color: theme.palette.primary.main }
}}
>
<IconEdit size={20} />
</IconButton>
<IconButton
size='small'
title='Delete'
@@ -13,6 +13,7 @@ import { NodeModelConfigs } from '../components/NodeModelConfigs'
import { getMinimumNodeHeight, NodeOutputHandles } from '../components/NodeOutputHandles'
import { NodeStatusIndicator, NodeWarningIndicator } from '../components/NodeStatusIndicator'
import { NodeToolbarActions } from '../components/NodeToolbarActions'
import { useOpenNodeEditor } from '../hooks'
import { useNodeColors } from '../hooks/useNodeColors'
import { CardWrapper } from '../styled'
@@ -31,6 +32,7 @@ function AgentFlowNodeComponent({ data }: AgentFlowNodeProps) {
const { apiBaseUrl } = useApiContext()
const ref = useRef<HTMLDivElement>(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 (
<div ref={ref} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}>
<div ref={ref} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} onDoubleClick={handleDoubleClick}>
<NodeToolbarActions
nodeId={data.id}
nodeName={data.name}
@@ -107,7 +113,7 @@ function AgentFlowNodeComponent({ data }: AgentFlowNodeProps) {
>
{data.label}
</Typography>
<NodeModelConfigs inputs={data.inputs} />
<NodeModelConfigs inputs={data.inputValues} />
</Box>
</Box>
@@ -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<HTMLDivElement>(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={{
@@ -2,3 +2,4 @@ export { useDragAndDrop } from './useDragAndDrop'
export { useFlowHandlers } from './useFlowHandlers'
export { useFlowNodes } from './useFlowNodes'
export { useNodeColors } from './useNodeColors'
export { useOpenNodeEditor } from './useOpenNodeEditor'
@@ -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 }
}
@@ -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
@@ -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<AgentflowContextValue | null>(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
}
@@ -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([])
@@ -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: