From 41eaa63d49f086bda3e5f378cf1d753bbe7f3d53 Mon Sep 17 00:00:00 2001 From: Ida Date: Thu, 14 May 2026 10:57:29 +0200 Subject: [PATCH] feat: added trigger nodes such that they are not hidden anymore --- .../editor/Automation2FlowEditor.tsx | 110 +++++---- .../FlowEditor/editor/NodeSidebar.tsx | 4 +- .../nodes/runtime/workflowStartSync.ts | 222 ------------------ .../FlowEditor/nodes/shared/categoryIcons.tsx | 2 +- .../FlowEditor/nodes/shared/constants.ts | 2 +- 5 files changed, 66 insertions(+), 274 deletions(-) delete mode 100644 src/components/FlowEditor/nodes/runtime/workflowStartSync.ts diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx index d250b94..e9ef634 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx @@ -1,9 +1,8 @@ /** * Automation2FlowEditor * - * n8n-style flow builder with backend-driven node list. - * Starts and invocations are driven by backend graph + defaults; canvas start - * node stays in sync on load via `syncCanvasStartNode`. + * n8n-style flow builder with backend-driven node list and categories. + * Start nodes come from the API (category `start`); invocations are synced on the server from the graph. */ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; @@ -47,10 +46,6 @@ import { CanvasHeader } from './CanvasHeader'; 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'; @@ -83,9 +78,6 @@ function cloneCanvasSnapshot(nodes: CanvasNode[], connections: CanvasConnection[ }; } -const _buildDefaultInvocations = (runLabel: string): WorkflowEntryPoint[] => - buildInvocationsForPrimaryKind('manual', [], runLabel); - interface Automation2FlowEditorProps { instanceId: string; mandateId?: string; @@ -123,7 +115,7 @@ export const Automation2FlowEditor: React.FC = ({ in const [error, setError] = useState(null); const [filter, setFilter] = useState(''); const [expandedCategories, setExpandedCategories] = useState>( - 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([]); const [canvasConnections, setCanvasConnections] = useState([]); @@ -146,9 +138,7 @@ export const Automation2FlowEditor: React.FC = ({ in const [currentWorkflowId, setCurrentWorkflowId] = useState(null); const [selectedNode, setSelectedNode] = useState(null); const [saving, setSaving] = useState(false); - const [invocations, setInvocations] = useState(() => - _buildDefaultInvocations(t('Jetzt ausführen')) - ); + const [invocations, setInvocations] = useState([]); const [leftPanelOpen, setLeftPanelOpen] = useState(true); const [tracingRunId, setTracingRunId] = useState(null); const [tracingNodeStatuses, setTracingNodeStatuses] = useState>({}); @@ -220,7 +210,18 @@ export const Automation2FlowEditor: React.FC = ({ in document.body.style.userSelect = 'none'; }, [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( () => @@ -303,20 +304,13 @@ export const Automation2FlowEditor: React.FC = ({ in if (!opts?.skipHistory && !suppressCanvasHistoryRef.current) { pushCanvasHistoryPastFromCurrent(); } - 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); + setInvocations(wfInvocations ?? []); + const g: Automation2Graph = graph ?? { nodes: [], connections: [] }; + const { nodes, connections } = fromApiGraph(g, nodeTypes); + setCanvasNodes(nodes); + setCanvasConnections(connections); }, - [nodeTypes, language, t, pushCanvasHistoryPastFromCurrent] + [nodeTypes, pushCanvasHistoryPastFromCurrent] ); const handleFromApiGraph = useCallback( @@ -345,6 +339,13 @@ export const Automation2FlowEditor: React.FC = ({ in }); return; } + if (missingStartNodeBlocking) { + setExecuteResult({ + success: false, + error: t('Ohne Start-Node kann der Workflow nicht ausgeführt werden.'), + }); + return; + } setExecuting(true); setExecuteResult(null); try { @@ -362,7 +363,7 @@ export const Automation2FlowEditor: React.FC = ({ in } finally { setExecuting(false); } - }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t, nodeErrors]); + }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t, nodeErrors, missingStartNodeBlocking]); const handleSave = useCallback(async () => { const graph = toApiGraph(canvasNodes, canvasConnections); @@ -378,19 +379,32 @@ export const Automation2FlowEditor: React.FC = ({ in 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, - }); + const _buildSaveResult = (): ExecuteGraphResponse => { + const parts: string[] = []; + if (errorCount > 0) { + parts.push( + t('Gespeichert mit {n} Pflicht-Fehlern in {m} Nodes.') + .replace('{n}', String(errorCount)) + .replace('{m}', String(errorNodeCount)) + ); + } + 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); try { 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()); } else { const label = await promptInput(t('Workflow-Name:'), { @@ -409,7 +423,7 @@ export const Automation2FlowEditor: React.FC = ({ in targetFeatureInstanceId, }); setCurrentWorkflowId(created.id); - if (created.invocations?.length) setInvocations(created.invocations); + setInvocations(created.invocations ?? []); setWorkflows((prev) => [...prev, created]); setExecuteResult(_buildSaveResult()); } @@ -418,7 +432,7 @@ export const Automation2FlowEditor: React.FC = ({ in } finally { 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( async (workflowId: string) => { @@ -443,7 +457,7 @@ export const Automation2FlowEditor: React.FC = ({ in setWorkflows((prev) => prev.filter((w) => w.id !== workflowId)); setCurrentWorkflowId((prev) => (prev === workflowId ? null : prev)); setExecuteResult(null); - applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen'))); + applyGraphWithSync({ nodes: [], connections: [] }, []); try { const result = await fetchWorkflows(request, instanceId); setWorkflows(Array.isArray(result) ? result : result.items); @@ -467,7 +481,7 @@ export const Automation2FlowEditor: React.FC = ({ in if (workflowId) handleLoad(workflowId); else { setExecuteResult(null); - applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen'))); + applyGraphWithSync({ nodes: [], connections: [] }, []); } }, [handleLoad, applyGraphWithSync, t] @@ -476,7 +490,7 @@ export const Automation2FlowEditor: React.FC = ({ in const handleNew = useCallback(() => { setCurrentWorkflowId(null); setExecuteResult(null); - applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen'))); + applyGraphWithSync({ nodes: [], connections: [] }, []); }, [applyGraphWithSync, t]); const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record) => { @@ -574,7 +588,7 @@ export const Automation2FlowEditor: React.FC = ({ in if (loading || nodeTypes.length === 0) return; if (currentWorkflowId || initialWorkflowId) return; if (canvasNodes.length > 0) return; - applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')), { + applyGraphWithSync({ nodes: [], connections: [] }, [], { skipHistory: true, }); }, [ @@ -598,7 +612,6 @@ export const Automation2FlowEditor: React.FC = ({ in 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)}`; @@ -795,7 +808,6 @@ export const Automation2FlowEditor: React.FC = ({ in language={language} expandedCategories={expandedCategories} onToggleCategory={toggleCategory} - excludedCategories={sidebarExcludedCategories} style={_sidebarStyle} /> ); @@ -937,7 +949,9 @@ export const Automation2FlowEditor: React.FC = ({ in executeBlockedReason={ hasGraphErrors ? 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={() => { if (firstErrorNodeId) { diff --git a/src/components/FlowEditor/editor/NodeSidebar.tsx b/src/components/FlowEditor/editor/NodeSidebar.tsx index d175e17..8369884 100644 --- a/src/components/FlowEditor/editor/NodeSidebar.tsx +++ b/src/components/FlowEditor/editor/NodeSidebar.tsx @@ -1,6 +1,6 @@ /** * 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'; @@ -21,7 +21,7 @@ interface NodeSidebarProps { language: string; expandedCategories: Set; 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; style?: React.CSSProperties; } diff --git a/src/components/FlowEditor/nodes/runtime/workflowStartSync.ts b/src/components/FlowEditor/nodes/runtime/workflowStartSync.ts deleted file mode 100644 index b92e3f6..0000000 --- a/src/components/FlowEditor/nodes/runtime/workflowStartSync.ts +++ /dev/null @@ -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, 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 = {}; - 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, - 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 }; - }); -} - -/** 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 { - 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 = {}; - 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]; -} diff --git a/src/components/FlowEditor/nodes/shared/categoryIcons.tsx b/src/components/FlowEditor/nodes/shared/categoryIcons.tsx index a0ba719..2285857 100644 --- a/src/components/FlowEditor/nodes/shared/categoryIcons.tsx +++ b/src/components/FlowEditor/nodes/shared/categoryIcons.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { FaPlay, FaCodeBranch, FaDatabase, FaPlug, FaUser, FaRobot, FaEnvelope, FaCloud, FaFileAlt, FaTasks } from 'react-icons/fa'; export const CATEGORY_ICONS: Record = { - trigger: , + start: , input: , flow: , data: , diff --git a/src/components/FlowEditor/nodes/shared/constants.ts b/src/components/FlowEditor/nodes/shared/constants.ts index 91d7359..2bb0d85 100644 --- a/src/components/FlowEditor/nodes/shared/constants.ts +++ b/src/components/FlowEditor/nodes/shared/constants.ts @@ -8,7 +8,7 @@ export const HIDDEN_NODE_IDS = new Set(); /** Default category display order */ export const CATEGORY_ORDER = [ - 'trigger', + 'start', 'input', 'flow', 'data',