frontend_nyla/src/components/Automation2FlowEditor/Automation2FlowEditor.tsx
2026-03-22 18:22:06 +01:00

328 lines
10 KiB
TypeScript

/**
* Automation2FlowEditor
*
* n8n-style flow builder with backend-driven node list.
* Composes: NodeSidebar, FlowCanvas, NodeConfigPanel, CanvasHeader.
*/
import React, { useState, useEffect, useCallback } 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,
} from '../../api/automation2Api';
import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas';
import { NodeConfigPanel } from './NodeConfigPanel';
import { NodeSidebar } from './NodeSidebar';
import { CanvasHeader } from './CanvasHeader';
import { getCategoryIcon } from './utils';
import { fromApiGraph, toApiGraph } from './graphUtils';
import styles from './Automation2FlowEditor.module.css';
const LOG = '[Automation2]';
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 [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'])
);
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 handleFromApiGraph = useCallback(
(graph: Automation2Graph) => {
const { nodes, connections } = fromApiGraph(graph, nodeTypes);
setCanvasNodes(nodes);
setCanvasConnections(connections);
},
[nodeTypes]
);
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 result = await executeGraph(
request,
instanceId,
graph,
currentWorkflowId ?? undefined
);
setExecuteResult(result);
} catch (err: unknown) {
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
} finally {
setExecuting(false);
}
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId]);
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]);
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 });
setExecuteResult({ success: true } as ExecuteGraphResponse);
} else {
const label = prompt('Workflow-Name:', 'Neuer Workflow') || 'Neuer Workflow';
const created = await createWorkflow(request, instanceId, { label, graph });
setCurrentWorkflowId(created.id);
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]);
const handleLoad = useCallback(
async (workflowId: string) => {
try {
const wf = await fetchWorkflow(request, instanceId, workflowId);
if (wf.graph) handleFromApiGraph(wf.graph);
} catch (err: unknown) {
setExecuteResult({
success: false,
error: err instanceof Error ? err.message : String(err),
});
}
},
[request, instanceId, handleFromApiGraph]
);
const handleWorkflowSelect = useCallback(
(workflowId: string | null) => {
setCurrentWorkflowId(workflowId);
if (workflowId) handleLoad(workflowId);
else {
setCanvasNodes([]);
setCanvasConnections([]);
setExecuteResult(null);
}
},
[handleLoad]
);
const handleNew = useCallback(() => {
setCanvasNodes([]);
setCanvasConnections([]);
setCurrentWorkflowId(null);
setExecuteResult(null);
}, []);
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
setCanvasNodes((prev) => prev.map((n) => (n.id === nodeId ? { ...n, parameters } : n)));
}, []);
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]);
useEffect(() => {
loadNodeTypes();
}, [loadNodeTypes]);
useEffect(() => {
loadWorkflows();
}, [loadWorkflows]);
useEffect(() => {
if (initialWorkflowId && workflows.length > 0 && !currentWorkflowId) {
handleWorkflowSelect(initialWorkflowId);
}
}, [initialWorkflowId, workflows, currentWorkflowId, handleWorkflowSelect]);
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) => {
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}
/>
);
};
return (
<div className={styles.container}>
{renderSidebar()}
<div className={styles.canvas}>
<CanvasHeader
workflows={workflows}
currentWorkflowId={currentWorkflowId}
onWorkflowSelect={handleWorkflowSelect}
onNew={handleNew}
onSave={handleSave}
onExecute={handleExecute}
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>
{selectedNode &&
['input.', 'ai.', 'email.', 'sharepoint.'].some((p) =>
selectedNode.type.startsWith(p)
) && (
<NodeConfigPanel
node={selectedNode}
nodeType={nodeTypes.find((nt) => nt.id === selectedNode.type)}
language={language}
onParametersChange={handleNodeParametersChange}
instanceId={instanceId}
request={request}
/>
)}
</div>
</div>
</div>
);
};
export default Automation2FlowEditor;