feat: added trigger nodes such that they are not hidden anymore
This commit is contained in:
parent
bc30ae7bd2
commit
41eaa63d49
5 changed files with 66 additions and 274 deletions
|
|
@ -1,9 +1,8 @@
|
||||||
/**
|
/**
|
||||||
* Automation2FlowEditor
|
* Automation2FlowEditor
|
||||||
*
|
*
|
||||||
* n8n-style flow builder with backend-driven node list.
|
* n8n-style flow builder with backend-driven node list and categories.
|
||||||
* Starts and invocations are driven by backend graph + defaults; canvas start
|
* Start nodes come from the API (category `start`); invocations are synced on the server from the graph.
|
||||||
* node stays in sync on load via `syncCanvasStartNode`.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
|
|
@ -47,10 +46,6 @@ import { CanvasHeader } from './CanvasHeader';
|
||||||
import { TemplatePicker } from './TemplatePicker';
|
import { TemplatePicker } from './TemplatePicker';
|
||||||
import { getCategoryIcon } from '../nodes/shared/utils';
|
import { getCategoryIcon } from '../nodes/shared/utils';
|
||||||
import { fromApiGraph, toApiGraph } from '../nodes/shared/graphUtils';
|
import { fromApiGraph, toApiGraph } from '../nodes/shared/graphUtils';
|
||||||
import {
|
|
||||||
syncCanvasStartNode,
|
|
||||||
buildInvocationsForPrimaryKind,
|
|
||||||
} from '../nodes/runtime/workflowStartSync';
|
|
||||||
import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry';
|
import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry';
|
||||||
import { findGraphErrors } from '../nodes/shared/paramValidation';
|
import { findGraphErrors } from '../nodes/shared/paramValidation';
|
||||||
import { getLabel as getParamLabel } from '../nodes/shared/utils';
|
import { getLabel as getParamLabel } from '../nodes/shared/utils';
|
||||||
|
|
@ -83,9 +78,6 @@ function cloneCanvasSnapshot(nodes: CanvasNode[], connections: CanvasConnection[
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const _buildDefaultInvocations = (runLabel: string): WorkflowEntryPoint[] =>
|
|
||||||
buildInvocationsForPrimaryKind('manual', [], runLabel);
|
|
||||||
|
|
||||||
interface Automation2FlowEditorProps {
|
interface Automation2FlowEditorProps {
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
mandateId?: string;
|
mandateId?: string;
|
||||||
|
|
@ -123,7 +115,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||||
new Set(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup', 'trustee'])
|
new Set(['start', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup', 'trustee'])
|
||||||
);
|
);
|
||||||
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
|
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
|
||||||
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
|
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
|
||||||
|
|
@ -146,9 +138,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
|
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
|
||||||
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
|
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>(() =>
|
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>([]);
|
||||||
_buildDefaultInvocations(t('Jetzt ausführen'))
|
|
||||||
);
|
|
||||||
const [leftPanelOpen, setLeftPanelOpen] = useState(true);
|
const [leftPanelOpen, setLeftPanelOpen] = useState(true);
|
||||||
const [tracingRunId, setTracingRunId] = useState<string | null>(null);
|
const [tracingRunId, setTracingRunId] = useState<string | null>(null);
|
||||||
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
|
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
|
||||||
|
|
@ -220,7 +210,18 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
document.body.style.userSelect = 'none';
|
document.body.style.userSelect = 'none';
|
||||||
}, [leftPanelWidth, sidebarWidth]);
|
}, [leftPanelWidth, sidebarWidth]);
|
||||||
|
|
||||||
const sidebarExcludedCategories = useMemo(() => new Set(['trigger']), []);
|
const startNodeTypeIds = useMemo(
|
||||||
|
() => new Set(nodeTypes.filter((n) => n.category === 'start').map((n) => n.id)),
|
||||||
|
[nodeTypes]
|
||||||
|
);
|
||||||
|
const hasCanvasStartNode = useMemo(
|
||||||
|
() => canvasNodes.some((n) => startNodeTypeIds.has(n.type)),
|
||||||
|
[canvasNodes, startNodeTypeIds]
|
||||||
|
);
|
||||||
|
const missingStartNodeBlocking = useMemo(
|
||||||
|
() => canvasNodes.length > 0 && !hasCanvasStartNode,
|
||||||
|
[canvasNodes.length, hasCanvasStartNode]
|
||||||
|
);
|
||||||
|
|
||||||
const nodeOutputsPreview = useMemo(
|
const nodeOutputsPreview = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -303,20 +304,13 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
if (!opts?.skipHistory && !suppressCanvasHistoryRef.current) {
|
if (!opts?.skipHistory && !suppressCanvasHistoryRef.current) {
|
||||||
pushCanvasHistoryPastFromCurrent();
|
pushCanvasHistoryPastFromCurrent();
|
||||||
}
|
}
|
||||||
const inv = wfInvocations?.length ? wfInvocations : _buildDefaultInvocations(t('Jetzt ausführen'));
|
setInvocations(wfInvocations ?? []);
|
||||||
setInvocations(inv);
|
const g: Automation2Graph = graph ?? { nodes: [], connections: [] };
|
||||||
if (!graph?.nodes?.length) {
|
const { nodes, connections } = fromApiGraph(g, nodeTypes);
|
||||||
const synced = syncCanvasStartNode([], [], inv, nodeTypes, language);
|
setCanvasNodes(nodes);
|
||||||
setCanvasNodes(synced.nodes);
|
setCanvasConnections(connections);
|
||||||
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, pushCanvasHistoryPastFromCurrent]
|
[nodeTypes, pushCanvasHistoryPastFromCurrent]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFromApiGraph = useCallback(
|
const handleFromApiGraph = useCallback(
|
||||||
|
|
@ -345,6 +339,13 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (missingStartNodeBlocking) {
|
||||||
|
setExecuteResult({
|
||||||
|
success: false,
|
||||||
|
error: t('Ohne Start-Node kann der Workflow nicht ausgeführt werden.'),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
setExecuting(true);
|
setExecuting(true);
|
||||||
setExecuteResult(null);
|
setExecuteResult(null);
|
||||||
try {
|
try {
|
||||||
|
|
@ -362,7 +363,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
} finally {
|
} finally {
|
||||||
setExecuting(false);
|
setExecuting(false);
|
||||||
}
|
}
|
||||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t, nodeErrors]);
|
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t, nodeErrors, missingStartNodeBlocking]);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
const graph = toApiGraph(canvasNodes, canvasConnections);
|
const graph = toApiGraph(canvasNodes, canvasConnections);
|
||||||
|
|
@ -378,19 +379,32 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const errorNodeCount = Object.keys(nodeErrors).length;
|
const errorNodeCount = Object.keys(nodeErrors).length;
|
||||||
const _buildSaveResult = (): ExecuteGraphResponse => ({
|
const _buildSaveResult = (): ExecuteGraphResponse => {
|
||||||
success: true,
|
const parts: string[] = [];
|
||||||
warning:
|
if (errorCount > 0) {
|
||||||
errorCount > 0
|
parts.push(
|
||||||
? t('Gespeichert mit {n} Pflicht-Fehlern in {m} Nodes.')
|
t('Gespeichert mit {n} Pflicht-Fehlern in {m} Nodes.')
|
||||||
.replace('{n}', String(errorCount))
|
.replace('{n}', String(errorCount))
|
||||||
.replace('{m}', String(errorNodeCount))
|
.replace('{m}', String(errorNodeCount))
|
||||||
: undefined,
|
);
|
||||||
});
|
}
|
||||||
|
if (canvasNodes.length > 0 && !hasCanvasStartNode) {
|
||||||
|
parts.push(t('Ohne Start-Node kann der Workflow nicht ausgeführt werden.'));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
warning: parts.length ? parts.join(' ') : undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (currentWorkflowId) {
|
if (currentWorkflowId) {
|
||||||
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations, targetFeatureInstanceId });
|
const updated = await updateWorkflow(request, instanceId, currentWorkflowId, {
|
||||||
|
graph,
|
||||||
|
invocations,
|
||||||
|
targetFeatureInstanceId,
|
||||||
|
});
|
||||||
|
setInvocations(updated.invocations ?? []);
|
||||||
setExecuteResult(_buildSaveResult());
|
setExecuteResult(_buildSaveResult());
|
||||||
} else {
|
} else {
|
||||||
const label = await promptInput(t('Workflow-Name:'), {
|
const label = await promptInput(t('Workflow-Name:'), {
|
||||||
|
|
@ -409,7 +423,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
targetFeatureInstanceId,
|
targetFeatureInstanceId,
|
||||||
});
|
});
|
||||||
setCurrentWorkflowId(created.id);
|
setCurrentWorkflowId(created.id);
|
||||||
if (created.invocations?.length) setInvocations(created.invocations);
|
setInvocations(created.invocations ?? []);
|
||||||
setWorkflows((prev) => [...prev, created]);
|
setWorkflows((prev) => [...prev, created]);
|
||||||
setExecuteResult(_buildSaveResult());
|
setExecuteResult(_buildSaveResult());
|
||||||
}
|
}
|
||||||
|
|
@ -418,7 +432,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors, targetFeatureInstanceId]);
|
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors, targetFeatureInstanceId, hasCanvasStartNode]);
|
||||||
|
|
||||||
const handleLoad = useCallback(
|
const handleLoad = useCallback(
|
||||||
async (workflowId: string) => {
|
async (workflowId: string) => {
|
||||||
|
|
@ -443,7 +457,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setWorkflows((prev) => prev.filter((w) => w.id !== workflowId));
|
setWorkflows((prev) => prev.filter((w) => w.id !== workflowId));
|
||||||
setCurrentWorkflowId((prev) => (prev === workflowId ? null : prev));
|
setCurrentWorkflowId((prev) => (prev === workflowId ? null : prev));
|
||||||
setExecuteResult(null);
|
setExecuteResult(null);
|
||||||
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
|
applyGraphWithSync({ nodes: [], connections: [] }, []);
|
||||||
try {
|
try {
|
||||||
const result = await fetchWorkflows(request, instanceId);
|
const result = await fetchWorkflows(request, instanceId);
|
||||||
setWorkflows(Array.isArray(result) ? result : result.items);
|
setWorkflows(Array.isArray(result) ? result : result.items);
|
||||||
|
|
@ -467,7 +481,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
if (workflowId) handleLoad(workflowId);
|
if (workflowId) handleLoad(workflowId);
|
||||||
else {
|
else {
|
||||||
setExecuteResult(null);
|
setExecuteResult(null);
|
||||||
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
|
applyGraphWithSync({ nodes: [], connections: [] }, []);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleLoad, applyGraphWithSync, t]
|
[handleLoad, applyGraphWithSync, t]
|
||||||
|
|
@ -476,7 +490,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
const handleNew = useCallback(() => {
|
const handleNew = useCallback(() => {
|
||||||
setCurrentWorkflowId(null);
|
setCurrentWorkflowId(null);
|
||||||
setExecuteResult(null);
|
setExecuteResult(null);
|
||||||
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
|
applyGraphWithSync({ nodes: [], connections: [] }, []);
|
||||||
}, [applyGraphWithSync, t]);
|
}, [applyGraphWithSync, t]);
|
||||||
|
|
||||||
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
|
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
|
||||||
|
|
@ -574,7 +588,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
if (loading || nodeTypes.length === 0) return;
|
if (loading || nodeTypes.length === 0) return;
|
||||||
if (currentWorkflowId || initialWorkflowId) return;
|
if (currentWorkflowId || initialWorkflowId) return;
|
||||||
if (canvasNodes.length > 0) return;
|
if (canvasNodes.length > 0) return;
|
||||||
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')), {
|
applyGraphWithSync({ nodes: [], connections: [] }, [], {
|
||||||
skipHistory: true,
|
skipHistory: true,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
|
|
@ -598,7 +612,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
|
|
||||||
const handleDropNodeType = useCallback(
|
const handleDropNodeType = useCallback(
|
||||||
(nodeTypeId: string, x: number, y: number) => {
|
(nodeTypeId: string, x: number, y: number) => {
|
||||||
if (nodeTypeId.startsWith('trigger.')) return;
|
|
||||||
const nt = nodeTypes.find((n) => n.id === nodeTypeId);
|
const nt = nodeTypes.find((n) => n.id === nodeTypeId);
|
||||||
if (!nt) return;
|
if (!nt) return;
|
||||||
const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
|
@ -795,7 +808,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
language={language}
|
language={language}
|
||||||
expandedCategories={expandedCategories}
|
expandedCategories={expandedCategories}
|
||||||
onToggleCategory={toggleCategory}
|
onToggleCategory={toggleCategory}
|
||||||
excludedCategories={sidebarExcludedCategories}
|
|
||||||
style={_sidebarStyle}
|
style={_sidebarStyle}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -937,7 +949,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
executeBlockedReason={
|
executeBlockedReason={
|
||||||
hasGraphErrors
|
hasGraphErrors
|
||||||
? t('Pflicht-Felder ohne Quelle vorhanden. Klicken markiert die erste betroffene Node.')
|
? t('Pflicht-Felder ohne Quelle vorhanden. Klicken markiert die erste betroffene Node.')
|
||||||
: null
|
: missingStartNodeBlocking
|
||||||
|
? t('Ohne Start-Node kann der Workflow nicht ausgeführt werden.')
|
||||||
|
: null
|
||||||
}
|
}
|
||||||
onExecuteBlockedClick={() => {
|
onExecuteBlockedClick={() => {
|
||||||
if (firstErrorNodeId) {
|
if (firstErrorNodeId) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/**
|
/**
|
||||||
* NodeSidebar - Sidebar with searchable, collapsible node list.
|
* NodeSidebar - Sidebar with searchable, collapsible node list.
|
||||||
* Groups node types by category (trigger, input, flow, data, ai, email, sharepoint).
|
* Groups node types by category (start, input, flow, data, ai, email, sharepoint).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
|
|
@ -21,7 +21,7 @@ interface NodeSidebarProps {
|
||||||
language: string;
|
language: string;
|
||||||
expandedCategories: Set<string>;
|
expandedCategories: Set<string>;
|
||||||
onToggleCategory: (id: string) => void;
|
onToggleCategory: (id: string) => void;
|
||||||
/** Hide palette categories (e.g. trigger — start node comes from workflow config only) */
|
/** Hide palette categories (optional; e.g. feature flags) */
|
||||||
excludedCategories?: Set<string>;
|
excludedCategories?: Set<string>;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
/**
|
|
||||||
* Single canonical start node on the canvas — id and type follow workflow primary entry kind.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
|
|
||||||
import type { NodeType } from '../../../../api/workflowApi';
|
|
||||||
import type { WorkflowEntryPoint } from '../../../../api/workflowApi';
|
|
||||||
import { getLabel } from '../shared/utils';
|
|
||||||
|
|
||||||
export const CANVAS_START_NODE_ID = 'start';
|
|
||||||
|
|
||||||
/** Primary entry is always the first invocation (gear configures index 0). */
|
|
||||||
export function getPrimaryEntry(invocations: WorkflowEntryPoint[] | undefined): WorkflowEntryPoint | undefined {
|
|
||||||
return invocations?.[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Kind of the primary entry (drives canvas node type) */
|
|
||||||
export function getPrimaryStartKind(invocations: WorkflowEntryPoint[] | undefined): string {
|
|
||||||
return getPrimaryEntry(invocations)?.kind ?? 'manual';
|
|
||||||
}
|
|
||||||
|
|
||||||
function entryTitle(entry: WorkflowEntryPoint | undefined, language: string): string {
|
|
||||||
if (!entry?.title) return '';
|
|
||||||
const t = entry.title;
|
|
||||||
if (typeof t === 'string') return t.trim();
|
|
||||||
const s = t[language] || t.de || t.en || Object.values(t)[0];
|
|
||||||
return (s != null ? String(s) : '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapKindToNodeType(kind: string): string {
|
|
||||||
if (kind === 'form') return 'trigger.form';
|
|
||||||
if (kind === 'schedule') return 'trigger.schedule';
|
|
||||||
// Immer aktiv: zunächst Standard-Start; Listener (E-Mail, Webhook, …) folgt separat
|
|
||||||
if (kind === 'always_on') return 'trigger.manual';
|
|
||||||
return 'trigger.manual';
|
|
||||||
}
|
|
||||||
|
|
||||||
function categoryForKind(kind: string): 'on_demand' | 'always_on' {
|
|
||||||
if (kind === 'manual' || kind === 'form') return 'on_demand';
|
|
||||||
return 'always_on';
|
|
||||||
}
|
|
||||||
|
|
||||||
function titleForStartNode(
|
|
||||||
kind: string,
|
|
||||||
invocations: WorkflowEntryPoint[],
|
|
||||||
nodeTypes: NodeType[],
|
|
||||||
language: string
|
|
||||||
): string {
|
|
||||||
const custom = entryTitle(getPrimaryEntry(invocations), language);
|
|
||||||
if (custom) return custom;
|
|
||||||
const nt = nodeTypes.find((n) => n.id === mapKindToNodeType(kind));
|
|
||||||
if (nt) return getLabel(nt.label, language);
|
|
||||||
return 'Start';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rewire connections when replacing node ids */
|
|
||||||
function rewireConnections(
|
|
||||||
connections: CanvasConnection[],
|
|
||||||
fromId: string,
|
|
||||||
toId: string
|
|
||||||
): CanvasConnection[] {
|
|
||||||
if (fromId === toId) return connections;
|
|
||||||
return connections.map((c) => ({
|
|
||||||
...c,
|
|
||||||
sourceId: c.sourceId === fromId ? toId : c.sourceId,
|
|
||||||
targetId: c.targetId === fromId ? toId : c.targetId,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Deep-rewrite ref.nodeId in parameters (e.g. flow.ifElse condition.ref) */
|
|
||||||
function rewireRefInParams(params: unknown, fromIds: Set<string>, toId: string): unknown {
|
|
||||||
if (params == null) return params;
|
|
||||||
if (typeof params === 'object' && params !== null && 'type' in params && 'nodeId' in params) {
|
|
||||||
const obj = params as { type?: string; nodeId?: string; path?: unknown };
|
|
||||||
if (obj.type === 'ref' && typeof obj.nodeId === 'string' && fromIds.has(obj.nodeId)) {
|
|
||||||
return { ...obj, nodeId: toId };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Array.isArray(params)) {
|
|
||||||
return params.map((item) => rewireRefInParams(item, fromIds, toId));
|
|
||||||
}
|
|
||||||
if (typeof params === 'object' && params !== null) {
|
|
||||||
const out: Record<string, unknown> = {};
|
|
||||||
for (const [k, v] of Object.entries(params)) {
|
|
||||||
out[k] = rewireRefInParams(v, fromIds, toId);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rewrite refs in all nodes' parameters when trigger id changes */
|
|
||||||
function rewireRefsInNodes(
|
|
||||||
nodes: CanvasNode[],
|
|
||||||
fromIds: Set<string>,
|
|
||||||
toId: string
|
|
||||||
): CanvasNode[] {
|
|
||||||
if (fromIds.size === 0) return nodes;
|
|
||||||
return nodes.map((n) => {
|
|
||||||
const p = n.parameters;
|
|
||||||
if (!p || typeof p !== 'object') return n;
|
|
||||||
const next = rewireRefInParams(p, fromIds, toId);
|
|
||||||
if (next === p) return n;
|
|
||||||
return { ...n, parameters: next as Record<string, unknown> };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remove duplicate trigger nodes; keep first, merge connections onto it */
|
|
||||||
function dedupeTriggers(
|
|
||||||
nodes: CanvasNode[],
|
|
||||||
connections: CanvasConnection[]
|
|
||||||
): { nodes: CanvasNode[]; connections: CanvasConnection[] } {
|
|
||||||
const triggers = nodes.filter((n) => n.type.startsWith('trigger.'));
|
|
||||||
if (triggers.length <= 1) return { nodes, connections };
|
|
||||||
|
|
||||||
const keep = triggers[0];
|
|
||||||
const removeIds = new Set(triggers.slice(1).map((n) => n.id));
|
|
||||||
let nextConn = connections;
|
|
||||||
for (const rid of removeIds) {
|
|
||||||
nextConn = rewireConnections(nextConn, rid, keep.id);
|
|
||||||
}
|
|
||||||
const newNodes = nodes.filter((n) => !removeIds.has(n.id));
|
|
||||||
return { nodes: newNodes, connections: nextConn };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalize canonical id `start` and update type/labels from primary kind */
|
|
||||||
export function syncCanvasStartNode(
|
|
||||||
nodes: CanvasNode[],
|
|
||||||
connections: CanvasConnection[],
|
|
||||||
invocations: WorkflowEntryPoint[],
|
|
||||||
nodeTypes: NodeType[],
|
|
||||||
language: string
|
|
||||||
): { nodes: CanvasNode[]; connections: CanvasConnection[] } {
|
|
||||||
const kind = getPrimaryStartKind(invocations);
|
|
||||||
const targetType = mapKindToNodeType(kind);
|
|
||||||
const title = titleForStartNode(kind, invocations, nodeTypes, language);
|
|
||||||
const nt = nodeTypes.find((n) => n.id === targetType);
|
|
||||||
const inputs = nt?.inputs ?? 0;
|
|
||||||
const outputs = nt?.outputs ?? 1;
|
|
||||||
|
|
||||||
const triggerIdsBeforeDedupe = new Set(nodes.filter((n) => n.type.startsWith('trigger.')).map((n) => n.id));
|
|
||||||
let { nodes: ns, connections: cs } = dedupeTriggers(nodes, connections);
|
|
||||||
|
|
||||||
let startIdx = ns.findIndex((n) => n.type.startsWith('trigger.'));
|
|
||||||
if (startIdx === -1) {
|
|
||||||
const newNode: CanvasNode = {
|
|
||||||
id: CANVAS_START_NODE_ID,
|
|
||||||
type: targetType,
|
|
||||||
x: 100,
|
|
||||||
y: 120,
|
|
||||||
title,
|
|
||||||
label: title,
|
|
||||||
inputs,
|
|
||||||
outputs,
|
|
||||||
color: nt?.meta?.color as string | undefined,
|
|
||||||
parameters: {},
|
|
||||||
};
|
|
||||||
ns = rewireRefsInNodes([newNode, ...ns], triggerIdsBeforeDedupe, CANVAS_START_NODE_ID);
|
|
||||||
return { nodes: ns, connections: cs };
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = ns[startIdx];
|
|
||||||
const oldId = current.id;
|
|
||||||
let nextConn = cs;
|
|
||||||
if (oldId !== CANVAS_START_NODE_ID) {
|
|
||||||
nextConn = rewireConnections(nextConn, oldId, CANVAS_START_NODE_ID);
|
|
||||||
}
|
|
||||||
ns = rewireRefsInNodes(ns, triggerIdsBeforeDedupe, CANVAS_START_NODE_ID);
|
|
||||||
|
|
||||||
const updated: CanvasNode = {
|
|
||||||
...current,
|
|
||||||
id: CANVAS_START_NODE_ID,
|
|
||||||
type: targetType,
|
|
||||||
title,
|
|
||||||
label: title,
|
|
||||||
inputs,
|
|
||||||
outputs,
|
|
||||||
color: nt?.meta?.color as string | undefined,
|
|
||||||
parameters:
|
|
||||||
targetType === current.type ? current.parameters ?? {} : preserveParametersForTypeSwitch(current, targetType),
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextNodes = [...ns];
|
|
||||||
nextNodes[startIdx] = updated;
|
|
||||||
return { nodes: nextNodes, connections: nextConn };
|
|
||||||
}
|
|
||||||
|
|
||||||
function preserveParametersForTypeSwitch(node: CanvasNode, newType: string): Record<string, unknown> {
|
|
||||||
const p = node.parameters ?? {};
|
|
||||||
if (newType === 'trigger.form' && p.formFields) return { formFields: p.formFields };
|
|
||||||
if (newType === 'trigger.schedule' && (p.cron || p.schedule)) {
|
|
||||||
const out: Record<string, unknown> = {};
|
|
||||||
if (p.cron != null) out.cron = p.cron;
|
|
||||||
if (p.schedule != null) out.schedule = p.schedule;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build invocations: replace primary (index 0), keep further entries (e.g. listener config later). */
|
|
||||||
export function buildInvocationsForPrimaryKind(
|
|
||||||
kind: string,
|
|
||||||
existing: WorkflowEntryPoint[] | undefined,
|
|
||||||
titleDe: string
|
|
||||||
): WorkflowEntryPoint[] {
|
|
||||||
const list = existing ?? [];
|
|
||||||
const primaryId =
|
|
||||||
list[0]?.id ??
|
|
||||||
(typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `ep-${Date.now()}`);
|
|
||||||
const category = categoryForKind(kind);
|
|
||||||
const primary: WorkflowEntryPoint = {
|
|
||||||
id: primaryId,
|
|
||||||
kind,
|
|
||||||
category,
|
|
||||||
enabled: true,
|
|
||||||
title: { de: titleDe, en: titleDe, fr: titleDe },
|
|
||||||
description: {},
|
|
||||||
config: {},
|
|
||||||
};
|
|
||||||
const rest = list.slice(1).filter((x) => x.id !== primaryId);
|
|
||||||
return [primary, ...rest];
|
|
||||||
}
|
|
||||||
|
|
@ -6,7 +6,7 @@ import React from 'react';
|
||||||
import { FaPlay, FaCodeBranch, FaDatabase, FaPlug, FaUser, FaRobot, FaEnvelope, FaCloud, FaFileAlt, FaTasks } from 'react-icons/fa';
|
import { FaPlay, FaCodeBranch, FaDatabase, FaPlug, FaUser, FaRobot, FaEnvelope, FaCloud, FaFileAlt, FaTasks } from 'react-icons/fa';
|
||||||
|
|
||||||
export const CATEGORY_ICONS: Record<string, React.ReactNode> = {
|
export const CATEGORY_ICONS: Record<string, React.ReactNode> = {
|
||||||
trigger: <FaPlay />,
|
start: <FaPlay />,
|
||||||
input: <FaUser />,
|
input: <FaUser />,
|
||||||
flow: <FaCodeBranch />,
|
flow: <FaCodeBranch />,
|
||||||
data: <FaDatabase />,
|
data: <FaDatabase />,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export const HIDDEN_NODE_IDS = new Set<string>();
|
||||||
|
|
||||||
/** Default category display order */
|
/** Default category display order */
|
||||||
export const CATEGORY_ORDER = [
|
export const CATEGORY_ORDER = [
|
||||||
'trigger',
|
'start',
|
||||||
'input',
|
'input',
|
||||||
'flow',
|
'flow',
|
||||||
'data',
|
'data',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue