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[] };
|
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)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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 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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue