frontend_nyla/src/components/Automation2FlowEditor/editor/Automation2FlowEditor.tsx

464 lines
16 KiB
TypeScript

/**
* 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<Automation2FlowEditorProps> = ({
instanceId,
language = 'de',
initialWorkflowId,
}) => {
const { request } = useApiRequest();
const { prompt: promptInput, PromptDialog } = usePrompt();
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState('');
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup'])
);
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
const [executing, setExecuting] = useState(false);
const [executeResult, setExecuteResult] = useState<ExecuteGraphResponse | null>(null);
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
const [saving, setSaving] = useState(false);
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>(DEFAULT_INVOCATIONS);
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
const sidebarExcludedCategories = useMemo(() => new Set(['trigger']), []);
const nodeOutputsPreview = useMemo(
() =>
buildNodeOutputsPreview(canvasNodes, executeResult?.nodeOutputs as Record<string, unknown> | 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<string, unknown>) => {
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<string, unknown>) => {
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<Pick<CanvasNode, 'title' | 'comment'>>) => {
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<string, string>)?.[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 (
<div className={styles.sidebar}>
<div className={styles.sidebarHeader}>
<h3 className={styles.sidebarTitle}>Nodes</h3>
</div>
<div className={styles.loading}>
<FaSpinner className={styles.spinner} style={{ marginBottom: '0.5rem' }} />
<p>Lade Node-Typen...</p>
</div>
</div>
);
}
if (error) {
return (
<div className={styles.sidebar}>
<div className={styles.sidebarHeader}>
<h3 className={styles.sidebarTitle}>Nodes</h3>
</div>
<div className={styles.error}>
<p>{error}</p>
<button className={styles.retryButton} onClick={loadNodeTypes}>
Erneut versuchen
</button>
</div>
</div>
);
}
return (
<NodeSidebar
nodeTypes={nodeTypes}
categories={categories}
filter={filter}
onFilterChange={setFilter}
language={language}
expandedCategories={expandedCategories}
onToggleCategory={toggleCategory}
excludedCategories={sidebarExcludedCategories}
/>
);
};
const configurableSelected =
selectedNode &&
['input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'trigger.', 'flow.', 'file.'].some((p) =>
selectedNode.type.startsWith(p)
);
return (
<div className={styles.container}>
{renderSidebar()}
<div className={styles.canvas}>
<CanvasHeader
workflows={workflows}
currentWorkflowId={currentWorkflowId}
onWorkflowSelect={handleWorkflowSelect}
onNew={handleNew}
onSave={handleSave}
onExecute={handleExecute}
onWorkflowSettings={() => setWorkflowSettingsOpen(true)}
saving={saving}
executing={executing}
hasNodes={canvasNodes.length > 0}
executeResult={executeResult}
/>
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<FlowCanvas
nodes={canvasNodes}
connections={canvasConnections}
nodeTypes={nodeTypes}
onNodesChange={setCanvasNodes}
onConnectionsChange={setCanvasConnections}
onDropNodeType={handleDropNodeType}
getLabel={(node) => node.title ?? node.label ?? node.type}
getCategoryIcon={getCategoryIcon}
onSelectionChange={setSelectedNode}
/>
</div>
{configurableSelected && selectedNode && (
<Automation2DataFlowProvider
node={selectedNode}
nodes={canvasNodes}
connections={canvasConnections}
nodeOutputsPreview={nodeOutputsPreview}
nodeTypes={nodeTypes}
language={language}
>
<NodeConfigPanel
node={selectedNode}
nodeType={nodeTypes.find((nt) => nt.id === selectedNode.type)}
language={language}
onParametersChange={handleNodeParametersChange}
onMergeNodeParameters={handleMergeNodeParameters}
onNodeUpdate={handleNodeUpdate}
instanceId={instanceId}
request={request}
/>
</Automation2DataFlowProvider>
)}
</div>
</div>
<PromptDialog />
<WorkflowConfigurationModal
open={workflowSettingsOpen}
onClose={() => setWorkflowSettingsOpen(false)}
invocations={invocations}
onApply={handleApplyWorkflowConfiguration}
/>
</div>
);
};
export default Automation2FlowEditor;