/** * Automation2FlowEditor * * n8n-style flow builder with backend-driven node list. * Workflow configuration (gear): primary start kind + invocations; canvas start node stays in sync. */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { FaSpinner } from 'react-icons/fa'; import { useApiRequest } from '../../../hooks/useApi'; import { fetchNodeTypes, executeGraph, fetchWorkflows, fetchWorkflow, createWorkflow, updateWorkflow, type NodeType, type NodeTypeCategory, type Automation2Graph, type Automation2Workflow, type ExecuteGraphResponse, type WorkflowEntryPoint, } from '../../../api/automation2Api'; import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas'; import { NodeConfigPanel } from './NodeConfigPanel'; import { NodeSidebar } from './NodeSidebar'; import { CanvasHeader } from './CanvasHeader'; import { WorkflowConfigurationModal } from './WorkflowConfigurationModal'; import { getCategoryIcon } from '../nodes/shared/utils'; import { fromApiGraph, toApiGraph } from '../nodes/shared/graphUtils'; import { syncCanvasStartNode, buildInvocationsForPrimaryKind, } from '../nodes/runtime/workflowStartSync'; import { buildNodeOutputsPreview } from '../nodes/shared/outputPreviewRegistry'; import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext'; import { usePrompt } from '../../../hooks/usePrompt'; import styles from './Automation2FlowEditor.module.css'; const LOG = '[Automation2]'; const DEFAULT_INVOCATIONS = (): WorkflowEntryPoint[] => buildInvocationsForPrimaryKind('manual', [], 'Jetzt ausführen'); interface Automation2FlowEditorProps { instanceId: string; language?: string; /** When set, load this workflow on mount (e.g. from workflows list edit) */ initialWorkflowId?: string | null; } export const Automation2FlowEditor: React.FC = ({ instanceId, language = 'de', initialWorkflowId, }) => { const { request } = useApiRequest(); const { prompt: promptInput, PromptDialog } = usePrompt(); const [nodeTypes, setNodeTypes] = useState([]); const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [filter, setFilter] = useState(''); const [expandedCategories, setExpandedCategories] = useState>( new Set(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup']) ); const [canvasNodes, setCanvasNodes] = useState([]); const [canvasConnections, setCanvasConnections] = useState([]); const [executing, setExecuting] = useState(false); const [executeResult, setExecuteResult] = useState(null); const [workflows, setWorkflows] = useState([]); const [currentWorkflowId, setCurrentWorkflowId] = useState(null); const [selectedNode, setSelectedNode] = useState(null); const [saving, setSaving] = useState(false); const [invocations, setInvocations] = useState(DEFAULT_INVOCATIONS); const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false); const sidebarExcludedCategories = useMemo(() => new Set(['trigger']), []); const nodeOutputsPreview = useMemo( () => buildNodeOutputsPreview(canvasNodes, executeResult?.nodeOutputs as Record | undefined), [canvasNodes, executeResult?.nodeOutputs] ); const applyGraphWithSync = useCallback( (graph: Automation2Graph | null | undefined, wfInvocations: WorkflowEntryPoint[] | undefined) => { const inv = wfInvocations?.length ? wfInvocations : DEFAULT_INVOCATIONS(); setInvocations(inv); if (!graph?.nodes?.length) { const synced = syncCanvasStartNode([], [], inv, nodeTypes, language); setCanvasNodes(synced.nodes); setCanvasConnections(synced.connections); return; } const { nodes, connections } = fromApiGraph(graph, nodeTypes); const synced = syncCanvasStartNode(nodes, connections, inv, nodeTypes, language); setCanvasNodes(synced.nodes); setCanvasConnections(synced.connections); }, [nodeTypes, language] ); const handleFromApiGraph = useCallback( (graph: Automation2Graph, wfInvocations?: WorkflowEntryPoint[]) => { applyGraphWithSync(graph, wfInvocations); }, [applyGraphWithSync] ); const handleExecute = useCallback(async () => { const graph = toApiGraph(canvasNodes, canvasConnections); if (graph.nodes.length === 0) { setExecuteResult({ success: false, error: 'Keine Nodes im Workflow.' }); return; } setExecuting(true); setExecuteResult(null); try { const ep = currentWorkflowId ? invocations[0]?.id : undefined; const result = await executeGraph(request, instanceId, graph, currentWorkflowId ?? undefined, { ...(ep ? { entryPointId: ep } : {}), }); setExecuteResult(result); } catch (err: unknown) { setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) }); } finally { setExecuting(false); } }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations]); const handleSave = useCallback(async () => { const graph = toApiGraph(canvasNodes, canvasConnections); if (graph.nodes.length === 0) { setExecuteResult({ success: false, error: 'Keine Nodes zum Speichern.' }); return; } setSaving(true); try { if (currentWorkflowId) { await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations }); setExecuteResult({ success: true } as ExecuteGraphResponse); } else { const label = await promptInput('Workflow-Name:', { title: 'Workflow speichern', defaultValue: 'Neuer Workflow', placeholder: 'Name des Workflows', }); if (!label) { setSaving(false); return; } const created = await createWorkflow(request, instanceId, { label: label.trim() || 'Neuer Workflow', graph, invocations, }); setCurrentWorkflowId(created.id); if (created.invocations?.length) setInvocations(created.invocations); setWorkflows((prev) => [...prev, created]); setExecuteResult({ success: true } as ExecuteGraphResponse); } } catch (err: unknown) { setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) }); } finally { setSaving(false); } }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations]); const handleLoad = useCallback( async (workflowId: string) => { try { const wf = await fetchWorkflow(request, instanceId, workflowId); if (wf.graph) { handleFromApiGraph(wf.graph, wf.invocations); } else { applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations); } } catch (err: unknown) { setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err), }); } }, [request, instanceId, handleFromApiGraph, applyGraphWithSync] ); const handleWorkflowSelect = useCallback( (workflowId: string | null) => { setCurrentWorkflowId(workflowId); if (workflowId) handleLoad(workflowId); else { setExecuteResult(null); applyGraphWithSync({ nodes: [], connections: [] }, DEFAULT_INVOCATIONS()); } }, [handleLoad, applyGraphWithSync] ); const handleNew = useCallback(() => { setCurrentWorkflowId(null); setExecuteResult(null); applyGraphWithSync({ nodes: [], connections: [] }, DEFAULT_INVOCATIONS()); }, [applyGraphWithSync]); const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record) => { setCanvasNodes((prev) => prev.map((n) => { if (n.id !== nodeId) return n; const next = { ...n, parameters }; if (n.type === 'flow.switch' && 'cases' in parameters) { const cases = (parameters.cases as unknown[]) ?? []; next.outputs = Math.max(1, cases.length); } return next; }) ); }, []); const handleMergeNodeParameters = useCallback((nodeId: string, patch: Record) => { setCanvasNodes((prev) => prev.map((n) => { if (n.id !== nodeId) return n; const merged = { ...(n.parameters ?? {}), ...patch }; const next = { ...n, parameters: merged }; if (n.type === 'flow.switch' && 'cases' in merged) { const cases = (merged.cases as unknown[]) ?? []; next.outputs = Math.max(1, cases.length); } return next; }) ); }, []); const handleNodeUpdate = useCallback( (nodeId: string, updates: Partial>) => { setCanvasNodes((prev) => prev.map((n) => (n.id === nodeId ? { ...n, ...updates } : n)) ); }, [] ); const handleApplyWorkflowConfiguration = useCallback( (next: WorkflowEntryPoint[]) => { setInvocations(next); setCanvasNodes((nodes) => { const r = syncCanvasStartNode(nodes, canvasConnections, next, nodeTypes, language); setCanvasConnections(r.connections); return r.nodes; }); }, [canvasConnections, nodeTypes, language] ); const loadNodeTypes = useCallback(async () => { if (!instanceId) return; setLoading(true); setError(null); try { const data = await fetchNodeTypes(request, instanceId, language); setNodeTypes(data.nodeTypes); setCategories(data.categories); } catch (err: unknown) { setError(err instanceof Error ? err.message : String(err)); setNodeTypes([]); setCategories([]); } finally { setLoading(false); } }, [instanceId, language, request]); const loadWorkflows = useCallback(async () => { if (!instanceId) return; try { const items = await fetchWorkflows(request, instanceId); setWorkflows(items); } catch (e) { console.error(`${LOG} loadWorkflows failed`, e); } }, [instanceId, request]); useEffect(() => { loadNodeTypes(); }, [loadNodeTypes]); useEffect(() => { loadWorkflows(); }, [loadWorkflows]); useEffect(() => { if (initialWorkflowId && workflows.length > 0 && !currentWorkflowId && nodeTypes.length > 0) { handleWorkflowSelect(initialWorkflowId); } }, [initialWorkflowId, workflows, currentWorkflowId, handleWorkflowSelect, nodeTypes.length]); useEffect(() => { if (loading || nodeTypes.length === 0) return; if (currentWorkflowId || initialWorkflowId) return; if (canvasNodes.length > 0) return; applyGraphWithSync({ nodes: [], connections: [] }, DEFAULT_INVOCATIONS()); }, [ loading, nodeTypes.length, currentWorkflowId, initialWorkflowId, canvasNodes.length, applyGraphWithSync, ]); const toggleCategory = useCallback((id: string) => { setExpandedCategories((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); const handleDropNodeType = useCallback( (nodeTypeId: string, x: number, y: number) => { if (nodeTypeId.startsWith('trigger.')) return; const nt = nodeTypes.find((n) => n.id === nodeTypeId); if (!nt) return; const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; const label = typeof nt.label === 'string' ? nt.label : (nt.label as Record)?.[language] ?? nt.id; setCanvasNodes((prev) => [ ...prev, { id, type: nodeTypeId, x, y, label, title: label, color: nt.meta?.color, inputs: nt.inputs ?? 1, outputs: nt.outputs ?? 1, parameters: {}, }, ]); }, [nodeTypes, language] ); const renderSidebar = () => { if (loading) { return (

Nodes

Lade Node-Typen...

); } if (error) { return (

Nodes

{error}

); } return ( ); }; const configurableSelected = selectedNode && ['input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'trigger.', 'flow.', 'file.'].some((p) => selectedNode.type.startsWith(p) ); return (
{renderSidebar()}
setWorkflowSettingsOpen(true)} saving={saving} executing={executing} hasNodes={canvasNodes.length > 0} executeResult={executeResult} />
node.title ?? node.label ?? node.type} getCategoryIcon={getCategoryIcon} onSelectionChange={setSelectedNode} />
{configurableSelected && selectedNode && ( nt.id === selectedNode.type)} language={language} onParametersChange={handleNodeParametersChange} onMergeNodeParameters={handleMergeNodeParameters} onNodeUpdate={handleNodeUpdate} instanceId={instanceId} request={request} /> )}
setWorkflowSettingsOpen(false)} invocations={invocations} onApply={handleApplyWorkflowConfiguration} />
); }; export default Automation2FlowEditor;