From 74dc7b85f8035e35eb3072164013785e0327e02b Mon Sep 17 00:00:00 2001 From: Ida Date: Wed, 13 May 2026 13:30:45 +0200 Subject: [PATCH] continous work on grafischer editor, loop verbessert --- src/api/workflowApi.ts | 52 ++++ .../editor/Automation2FlowEditor.module.css | 8 +- .../editor/Automation2FlowEditor.tsx | 2 + .../FlowEditor/editor/FlowCanvas.tsx | 239 +++++++++++++++--- .../FlowEditor/nodes/shared/DataPicker.tsx | 61 +++-- .../FlowEditor/nodes/shared/scopeHelpers.ts | 55 ---- .../Automation2WorkflowsTasks.module.css | 47 ++++ .../GraphicalEditorKeepAlive.test.tsx | 96 ------- .../GraphicalEditorWorkflowsTasksPage.tsx | 56 +++- 9 files changed, 412 insertions(+), 204 deletions(-) delete mode 100644 src/components/FlowEditor/nodes/shared/scopeHelpers.ts delete mode 100644 src/pages/views/graphicalEditor/GraphicalEditorKeepAlive.test.tsx diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index 9d7b6e7..7d7b6c8 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -347,6 +347,41 @@ export async function postUpstreamPaths( return { paths: (data?.paths ?? []) as UpstreamPathEntry[] }; } +/** Scope-aware data sources for the DataPicker — all loop-scope logic lives on the backend. */ +export interface GraphDataSources { + /** Ancestor node IDs that are valid sources (loop body nodes excluded when on Done branch). */ + availableSourceIds: string[]; + /** Maps nodeId → output port index to use instead of 0 (e.g. loop node on Done branch → 1). */ + portIndexOverrides: Record; + /** IDs of flow.loop nodes whose body the current node is inside (show currentItem etc.). */ + loopBodyContextIds: string[]; +} + +/** + * POST /api/workflows/{instanceId}/graph-data-sources + * + * Returns scope-aware source list so the DataPicker needs zero graph-traversal logic. + * The graph connections must use { source, target, sourceOutput?, targetInput? } format. + */ +export async function fetchGraphDataSources( + request: ApiRequestFunction, + instanceId: string, + nodeId: string, + nodes: Array<{ id: string; type?: string }>, + connections: Array<{ source: string; target: string; sourceOutput?: number; targetInput?: number }>, +): Promise { + const data = await request({ + url: `/api/workflows/${instanceId}/graph-data-sources`, + method: 'post', + data: { nodeId, graph: { nodes, connections } }, + }); + return { + availableSourceIds: data?.availableSourceIds ?? [], + portIndexOverrides: data?.portIndexOverrides ?? {}, + loopBodyContextIds: data?.loopBodyContextIds ?? [], + }; +} + /** GET saved workflow graph variant of upstream-paths (requires workflowId). */ export async function getUpstreamPathsSaved( request: ApiRequestFunction, @@ -692,6 +727,23 @@ export async function completeTask( }); } +/** Cancel a pending human task and stop its workflow run (Graphical Editor). */ +export async function cancelPendingTaskStopRun( + request: ApiRequestFunction, + instanceId: string, + taskId: string +): Promise<{ success: boolean; runId?: string | null; taskId: string }> { + const data = await request({ + url: `/api/workflows/${instanceId}/tasks/${taskId}/cancel`, + method: 'post', + }); + return { + success: Boolean(data?.success), + runId: data?.runId, + taskId: data?.taskId ?? taskId, + }; +} + // ------------------------------------------------------------------------- // Versions (AutoVersion Lifecycle) // ------------------------------------------------------------------------- diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.module.css b/src/components/FlowEditor/editor/Automation2FlowEditor.module.css index 305039f..93a064f 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.module.css +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.module.css @@ -486,14 +486,16 @@ flex: 1; padding: 2rem; min-height: 400px; - overflow: hidden; + overflow-x: visible; + overflow-y: hidden; } .canvasDropZone { position: relative; min-height: 100%; height: 100%; - overflow: hidden; + /* Schleifen-Rücklauf: SVG-Pfade dürfen Knotenbox leicht verlassen ohne abzuschneiden */ + overflow: visible; border-radius: 8px; /* Infinite grid: on viewport, moves with pan/zoom via inline style */ background-image: radial-gradient(circle, var(--canvas-grid, var(--border-color, #e0e0e0)) 1px, transparent 1px); @@ -746,6 +748,8 @@ min-width: 0; overflow-wrap: anywhere; word-break: break-word; + position: relative; + z-index: 10; } .nodeConfigPanel h4 { diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx index 622d747..890f7bc 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx @@ -908,6 +908,7 @@ export const Automation2FlowEditor: React.FC = ({ in /> {configurableSelected && selectedNode && ( +
= ({ in verboseSchema={verboseSchema} /> +
)} diff --git a/src/components/FlowEditor/editor/FlowCanvas.tsx b/src/components/FlowEditor/editor/FlowCanvas.tsx index c4f8147..01c5bb1 100644 --- a/src/components/FlowEditor/editor/FlowCanvas.tsx +++ b/src/components/FlowEditor/editor/FlowCanvas.tsx @@ -139,6 +139,133 @@ function _checkConnectionCompatibility( return 'warning'; } +/** flow.loop Eingang 0: Hauptfluss + Schleifen-Rücklauf — mehrere Kanten pro Port. */ +function allowsMultipleInboundOnInputPort(targetNode: CanvasNode, targetHandleIndex: number): boolean { + return targetNode.type === 'flow.loop' && targetHandleIndex === 0; +} + +/** Kanten-Rücklauf visuell links um die Knoten zur Loop oben. */ +function isLoopFeedbackEdge(c: CanvasConnection, srcNode: CanvasNode, tgtNode: CanvasNode): boolean { + if (tgtNode.type !== 'flow.loop' || c.targetHandle !== 0) return false; + if (c.sourceId === c.targetId) return true; + return srcNode.y > tgtNode.y + 4; +} + +const NODE_OBSTACLE_PAD = 12; + +type Obstacle = { left: number; top: number; right: number; bottom: number }; + +function obstacleRects(allNodes: CanvasNode[], skipIds: Set, pad: number): Obstacle[] { + return allNodes + .filter((n) => !skipIds.has(n.id)) + .map((n) => ({ + left: n.x - pad, + top: n.y - pad, + right: n.x + NODE_WIDTH + pad, + bottom: n.y + NODE_HEIGHT + pad, + })); +} + +function pointInObstacle(x: number, y: number, o: Obstacle): boolean { + return x >= o.left && x <= o.right && y >= o.top && y <= o.bottom; +} + +function cubicCrossesObstacles( + x0: number, + y0: number, + x1: number, + y1: number, + x2: number, + y2: number, + x3: number, + y3: number, + obstacles: Obstacle[], + tMargin = 0.08, +): boolean { + const steps = 40; + for (let i = 1; i < steps; i++) { + const t = i / steps; + if (t < tMargin || t > 1 - tMargin) continue; + const u = 1 - t; + const x = u * u * u * x0 + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * x3; + const y = u * u * u * y0 + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * y3; + for (const o of obstacles) { + if (pointInObstacle(x, y, o)) return true; + } + } + return false; +} + +/** + * Schleifen-Rücklauf — zwei Kubiken, C¹-stetig: + * + * C1: M sx sy C sx (sy+k) laneX (sy+k) laneX jY + * C2: C laneX (tyIn-k) tx (tyIn-k) tx tyIn + * + * Tangente am Start = (0,+k) → senkrecht RUNTER aus dem Quell-Port ✓ + * Tangente am Ende = (0,+k) → senkrecht RUNTER in den Ziel-Port ✓ + * Tangente an der Verbindungsstelle (laneX, jY): beide Seiten = (0, (tyIn-sy)/2-k) — gleich → kein Knick ✓ + * laneX wird per Sampling solange nach links verschoben, bis keine Kollision vorliegt. + */ +function feedbackConnectionPathD( + src: { x: number; y: number }, + tgt: { x: number; y: number }, + srcNode: CanvasNode, + tgtNode: CanvasNode, + allNodes: CanvasNode[], +): string { + const sx = src.x; + const sy = src.y; + const tx = tgt.x; + const tyIn = tgt.y - HANDLE_OFFSET; + + const minNx = allNodes.length + ? Math.min(...allNodes.map((n) => n.x)) + : Math.min(srcNode.x, tgtNode.x); + + const vert = Math.max(60, sy - tyIn); + const k = Math.min(vert * 0.38, 130); + const jY = (sy + tyIn) / 2; + + const skipIds = srcNode.id === tgtNode.id ? new Set([srcNode.id]) : new Set(); + const obstacles = obstacleRects(allNodes, skipIds, NODE_OBSTACLE_PAD); + + for (let margin = 72; margin <= 640; margin += 24) { + const laneX = Math.min(minNx - margin, Math.min(sx, tx) - margin); + const ok = + !cubicCrossesObstacles(sx, sy, sx, sy + k, laneX, sy + k, laneX, jY, obstacles) && + !cubicCrossesObstacles(laneX, jY, laneX, tyIn - k, tx, tyIn - k, tx, tyIn, obstacles); + if (ok) { + return ( + `M ${sx} ${sy}` + + ` C ${sx} ${sy + k} ${laneX} ${sy + k} ${laneX} ${jY}` + + ` C ${laneX} ${tyIn - k} ${tx} ${tyIn - k} ${tx} ${tyIn}` + ); + } + } + const laneX = Math.min(minNx - 640, Math.min(sx, tx) - 640); + return ( + `M ${sx} ${sy}` + + ` C ${sx} ${sy + k} ${laneX} ${sy + k} ${laneX} ${jY}` + + ` C ${laneX} ${tyIn - k} ${tx} ${tyIn - k} ${tx} ${tyIn}` + ); +} + +function connectionPathD( + src: { x: number; y: number }, + tgt: { x: number; y: number }, + srcNode: CanvasNode, + tgtNode: CanvasNode, + feedback: boolean, + allNodes: CanvasNode[], +): string { + if (!feedback) { + const dy = tgt.y - src.y; + return `M ${src.x} ${src.y} C ${src.x} ${src.y + Math.abs(dy) / 2}, ${tgt.x} ${tgt.y - Math.abs(dy) / 2}, ${tgt.x} ${tgt.y}`; + } + return feedbackConnectionPathD(src, tgt, srcNode, tgtNode, allNodes); +} + interface FlowCanvasProps { nodes: CanvasNode[]; connections: CanvasConnection[]; @@ -254,9 +381,9 @@ export const FlowCanvas: React.FC = ({ nodes, const centerX = node.x + w / 2; if (isOutput) { - if (ioCount === 1) return { x: centerX, y: node.y + h, side: 'bottom' }; + if (ioCount === 1) return { x: centerX, y: node.y + h + HANDLE_OFFSET, side: 'bottom' }; const step = w / (ioCount + 1); - return { x: node.x + step * (ioIndex + 1), y: node.y + h, side: 'bottom' }; + return { x: node.x + step * (ioIndex + 1), y: node.y + h + HANDLE_OFFSET, side: 'bottom' }; } else { if (ioCount === 1) return { x: centerX, y: node.y, side: 'top' }; const step = w / (ioCount + 1); @@ -272,6 +399,21 @@ export const FlowCanvas: React.FC = ({ nodes, return used; }, [connections]); + /** Mehrere Kanten auf denselben Eingang: leicht versetzte Ziel-X für sichtbare, getrennte Enden. */ + const inboundStacksByTarget = useMemo(() => { + const m = new Map(); + for (const c of connections) { + const key = `${c.targetId}-${c.targetHandle}`; + const list = m.get(key); + if (list) list.push(c); + else m.set(key, [c]); + } + for (const list of m.values()) { + list.sort((a, b) => a.id.localeCompare(b.id)); + } + return m; + }, [connections]); + const handleDrop = useCallback( async (e: React.DragEvent) => { e.preventDefault(); @@ -341,7 +483,10 @@ export const FlowCanvas: React.FC = ({ nodes, setSelectedConnectionId(null); return; } - if (getUsedTargetHandles.has(key)) { + if ( + getUsedTargetHandles.has(key) && + !allowsMultipleInboundOnInputPort(targetNode, targetHandleIndex) + ) { setSelectedConnectionId(null); return; } @@ -357,13 +502,20 @@ export const FlowCanvas: React.FC = ({ nodes, return; } - if (!connectingFrom || connectingFrom.nodeId === targetNodeId) { + const allowLoopSelfFeedback = + targetNode.type === 'flow.loop' && + targetHandleIndex === 0 && + connectingFrom.handleIndex >= targetNode.inputs; + if ( + !connectingFrom || + (connectingFrom.nodeId === targetNodeId && !allowLoopSelfFeedback) + ) { setConnectingFrom(null); setDragPos(null); return; } const key = `${targetNodeId}-${targetHandleIndex}`; - if (getUsedTargetHandles.has(key)) { + if (getUsedTargetHandles.has(key) && !allowsMultipleInboundOnInputPort(targetNode, targetHandleIndex)) { setConnectingFrom(null); setDragPos(null); return; @@ -582,10 +734,11 @@ export const FlowCanvas: React.FC = ({ nodes, const CANVAS_SIZE = 8000; const svgBounds = useMemo(() => { if (nodes.length === 0) return { width: CANVAS_SIZE, height: CANVAS_SIZE }; - let maxX = 0, maxY = 0; + let maxX = 0; + let maxY = 0; nodes.forEach((n) => { maxX = Math.max(maxX, n.x + NODE_WIDTH + 200); - maxY = Math.max(maxY, n.y + NODE_HEIGHT + 200); + maxY = Math.max(maxY, n.y + NODE_HEIGHT + 320); }); return { width: Math.max(maxX, CANVAS_SIZE), height: Math.max(maxY, CANVAS_SIZE) }; }, [nodes]); @@ -700,7 +853,7 @@ export const FlowCanvas: React.FC = ({ nodes, className={styles.connectionsLayer} width={svgBounds.width} height={svgBounds.height} - style={{ position: 'absolute', left: 0, top: 0 }} + style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible' }} > = ({ nodes, const tgtNode = nodes.find((n) => n.id === c.targetId); if (!srcNode || !tgtNode) return null; const src = getHandlePosition(srcNode, c.sourceHandle); - const tgt = getHandlePosition(tgtNode, c.targetHandle); - const dy = tgt.y - src.y; - const pathD = `M ${src.x} ${src.y} C ${src.x} ${src.y + Math.abs(dy) / 2}, ${tgt.x} ${tgt.y - Math.abs(dy) / 2}, ${tgt.x} ${tgt.y}`; + const tgtBase = getHandlePosition(tgtNode, c.targetHandle); + const stack = inboundStacksByTarget.get(`${c.targetId}-${c.targetHandle}`) ?? [c]; + const si = stack.findIndex((x) => x.id === c.id); + const spread = 12; + const tgt = + stack.length > 1 + ? { ...tgtBase, x: tgtBase.x + (si - (stack.length - 1) / 2) * spread } + : tgtBase; + const feedback = isLoopFeedbackEdge(c, srcNode, tgtNode); + const pathD = connectionPathD(src, tgt, srcNode, tgtNode, feedback, nodes); const isSelected = selectedConnectionId === c.id; const isWarning = connectionWarnings[c.id]; const strokeColor = isSelected @@ -770,6 +930,8 @@ export const FlowCanvas: React.FC = ({ nodes, fill="none" stroke={strokeColor} strokeWidth={isSelected ? 3 : 2} + strokeLinecap="round" + strokeLinejoin="round" strokeDasharray={isWarning && !isSelected ? '6 3' : undefined} markerEnd={isSelected ? 'url(#arrowhead-selected)' : 'url(#arrowhead)'} pointerEvents="none" @@ -881,7 +1043,10 @@ export const FlowCanvas: React.FC = ({ nodes, ) : null} {handles.map(({ index, isOutput }) => { const pos = getHandlePosition(node, index); - const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${index}`); + const used = + !isOutput && + getUsedTargetHandles.has(`${node.id}-${index}`) && + !allowsMultipleInboundOnInputPort(node, index); const selConn = selectedConnectionId ? connections.find((c) => c.id === selectedConnectionId) : null; const isCurrentTargetOfSelection = selConn && selConn.targetId === node.id && selConn.targetHandle === index; @@ -910,25 +1075,37 @@ export const FlowCanvas: React.FC = ({ nodes, left: pos.x - node.x - HANDLE_OFFSET, }} > - {outputLabel && pos.side === 'bottom' && ( - {outputLabel} - )} -
handleHandleMouseDown(e, node.id, index, isOutput)} - onMouseUp={(e) => !isOutput && handleHandleMouseUp(e, node.id, index)} - title={ - outputLabel ?? - (selectedConnectionId && !isOutput - ? used - ? t('Aktuelles Ziel klicken, um abzuwählen') - : t('Klicken zum Umleiten') - : undefined) - } - /> - {outputLabel && pos.side === 'top' && ( - {outputLabel} + {outputLabel && pos.side === 'bottom' && isOutput ? ( + <> +
handleHandleMouseDown(e, node.id, index, isOutput)} + onMouseUp={(e) => !isOutput && handleHandleMouseUp(e, node.id, index)} + title={outputLabel} + /> + {outputLabel} + + ) : ( + <> +
handleHandleMouseDown(e, node.id, index, isOutput)} + onMouseUp={(e) => !isOutput && handleHandleMouseUp(e, node.id, index)} + title={ + outputLabel ?? + (selectedConnectionId && !isOutput + ? used + ? t('Aktuelles Ziel klicken, um abzuwählen') + : t('Klicken zum Umleiten') + : undefined) + } + /> + {outputLabel && pos.side === 'top' && ( + {outputLabel} + )} + )}
); diff --git a/src/components/FlowEditor/nodes/shared/DataPicker.tsx b/src/components/FlowEditor/nodes/shared/DataPicker.tsx index 5c7a7c4..9cc9fb9 100644 --- a/src/components/FlowEditor/nodes/shared/DataPicker.tsx +++ b/src/components/FlowEditor/nodes/shared/DataPicker.tsx @@ -6,12 +6,12 @@ * Includes a System Variables section. */ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { createRef, createSystemVar, type DataRef, type SystemVarRef, isCompatible } from './dataRef'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; -import type { DataPickOption, GraphDefinedSchemaRef, NodeType, PortField, PortSchema } from '../../../../api/workflowApi'; -import { findLoopAncestorIds } from './scopeHelpers'; +import type { DataPickOption, GraphDataSources, GraphDefinedSchemaRef, NodeType, PortField, PortSchema } from '../../../../api/workflowApi'; +import { fetchGraphDataSources } from '../../../../api/workflowApi'; import styles from '../../editor/Automation2FlowEditor.module.css'; import { useLanguage } from '../../../../providers/language/LanguageContext'; @@ -276,20 +276,43 @@ export const DataPicker: React.FC = ({ open, // other hook) below it would change the hook count when the picker toggles // open/closed and crash the whole tree (white screen). const connectionsRaw = ctx?.connections ?? []; + const nodesRaw = ctx?.nodes ?? []; + // sourceHandle is a flat handle index (inputs first, then outputs). + // The backend expects sourceOutput as an output-port index (0-based after inputs). + const nodeInputsById = useMemo( + () => new Map(nodesRaw.map((n) => [n.id, n.inputs ?? 0])), + [nodesRaw], + ); const connections = useMemo( () => connectionsRaw.map((c) => ({ source: c.sourceId, target: c.targetId, - sourceOutput: c.sourceHandle, + sourceOutput: c.sourceHandle - (nodeInputsById.get(c.sourceId) ?? 0), + targetInput: c.targetHandle, })), - [connectionsRaw], + [connectionsRaw, nodeInputsById], ); - const loopAncestorIds = useMemo(() => { - const cid = ctx?.currentNodeId; - if (!cid) return [] as string[]; - return findLoopAncestorIds(nodes, connections, cid); - }, [ctx?.currentNodeId, nodes, connections]); + + // Fetch scope data from the backend when the picker opens — zero topology logic in JS. + const [scopeData, setScopeData] = useState(null); + const scopeFetchKey = useRef(''); + useEffect(() => { + if (!open || !ctx?.instanceId || !ctx?.request || !ctx?.currentNodeId) return; + const key = `${ctx.instanceId}:${ctx.currentNodeId}:${connections.length}:${(ctx.nodes ?? []).length}`; + if (scopeFetchKey.current === key) return; // already fetched for this state + scopeFetchKey.current = key; + const nodeShapes = (ctx.nodes ?? []).map((n) => ({ id: n.id, type: n.type })); + fetchGraphDataSources(ctx.request, ctx.instanceId, ctx.currentNodeId, nodeShapes, connections) + .then(setScopeData) + .catch(() => setScopeData(null)); + }, [open, ctx?.instanceId, ctx?.request, ctx?.currentNodeId, connections, nodesRaw]); + + // Derived: effective source ids and loop context — use backend result when available, + // fall back to the prop (e.g. in tests or offline). + const effectiveSourceIds = scopeData?.availableSourceIds ?? availableSourceIds; + const portIndexOverrides = scopeData?.portIndexOverrides ?? {}; + const loopBodyContextIds = scopeData?.loopBodyContextIds ?? []; if (!open) return null; @@ -370,13 +393,13 @@ export const DataPicker: React.FC = ({ open,
{/* System Variables Section */} - {loopAncestorIds.length > 0 && ( + {loopBodyContextIds.length > 0 && (
{t('Schleife (lexikalisch)')}
- {loopAncestorIds.map((loopId) => { + {loopBodyContextIds.map((loopId) => { const loopNode = nodes.find((n) => n.id === loopId); const loopLabel = loopNode ? getNodeLabel(loopNode as { id: string; title?: string }) : loopId; const loopSchema = catalog.LoopItem; @@ -456,7 +479,7 @@ export const DataPicker: React.FC = ({ open, {/* Node outputs */} {(() => { - const filteredIds = availableSourceIds.filter((nodeId) => { + const filteredIds = effectiveSourceIds.filter((nodeId) => { const node = nodes.find((n) => n.id === nodeId); return node?.type !== 'trigger.manual'; }); @@ -472,15 +495,17 @@ export const DataPicker: React.FC = ({ open, const typeLabel = nodeTypeDef?.label ?? node?.type ?? ''; const isExpanded = expandedNodes.has(nodeId); - const port0Def = nodeTypeDef?.outputPorts?.[0]; + // Use the port index the backend says to use (e.g. 1 for loop on Done branch) + const portIdx = portIndexOverrides[nodeId] ?? 0; + const portDef = nodeTypeDef?.outputPorts?.[portIdx]; const backendPick = - port0Def?.dataPickOptions && - Array.isArray(port0Def.dataPickOptions) && - port0Def.dataPickOptions.length > 0; + portDef?.dataPickOptions && + Array.isArray(portDef.dataPickOptions) && + portDef.dataPickOptions.length > 0; let schemaPaths: PickablePath[]; if (backendPick) { - schemaPaths = _pathsFromDataPickOptions(port0Def!.dataPickOptions!); + schemaPaths = _pathsFromDataPickOptions(portDef!.dataPickOptions!); } else { const resolvedSchema = _resolveSchemaForNode( nodeId, diff --git a/src/components/FlowEditor/nodes/shared/scopeHelpers.ts b/src/components/FlowEditor/nodes/shared/scopeHelpers.ts deleted file mode 100644 index 2157c2f..0000000 --- a/src/components/FlowEditor/nodes/shared/scopeHelpers.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Lexical scope for DataPicker: ancestor node ids reachable backward on the graph. - */ - -export interface GraphEdgeLike { - source: string; - target: string; -} - -export interface GraphNodeLike { - id: string; - type?: string; -} - -/** All node ids that can reach targetNodeId via incoming edges (excluding target). */ -export function computeAncestorNodeIds( - _nodes: GraphNodeLike[], - connections: GraphEdgeLike[], - targetNodeId: string -): Set { - const preds = new Map>(); - for (const c of connections) { - const src = c.source; - const tgt = c.target; - if (!src || !tgt) continue; - if (!preds.has(tgt)) preds.set(tgt, new Set()); - preds.get(tgt)!.add(src); - } - const seen = new Set(); - const stack = [targetNodeId]; - while (stack.length) { - const cur = stack.pop()!; - const ps = preds.get(cur); - if (!ps) continue; - for (const p of ps) { - if (!seen.has(p)) { - seen.add(p); - stack.push(p); - } - } - } - seen.delete(targetNodeId); - return seen; -} - -/** Node ids of flow.loop ancestors (subset of ancestors). */ -export function findLoopAncestorIds( - nodes: GraphNodeLike[], - connections: GraphEdgeLike[], - targetNodeId: string -): string[] { - const anc = computeAncestorNodeIds(nodes, connections, targetNodeId); - const byId = new Map(nodes.map((n) => [n.id, n])); - return [...anc].filter((id) => byId.get(id)?.type === 'flow.loop'); -} diff --git a/src/pages/views/graphicalEditor/Automation2WorkflowsTasks.module.css b/src/pages/views/graphicalEditor/Automation2WorkflowsTasks.module.css index 8c5dcad..47c2c39 100644 --- a/src/pages/views/graphicalEditor/Automation2WorkflowsTasks.module.css +++ b/src/pages/views/graphicalEditor/Automation2WorkflowsTasks.module.css @@ -276,6 +276,46 @@ background: var(--bg-primary, #fff); } +.taskCardDismissable { + position: relative; + padding-top: 0.85rem; + padding-right: 2.25rem; +} + +.dismissOpenTaskBtn { + position: absolute; + top: 0.35rem; + right: 0.35rem; + width: 1.85rem; + height: 1.85rem; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + border-radius: 50%; + background: transparent; + color: var(--text-secondary, #888); + cursor: pointer; + font-size: 1rem; + line-height: 1; +} + +.dismissOpenTaskBtn:hover:not(:disabled) { + color: var(--danger-color, #c82333); + background: rgba(220, 53, 69, 0.08); +} + +.dismissOpenTaskBtn:focus-visible { + outline: 2px solid var(--primary-color, #007bff); + outline-offset: 2px; +} + +.dismissOpenTaskBtn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + .taskCard:last-child { margin-bottom: 0; } @@ -396,6 +436,13 @@ cursor: not-allowed; } +/* Override broad .taskCard button[type='button'] primary styling for dismiss control */ +.taskCard button.dismissOpenTaskBtn { + background: transparent; + color: var(--text-secondary, #888); + padding: 0; +} + /* Upload task */ .uploadTaskBlock { display: flex; diff --git a/src/pages/views/graphicalEditor/GraphicalEditorKeepAlive.test.tsx b/src/pages/views/graphicalEditor/GraphicalEditorKeepAlive.test.tsx deleted file mode 100644 index c7a73b5..0000000 --- a/src/pages/views/graphicalEditor/GraphicalEditorKeepAlive.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright (c) 2025 Patrick Motsch -// All rights reserved. -// -// Persistence is per (mandateId, instanceId): switching to a different mandate -// or instance must remount the editor page so its internal state (loaded -// workflow, currentWorkflowId, …) is reset and saves go to the right tenant. - -import React from 'react'; -import { describe, expect, it, vi } from 'vitest'; -import { render, screen, act } from '@testing-library/react'; -import { MemoryRouter, useNavigate } from 'react-router-dom'; - -const _mountCount = { value: 0 }; - -vi.mock('./GraphicalEditorPage', () => ({ - GraphicalEditorPage: ({ persistentMandateId, persistentInstanceId }: { persistentMandateId?: string; persistentInstanceId?: string }) => { - React.useEffect(() => { - _mountCount.value += 1; - }, []); - return
{persistentMandateId}::{persistentInstanceId}
; - }, -})); - -import { GraphicalEditorKeepAlive } from './GraphicalEditorKeepAlive'; - -let _navigateTo: ((path: string) => void) | null = null; -const _NavCapture: React.FC = () => { - _navigateTo = useNavigate(); - return null; -}; - -function _renderHarness(initialPath: string) { - return render( - - <_NavCapture /> - - , - ); -} - -function _navigate(path: string) { - act(() => { - _navigateTo?.(path); - }); -} - -describe('GraphicalEditorKeepAlive — persistence per (mandate, instance)', () => { - it('remounts the page when the mandate changes', () => { - _mountCount.value = 0; - _renderHarness('/mandates/mA/graphicalEditor/iA/editor'); - expect(_mountCount.value).toBe(1); - expect(screen.getByTestId('ge-page').textContent).toBe('mA::iA'); - - _navigate('/mandates/mB/graphicalEditor/iA/editor'); - - expect(_mountCount.value).toBe(2); - expect(screen.getByTestId('ge-page').textContent).toBe('mB::iA'); - }); - - it('remounts the page when the instance changes', () => { - _mountCount.value = 0; - _renderHarness('/mandates/mA/graphicalEditor/iA/editor'); - expect(_mountCount.value).toBe(1); - - _navigate('/mandates/mA/graphicalEditor/iZ/editor'); - - expect(_mountCount.value).toBe(2); - expect(screen.getByTestId('ge-page').textContent).toBe('mA::iZ'); - }); - - it('does NOT remount when the route stays on the same (mandate, instance)', () => { - _mountCount.value = 0; - _renderHarness('/mandates/mA/graphicalEditor/iA/editor'); - expect(_mountCount.value).toBe(1); - - _navigate('/mandates/mA/graphicalEditor/iA/editor'); - - expect(_mountCount.value).toBe(1); - }); - - it('keeps the cached page mounted (no remount) when the user navigates AWAY and BACK to the same scope', () => { - _mountCount.value = 0; - _renderHarness('/mandates/mA/graphicalEditor/iA/editor'); - expect(_mountCount.value).toBe(1); - - // Away to a non-editor route: the regex match fails, refs keep their - // previous values — the cached page must not remount. - _navigate('/admin/languages'); - expect(_mountCount.value).toBe(1); - expect(screen.getByTestId('ge-page').textContent).toBe('mA::iA'); - - // Back to the same (mandate, instance) — still no remount. - _navigate('/mandates/mA/graphicalEditor/iA/editor'); - expect(_mountCount.value).toBe(1); - }); -}); diff --git a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx index 574ff25..9299209 100644 --- a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx +++ b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx @@ -7,11 +7,12 @@ */ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Link } from 'react-router-dom'; -import { FaChevronDown, FaChevronRight, FaPlay, FaSpinner, FaUpload } from 'react-icons/fa'; +import { FaChevronDown, FaChevronRight, FaPlay, FaSpinner, FaTimes, FaUpload } from 'react-icons/fa'; import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useApiRequest } from '../../../hooks/useApi'; import { fetchTasks, + cancelPendingTaskStopRun, completeTask, fetchCompletedRuns, fetchWorkflows, @@ -105,6 +106,7 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => { const [completedExpanded, setCompletedExpanded] = useState(false); const [outputExpanded, setOutputExpanded] = useState(true); const [submitting, setSubmitting] = useState(null); + const [dismissingTaskId, setDismissingTaskId] = useState(null); const [executingWorkflowId, setExecutingWorkflowId] = useState(null); const load = useCallback(async () => { @@ -157,6 +159,27 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => { } }; + const handleDismissOpenTask = async (taskId: string) => { + if (!instanceId) return; + setDismissingTaskId(taskId); + try { + const res = await cancelPendingTaskStopRun(request, instanceId, taskId); + if (res.success) { + showSuccess(t('Ausführung abgebrochen')); + await load(); + } else { + showError(t('Abbrechen fehlgeschlagen')); + } + } catch (e: unknown) { + const msg = + (e as { message?: string })?.message ?? t('Abbrechen fehlgeschlagen'); + showError(msg); + console.error('[graphicalEditor] cancel task failed', e); + } finally { + setDismissingTaskId(null); + } + }; + const handleStartWorkflow = useCallback( async (wf: Automation2Workflow) => { if (!instanceId || !wf.graph) return; @@ -228,6 +251,9 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => { instanceId={instanceId ?? undefined} onSubmit={(result) => handleComplete(task.id, result)} submitting={submitting === task.id} + showDismiss + onDismiss={() => handleDismissOpenTask(task.id)} + dismissing={dismissingTaskId === task.id} /> ))}
@@ -406,6 +432,10 @@ interface TaskCardProps { onSubmit: (result: Record) => void; submitting: boolean; readOnly?: boolean; + /** Open-task card: show top-right control to cancel run and remove from list. */ + showDismiss?: boolean; + onDismiss?: () => void; + dismissing?: boolean; } /** Check if file matches accept string (e.g. ".pdf,image/*"). */ @@ -507,6 +537,9 @@ const TaskCard: React.FC = ({ onSubmit, submitting, readOnly = false, + showDismiss = false, + onDismiss, + dismissing = false, }) => { const { t } = useLanguage(); const { request } = useApiRequest(); @@ -897,8 +930,27 @@ const TaskCard: React.FC = ({ } }; + const cardClass = showDismiss + ? `${styles.taskCard} ${styles.taskCardDismissable}` + : styles.taskCard; + return ( -
+
+ {showDismiss && onDismiss ? ( + + ) : null}
{t('Workflow')}