mirror of
https://github.com/FlowiseAI/Flowise.git
synced 2026-10-12 02:02:17 +00:00
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:
@@ -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'
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user