/** * 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, useRef } from 'react'; import { FaSpinner } from 'react-icons/fa'; import { useApiRequest } from '../../../hooks/useApi'; import { fetchNodeTypes, executeGraph, fetchWorkflows, fetchWorkflow, createWorkflow, updateWorkflow, fetchVersions, createDraftVersion, publishVersion, unpublishVersion, archiveVersion, createTemplateFromWorkflow, copyTemplate, type NodeType, type NodeTypeCategory, type Automation2Graph, type Automation2Workflow, type ExecuteGraphResponse, type WorkflowEntryPoint, type AutoVersion, type AutoTemplateScope, } from '../../../api/workflowApi'; 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 { TemplatePicker } from './TemplatePicker'; import { getCategoryIcon } from '../nodes/shared/utils'; import { fromApiGraph, toApiGraph } from '../nodes/shared/graphUtils'; import { syncCanvasStartNode, buildInvocationsForPrimaryKind, } from '../nodes/runtime/workflowStartSync'; import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry'; import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext'; import { usePrompt } from '../../../hooks/usePrompt'; import { EditorChatPanel } from './EditorChatPanel'; import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel'; import { RunTracingPanel } from './RunTracingPanel'; import { UnifiedDataBar } from '../../../components/UnifiedDataBar'; import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar'; import styles from './Automation2FlowEditor.module.css'; const LOG = '[Automation2]'; const DEFAULT_INVOCATIONS = (): WorkflowEntryPoint[] => buildInvocationsForPrimaryKind('manual', [], 'Jetzt ausführen'); interface Automation2FlowEditorProps { instanceId: string; mandateId?: string; language?: string; /** When set, load this workflow on mount (e.g. from workflows list edit) */ initialWorkflowId?: string | null; pendingFiles?: PendingFile[]; onRemovePendingFile?: (fileId: string) => void; dataSources?: EditorDataSource[]; featureDataSources?: EditorFeatureDataSource[]; onFileSelect?: (fileId: string, fileName?: string) => void; onSourcesChanged?: () => void; } export const Automation2FlowEditor: React.FC = ({ instanceId, mandateId, language = 'de', initialWorkflowId, pendingFiles, onRemovePendingFile, dataSources, featureDataSources, onFileSelect, onSourcesChanged, }) => { const { request } = useApiRequest(); const { prompt: promptInput, PromptDialog } = usePrompt(); const [nodeTypes, setNodeTypes] = useState([]); const [categories, setCategories] = useState([]); const [portTypeCatalog, setPortTypeCatalog] = useState>({}); const [systemVariables, setSystemVariables] = 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', 'trustee']) ); 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 [leftPanelOpen, setLeftPanelOpen] = useState(true); const [tracingRunId, setTracingRunId] = useState(null); const [tracingNodeStatuses, setTracingNodeStatuses] = useState>({}); const [rightTab, setRightTab] = useState<'nodes' | 'tracing'>('nodes'); const [udbTab, setUdbTab] = useState('chats'); const udbContext: UdbContext = useMemo(() => ({ instanceId, mandateId: mandateId || '', featureInstanceId: instanceId, }), [instanceId, mandateId]); const [versions, setVersions] = useState([]); const [currentVersionId, setCurrentVersionId] = useState(null); const [versionLoading, setVersionLoading] = useState(false); const [leftPanelWidth, setLeftPanelWidth] = useState(() => { try { const v = parseInt(localStorage.getItem('flowEditor.leftPanelWidth') ?? ''); return v >= 240 && v <= 600 ? v : 340; } catch { return 340; } }); const [sidebarWidth, setSidebarWidth] = useState(() => { try { const v = parseInt(localStorage.getItem('flowEditor.sidebarWidth') ?? ''); return v >= 200 && v <= 500 ? v : 280; } catch { return 280; } }); const resizingRef = useRef<{ target: 'left' | 'right'; startX: number; startW: number } | null>(null); useEffect(() => { const _onMouseMove = (e: MouseEvent) => { if (!resizingRef.current) return; const { target, startX, startW } = resizingRef.current; const delta = e.clientX - startX; if (target === 'left') { setLeftPanelWidth(Math.max(240, Math.min(600, startW + delta))); } else { setSidebarWidth(Math.max(200, Math.min(500, startW - delta))); } }; const _onMouseUp = () => { if (!resizingRef.current) return; const { target } = resizingRef.current; resizingRef.current = null; document.body.style.cursor = ''; document.body.style.userSelect = ''; if (target === 'left') { setLeftPanelWidth((w) => { try { localStorage.setItem('flowEditor.leftPanelWidth', String(w)); } catch {} return w; }); } else { setSidebarWidth((w) => { try { localStorage.setItem('flowEditor.sidebarWidth', String(w)); } catch {} return w; }); } }; document.addEventListener('mousemove', _onMouseMove); document.addEventListener('mouseup', _onMouseUp); return () => { document.removeEventListener('mousemove', _onMouseMove); document.removeEventListener('mouseup', _onMouseUp); }; }, []); const _startResize = useCallback((target: 'left' | 'right', e: React.MouseEvent) => { e.preventDefault(); resizingRef.current = { target, startX: e.clientX, startW: target === 'left' ? leftPanelWidth : sidebarWidth }; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; }, [leftPanelWidth, sidebarWidth]); const sidebarExcludedCategories = useMemo(() => new Set(['trigger']), []); const nodeOutputsPreview = useMemo( () => buildNodeOutputsPreview(canvasNodes, nodeTypes, executeResult?.nodeOutputs as Record | undefined), [canvasNodes, nodeTypes, 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); if (result.runId) { setTracingRunId(result.runId); setRightTab('tracing'); } } 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); if (data.portTypeCatalog) { setPortTypeCatalog(data.portTypeCatalog); setRegistryCatalog(data.portTypeCatalog as never); } if (data.systemVariables) setSystemVariables(data.systemVariables); } 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 result = await fetchWorkflows(request, instanceId); setWorkflows(Array.isArray(result) ? result : result.items); } catch (e) { console.error(`${LOG} loadWorkflows failed`, e); } }, [instanceId, request]); useEffect(() => { loadNodeTypes(); }, [loadNodeTypes]); useEffect(() => { loadWorkflows(); }, [loadWorkflows]); const lastAppliedInitialRef = useRef(undefined); useEffect(() => { if (!initialWorkflowId || workflows.length === 0 || nodeTypes.length === 0) return; if (lastAppliedInitialRef.current === initialWorkflowId) return; lastAppliedInitialRef.current = initialWorkflowId; handleWorkflowSelect(initialWorkflowId); }, [initialWorkflowId, workflows, 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 loadVersions = useCallback(async () => { if (!instanceId || !currentWorkflowId) { setVersions([]); return; } try { const v = await fetchVersions(request, instanceId, currentWorkflowId); setVersions(v); } catch (e) { console.error(`${LOG} loadVersions failed`, e); } }, [instanceId, currentWorkflowId, request]); useEffect(() => { loadVersions(); }, [loadVersions]); const handleVersionSelect = useCallback( (versionId: string | null) => { setCurrentVersionId(versionId); if (versionId) { const v = versions.find((ver) => ver.id === versionId); if (v?.graph) { handleFromApiGraph(v.graph, v.invocations); } } }, [versions, handleFromApiGraph] ); const handlePublishVersion = useCallback( async (versionId: string) => { if (!instanceId) return; setVersionLoading(true); try { await publishVersion(request, instanceId, versionId); await loadVersions(); } catch (e: unknown) { setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) }); } finally { setVersionLoading(false); } }, [request, instanceId, loadVersions] ); const handleUnpublishVersion = useCallback( async (versionId: string) => { if (!instanceId) return; setVersionLoading(true); try { await unpublishVersion(request, instanceId, versionId); await loadVersions(); } catch (e: unknown) { setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) }); } finally { setVersionLoading(false); } }, [request, instanceId, loadVersions] ); const handleArchiveVersion = useCallback( async (versionId: string) => { if (!instanceId) return; setVersionLoading(true); try { await archiveVersion(request, instanceId, versionId); await loadVersions(); } catch (e: unknown) { setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) }); } finally { setVersionLoading(false); } }, [request, instanceId, loadVersions] ); const handleCreateDraft = useCallback(async () => { if (!instanceId || !currentWorkflowId) return; setVersionLoading(true); try { const draft = await createDraftVersion(request, instanceId, currentWorkflowId); await loadVersions(); setCurrentVersionId(draft.id); } catch (e: unknown) { setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) }); } finally { setVersionLoading(false); } }, [request, instanceId, currentWorkflowId, loadVersions]); // Template: save current workflow as template const [templateSaving, setTemplateSaving] = useState(false); const handleSaveAsTemplate = useCallback( async (scope: AutoTemplateScope) => { if (!instanceId || !currentWorkflowId) return; setTemplateSaving(true); try { await createTemplateFromWorkflow(request, instanceId, currentWorkflowId, scope); setExecuteResult({ success: true, error: undefined } as unknown as ExecuteGraphResponse); } catch (e: unknown) { setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) }); } finally { setTemplateSaving(false); } }, [request, instanceId, currentWorkflowId] ); // Template: new workflow from template const [templatePickerOpen, setTemplatePickerOpen] = useState(false); const handleNewFromTemplate = useCallback( async (templateId: string) => { if (!instanceId) return; try { const wf = await copyTemplate(request, instanceId, templateId); setWorkflows((prev) => [...prev, wf]); setCurrentWorkflowId(wf.id); if (wf.graph) handleFromApiGraph(wf.graph, wf.invocations); setTemplatePickerOpen(false); } catch (e: unknown) { setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) }); } }, [request, instanceId, handleFromApiGraph] ); const handleWorkflowRename = useCallback(async (workflowId: string, newName: string) => { try { await updateWorkflow(request, instanceId, workflowId, { label: newName }); setWorkflows((prev) => prev.map((w) => w.id === workflowId ? { ...w, label: newName } : w)); } catch (e: unknown) { console.error(`${LOG} rename failed`, e); } }, [request, instanceId]); const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]); 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.', 'trustee.'].some((p) => selectedNode.type.startsWith(p) ); return (
{/* Left panel: Workspace (Chats / Dateien / Quellen) */} {leftPanelOpen && (<>
{(['chats', 'files', 'sources'] as const).map((tab) => ( ))}
{udbTab === 'chats' ? ( { if (currentWorkflowId) handleLoad(currentWorkflowId); }} pendingFiles={pendingFiles} onRemovePendingFile={onRemovePendingFile} dataSources={dataSources} featureDataSources={featureDataSources} /> ) : ( )}
_startResize('left', e)} /> )} {/* Canvas area - center */}
setWorkflowSettingsOpen(true)} onToggleChat={() => setLeftPanelOpen((prev) => !prev)} saving={saving} executing={executing} hasNodes={canvasNodes.length > 0} executeResult={executeResult} versions={versions} currentVersionId={currentVersionId} onVersionSelect={handleVersionSelect} onPublishVersion={handlePublishVersion} onUnpublishVersion={handleUnpublishVersion} onArchiveVersion={handleArchiveVersion} onCreateDraft={handleCreateDraft} versionLoading={versionLoading} onSaveAsTemplate={handleSaveAsTemplate} templateSaving={templateSaving} onNewFromTemplate={() => setTemplatePickerOpen(true)} onWorkflowRename={handleWorkflowRename} />
node.title ?? node.label ?? node.type} getCategoryIcon={getCategoryIcon} onSelectionChange={setSelectedNode} highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined} />
{configurableSelected && selectedNode && ( } systemVariables={systemVariables as Record} > nt.id === selectedNode.type)} language={language} onParametersChange={handleNodeParametersChange} onMergeNodeParameters={handleMergeNodeParameters} onNodeUpdate={handleNodeUpdate} instanceId={instanceId} request={request} /> )}
{/* Right panel: Nodes + Tracing tabs */}
_startResize('right', e)} />
{rightTab === 'nodes' ? ( renderSidebar() ) : ( { const node = canvasNodes.find((n) => n.id === nodeId); if (node) setSelectedNode(node); }} onActiveStepsChange={setTracingNodeStatuses} /> )}
setWorkflowSettingsOpen(false)} invocations={invocations} onApply={handleApplyWorkflowConfiguration} /> setTemplatePickerOpen(false)} onSelect={handleNewFromTemplate} instanceId={instanceId} request={request} />
); }; export default Automation2FlowEditor;