continous work on grafischer editor, loop verbessert

This commit is contained in:
Ida 2026-05-13 13:30:45 +02:00
parent 66a7a6fa56
commit 74dc7b85f8
9 changed files with 412 additions and 204 deletions

View file

@ -347,6 +347,41 @@ export async function postUpstreamPaths(
return { paths: (data?.paths ?? []) as UpstreamPathEntry[] }; 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<string, number>;
/** 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<GraphDataSources> {
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). */ /** GET saved workflow graph variant of upstream-paths (requires workflowId). */
export async function getUpstreamPathsSaved( export async function getUpstreamPathsSaved(
request: ApiRequestFunction, 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) // Versions (AutoVersion Lifecycle)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View file

@ -486,14 +486,16 @@
flex: 1; flex: 1;
padding: 2rem; padding: 2rem;
min-height: 400px; min-height: 400px;
overflow: hidden; overflow-x: visible;
overflow-y: hidden;
} }
.canvasDropZone { .canvasDropZone {
position: relative; position: relative;
min-height: 100%; min-height: 100%;
height: 100%; height: 100%;
overflow: hidden; /* Schleifen-Rücklauf: SVG-Pfade dürfen Knotenbox leicht verlassen ohne abzuschneiden */
overflow: visible;
border-radius: 8px; border-radius: 8px;
/* Infinite grid: on viewport, moves with pan/zoom via inline style */ /* 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); background-image: radial-gradient(circle, var(--canvas-grid, var(--border-color, #e0e0e0)) 1px, transparent 1px);
@ -746,6 +748,8 @@
min-width: 0; min-width: 0;
overflow-wrap: anywhere; overflow-wrap: anywhere;
word-break: break-word; word-break: break-word;
position: relative;
z-index: 10;
} }
.nodeConfigPanel h4 { .nodeConfigPanel h4 {

View file

@ -908,6 +908,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
/> />
</div> </div>
{configurableSelected && selectedNode && ( {configurableSelected && selectedNode && (
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column' }}>
<Automation2DataFlowProvider <Automation2DataFlowProvider
node={selectedNode} node={selectedNode}
nodes={canvasNodes} nodes={canvasNodes}
@ -933,6 +934,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
verboseSchema={verboseSchema} verboseSchema={verboseSchema}
/> />
</Automation2DataFlowProvider> </Automation2DataFlowProvider>
</div>
)} )}
</div> </div>
</div> </div>

View file

@ -139,6 +139,133 @@ function _checkConnectionCompatibility(
return 'warning'; 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<string>, 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<string>();
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 { interface FlowCanvasProps {
nodes: CanvasNode[]; nodes: CanvasNode[];
connections: CanvasConnection[]; connections: CanvasConnection[];
@ -254,9 +381,9 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
const centerX = node.x + w / 2; const centerX = node.x + w / 2;
if (isOutput) { 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); 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 { } else {
if (ioCount === 1) return { x: centerX, y: node.y, side: 'top' }; if (ioCount === 1) return { x: centerX, y: node.y, side: 'top' };
const step = w / (ioCount + 1); const step = w / (ioCount + 1);
@ -272,6 +399,21 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
return used; return used;
}, [connections]); }, [connections]);
/** Mehrere Kanten auf denselben Eingang: leicht versetzte Ziel-X für sichtbare, getrennte Enden. */
const inboundStacksByTarget = useMemo(() => {
const m = new Map<string, CanvasConnection[]>();
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( const handleDrop = useCallback(
async (e: React.DragEvent) => { async (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
@ -341,7 +483,10 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
setSelectedConnectionId(null); setSelectedConnectionId(null);
return; return;
} }
if (getUsedTargetHandles.has(key)) { if (
getUsedTargetHandles.has(key) &&
!allowsMultipleInboundOnInputPort(targetNode, targetHandleIndex)
) {
setSelectedConnectionId(null); setSelectedConnectionId(null);
return; return;
} }
@ -357,13 +502,20 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
return; 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); setConnectingFrom(null);
setDragPos(null); setDragPos(null);
return; return;
} }
const key = `${targetNodeId}-${targetHandleIndex}`; const key = `${targetNodeId}-${targetHandleIndex}`;
if (getUsedTargetHandles.has(key)) { if (getUsedTargetHandles.has(key) && !allowsMultipleInboundOnInputPort(targetNode, targetHandleIndex)) {
setConnectingFrom(null); setConnectingFrom(null);
setDragPos(null); setDragPos(null);
return; return;
@ -582,10 +734,11 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
const CANVAS_SIZE = 8000; const CANVAS_SIZE = 8000;
const svgBounds = useMemo(() => { const svgBounds = useMemo(() => {
if (nodes.length === 0) return { width: CANVAS_SIZE, height: CANVAS_SIZE }; 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) => { nodes.forEach((n) => {
maxX = Math.max(maxX, n.x + NODE_WIDTH + 200); 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) }; return { width: Math.max(maxX, CANVAS_SIZE), height: Math.max(maxY, CANVAS_SIZE) };
}, [nodes]); }, [nodes]);
@ -700,7 +853,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
className={styles.connectionsLayer} className={styles.connectionsLayer}
width={svgBounds.width} width={svgBounds.width}
height={svgBounds.height} height={svgBounds.height}
style={{ position: 'absolute', left: 0, top: 0 }} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible' }}
> >
<defs> <defs>
<marker <marker
@ -739,9 +892,16 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
const tgtNode = nodes.find((n) => n.id === c.targetId); const tgtNode = nodes.find((n) => n.id === c.targetId);
if (!srcNode || !tgtNode) return null; if (!srcNode || !tgtNode) return null;
const src = getHandlePosition(srcNode, c.sourceHandle); const src = getHandlePosition(srcNode, c.sourceHandle);
const tgt = getHandlePosition(tgtNode, c.targetHandle); const tgtBase = getHandlePosition(tgtNode, c.targetHandle);
const dy = tgt.y - src.y; const stack = inboundStacksByTarget.get(`${c.targetId}-${c.targetHandle}`) ?? [c];
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 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 isSelected = selectedConnectionId === c.id;
const isWarning = connectionWarnings[c.id]; const isWarning = connectionWarnings[c.id];
const strokeColor = isSelected const strokeColor = isSelected
@ -770,6 +930,8 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
fill="none" fill="none"
stroke={strokeColor} stroke={strokeColor}
strokeWidth={isSelected ? 3 : 2} strokeWidth={isSelected ? 3 : 2}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={isWarning && !isSelected ? '6 3' : undefined} strokeDasharray={isWarning && !isSelected ? '6 3' : undefined}
markerEnd={isSelected ? 'url(#arrowhead-selected)' : 'url(#arrowhead)'} markerEnd={isSelected ? 'url(#arrowhead-selected)' : 'url(#arrowhead)'}
pointerEvents="none" pointerEvents="none"
@ -881,7 +1043,10 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
) : null} ) : null}
{handles.map(({ index, isOutput }) => { {handles.map(({ index, isOutput }) => {
const pos = getHandlePosition(node, index); 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 selConn = selectedConnectionId ? connections.find((c) => c.id === selectedConnectionId) : null;
const isCurrentTargetOfSelection = const isCurrentTargetOfSelection =
selConn && selConn.targetId === node.id && selConn.targetHandle === index; selConn && selConn.targetId === node.id && selConn.targetHandle === index;
@ -910,9 +1075,19 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
left: pos.x - node.x - HANDLE_OFFSET, left: pos.x - node.x - HANDLE_OFFSET,
}} }}
> >
{outputLabel && pos.side === 'bottom' && ( {outputLabel && pos.side === 'bottom' && isOutput ? (
<>
<div
className={`${styles.handle} ${styles.handleOutput} ${canConnect ? styles.handleConnectable : ''}`}
style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
onMouseDown={(e) => handleHandleMouseDown(e, node.id, index, isOutput)}
onMouseUp={(e) => !isOutput && handleHandleMouseUp(e, node.id, index)}
title={outputLabel}
/>
<span className={styles.handleLabel}>{outputLabel}</span> <span className={styles.handleLabel}>{outputLabel}</span>
)} </>
) : (
<>
<div <div
className={`${styles.handle} ${isOutput ? styles.handleOutput : styles.handleInput} ${canConnect ? styles.handleConnectable : ''}`} className={`${styles.handle} ${isOutput ? styles.handleOutput : styles.handleInput} ${canConnect ? styles.handleConnectable : ''}`}
style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }} style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
@ -930,6 +1105,8 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
{outputLabel && pos.side === 'top' && ( {outputLabel && pos.side === 'top' && (
<span className={styles.handleLabel}>{outputLabel}</span> <span className={styles.handleLabel}>{outputLabel}</span>
)} )}
</>
)}
</div> </div>
); );
})} })}

