992 lines
38 KiB
TypeScript
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;
|