continous work on grafischer editor, loop verbessert
This commit is contained in:
parent
66a7a6fa56
commit
74dc7b85f8
9 changed files with 412 additions and 204 deletions
|
|
@ -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<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). */
|
||||
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)
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -908,6 +908,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
/>
|
||||
</div>
|
||||
{configurableSelected && selectedNode && (
|
||||
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<Automation2DataFlowProvider
|
||||
node={selectedNode}
|
||||
nodes={canvasNodes}
|
||||
|
|
@ -933,6 +934,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
verboseSchema={verboseSchema}
|
||||
/>
|
||||
</Automation2DataFlowProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
nodes: CanvasNode[];
|
||||
connections: CanvasConnection[];
|
||||
|
|
@ -254,9 +381,9 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ 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<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(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -341,7 +483,10 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ 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' }}
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
|
|
@ -739,9 +892,16 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ nodes,
|
|||
left: pos.x - node.x - HANDLE_OFFSET,
|
||||
}}
|
||||
>
|
||||
{outputLabel && pos.side === 'bottom' && (
|
||||
<span className={styles.handleLabel}>{outputLabel}</span>
|
||||
)}
|
||||
<div
|
||||
className={`${styles.handle} ${isOutput ? styles.handleOutput : styles.handleInput} ${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 ??
|
||||
(selectedConnectionId && !isOutput
|
||||
? used
|
||||
? t('Aktuelles Ziel klicken, um abzuwählen')
|
||||
: t('Klicken zum Umleiten')
|
||||
: undefined)
|
||||
}
|
||||
/>
|
||||
{outputLabel && pos.side === 'top' && (
|
||||
<span className={styles.handleLabel}>{outputLabel}</span>
|
||||
{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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={`${styles.handle} ${isOutput ? styles.handleOutput : styles.handleInput} ${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 ??
|
||||
(selectedConnectionId && !isOutput
|
||||
? used
|
||||
? t('Aktuelles Ziel klicken, um abzuwählen')
|
||||
: t('Klicken zum Umleiten')
|
||||
: undefined)
|
||||
}
|
||||
/>
|
||||
{outputLabel && pos.side === 'top' && (
|
||||
<span className={styles.handleLabel}>{outputLabel}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<DataPickerProps> = ({ 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<GraphDataSources | null>(null);
|
||||
const scopeFetchKey = useRef<string>('');
|
||||
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<DataPickerProps> = ({ open,
|
|||
</div>
|
||||
<div className={styles.dataPickerBody}>
|
||||
{/* System Variables Section */}
|
||||
{loopAncestorIds.length > 0 && (
|
||||
{loopBodyContextIds.length > 0 && (
|
||||
<div className={styles.dataPickerNodeSection}>
|
||||
<div className={styles.dataPickerNodeHeader} style={{ cursor: 'default' }}>
|
||||
<span className={styles.dataPickerNodeLabel}>{t('Schleife (lexikalisch)')}</span>
|
||||
</div>
|
||||
<div className={styles.dataPickerTree}>
|
||||
{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<DataPickerProps> = ({ 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<DataPickerProps> = ({ 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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string | null>(null);
|
||||
const [dismissingTaskId, setDismissingTaskId] = useState<string | null>(null);
|
||||
const [executingWorkflowId, setExecutingWorkflowId] = useState<string | null>(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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -406,6 +432,10 @@ interface TaskCardProps {
|
|||
onSubmit: (result: Record<string, unknown>) => 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<TaskCardProps> = ({
|
|||
onSubmit,
|
||||
submitting,
|
||||
readOnly = false,
|
||||
showDismiss = false,
|
||||
onDismiss,
|
||||
dismissing = false,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { request } = useApiRequest();
|
||||
|
|
@ -897,8 +930,27 @@ const TaskCard: React.FC<TaskCardProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const cardClass = showDismiss
|
||||
? `${styles.taskCard} ${styles.taskCardDismissable}`
|
||||
: styles.taskCard;
|
||||
|
||||
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.taskMetaRow}>
|
||||
<span className={styles.metaLabel}>{t('Workflow')}</span>
|
||||
|
|
|
|||
Loading…
Reference in a new issue