View file

@ -6,12 +6,12 @@
* Includes a System Variables section. * 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 { createPortal } from 'react-dom';
import { createRef, createSystemVar, type DataRef, type SystemVarRef, isCompatible } from './dataRef'; import { createRef, createSystemVar, type DataRef, type SystemVarRef, isCompatible } from './dataRef';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import type { DataPickOption, GraphDefinedSchemaRef, NodeType, PortField, PortSchema } from '../../../../api/workflowApi'; import type { DataPickOption, GraphDataSources, GraphDefinedSchemaRef, NodeType, PortField, PortSchema } from '../../../../api/workflowApi';
import { findLoopAncestorIds } from './scopeHelpers'; import { fetchGraphDataSources } from '../../../../api/workflowApi';
import styles from '../../editor/Automation2FlowEditor.module.css'; import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext'; import { useLanguage } from '../../../../providers/language/LanguageContext';
@ -276,20 +276,43 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
// other hook) below it would change the hook count when the picker toggles // other hook) below it would change the hook count when the picker toggles
// open/closed and crash the whole tree (white screen). // open/closed and crash the whole tree (white screen).
const connectionsRaw = ctx?.connections ?? []; 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( const connections = useMemo(
() => () =>
connectionsRaw.map((c) => ({ connectionsRaw.map((c) => ({
source: c.sourceId, source: c.sourceId,
target: c.targetId, 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; // Fetch scope data from the backend when the picker opens — zero topology logic in JS.
if (!cid) return [] as string[]; const [scopeData, setScopeData] = useState<GraphDataSources | null>(null);
return findLoopAncestorIds(nodes, connections, cid); const scopeFetchKey = useRef<string>('');
}, [ctx?.currentNodeId, nodes, connections]); 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; if (!open) return null;
@ -370,13 +393,13 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
</div> </div>
<div className={styles.dataPickerBody}> <div className={styles.dataPickerBody}>
{/* System Variables Section */} {/* System Variables Section */}
{loopAncestorIds.length > 0 && ( {loopBodyContextIds.length > 0 && (
<div className={styles.dataPickerNodeSection}> <div className={styles.dataPickerNodeSection}>
<div className={styles.dataPickerNodeHeader} style={{ cursor: 'default' }}> <div className={styles.dataPickerNodeHeader} style={{ cursor: 'default' }}>
<span className={styles.dataPickerNodeLabel}>{t('Schleife (lexikalisch)')}</span> <span className={styles.dataPickerNodeLabel}>{t('Schleife (lexikalisch)')}</span>
</div> </div>
<div className={styles.dataPickerTree}> <div className={styles.dataPickerTree}>
{loopAncestorIds.map((loopId) => { {loopBodyContextIds.map((loopId) => {
const loopNode = nodes.find((n) => n.id === loopId); const loopNode = nodes.find((n) => n.id === loopId);
const loopLabel = loopNode ? getNodeLabel(loopNode as { id: string; title?: string }) : loopId; const loopLabel = loopNode ? getNodeLabel(loopNode as { id: string; title?: string }) : loopId;
const loopSchema = catalog.LoopItem; const loopSchema = catalog.LoopItem;
@ -456,7 +479,7 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
{/* Node outputs */} {/* Node outputs */}
{(() => { {(() => {
const filteredIds = availableSourceIds.filter((nodeId) => { const filteredIds = effectiveSourceIds.filter((nodeId) => {
const node = nodes.find((n) => n.id === nodeId); const node = nodes.find((n) => n.id === nodeId);
return node?.type !== 'trigger.manual'; return node?.type !== 'trigger.manual';
}); });
@ -472,15 +495,17 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
const typeLabel = nodeTypeDef?.label ?? node?.type ?? ''; const typeLabel = nodeTypeDef?.label ?? node?.type ?? '';
const isExpanded = expandedNodes.has(nodeId); 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 = const backendPick =
port0Def?.dataPickOptions && portDef?.dataPickOptions &&
Array.isArray(port0Def.dataPickOptions) && Array.isArray(portDef.dataPickOptions) &&
port0Def.dataPickOptions.length > 0; portDef.dataPickOptions.length > 0;
let schemaPaths: PickablePath[]; let schemaPaths: PickablePath[];
if (backendPick) { if (backendPick) {
schemaPaths = _pathsFromDataPickOptions(port0Def!.dataPickOptions!); schemaPaths = _pathsFromDataPickOptions(portDef!.dataPickOptions!);
} else { } else {
const resolvedSchema = _resolveSchemaForNode( const resolvedSchema = _resolveSchemaForNode(
nodeId, nodeId,

View file

@ -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<string> {
const preds = new Map<string, Set<string>>();
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<string>();
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');
}

View file

@ -276,6 +276,46 @@
background: var(--bg-primary, #fff); 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 { .taskCard:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
@ -396,6 +436,13 @@
cursor: not-allowed; 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 */ /* Upload task */
.uploadTaskBlock { .uploadTaskBlock {
display: flex; display: flex;

View file

@ -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 <div data-testid="ge-page">{persistentMandateId}::{persistentInstanceId}</div>;
},
}));
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(
<MemoryRouter initialEntries={[initialPath]}>
<_NavCapture />
<GraphicalEditorKeepAlive isVisible />
</MemoryRouter>,
);
}
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);
});
});

View file

@ -7,11 +7,12 @@
*/ */
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom'; 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 { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi'; import { useApiRequest } from '../../../hooks/useApi';
import { import {
fetchTasks, fetchTasks,
cancelPendingTaskStopRun,
completeTask, completeTask,
fetchCompletedRuns, fetchCompletedRuns,
fetchWorkflows, fetchWorkflows,
@ -105,6 +106,7 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => {
const [completedExpanded, setCompletedExpanded] = useState(false); const [completedExpanded, setCompletedExpanded] = useState(false);
const [outputExpanded, setOutputExpanded] = useState(true); const [outputExpanded, setOutputExpanded] = useState(true);
const [submitting, setSubmitting] = useState<string | null>(null); const [submitting, setSubmitting] = useState<string | null>(null);
const [dismissingTaskId, setDismissingTaskId] = useState<string | null>(null);
const [executingWorkflowId, setExecutingWorkflowId] = useState<string | null>(null); const [executingWorkflowId, setExecutingWorkflowId] = useState<string | null>(null);
const load = useCallback(async () => { 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( const handleStartWorkflow = useCallback(
async (wf: Automation2Workflow) => { async (wf: Automation2Workflow) => {
if (!instanceId || !wf.graph) return; if (!instanceId || !wf.graph) return;
@ -228,6 +251,9 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => {
instanceId={instanceId ?? undefined} instanceId={instanceId ?? undefined}
onSubmit={(result) => handleComplete(task.id, result)} onSubmit={(result) => handleComplete(task.id, result)}
submitting={submitting === task.id} submitting={submitting === task.id}
showDismiss
onDismiss={() => handleDismissOpenTask(task.id)}
dismissing={dismissingTaskId === task.id}
/> />
))} ))}
</div> </div>
@ -406,6 +432,10 @@ interface TaskCardProps {
onSubmit: (result: Record<string, unknown>) => void; onSubmit: (result: Record<string, unknown>) => void;
submitting: boolean; submitting: boolean;
readOnly?: 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/*"). */ /** Check if file matches accept string (e.g. ".pdf,image/*"). */
@ -507,6 +537,9 @@ const TaskCard: React.FC<TaskCardProps> = ({
onSubmit, onSubmit,
submitting, submitting,
readOnly = false, readOnly = false,
showDismiss = false,
onDismiss,
dismissing = false,
}) => { }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const { request } = useApiRequest(); const { request } = useApiRequest();
@ -897,8 +930,27 @@ const TaskCard: React.FC<TaskCardProps> = ({
} }
}; };
const cardClass = showDismiss
? `${styles.taskCard} ${styles.taskCardDismissable}`
: styles.taskCard;
return ( return (
<div className={styles.taskCard}> <div className={cardClass}>
{showDismiss && onDismiss ? (
<button
type="button"
className={styles.dismissOpenTaskBtn}
title={t('Task entfernen und Ausführung abbrechen')}
aria-label={t('Task entfernen und Ausführung abbrechen')}
disabled={submitting || dismissing}
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
>
{dismissing ? <FaSpinner className={styles.spinner} /> : <FaTimes />}
</button>
) : null}
<div className={styles.taskMeta}> <div className={styles.taskMeta}>
<div className={styles.taskMetaRow}> <div className={styles.taskMetaRow}>
<span className={styles.metaLabel}>{t('Workflow')}</span> <span className={styles.metaLabel}>{t('Workflow')}</span>