335 lines
11 KiB
TypeScript
335 lines
11 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'])
|
|
);
|
|
const [expandedIoMethods, setExpandedIoMethods] = useState<Set<string>>(new Set());
|
|
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 toggleIoMethod = useCallback((method: string) => {
|
|
setExpandedIoMethods((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(method)) next.delete(method);
|
|
else next.add(method);
|
|
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}
|
|
expandedIoMethods={expandedIoMethods}
|
|
onToggleCategory={toggleCategory}
|
|
onToggleIoMethod={toggleIoMethod}
|
|
/>
|
|
);
|
|
};
|
|
|
|
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?.type?.startsWith('input.') && (
|
|
<NodeConfigPanel
|
|
node={selectedNode}
|
|
nodeType={nodeTypes.find((nt) => nt.id === selectedNode.type)}
|
|
language={language}
|
|
onParametersChange={handleNodeParametersChange}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Automation2FlowEditor;
|