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

992 lines
38 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, 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,
importWorkflowFromFile,
type NodeType,
type NodeTypeCategory,
type Automation2Graph,
type Automation2Workflow,
type ExecuteGraphResponse,
type WorkflowEntryPoint,
type AutoVersion,
type AutoTemplateScope,
} from '../../../api/workflowApi';
import { FlowCanvas, computeAutoLayout, 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 { findGraphErrors } from '../nodes/shared/paramValidation';
import { getLabel as getParamLabel } from '../nodes/shared/utils';
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
import { usePrompt } from '../../../hooks/usePrompt';
import { EditorChatPanel } from './EditorChatPanel';
import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel';
import { EditorWorkflowChatList } from './EditorWorkflowChatList';
import { RunTracingPanel } from './RunTracingPanel';
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { useToast } from '../../../contexts/ToastContext';
import { useFeatureStore } from '../../../stores/featureStore';
const LOG = '[Automation2]';
const _buildDefaultInvocations = (runLabel: string): WorkflowEntryPoint[] =>
buildInvocationsForPrimaryKind('manual', [], runLabel);
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<Automation2FlowEditorProps> = ({ instanceId,
mandateId,
language = 'de',
initialWorkflowId,
pendingFiles,
onRemovePendingFile,
dataSources,
featureDataSources,
onFileSelect,
onSourcesChanged,
}) => {
const { t } = useLanguage();
const { showError } = useToast();
const { request } = useApiRequest();
const { prompt: promptInput, PromptDialog } = usePrompt();
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
const [portTypeCatalog, setPortTypeCatalog] = useState<Record<string, unknown>>({});
const [systemVariables, setSystemVariables] = useState<Record<string, unknown>>({});
const [formFieldTypes, setFormFieldTypes] = useState<import('../../../api/workflowApi').FormFieldType[]>([]);
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', 'trustee'])
);
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[]>(() =>
_buildDefaultInvocations(t('Jetzt ausführen'))
);
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
const [leftPanelOpen, setLeftPanelOpen] = useState(true);
const [tracingRunId, setTracingRunId] = useState<string | null>(null);
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
const [rightTab, setRightTab] = useState<'nodes' | 'tracing'>('nodes');
type LeftTab = UdbTab | 'ai';
const [udbTab, setUdbTab] = useState<LeftTab>('ai');
const udbContext: UdbContext = useMemo(() => ({
instanceId,
mandateId: mandateId || '',
featureInstanceId: instanceId,
surface: 'graphEditor',
}), [instanceId, mandateId]);
const [versions, setVersions] = useState<AutoVersion[]>([]);
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
const [versionLoading, setVersionLoading] = useState(false);
const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId);
const featureStore = useFeatureStore();
const targetInstanceOptions = useMemo(() => {
const allInstances = featureStore.getAllInstances();
return allInstances
.filter((inst) => inst.mandateId === mandateId || !mandateId)
.map((inst) => ({ id: inst.id, label: inst.instanceLabel || inst.featureCode || inst.id }));
}, [featureStore, mandateId]);
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; }
});
// Verbose schema toggle: shows the static type-reference block (input/output
// schema) and parameter type-badges in NodeConfigPanel. Only the
// CanvasHeader exposes the toggle (sysadmin-only); persisted to localStorage.
const [verboseSchema, setVerboseSchema] = useState(() => {
try { return localStorage.getItem('flowEditor.verboseSchema') === '1'; } catch { return false; }
});
useEffect(() => {
try { localStorage.setItem('flowEditor.verboseSchema', verboseSchema ? '1' : '0'); } catch { /* ignore */ }
}, [verboseSchema]);
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<string, unknown> | undefined),
[canvasNodes, nodeTypes, executeResult?.nodeOutputs]
);
// Phase-4 Schicht-4 — Per-node required-but-unbound errors used by both the
// canvas error badges and the Run-button gate. Graph-level: Save stays
// unconditional (Schicht-4 invariant: WIP must always be persistable).
const nodeErrors = useMemo(
() =>
findGraphErrors(
canvasNodes,
nodeTypes,
(p) => getParamLabel(p.description, language) || p.name,
),
[canvasNodes, nodeTypes, language]
);
const hasGraphErrors = useMemo(() => Object.keys(nodeErrors).length > 0, [nodeErrors]);
const firstErrorNodeId = useMemo(() => Object.keys(nodeErrors)[0] ?? null, [nodeErrors]);
const applyGraphWithSync = useCallback(
(graph: Automation2Graph | null | undefined, wfInvocations: WorkflowEntryPoint[] | undefined) => {
const inv = wfInvocations?.length ? wfInvocations : _buildDefaultInvocations(t('Jetzt ausführen'));
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, t]
);
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: t('Keine Nodes im Workflow.') });
return;
}
// Phase-4 Schicht-4: Run blockiert bei Pflicht-Fehlern. Save bleibt offen.
if (Object.keys(nodeErrors).length > 0) {
const firstId = Object.keys(nodeErrors)[0];
const firstNode = canvasNodes.find((n) => n.id === firstId);
if (firstNode) setSelectedNode(firstNode);
setExecuteResult({
success: false,
error:
t('Workflow hat Pflicht-Felder ohne Quelle. Bitte erst beheben.') +
(firstNode ? ` (${firstNode.title ?? firstNode.label ?? firstNode.type})` : ''),
});
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, t, nodeErrors]);
const handleSave = useCallback(async () => {
const graph = toApiGraph(canvasNodes, canvasConnections);
if (graph.nodes.length === 0) {
setExecuteResult({ success: false, error: t('Keine Nodes zum Speichern.') });
return;
}
// Phase-4 Schicht-4 / AC 9: Save bleibt bei Pflicht-Fehlern erlaubt,
// aber wir berichten die Anzahl in einem nicht-blockierenden Warning,
// damit der User die WIP-Lücken nicht stillschweigend persistiert.
const errorCount = Object.values(nodeErrors).reduce(
(acc, list) => acc + list.length,
0,
);
const errorNodeCount = Object.keys(nodeErrors).length;
const _buildSaveResult = (): ExecuteGraphResponse => ({
success: true,
warning:
errorCount > 0
? t('Gespeichert mit {n} Pflicht-Fehlern in {m} Nodes.')
.replace('{n}', String(errorCount))
.replace('{m}', String(errorNodeCount))
: undefined,
});
setSaving(true);
try {
if (currentWorkflowId) {
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations, targetFeatureInstanceId });
setExecuteResult(_buildSaveResult());
} else {
const label = await promptInput(t('Workflow-Name:'), {
title: t('Workflow speichern'),
defaultValue: t('Neuer Workflow'),
placeholder: t('Name des Workflows'),
});
if (!label) {
setSaving(false);
return;
}
const created = await createWorkflow(request, instanceId, {
label: label.trim() || t('Neuer Workflow'),
graph,
invocations,
targetFeatureInstanceId,
});
setCurrentWorkflowId(created.id);
if (created.invocations?.length) setInvocations(created.invocations);
setWorkflows((prev) => [...prev, created]);
setExecuteResult(_buildSaveResult());
}
} catch (err: unknown) {
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
} finally {
setSaving(false);
}
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors, targetFeatureInstanceId]);
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);
}
setTargetFeatureInstanceId(wf.targetFeatureInstanceId ?? instanceId);
setWorkflows((prev) => {
const idx = prev.findIndex((w) => w.id === workflowId);
if (idx === -1) return [...prev, wf];
const next = prev.slice();
next[idx] = { ...prev[idx], ...wf };
return next;
});
} catch (err: unknown) {
const status = (err as { response?: { status?: number } })?.response?.status;
if (status === 404) {
setWorkflows((prev) => prev.filter((w) => w.id !== workflowId));
setCurrentWorkflowId((prev) => (prev === workflowId ? null : prev));
setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
try {
const result = await fetchWorkflows(request, instanceId);
setWorkflows(Array.isArray(result) ? result : result.items);
} catch (refreshErr) {
console.error(`${LOG} workflows refresh failed`, refreshErr);
}
return;
}
setExecuteResult({
success: false,
error: err instanceof Error ? err.message : String(err),
});
}
},
[request, instanceId, handleFromApiGraph, applyGraphWithSync, t]
);
const handleWorkflowSelect = useCallback(
(workflowId: string | null) => {
setCurrentWorkflowId(workflowId);
if (workflowId) handleLoad(workflowId);
else {
setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
}
},
[handleLoad, applyGraphWithSync, t]
);
const handleNew = useCallback(() => {
setCurrentWorkflowId(null);
setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
}, [applyGraphWithSync, t]);
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);
if (data.portTypeCatalog) {
setPortTypeCatalog(data.portTypeCatalog);
setRegistryCatalog(data.portTypeCatalog as never);
}
if (data.systemVariables) setSystemVariables(data.systemVariables);
if (data.formFieldTypes) setFormFieldTypes(data.formFieldTypes);
} 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<string | null | undefined>(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: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
}, [
loading,
nodeTypes.length,
currentWorkflowId,
initialWorkflowId,
canvasNodes.length,
applyGraphWithSync,
t,
]);
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 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 handleTargetInstanceChange = useCallback(async (newTargetId: string) => {
setTargetFeatureInstanceId(newTargetId || null);
if (currentWorkflowId && newTargetId) {
try {
await updateWorkflow(request, instanceId, currentWorkflowId, { targetFeatureInstanceId: newTargetId });
} catch (e: unknown) {
console.error(`${LOG} target instance update failed`, e);
}
}
}, [request, instanceId, currentWorkflowId]);
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) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`${LOG} rename failed`, e);
showError(t('Workflow umbenennen fehlgeschlagen: {msg}', { msg }));
}
}, [request, instanceId, showError, t]);
const handleAutoLayout = useCallback(() => {
setCanvasNodes((prev) => computeAutoLayout(prev, canvasConnections));
}, [canvasConnections]);
const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]);
const renderSidebar = () => {
if (loading) {
return (
<div className={styles.sidebar} style={_sidebarStyle}>
<div className={styles.sidebarHeader}>
<h3 className={styles.sidebarTitle}>{t('Knoten')}</h3>
</div>
<div className={styles.loading}>
<FaSpinner className={styles.spinner} style={{ marginBottom: '0.5rem' }} />
<p>{t('Lade Nodetypen…')}</p>
</div>
</div>
);
}
if (error) {
return (
<div className={styles.sidebar} style={_sidebarStyle}>
<div className={styles.sidebarHeader}>
<h3 className={styles.sidebarTitle}>{t('Knoten')}</h3>
</div>
<div className={styles.error}>
<p>{error}</p>
<button className={styles.retryButton} onClick={loadNodeTypes}>
{t('Erneut versuchen')}
</button>
</div>
</div>
);
}
return (
<NodeSidebar
nodeTypes={nodeTypes}
categories={categories}
filter={filter}
onFilterChange={setFilter}
language={language}
expandedCategories={expandedCategories}
onToggleCategory={toggleCategory}
excludedCategories={sidebarExcludedCategories}
style={_sidebarStyle}
/>
);
};
const configurableSelected =
selectedNode &&
[
'input.',
'ai.',
'email.',
'sharepoint.',
'clickup.',
'trigger.',
'flow.',
'file.',
'trustee.',
'context.',
'data.',
'redmine.',
].some((p) => selectedNode.type.startsWith(p));
return (
<div className={styles.container}>
{/* Left panel: Workspace (Chats / Dateien / Quellen) */}
{leftPanelOpen && (<>
<div style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}>
<div className={styles.rightTabBar}>
{(['ai', 'chats', 'files', 'sources'] as const).map((tab) => (
<button
key={tab}
className={`${styles.rightTab} ${udbTab === tab ? styles.rightTabActive : ''}`}
onClick={() => setUdbTab(tab)}
>
{{ ai: t('KI'), chats: t('Chats'), files: t('Dateien'), sources: t('Quellen') }[tab]}
</button>
))}
</div>
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
{/*
KI-Panel bleibt gemountet, damit der Chatverlauf beim Tab-Wechsel
(Chats / Dateien / Quellen) erhalten bleibt. Nur per CSS umblenden.
`key={currentWorkflowId}` setzt den Verlauf sauber zurück, wenn der
Nutzer einen anderen Workflow wählt.
*/}
<div style={{
display: udbTab === 'ai' ? 'flex' : 'none',
flexDirection: 'column',
height: '100%',
}}>
<EditorChatPanel
key={currentWorkflowId || '__noWorkflow__'}
instanceId={instanceId}
workflowId={currentWorkflowId}
onGraphUpdated={() => { if (currentWorkflowId) handleLoad(currentWorkflowId); }}
pendingFiles={pendingFiles}
onRemovePendingFile={onRemovePendingFile}
dataSources={dataSources}
featureDataSources={featureDataSources}
/>
</div>
{udbTab === 'chats' && (
<EditorWorkflowChatList
workflows={workflows}
currentWorkflowId={currentWorkflowId}
onSelect={handleWorkflowSelect}
onNew={handleNew}
t={t}
/>
)}
{(udbTab === 'files' || udbTab === 'sources') && (
<UnifiedDataBar
context={udbContext}
activeTab={udbTab as UdbTab}
onTabChange={(tab) => setUdbTab(tab as LeftTab)}
hideTabs={['chats']}
onFileSelect={onFileSelect}
onSourcesChanged={onSourcesChanged}
onWorkflowImportedFromFile={async (workflowId) => {
await loadWorkflows();
handleWorkflowSelect(workflowId);
}}
/>
)}
</div>
</div>
<div className={styles.resizeDivider} onMouseDown={(e) => _startResize('left', e)} />
</>)}
{/* Canvas area - center */}
<div className={styles.canvas}>
<CanvasHeader
workflows={workflows}
currentWorkflowId={currentWorkflowId}
onWorkflowSelect={handleWorkflowSelect}
onNew={handleNew}
onSave={handleSave}
onExecute={handleExecute}
onWorkflowSettings={() => setWorkflowSettingsOpen(true)}
onToggleChat={() => setLeftPanelOpen((prev) => !prev)}
saving={saving}
executing={executing}
hasNodes={canvasNodes.length > 0}
executeBlockedReason={
hasGraphErrors
? t('Pflicht-Felder ohne Quelle vorhanden. Klicken markiert die erste betroffene Node.')
: null
}
onExecuteBlockedClick={() => {
if (firstErrorNodeId) {
const n = canvasNodes.find((x) => x.id === firstErrorNodeId);
if (n) setSelectedNode(n);
}
}}
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}
onAutoLayout={handleAutoLayout}
verboseSchema={verboseSchema}
onVerboseSchemaChange={setVerboseSchema}
targetFeatureInstanceId={targetFeatureInstanceId}
onTargetInstanceChange={handleTargetInstanceChange}
targetInstanceOptions={targetInstanceOptions}
/>
<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}
highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined}
nodeErrors={nodeErrors}
onExternalDrop={async (mime, payload) => {
if (mime !== 'application/json+workflow' || !instanceId) return false;
const p = payload as { files?: Array<{ id: string }> } | undefined;
const fileId = p?.files?.[0]?.id;
if (!fileId) return false;
try {
const result = await importWorkflowFromFile(request, instanceId, { fileId });
await loadWorkflows();
if (result?.workflow?.id) handleWorkflowSelect(result.workflow.id);
return true;
} catch (e) {
console.error(`${LOG} workflow drop import failed`, e);
return false;
}
}}
/>
</div>
{configurableSelected && selectedNode && (
<Automation2DataFlowProvider
node={selectedNode}
nodes={canvasNodes}
connections={canvasConnections}
nodeOutputsPreview={nodeOutputsPreview}
nodeTypes={nodeTypes}
language={language}
portTypeCatalog={portTypeCatalog as Record<string, never>}
systemVariables={systemVariables as Record<string, never>}
formFieldTypes={formFieldTypes}
instanceId={instanceId}
request={request}
>
<NodeConfigPanel
node={selectedNode}
nodeType={nodeTypes.find((nt) => nt.id === selectedNode.type)}
language={language}
onParametersChange={handleNodeParametersChange}
onMergeNodeParameters={handleMergeNodeParameters}
onNodeUpdate={handleNodeUpdate}
instanceId={instanceId}
request={request}
verboseSchema={verboseSchema}
/>
</Automation2DataFlowProvider>
)}
</div>
</div>
{/* Right panel: Nodes + Tracing tabs */}
<div className={styles.resizeDivider} onMouseDown={(e) => _startResize('right', e)} />
<div style={{ width: sidebarWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-secondary, #f8f9fa)' }}>
<div className={styles.rightTabBar}>
<button
className={`${styles.rightTab} ${rightTab === 'nodes' ? styles.rightTabActive : ''}`}
onClick={() => setRightTab('nodes')}
>
{t('Knoten')}
</button>
<button
className={`${styles.rightTab} ${rightTab === 'tracing' ? styles.rightTabActive : ''}`}
onClick={() => { setRightTab('tracing'); if (!tracingRunId) setTracingRunId('select'); }}
>
{t('Ablaufverfolgung')}
</button>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
{rightTab === 'nodes' ? (
renderSidebar()
) : (
<RunTracingPanel
instanceId={instanceId}
runId={tracingRunId === 'select' ? null : tracingRunId}
onNodeSelect={(nodeId) => {
const node = canvasNodes.find((n) => n.id === nodeId);
if (node) setSelectedNode(node);
}}
onActiveStepsChange={setTracingNodeStatuses}
/>
)}
</div>
</div>
<PromptDialog />
<WorkflowConfigurationModal
open={workflowSettingsOpen}
onClose={() => setWorkflowSettingsOpen(false)}
invocations={invocations}
onApply={handleApplyWorkflowConfiguration}
/>
<TemplatePicker
open={templatePickerOpen}
onClose={() => setTemplatePickerOpen(false)}
onSelect={handleNewFromTemplate}
instanceId={instanceId}
request={request}
/>
</div>
);
};
export default Automation2FlowEditor;