@saltcorn/workflow-editor 1.6.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1787 @@
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ } from "react";
9
+ import {
10
+ Background,
11
+ Controls,
12
+ Handle,
13
+ MiniMap,
14
+ MarkerType,
15
+ Position,
16
+ useEdgesState,
17
+ useNodesState,
18
+ ReactFlow,
19
+ } from "@xyflow/react";
20
+ import "@xyflow/react/dist/style.css";
21
+ import "./workflow.css";
22
+ import WorkflowEdge from "./WorkflowEdge";
23
+
24
+ const handleStyle = {
25
+ width: "10px",
26
+ height: "10px",
27
+ borderRadius: "50%",
28
+ border: "2px solid var(--wf-handle-border, #3f3f3f)",
29
+ background: "var(--wf-handle-bg, #fff)",
30
+ };
31
+
32
+ const DEFAULT_NODE_WIDTH = 220;
33
+ const DEFAULT_NODE_HEIGHT = 120;
34
+ const H_GAP = 80;
35
+ const V_GAP = 60;
36
+ const ADD_NODE_SIZE = 32;
37
+ const ADD_GAP = 32;
38
+ const DEFAULT_ARROW_COLOR = "var(--wf-edge-label, #6c757d)";
39
+ const makeMarker = (color = DEFAULT_ARROW_COLOR) => ({
40
+ type: MarkerType.ArrowClosed,
41
+ width: 18,
42
+ height: 18,
43
+ color,
44
+ });
45
+
46
+ const normalizeSize = (size) => {
47
+ if (!size) return null;
48
+ const width = Number(size.width);
49
+ const height = Number(size.height);
50
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
51
+ return { width, height };
52
+ };
53
+
54
+ const getWorkflowSize = (step) =>
55
+ normalizeSize(step?.configuration?.workflow_size);
56
+
57
+ const StartNode = ({ data }) => (
58
+ <div className="wf-start-node">
59
+ <div className="wf-start-title">{data.strings.start}</div>
60
+ <Handle
61
+ style={handleStyle}
62
+ id="start"
63
+ type="source"
64
+ className="wf-handle-source"
65
+ position={Position.Bottom}
66
+ />
67
+ </div>
68
+ );
69
+
70
+ const AddNode = ({ data }) => {
71
+ const handleClick = (e) => {
72
+ e.stopPropagation();
73
+ data.onAddAfter?.(data.afterStepId);
74
+ };
75
+ const handleKeyDown = (e) => {
76
+ if (e.key === "Enter" || e.key === " ") {
77
+ e.preventDefault();
78
+ data.onAddAfter?.(data.afterStepId);
79
+ }
80
+ };
81
+ return (
82
+ <div
83
+ className="wf-add-node"
84
+ role="button"
85
+ tabIndex={0}
86
+ aria-label={data.strings.addStep}
87
+ onClick={handleClick}
88
+ onKeyDown={handleKeyDown}
89
+ >
90
+ <span aria-hidden>+</span>
91
+ <Handle type="target" position={Position.Top} />
92
+ </div>
93
+ );
94
+ };
95
+
96
+ const StepNode = ({ data }) => {
97
+ const summaryLines = Array.isArray(data.actionSummary)
98
+ ? data.actionSummary
99
+ : data.actionSummary
100
+ ? String(data.actionSummary)
101
+ .split("\n")
102
+ .map((s) => s.trim())
103
+ .filter(Boolean)
104
+ : data.actionDescription
105
+ ? [data.actionDescription]
106
+ : [];
107
+
108
+ const handleClick = (e) => {
109
+ if (e.target?.closest(".react-flow__handle")) return;
110
+ e.stopPropagation();
111
+ if (data.onEdit) data.onEdit(data.id);
112
+ };
113
+
114
+ const handleKeyDown = (e) => {
115
+ if (e.key === "Enter" || e.key === " ") {
116
+ e.preventDefault();
117
+ if (data.onEdit) data.onEdit(data.id);
118
+ }
119
+ };
120
+
121
+ const targetPosition = Position.Top;
122
+ const sourcePosition = Position.Bottom;
123
+ const isLoop = data.action_name === "ForLoop";
124
+ const { showBackIn, showBackOut, showLoopIn, showLoopOut, hideMainSource } =
125
+ data || {};
126
+
127
+ const hasLeftSource = !!showBackOut;
128
+ const hasLeftTarget = !!showBackIn || !!showLoopIn;
129
+
130
+ let backInStyle = handleStyle;
131
+ let loopInStyle = handleStyle;
132
+ let backOutStyle = handleStyle;
133
+
134
+ if (hasLeftSource && hasLeftTarget) {
135
+ backInStyle = { ...handleStyle, top: "68%" };
136
+ loopInStyle = { ...handleStyle, top: "32%" };
137
+ backOutStyle = { ...handleStyle, top: "32%" };
138
+ }
139
+
140
+ return (
141
+ <div
142
+ className="wf-node"
143
+ onClick={handleClick}
144
+ role="button"
145
+ tabIndex={0}
146
+ onKeyDown={handleKeyDown}
147
+ >
148
+ <div className="wf-node__header">
149
+ <div className="wf-node__title-row">
150
+ <div className="wf-node__title">{data.name}</div>
151
+ <div className="wf-node__action">{data.action_name}</div>
152
+ </div>
153
+ </div>
154
+ {summaryLines.length ? (
155
+ <ul className="wf-node__summary">
156
+ {summaryLines.map((line, ix) => (
157
+ <li key={ix}>{line}</li>
158
+ ))}
159
+ </ul>
160
+ ) : null}
161
+ {data.only_if ? (
162
+ <div className="wf-node__meta">
163
+ <span className="wf-label">{data.strings.onlyIf}:</span>{" "}
164
+ {data.only_if}
165
+ </div>
166
+ ) : null}
167
+ {/* {data.next_step ? (
168
+ <div className="wf-node__meta">
169
+ <span className="wf-label">{data.strings.nextStep}:</span>{" "}
170
+ {data.next_step}
171
+ </div>
172
+ ) : null} */}
173
+ {/* {data.isLoop && data.loop_body_initial_step ? (
174
+ <div className="wf-node__meta">
175
+ <span className="wf-label">{data.strings.loopBody}:</span>{" "}
176
+ {data.loop_body_initial_step}
177
+ </div>
178
+ ) : null} */}
179
+ <Handle style={handleStyle} type="target" position={targetPosition} />
180
+ {!hideMainSource ? (
181
+ <Handle
182
+ style={handleStyle}
183
+ id="main"
184
+ type="source"
185
+ className="wf-handle-source"
186
+ position={sourcePosition}
187
+ />
188
+ ) : null}
189
+ {/* Back edges use left handles to untangle cross-links */}
190
+ {showBackIn ? (
191
+ <Handle
192
+ style={backInStyle}
193
+ id="back-in"
194
+ type="target"
195
+ position={Position.Left}
196
+ />
197
+ ) : null}
198
+ {showBackOut ? (
199
+ <Handle
200
+ style={backOutStyle}
201
+ id="back-out"
202
+ type="source"
203
+ className="wf-handle-source"
204
+ position={Position.Left}
205
+ />
206
+ ) : null}
207
+ {isLoop ? (
208
+ <>
209
+ {showLoopIn ? (
210
+ <Handle
211
+ style={loopInStyle}
212
+ id="loop-in"
213
+ type="target"
214
+ className="wf-handle-target-loop"
215
+ position={Position.Left}
216
+ />
217
+ ) : null}
218
+ {showLoopOut ? (
219
+ <Handle
220
+ style={handleStyle}
221
+ id="loop-out"
222
+ type="source"
223
+ className="wf-handle-source-loop"
224
+ position={Position.Right}
225
+ />
226
+ ) : null}
227
+ </>
228
+ ) : null}
229
+ </div>
230
+ );
231
+ };
232
+
233
+ const nodeTypes = {
234
+ start: StartNode,
235
+ step: StepNode,
236
+ add: AddNode,
237
+ };
238
+
239
+ const edgeTypes = {
240
+ "workflow-main": WorkflowEdge,
241
+ };
242
+
243
+ const findLoopBackLinks = (steps) => {
244
+ const byName = new Map(steps.map((s) => [s.name, s]));
245
+ const loopBacks = {};
246
+ steps
247
+ .filter((s) => s.action_name === "ForLoop")
248
+ .forEach((forStep) => {
249
+ const visited = new Set();
250
+ let cursor = byName.get(forStep.configuration?.loop_body_initial_step);
251
+ let lastStep = null;
252
+ while (cursor && !visited.has(cursor.name)) {
253
+ visited.add(cursor.name);
254
+ lastStep = cursor;
255
+ if (!cursor.next_step || cursor.next_step === forStep.name) break;
256
+ cursor = byName.get(cursor.next_step);
257
+ }
258
+ if (lastStep && !lastStep.next_step) {
259
+ loopBacks[lastStep.name] = forStep.name;
260
+ }
261
+ });
262
+ return loopBacks;
263
+ };
264
+
265
+ const findLoopBodyStepIds = (steps) => {
266
+ const byName = new Map(steps.map((s) => [s.name, s]));
267
+ const loopBodyIds = new Set();
268
+ steps
269
+ .filter((s) => s.action_name === "ForLoop")
270
+ .forEach((loopStep) => {
271
+ const visited = new Set();
272
+ let cursor = byName.get(loopStep.configuration?.loop_body_initial_step);
273
+ while (cursor && !visited.has(cursor.name)) {
274
+ visited.add(cursor.name);
275
+ loopBodyIds.add(String(cursor.id));
276
+ if (!cursor.next_step || cursor.next_step === loopStep.name) break;
277
+ cursor = byName.get(cursor.next_step);
278
+ }
279
+ });
280
+ return loopBodyIds;
281
+ };
282
+
283
+ const buildGraph = (
284
+ steps,
285
+ strings,
286
+ actionExplainers,
287
+ { startPosition, addPositions } = {}
288
+ ) => {
289
+ const idByName = {};
290
+ const nameById = {};
291
+ const sizeById = {};
292
+ steps.forEach((s) => {
293
+ idByName[s.name] = String(s.id);
294
+ nameById[String(s.id)] = s.name;
295
+ const size = getWorkflowSize(s);
296
+ sizeById[String(s.id)] = size || {
297
+ width: DEFAULT_NODE_WIDTH,
298
+ height: DEFAULT_NODE_HEIGHT,
299
+ };
300
+ });
301
+
302
+ const allStepNames = steps.map((s) => s.name).filter(Boolean);
303
+ const orderIndex = new Map(steps.map((s, idx) => [String(s.id), idx]));
304
+
305
+ const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
306
+
307
+ const extractNextStepNames = (rawNextStep) => {
308
+ if (!rawNextStep) return [];
309
+ const trimmed = String(rawNextStep).trim();
310
+
311
+ // Simple case: next_step is directly a step name
312
+ if (idByName[trimmed]) return [trimmed];
313
+
314
+ // Expression case: find all step names referenced in the expression
315
+ const expr = trimmed;
316
+ const hits = new Set();
317
+ allStepNames.forEach((name) => {
318
+ if (!name) return;
319
+ try {
320
+ const re = new RegExp(`\\b${escapeRegExp(name)}\\b`);
321
+ if (re.test(expr)) hits.add(name);
322
+ } catch (e) {
323
+ // Fallback: simple substring match if regex construction fails
324
+ if (expr.includes(name)) hits.add(name);
325
+ }
326
+ });
327
+ return [...hits];
328
+ };
329
+
330
+ const initial = steps.find((s) => s.initial_step);
331
+ const loopBackLinks = findLoopBackLinks(steps);
332
+ const loopBodyIds = findLoopBodyStepIds(steps);
333
+
334
+ // Track which handles are actualy used by edges per node
335
+ const handleUsage = {};
336
+ const markHandleUsage = (nodeId, handleId) => {
337
+ if (!nodeId || !handleId) return;
338
+ const key = String(nodeId);
339
+ if (!handleUsage[key]) handleUsage[key] = {};
340
+ handleUsage[key][handleId] = true;
341
+ };
342
+
343
+ // Track whether a node has forward/back edges to decide when to hide main bottom handle
344
+ const edgeStats = {};
345
+ const markEdgeKind = (nodeId, { back = false, forward = false } = {}) => {
346
+ if (!nodeId) return;
347
+ const key = String(nodeId);
348
+ if (!edgeStats[key])
349
+ edgeStats[key] = { hasBackEdge: false, hasForwardEdge: false };
350
+ if (back) edgeStats[key].hasBackEdge = true;
351
+ if (forward) edgeStats[key].hasForwardEdge = true;
352
+ };
353
+
354
+ const adjacency = new Map();
355
+ steps.forEach((s) => {
356
+ const targets = [];
357
+ const nextNames = extractNextStepNames(s.next_step);
358
+ nextNames.forEach((name) => {
359
+ const id = idByName[name];
360
+ if (id) targets.push(id);
361
+ });
362
+ adjacency.set(String(s.id), [...new Set(targets)]);
363
+ });
364
+
365
+ // Revising logical ordering based on workflow graph traversal from initial step
366
+ if (initial) {
367
+ const visited = new Set();
368
+ const queue = [String(initial.id)];
369
+ let index = 0;
370
+ while (queue.length) {
371
+ const nodeId = queue.shift();
372
+ if (visited.has(nodeId)) continue;
373
+ visited.add(nodeId);
374
+ orderIndex.set(nodeId, index++);
375
+ const neighbors = adjacency.get(nodeId) || [];
376
+ neighbors.forEach((n) => {
377
+ if (!visited.has(n)) queue.push(n);
378
+ });
379
+ }
380
+ }
381
+
382
+ const positions = {};
383
+ let yCursor = 0;
384
+ steps.forEach((s) => {
385
+ const id = String(s.id);
386
+ const size = sizeById[id] || {
387
+ width: DEFAULT_NODE_WIDTH,
388
+ height: DEFAULT_NODE_HEIGHT,
389
+ };
390
+ positions[id] = { x: 0, y: yCursor };
391
+ yCursor += size.height + V_GAP;
392
+ });
393
+
394
+ // Nudge ForLoop bodies to be vertical under their loop parent for readability
395
+ const positionOverrides = {};
396
+ steps
397
+ .filter((s) => s.action_name === "ForLoop")
398
+ .forEach((loopStep) => {
399
+ const loopPos =
400
+ loopStep.configuration?.workflow_position ||
401
+ positions[String(loopStep.id)];
402
+ if (!loopPos) return;
403
+ const byName = new Map(steps.map((s) => [s.name, s]));
404
+ const body = [];
405
+ const seen = new Set();
406
+ let cursor = byName.get(loopStep.configuration?.loop_body_initial_step);
407
+ while (cursor && !seen.has(cursor.name)) {
408
+ seen.add(cursor.name);
409
+ body.push(cursor);
410
+ if (!cursor.next_step || cursor.next_step === loopStep.name) break;
411
+ cursor = byName.get(cursor.next_step);
412
+ }
413
+
414
+ const loopSize = sizeById[String(loopStep.id)] || {
415
+ width: DEFAULT_NODE_WIDTH,
416
+ height: DEFAULT_NODE_HEIGHT,
417
+ };
418
+ let yCursor = loopPos.y + loopSize.height + V_GAP;
419
+
420
+ body.forEach((s) => {
421
+ if (s.configuration?.workflow_position) return; // respect saved positions
422
+ const bodySize = sizeById[String(s.id)] || {
423
+ width: DEFAULT_NODE_WIDTH,
424
+ height: DEFAULT_NODE_HEIGHT,
425
+ };
426
+ positionOverrides[String(s.id)] = {
427
+ x: loopPos.x,
428
+ y: yCursor,
429
+ };
430
+ yCursor += bodySize.height + V_GAP;
431
+ });
432
+ });
433
+
434
+ const nodePositions = {};
435
+ const startNodePosition =
436
+ startPosition ||
437
+ (initial
438
+ ? {
439
+ x: positions[String(initial.id)]?.x ?? 0,
440
+ y: (positions[String(initial.id)]?.y ?? 0) - 160,
441
+ }
442
+ : { x: 0, y: -160 });
443
+
444
+ const nodes = [
445
+ {
446
+ id: "start",
447
+ type: "start",
448
+ data: { strings },
449
+ position: startNodePosition,
450
+ draggable: true,
451
+ },
452
+ ...steps.map((step, ix) => {
453
+ return {
454
+ id: String(step.id),
455
+ type: "step",
456
+ position: step.configuration?.workflow_position ||
457
+ positionOverrides[String(step.id)] ||
458
+ positions[String(step.id)] || {
459
+ x: (ix % 3) * 260,
460
+ y: Math.floor(ix / 3) * 180 + 10,
461
+ },
462
+ data: {
463
+ ...step,
464
+ id: String(step.id),
465
+ loop_body_initial_step: step.configuration?.loop_body_initial_step,
466
+ isLoop: step.action_name === "ForLoop",
467
+ inLoopBody: loopBodyIds.has(String(step.id)),
468
+ actionSummary:
469
+ step.summary ||
470
+ (actionExplainers[step.action_name]
471
+ ? [actionExplainers[step.action_name]]
472
+ : []),
473
+ actionDescription: actionExplainers[step.action_name],
474
+ strings,
475
+ },
476
+ };
477
+ }),
478
+ ];
479
+
480
+ nodes.forEach((n) => {
481
+ if (n.type === "step") nodePositions[n.id] = n.position;
482
+ });
483
+
484
+ const edges = [];
485
+ if (initial)
486
+ edges.push({
487
+ id: `e-start-${initial.id}`,
488
+ source: "start",
489
+ target: String(initial.id),
490
+ type: "smoothstep",
491
+ animated: true,
492
+ markerEnd: makeMarker(),
493
+ });
494
+ else {
495
+ const addId = "add-start";
496
+ edges.push({
497
+ id: "e-start-adder",
498
+ source: "start",
499
+ target: addId,
500
+ type: "bezier",
501
+ animated: false,
502
+ deletable: false,
503
+ style: {
504
+ strokeDasharray: "4 2",
505
+ stroke: "var(--wf-edge-adder, #0d6efd)",
506
+ },
507
+ markerEnd: makeMarker("var(--wf-edge-adder, #0d6efd)"),
508
+ });
509
+ }
510
+
511
+ steps.forEach((step) => {
512
+ if (step.next_step) {
513
+ const rawNext = String(step.next_step || "").trim();
514
+ const directTargetId = idByName[rawNext];
515
+ const nextNames = extractNextStepNames(step.next_step);
516
+ const isSimpleNextStep =
517
+ !!directTargetId && nextNames.length === 1 && nextNames[0] === rawNext;
518
+ nextNames.forEach((name) => {
519
+ const targetId = idByName[name];
520
+ if (!targetId) return;
521
+ const srcOrder = orderIndex.get(String(step.id));
522
+ const tgtOrder = orderIndex.get(targetId);
523
+ const srcPos = nodePositions[String(step.id)];
524
+ const tgtPos = nodePositions[targetId];
525
+ const isBackByOrder =
526
+ srcOrder !== undefined &&
527
+ tgtOrder !== undefined &&
528
+ tgtOrder < srcOrder;
529
+ const isBackVisually =
530
+ srcPos &&
531
+ tgtPos &&
532
+ typeof srcPos.y === "number" &&
533
+ typeof tgtPos.y === "number"
534
+ ? tgtPos.y < srcPos.y
535
+ : false;
536
+ const isBackEdge = isBackByOrder && isBackVisually;
537
+ // record handle usage and edge direction for this source node
538
+ const sourceId = String(step.id);
539
+ if (isBackEdge) {
540
+ markHandleUsage(sourceId, "back-out");
541
+ markHandleUsage(targetId, "back-in");
542
+ markEdgeKind(sourceId, { back: true });
543
+ } else {
544
+ // uses the main bottom source handle
545
+ markHandleUsage(sourceId, "main");
546
+ markEdgeKind(sourceId, { forward: true });
547
+ }
548
+ edges.push({
549
+ id: `e-${step.id}-${name}`,
550
+ source: String(step.id),
551
+ target: targetId,
552
+ type: "workflow-main",
553
+ animated: true,
554
+ sourceHandle: isBackEdge ? "back-out" : undefined,
555
+ targetHandle: isBackEdge ? "back-in" : undefined,
556
+ markerEnd: makeMarker(),
557
+ data: {
558
+ startLabel: step.name,
559
+ sourceStepName: step.name,
560
+ sourceId: String(step.id),
561
+ targetId,
562
+ canInsertBetween: isSimpleNextStep,
563
+ },
564
+ });
565
+ });
566
+ }
567
+ if (step.action_name === "ForLoop") {
568
+ const loopTarget = step.configuration?.loop_body_initial_step;
569
+ if (loopTarget) {
570
+ const loopId = idByName[loopTarget];
571
+ const sourceId = String(step.id);
572
+ markHandleUsage(sourceId, "loop-out");
573
+ markEdgeKind(sourceId, { forward: true });
574
+ if (!loopId) {
575
+ markHandleUsage(sourceId, "loop-in");
576
+ }
577
+ edges.push({
578
+ id: `loop-${step.id}-${loopTarget}`,
579
+ source: String(step.id),
580
+ target: loopId || String(step.id),
581
+ type: "smoothstep",
582
+ sourceHandle: "loop-out",
583
+ targetHandle: loopId ? undefined : "loop-in",
584
+ style: {
585
+ stroke: "var(--wf-edge-loop, #f59f00)",
586
+ strokeDasharray: "6 4",
587
+ },
588
+ label: strings.loopBody,
589
+ markerEnd: makeMarker("var(--wf-edge-loop, #f59f00)"),
590
+ data: { loop: true, missing: !loopId },
591
+ });
592
+ }
593
+ }
594
+
595
+ if (!step.next_step && loopBackLinks[step.name]) {
596
+ const forLoopName = loopBackLinks[step.name];
597
+ const loopId = idByName[forLoopName];
598
+ if (loopId) {
599
+ const sourceId = String(step.id);
600
+ markHandleUsage(sourceId, "back-out");
601
+ markHandleUsage(loopId, "loop-in");
602
+ markEdgeKind(sourceId, { back: true });
603
+ edges.push({
604
+ id: `loopback-${step.id}-${forLoopName}`,
605
+ source: String(step.id),
606
+ target: loopId,
607
+ type: "smoothstep",
608
+ sourceHandle: "back-out",
609
+ targetHandle: "loop-in",
610
+ style: {
611
+ stroke: "var(--wf-edge-loop, #f59f00)",
612
+ strokeDasharray: "6 4",
613
+ },
614
+ data: { loop: true, loopBack: true },
615
+ markerEnd: makeMarker("var(--wf-edge-loop, #f59f00)"),
616
+ deletable: false,
617
+ });
618
+ }
619
+ }
620
+ });
621
+
622
+ const addNodes = [];
623
+ if (!steps.length) {
624
+ const addId = "add-start";
625
+ addNodes.push({
626
+ id: addId,
627
+ type: "add",
628
+ position: addPositions?.[addId] || {
629
+ x: startNodePosition.x,
630
+ y: startNodePosition.y + 160,
631
+ },
632
+ data: { strings, afterStepId: "start" },
633
+ draggable: true,
634
+ selectable: false,
635
+ deletable: false,
636
+ });
637
+ }
638
+ steps.forEach((step) => {
639
+ const hasNextTargets = extractNextStepNames(step.next_step).length > 0;
640
+ if (hasNextTargets || loopBackLinks[step.name]) return;
641
+ const basePos = nodePositions[String(step.id)] || { x: 0, y: 0 };
642
+ const baseSize = sizeById[String(step.id)] || {
643
+ width: DEFAULT_NODE_WIDTH,
644
+ height: DEFAULT_NODE_HEIGHT,
645
+ };
646
+ const addId = `add-${step.id}`;
647
+ addNodes.push({
648
+ id: addId,
649
+ type: "add",
650
+ position: addPositions?.[addId] || {
651
+ x: basePos.x + baseSize.width / 2 - ADD_NODE_SIZE / 2,
652
+ y: basePos.y + baseSize.height + ADD_GAP,
653
+ },
654
+ data: { strings, afterStepId: String(step.id) },
655
+ draggable: true,
656
+ selectable: false,
657
+ deletable: false,
658
+ });
659
+ edges.push({
660
+ id: `e-${step.id}-adder`,
661
+ source: String(step.id),
662
+ target: addId,
663
+ type: "bezier",
664
+ animated: false,
665
+ deletable: false,
666
+ style: {
667
+ strokeDasharray: "4 2",
668
+ stroke: "var(--wf-edge-adder, #0d6efd)",
669
+ },
670
+ markerEnd: makeMarker("var(--wf-edge-adder, #0d6efd)"),
671
+ });
672
+ });
673
+
674
+ const allNodes = [...nodes, ...addNodes].map((node) => {
675
+ if (node.type !== "step") return node;
676
+ const id = String(node.id);
677
+ const usage = handleUsage[id] || {};
678
+ const stats = edgeStats[id] || {
679
+ hasBackEdge: false,
680
+ hasForwardEdge: false,
681
+ };
682
+ const hideMainSource = stats.hasBackEdge && !stats.hasForwardEdge;
683
+
684
+ return {
685
+ ...node,
686
+ data: {
687
+ ...node.data,
688
+ showBackIn: !!usage["back-in"],
689
+ showBackOut: !!usage["back-out"],
690
+ showLoopIn: !!usage["loop-in"],
691
+ showLoopOut: !!usage["loop-out"],
692
+ hideMainSource,
693
+ },
694
+ };
695
+ });
696
+
697
+ return { nodes: allNodes, edges, idByName, nameById };
698
+ };
699
+
700
+ const StepDrawer = ({
701
+ modal,
702
+ innerRef,
703
+ outerRef,
704
+ onClose,
705
+ submitting,
706
+ error,
707
+ data,
708
+ onDelete,
709
+ drawerWidth,
710
+ onResizeStart,
711
+ }) => {
712
+ const isOpen = !!modal;
713
+ return (
714
+ <div
715
+ ref={outerRef}
716
+ className={`wf-drawer${isOpen ? " wf-drawer--open" : ""}`}
717
+ role="dialog"
718
+ aria-modal="false"
719
+ aria-hidden={!isOpen}
720
+ aria-label={modal?.title || data?.strings?.configure}
721
+ style={{ "--wf-drawer-width": `${drawerWidth}px` }}
722
+ >
723
+ {modal ? (
724
+ <>
725
+ <div className="wf-drawer__resize" onPointerDown={onResizeStart} />
726
+ <div className="wf-drawer__header d-flex justify-content-between align-items-center">
727
+ <div>
728
+ <h5 className="mb-0">{modal.title}</h5>
729
+ </div>
730
+ <button
731
+ className="btn-close"
732
+ onClick={onClose}
733
+ aria-label="Close"
734
+ />
735
+ </div>
736
+ <div className="wf-drawer__body">
737
+ <div
738
+ ref={innerRef}
739
+ dangerouslySetInnerHTML={{ __html: modal.body }}
740
+ />
741
+ {error ? (
742
+ <div className="alert alert-danger mt-3">{error}</div>
743
+ ) : null}
744
+ </div>
745
+ <div className="wf-drawer__footer d-flex justify-content-end gap-2">
746
+ <button
747
+ className="btn btn-sm btn-outline-danger"
748
+ onClick={(e) => {
749
+ e.stopPropagation();
750
+ onDelete(modal.stepId);
751
+ }}
752
+ >
753
+ {data.strings.deleteStep}
754
+ </button>
755
+ <button className="btn btn-secondary" onClick={onClose}>
756
+ Close
757
+ </button>
758
+ <button
759
+ className="btn btn-primary"
760
+ onClick={() =>
761
+ innerRef.current
762
+ ?.querySelector("form")
763
+ ?.dispatchEvent(
764
+ new Event("submit", { cancelable: true, bubbles: true })
765
+ )
766
+ }
767
+ disabled={submitting}
768
+ >
769
+ {submitting ? "Saving..." : "Save"}
770
+ </button>
771
+ </div>
772
+ </>
773
+ ) : null}
774
+ </div>
775
+ );
776
+ };
777
+
778
+ const WorkflowEditor = ({ data }) => {
779
+ const [steps, setSteps] = useState(data.steps || []);
780
+ const [nodes, setNodes, onNodesChange] = useNodesState([]);
781
+ const [edges, setEdges, rfOnEdgesChange] = useEdgesState([]);
782
+ const [nameById, setNameById] = useState({});
783
+ const [idByName, setIdByName] = useState({});
784
+ const [startPosition, setStartPosition] = useState(
785
+ data.config?.workflow_start_position || null
786
+ );
787
+ const [addPositions, setAddPositions] = useState({});
788
+ const pendingAddRef = useRef(null);
789
+ const positionDebounceRef = useRef(null);
790
+ const pendingPositionRef = useRef(new Map());
791
+ const [loading, setLoading] = useState(false);
792
+ const [message, setMessage] = useState("");
793
+ const [error, setError] = useState("");
794
+ const [modal, setModal] = useState(null);
795
+ const [savingModal, setSavingModal] = useState(false);
796
+ const [savingPositions, setSavingPositions] = useState(false);
797
+ const [sizeSyncing, setSizeSyncing] = useState(false);
798
+ const [drawerWidth, setDrawerWidth] = useState(() => {
799
+ if (typeof window !== "undefined") {
800
+ return Math.max(600, Math.round(window.innerWidth * 0.5));
801
+ }
802
+ return 600;
803
+ });
804
+ const modalRef = useRef(null);
805
+ const drawerRef = useRef(null);
806
+ const shellRef = useRef(null);
807
+ const resizingRef = useRef(false);
808
+ const connectSourceRef = useRef(null);
809
+ const [selectedEdgeId, setSelectedEdgeId] = useState(null);
810
+
811
+ const drawerStorageKey = useMemo(
812
+ () => `wf-drawer:${data?.trigger?.id || "default"}`,
813
+ [data?.trigger?.id]
814
+ );
815
+
816
+ const viewportStorageKey = useMemo(
817
+ () => `wf-viewport:${data?.trigger?.id || "default"}`,
818
+ [data?.trigger?.id]
819
+ );
820
+
821
+ const rfInstanceRef = useRef(null);
822
+
823
+ const persistDrawerState = useCallback(
824
+ (payload) => {
825
+ try {
826
+ if (typeof window === "undefined") return;
827
+ window.sessionStorage.setItem(
828
+ drawerStorageKey,
829
+ JSON.stringify(payload || {})
830
+ );
831
+ } catch (e) {
832
+ // ignore storage errors
833
+ }
834
+ },
835
+ [drawerStorageKey]
836
+ );
837
+
838
+ const clearDrawerState = useCallback(() => {
839
+ try {
840
+ if (typeof window === "undefined") return;
841
+ window.sessionStorage.removeItem(drawerStorageKey);
842
+ } catch (e) {
843
+ // ignore storage errors
844
+ }
845
+ }, [drawerStorageKey]);
846
+
847
+ const loadViewport = useCallback(() => {
848
+ if (typeof window === "undefined") return null;
849
+ try {
850
+ const raw = window.sessionStorage.getItem(viewportStorageKey);
851
+ if (!raw) return null;
852
+ const parsed = JSON.parse(raw);
853
+ if (!parsed || typeof parsed !== "object") return null;
854
+ const { x, y, zoom } = parsed;
855
+ if (
856
+ Number.isFinite(Number(x)) &&
857
+ Number.isFinite(Number(y)) &&
858
+ Number.isFinite(Number(zoom))
859
+ ) {
860
+ return { x: Number(x), y: Number(y), zoom: Number(zoom) };
861
+ }
862
+ } catch (e) {
863
+ // ignore storage errors
864
+ }
865
+ return null;
866
+ }, [viewportStorageKey]);
867
+
868
+ const persistViewport = useCallback(
869
+ (viewport) => {
870
+ if (!viewport) return;
871
+ const { x, y, zoom } = viewport;
872
+ if (
873
+ !Number.isFinite(Number(x)) ||
874
+ !Number.isFinite(Number(y)) ||
875
+ !Number.isFinite(Number(zoom))
876
+ )
877
+ return;
878
+ try {
879
+ if (typeof window === "undefined") return;
880
+ window.sessionStorage.setItem(
881
+ viewportStorageKey,
882
+ JSON.stringify({ x: Number(x), y: Number(y), zoom: Number(zoom) })
883
+ );
884
+ } catch (e) {
885
+ // ignore storage errors
886
+ }
887
+ },
888
+ [viewportStorageKey]
889
+ );
890
+
891
+ const onInit = useCallback((instance) => {
892
+ rfInstanceRef.current = instance;
893
+ }, []);
894
+
895
+ const [selectedNodes, setSelectedNodes] = useState([]);
896
+ const pendingViewportRef = useRef(loadViewport());
897
+ const appliedViewportRef = useRef(false);
898
+
899
+ const handleSelectionChange = useCallback(({ nodes = [], edges = [] }) => {
900
+ setSelectedNodes(nodes);
901
+ if (edges.length) setSelectedEdgeId(edges[0].id);
902
+ else setSelectedEdgeId(null);
903
+ }, []);
904
+ const handleKeyDelete = useCallback(
905
+ (e) => {
906
+ if (modal) return;
907
+ const tag = e.target?.tagName?.toLowerCase();
908
+ if (
909
+ tag === "input" ||
910
+ tag === "textarea" ||
911
+ tag === "select" ||
912
+ tag === "button" ||
913
+ e.target?.isContentEditable
914
+ )
915
+ return;
916
+ if (e.key !== "Delete" && e.key !== "Backspace") return;
917
+ const stepNode = selectedNodes.find((n) => n.type === "step");
918
+ if (!stepNode) return;
919
+ e.preventDefault();
920
+ onDelete(stepNode.id);
921
+ },
922
+ [modal, onDelete, selectedNodes]
923
+ );
924
+
925
+ const strings = data.strings || {};
926
+ const refreshGraph = useCallback(
927
+ (nextSteps) => {
928
+ const {
929
+ nodes: n,
930
+ edges: e,
931
+ idByName: idMap,
932
+ nameById: nameMap,
933
+ } = buildGraph(nextSteps, strings, data.actionExplainers || {}, {
934
+ startPosition,
935
+ addPositions,
936
+ });
937
+ setNodes(n);
938
+ setEdges(e);
939
+ setIdByName(idMap);
940
+ setNameById(nameMap);
941
+ },
942
+ [
943
+ addPositions,
944
+ data.actionExplainers,
945
+ setEdges,
946
+ setNodes,
947
+ startPosition,
948
+ strings,
949
+ ]
950
+ );
951
+
952
+ useEffect(() => {
953
+ refreshGraph(steps);
954
+ }, [steps, refreshGraph]);
955
+
956
+ useEffect(() => {
957
+ if (!rfInstanceRef.current) return;
958
+ if (!pendingViewportRef.current) return;
959
+ if (appliedViewportRef.current) return;
960
+ rfInstanceRef.current.setViewport(pendingViewportRef.current, {
961
+ duration: 0,
962
+ });
963
+ appliedViewportRef.current = true;
964
+ }, [nodes]);
965
+
966
+ useEffect(() => {
967
+ window.addEventListener("keydown", handleKeyDelete);
968
+ return () => window.removeEventListener("keydown", handleKeyDelete);
969
+ }, [handleKeyDelete]);
970
+
971
+ useEffect(() => {
972
+ if (!rfInstanceRef.current) return;
973
+
974
+ const currentNodes = rfInstanceRef.current.getNodes();
975
+
976
+ // Ensure all step nodes have dimensions
977
+ const stepNodes = currentNodes.filter(
978
+ (n) => n.type === "step" && n.width && n.height
979
+ );
980
+ if (!stepNodes.length) return;
981
+
982
+ const stepById = new Map(stepNodes.map((n) => [n.id, n]));
983
+ const updates = [];
984
+
985
+ currentNodes.forEach((node) => {
986
+ if (!node.id.startsWith("add-")) return;
987
+ // Respect user-dragged add nodes
988
+ if (addPositions?.[node.id]) return;
989
+
990
+ const ADD_GAP = 32;
991
+
992
+ const stepNode = stepById.get(node.id.replace("add-", ""));
993
+ if (!stepNode) return;
994
+
995
+ const nextX =
996
+ stepNode.position.x +
997
+ stepNode.width / 2 -
998
+ (node.width || ADD_NODE_SIZE) / 2;
999
+
1000
+ const nextY = stepNode.position.y + stepNode.height + ADD_GAP;
1001
+
1002
+ const nextPosition = { x: nextX, y: nextY };
1003
+
1004
+ // Only update if the position actually changed to avoid infinite loops
1005
+ if (
1006
+ node.position &&
1007
+ node.position.x === nextPosition.x &&
1008
+ node.position.y === nextPosition.y
1009
+ ) {
1010
+ return;
1011
+ }
1012
+
1013
+ updates.push({
1014
+ id: node.id,
1015
+ position: nextPosition,
1016
+ });
1017
+ });
1018
+
1019
+ if (!updates.length) return;
1020
+
1021
+ setNodes((nds) =>
1022
+ nds.map((n) => {
1023
+ const u = updates.find((x) => x.id === n.id);
1024
+ return u ? { ...n, position: u.position } : n;
1025
+ })
1026
+ );
1027
+ }, [nodes, addPositions, setNodes]);
1028
+
1029
+ useLayoutEffect(() => {
1030
+ if (sizeSyncing) return;
1031
+ if (!rfInstanceRef.current) return;
1032
+ const currentNodes = rfInstanceRef.current.getNodes();
1033
+ const stepNodes = currentNodes.filter(
1034
+ (n) => n.type === "step" && n.width && n.height
1035
+ );
1036
+ if (!stepNodes.length) return;
1037
+ const updates = [];
1038
+ stepNodes.forEach((node) => {
1039
+ const step = steps.find((s) => String(s.id) === String(node.id));
1040
+ if (!step) return;
1041
+ const stored = getWorkflowSize(step);
1042
+ const width = Math.round(node.width);
1043
+ const height = Math.round(node.height);
1044
+ if (!stored || stored.width !== width || stored.height !== height) {
1045
+ updates.push({ id: node.id, width, height });
1046
+ }
1047
+ });
1048
+ if (updates.length) persistSizes(updates);
1049
+ }, [nodes, persistSizes, sizeSyncing, steps]);
1050
+
1051
+ const fetchJson = useCallback(async (url, options = {}) => {
1052
+ const baseHeaders = {
1053
+ "x-requested-with": "XMLHttpRequest",
1054
+ };
1055
+ if (!(options.body instanceof FormData) && !options.skipJsonHeader)
1056
+ baseHeaders["Content-Type"] = "application/json";
1057
+ const res = await fetch(url, {
1058
+ ...options,
1059
+ headers: { ...baseHeaders, ...(options.headers || {}) },
1060
+ });
1061
+ const json = await res.json();
1062
+ if (!res.ok || json.error) {
1063
+ throw new Error(json.error || res.statusText);
1064
+ }
1065
+ return json;
1066
+ }, []);
1067
+
1068
+ const reload = useCallback(async () => {
1069
+ setLoading(true);
1070
+ setError("");
1071
+ try {
1072
+ let fresh = await fetchJson(data.urls.data);
1073
+
1074
+ // If a step was just added via an adder node, wire up next_step
1075
+ if (pendingAddRef.current) {
1076
+ const { afterStepId, prevIds, insertBetween } = pendingAddRef.current;
1077
+ const newSteps = (fresh.steps || []).filter(
1078
+ (s) => !prevIds.has(String(s.id))
1079
+ );
1080
+ if (newSteps.length === 1) {
1081
+ const newStep = newSteps[0];
1082
+ if (insertBetween && insertBetween.originalTargetName) {
1083
+ // Insert new step between source and its former target
1084
+ await updateConnection({
1085
+ step_id: newStep.id,
1086
+ next_step: insertBetween.originalTargetName,
1087
+ });
1088
+ await updateConnection({
1089
+ step_id: afterStepId,
1090
+ next_step: newStep.name,
1091
+ });
1092
+ } else {
1093
+ await updateConnection({
1094
+ step_id: afterStepId,
1095
+ next_step: newStep.name,
1096
+ });
1097
+ }
1098
+
1099
+ // Re-fetch so we get the updated connections reflected in steps
1100
+ fresh = await fetchJson(data.urls.data);
1101
+ }
1102
+ pendingAddRef.current = null;
1103
+ }
1104
+
1105
+ setSteps(fresh.steps || []);
1106
+
1107
+ setMessage(strings.refresh);
1108
+ setTimeout(() => setMessage(""), 1200);
1109
+ } catch (e) {
1110
+ setError(e.message);
1111
+ } finally {
1112
+ setLoading(false);
1113
+ }
1114
+ }, [data.urls.data, fetchJson, strings.refresh, updateConnection]);
1115
+
1116
+ const persistPositions = useCallback(
1117
+ async (positions = []) => {
1118
+ if (!positions.length) return;
1119
+ try {
1120
+ setSavingPositions(true);
1121
+ await fetchJson(data.urls.positions, {
1122
+ method: "POST",
1123
+ body: JSON.stringify({ positions }),
1124
+ headers: {
1125
+ "X-CSRF-Token": data.csrfToken || "",
1126
+ },
1127
+ });
1128
+ // keep local state in sync so refresh uses saved positions
1129
+ setSteps((prev) =>
1130
+ prev.map((s) => {
1131
+ const hit = positions.find((p) => String(p.id) === String(s.id));
1132
+ return hit
1133
+ ? {
1134
+ ...s,
1135
+ configuration: {
1136
+ ...(s.configuration || {}),
1137
+ workflow_position: { x: hit.x, y: hit.y },
1138
+ },
1139
+ }
1140
+ : s;
1141
+ })
1142
+ );
1143
+ } catch (e) {
1144
+ setError(e.message);
1145
+ } finally {
1146
+ setSavingPositions(false);
1147
+ }
1148
+ },
1149
+ [data.urls.positions, fetchJson, setSteps]
1150
+ );
1151
+
1152
+ const schedulePersistPositions = useCallback(
1153
+ (positions = []) => {
1154
+ if (!positions.length) return;
1155
+ positions.forEach((p) => pendingPositionRef.current.set(String(p.id), p));
1156
+ if (positionDebounceRef.current)
1157
+ clearTimeout(positionDebounceRef.current);
1158
+ positionDebounceRef.current = setTimeout(() => {
1159
+ const toSave = Array.from(pendingPositionRef.current.values());
1160
+ pendingPositionRef.current.clear();
1161
+ positionDebounceRef.current = null;
1162
+ persistPositions(toSave);
1163
+ }, 300);
1164
+ },
1165
+ [persistPositions]
1166
+ );
1167
+
1168
+ useEffect(() => {
1169
+ return () => {
1170
+ if (positionDebounceRef.current)
1171
+ clearTimeout(positionDebounceRef.current);
1172
+ };
1173
+ }, []);
1174
+
1175
+ const persistSizes = useCallback(
1176
+ async (sizes = []) => {
1177
+ if (!sizes.length) return;
1178
+ try {
1179
+ setSizeSyncing(true);
1180
+ await fetchJson(data.urls.sizes, {
1181
+ method: "POST",
1182
+ body: JSON.stringify({ sizes }),
1183
+ headers: {
1184
+ "X-CSRF-Token": data.csrfToken || "",
1185
+ },
1186
+ });
1187
+ setSteps((prev) =>
1188
+ prev.map((s) => {
1189
+ const hit = sizes.find((p) => String(p.id) === String(s.id));
1190
+ return hit
1191
+ ? {
1192
+ ...s,
1193
+ configuration: {
1194
+ ...(s.configuration || {}),
1195
+ workflow_size: { width: hit.width, height: hit.height },
1196
+ },
1197
+ }
1198
+ : s;
1199
+ })
1200
+ );
1201
+ } catch (e) {
1202
+ setError(e.message);
1203
+ } finally {
1204
+ setSizeSyncing(false);
1205
+ }
1206
+ },
1207
+ [data.csrfToken, data.urls.sizes, fetchJson, setSteps]
1208
+ );
1209
+
1210
+ const openStepForm = useCallback(
1211
+ async ({ stepId, initial_step, after_step } = {}) => {
1212
+ try {
1213
+ setError("");
1214
+ const descriptor = {
1215
+ stepId: stepId || null,
1216
+ initial_step: !!initial_step,
1217
+ after_step: after_step || null,
1218
+ };
1219
+ persistDrawerState(descriptor);
1220
+ const base = `${data.urls.stepForm}${stepId ? `/${stepId}` : ""}`;
1221
+ const url = new URL(base, window.location.origin);
1222
+ url.searchParams.set("render", "dialog");
1223
+ if (initial_step) url.searchParams.set("initial_step", "true");
1224
+ if (after_step) url.searchParams.set("after_step", after_step);
1225
+ const res = await fetchJson(url.toString());
1226
+ setModal({ title: res.title, body: res.form, stepId: stepId || null });
1227
+ } catch (e) {
1228
+ clearDrawerState();
1229
+ setError(e.message);
1230
+ }
1231
+ },
1232
+ [clearDrawerState, data.urls.stepForm, fetchJson, persistDrawerState]
1233
+ );
1234
+
1235
+ const handleModalSubmit = useCallback(
1236
+ async (formEl) => {
1237
+ if (!formEl) return;
1238
+ setSavingModal(true);
1239
+ setError("");
1240
+ const formData = new FormData(formEl);
1241
+ if (!formData.get("_csrf") && data.csrfToken)
1242
+ formData.append("_csrf", data.csrfToken);
1243
+ const action = formEl.getAttribute("action") || data.urls.stepForm;
1244
+ const method = (formEl.getAttribute("method") || "post").toUpperCase();
1245
+ const res = await fetch(action, {
1246
+ method,
1247
+ body: formData,
1248
+ headers: { "x-requested-with": "XMLHttpRequest" },
1249
+ });
1250
+ const json = await res.json();
1251
+ if (json.error) throw new Error(json.error);
1252
+ setModal(null);
1253
+ clearDrawerState();
1254
+ await reload();
1255
+ },
1256
+ [clearDrawerState, data.csrfToken, data.urls.stepForm, reload]
1257
+ );
1258
+
1259
+ useEffect(() => {
1260
+ if (!modal) return undefined;
1261
+ const formEl = modalRef.current?.querySelector("form");
1262
+ if (!formEl) return undefined;
1263
+ // Re-run show-if logic so action-specific fields render correctly
1264
+ setTimeout(() => {
1265
+ if (typeof window !== "undefined") {
1266
+ if (window.apply_showif) window.apply_showif();
1267
+ // Initialize Monaco and any other dynamic widgets inside the drawer
1268
+ if (window.initialize_page) window.initialize_page();
1269
+ }
1270
+ }, 0);
1271
+
1272
+ const actionSelect = formEl.querySelector('[name="wf_action_name"]');
1273
+ const handleActionChange = () => {
1274
+ if (typeof window !== "undefined" && window.apply_showif)
1275
+ window.apply_showif();
1276
+ };
1277
+ if (actionSelect)
1278
+ actionSelect.addEventListener("change", handleActionChange);
1279
+
1280
+ const submitHandler = async (e) => {
1281
+ e.preventDefault();
1282
+ try {
1283
+ await handleModalSubmit(formEl);
1284
+ } catch (err) {
1285
+ setError(err.message);
1286
+ } finally {
1287
+ setSavingModal(false);
1288
+ }
1289
+ };
1290
+ formEl.addEventListener("submit", submitHandler);
1291
+ return () => {
1292
+ formEl.removeEventListener("submit", submitHandler);
1293
+ if (actionSelect)
1294
+ actionSelect.removeEventListener("change", handleActionChange);
1295
+ };
1296
+ }, [handleModalSubmit, modal]);
1297
+
1298
+ useEffect(() => {
1299
+ if (typeof window === "undefined") return undefined;
1300
+ const raw = window.sessionStorage.getItem(drawerStorageKey);
1301
+ if (!raw) return undefined;
1302
+ try {
1303
+ const parsed = JSON.parse(raw);
1304
+ if (parsed && typeof parsed === "object") openStepForm(parsed);
1305
+ } catch (e) {
1306
+ clearDrawerState();
1307
+ }
1308
+ return undefined;
1309
+ }, [clearDrawerState, drawerStorageKey, openStepForm]);
1310
+
1311
+ const updateConnection = useCallback(
1312
+ async (payload) => {
1313
+ const body = new URLSearchParams();
1314
+ Object.entries({ ...payload, _csrf: data.csrfToken }).forEach(
1315
+ ([k, v]) => {
1316
+ if (typeof v !== "undefined" && v !== null) body.append(k, v);
1317
+ }
1318
+ );
1319
+ await fetchJson(data.urls.connect, {
1320
+ method: "POST",
1321
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1322
+ body,
1323
+ });
1324
+ },
1325
+ [data.csrfToken, data.urls.connect, fetchJson]
1326
+ );
1327
+
1328
+ const onConnect = useCallback(
1329
+ async (connection) => {
1330
+ // Valid connection landed; clear pending source so we don't treat it as a cancel
1331
+ connectSourceRef.current = null;
1332
+ setSelectedEdgeId(connection.id || null);
1333
+ const loop = connection.sourceHandle === "loop";
1334
+ const targetName = nameById[connection.target];
1335
+ if (connection.source === "start") {
1336
+ const targetId = connection.target;
1337
+ await updateConnection({ step_id: targetId, initial_step: true });
1338
+ await reload();
1339
+ return;
1340
+ }
1341
+ const stepName = nameById[connection.source];
1342
+ if (!stepName) return;
1343
+ if (loop) {
1344
+ await updateConnection({
1345
+ step_id: connection.source,
1346
+ loop_body_step: targetName || "",
1347
+ });
1348
+ } else {
1349
+ // If this source already has a simple next_step, treat this as inserting the target step in between source and its former target.
1350
+ const srcStep = steps.find(
1351
+ (s) => String(s.id) === String(connection.source)
1352
+ );
1353
+ const tgtStep = steps.find(
1354
+ (s) => String(s.id) === String(connection.target)
1355
+ );
1356
+ const rawNext = srcStep?.next_step
1357
+ ? String(srcStep.next_step).trim()
1358
+ : "";
1359
+ const simpleNextId = rawNext && idByName[rawNext];
1360
+ const targetHasNext = !!(
1361
+ tgtStep &&
1362
+ tgtStep.next_step &&
1363
+ String(tgtStep.next_step).trim()
1364
+ );
1365
+
1366
+ if (
1367
+ simpleNextId &&
1368
+ simpleNextId !== connection.target &&
1369
+ !targetHasNext
1370
+ ) {
1371
+ // Rewire: source -> new target, new target -> old target name
1372
+ await updateConnection({
1373
+ step_id: connection.target,
1374
+ next_step: rawNext,
1375
+ });
1376
+ }
1377
+
1378
+ await updateConnection({
1379
+ step_id: connection.source,
1380
+ next_step: targetName || "",
1381
+ });
1382
+ }
1383
+ await reload();
1384
+ },
1385
+ [idByName, nameById, reload, steps, updateConnection]
1386
+ );
1387
+
1388
+ const onConnectStart = useCallback((_, params) => {
1389
+ if (params?.nodeId && params?.handleType === "source") {
1390
+ connectSourceRef.current = params;
1391
+ }
1392
+ }, []);
1393
+
1394
+ const onConnectEnd = useCallback(
1395
+ (event) => {
1396
+ const pending = connectSourceRef.current;
1397
+ connectSourceRef.current = null;
1398
+ if (!pending) return;
1399
+ const targetEl = event?.target;
1400
+ if (targetEl?.classList?.contains("react-flow__pane")) {
1401
+ // Dropped on empty space: clear next_step mark as final/unconnected
1402
+ if (pending.nodeId && pending.handleId !== "loop-out") {
1403
+ onClearNext(pending.nodeId);
1404
+ }
1405
+ }
1406
+ },
1407
+ [onClearNext]
1408
+ );
1409
+
1410
+ const onEdgesChange = useCallback(
1411
+ (changes) => {
1412
+ const removed = changes.filter((c) => c.type === "remove");
1413
+ if (removed.length) {
1414
+ const updates = [];
1415
+ removed.forEach((chg) => {
1416
+ const edge = edges.find((e) => e.id === chg.id);
1417
+ if (!edge) return;
1418
+ if (edge.source === "start") {
1419
+ updates.push(
1420
+ updateConnection({
1421
+ step_id: edge.target,
1422
+ initial_step: false,
1423
+ })
1424
+ );
1425
+ return;
1426
+ }
1427
+ if (edge.data?.loop) {
1428
+ updates.push(
1429
+ updateConnection({ step_id: edge.source, loop_body_step: "" })
1430
+ );
1431
+ } else {
1432
+ updates.push(
1433
+ updateConnection({ step_id: edge.source, next_step: "" })
1434
+ );
1435
+ }
1436
+ });
1437
+ Promise.all(updates)
1438
+ .then(() => reload())
1439
+ .catch((e) => setError(e.message));
1440
+ }
1441
+ rfOnEdgesChange(changes);
1442
+ },
1443
+ [edges, reload, rfOnEdgesChange, updateConnection]
1444
+ );
1445
+
1446
+ const onNodesChangeWrapped = useCallback(
1447
+ (changes) => {
1448
+ const finishedPositions = changes
1449
+ .filter((c) => c.type === "position" && c.position && c.dragging)
1450
+ .map((c) => ({ id: c.id, x: c.position.x, y: c.position.y }));
1451
+
1452
+ if (finishedPositions.length) {
1453
+ finishedPositions
1454
+ .filter((p) => p.id === "start")
1455
+ .forEach((p) => setStartPosition({ x: p.x, y: p.y }));
1456
+
1457
+ const addUpdates = finishedPositions.filter((p) =>
1458
+ p.id.startsWith("add-")
1459
+ );
1460
+ if (addUpdates.length) {
1461
+ setAddPositions((prev) => {
1462
+ const next = { ...prev };
1463
+ addUpdates.forEach((p) => {
1464
+ next[p.id] = { x: p.x, y: p.y };
1465
+ });
1466
+ return next;
1467
+ });
1468
+ }
1469
+
1470
+ const stepPositions = finishedPositions.filter(
1471
+ (p) => /^[0-9]+$/.test(p.id) || p.id === "start"
1472
+ );
1473
+ if (stepPositions.length) schedulePersistPositions(stepPositions);
1474
+ }
1475
+ onNodesChange(changes);
1476
+ },
1477
+ [onNodesChange, schedulePersistPositions, setAddPositions, setStartPosition]
1478
+ );
1479
+
1480
+ const onAddAfter = useCallback(
1481
+ (id) => {
1482
+ if (id === "start") {
1483
+ openStepForm({ initial_step: true });
1484
+ return;
1485
+ }
1486
+ pendingAddRef.current = {
1487
+ afterStepId: id,
1488
+ prevIds: new Set(steps.map((s) => String(s.id))),
1489
+ };
1490
+ openStepForm({ after_step: id });
1491
+ },
1492
+ [openStepForm, steps]
1493
+ );
1494
+
1495
+ const onInsertBetween = useCallback(
1496
+ ({ source, target }) => {
1497
+ if (!source || !target || source === "start") return;
1498
+ const originalTargetName = nameById[target];
1499
+ if (!originalTargetName) return;
1500
+ pendingAddRef.current = {
1501
+ afterStepId: source,
1502
+ prevIds: new Set(steps.map((s) => String(s.id))),
1503
+ insertBetween: { originalTargetName },
1504
+ };
1505
+ openStepForm({ after_step: source });
1506
+ },
1507
+ [nameById, openStepForm, steps]
1508
+ );
1509
+
1510
+ const onSetStart = useCallback(
1511
+ async (id) => {
1512
+ await updateConnection({ step_id: id, initial_step: true });
1513
+ await reload();
1514
+ },
1515
+ [reload, updateConnection]
1516
+ );
1517
+
1518
+ const onClearNext = useCallback(
1519
+ async (id) => {
1520
+ await updateConnection({ step_id: id, next_step: "" });
1521
+ await reload();
1522
+ },
1523
+ [reload, updateConnection]
1524
+ );
1525
+
1526
+ const onDelete = useCallback(
1527
+ async (id) => {
1528
+ if (!window.confirm(strings.confirmDelete)) return;
1529
+ try {
1530
+ const formData = new FormData();
1531
+ formData.append("_csrf", data.csrfToken || "");
1532
+ await fetchJson(`${data.urls.deleteStep}/${id}`, {
1533
+ method: "POST",
1534
+ body: formData,
1535
+ });
1536
+ setModal(null);
1537
+ setError("");
1538
+ clearDrawerState();
1539
+ await reload();
1540
+ } catch (e) {
1541
+ setError(e.message);
1542
+ }
1543
+ },
1544
+ [clearDrawerState, data.csrfToken, fetchJson, reload, strings.confirmDelete]
1545
+ );
1546
+
1547
+ useEffect(() => {
1548
+ setNodes((nds) =>
1549
+ nds.map((n) =>
1550
+ n.type === "start"
1551
+ ? {
1552
+ ...n,
1553
+ data: {
1554
+ ...n.data,
1555
+ onAddInitial: () => openStepForm({ initial_step: true }),
1556
+ },
1557
+ }
1558
+ : n.type === "add"
1559
+ ? {
1560
+ ...n,
1561
+ data: {
1562
+ ...n.data,
1563
+ onAddAfter: (afterId) => onAddAfter(afterId),
1564
+ },
1565
+ }
1566
+ : {
1567
+ ...n,
1568
+ data: {
1569
+ ...n.data,
1570
+ onEdit: (id) => openStepForm({ stepId: id }),
1571
+ onAddAfter,
1572
+ onSetStart,
1573
+ onClearNext,
1574
+ onDelete,
1575
+ },
1576
+ }
1577
+ )
1578
+ );
1579
+ }, [
1580
+ onAddAfter,
1581
+ onClearNext,
1582
+ onDelete,
1583
+ onSetStart,
1584
+ openStepForm,
1585
+ setNodes,
1586
+ steps,
1587
+ ]);
1588
+
1589
+ useEffect(() => {
1590
+ setEdges((eds) => {
1591
+ let changed = false;
1592
+ const nextEdges = eds.map((e) => {
1593
+ if (!e.data || !e.data.canInsertBetween) return e;
1594
+ if (e.data.onAddBetween === onInsertBetween) return e;
1595
+ changed = true;
1596
+ return {
1597
+ ...e,
1598
+ data: {
1599
+ ...e.data,
1600
+ onAddBetween: onInsertBetween,
1601
+ },
1602
+ };
1603
+ });
1604
+ return changed ? nextEdges : eds;
1605
+ });
1606
+ }, [onInsertBetween, setEdges, edges]);
1607
+
1608
+ const hasSteps = steps.length > 0;
1609
+ const shellClass = modal ? "wf-shell wf-shell--drawer-open" : "wf-shell";
1610
+ const shellStyle = { "--wf-drawer-width": `${drawerWidth}px` };
1611
+ const showMiniMap = !modal;
1612
+ const shouldFitView = !pendingViewportRef.current;
1613
+
1614
+ const onEdgeClick = useCallback((_, edge) => {
1615
+ setSelectedEdgeId(edge.id);
1616
+ }, []);
1617
+
1618
+ useEffect(() => {
1619
+ setEdges((edgs) =>
1620
+ edgs.map((e) => {
1621
+ const selected = e.id === selectedEdgeId;
1622
+ const nextClass = selected
1623
+ ? `${e.className || ""} wf-edge-selected`.trim()
1624
+ : (e.className || "").replace(/\s*wf-edge-selected\b/, "");
1625
+ const nextData =
1626
+ e.type === "workflow-main" ? { ...(e.data || {}), selected } : e.data;
1627
+ if (nextClass === (e.className || "") && nextData === e.data) return e;
1628
+ return { ...e, className: nextClass, data: nextData };
1629
+ })
1630
+ );
1631
+ }, [selectedEdgeId, setEdges]);
1632
+
1633
+ const handleResize = useCallback((e) => {
1634
+ if (!resizingRef.current) return;
1635
+ const maxWidth = Math.min(900, Math.max(360, window.innerWidth - 40));
1636
+ const nextWidth = Math.min(
1637
+ Math.max(600, window.innerWidth - e.clientX),
1638
+ maxWidth
1639
+ );
1640
+ setDrawerWidth(nextWidth);
1641
+ }, []);
1642
+
1643
+ const stopResize = useCallback(() => {
1644
+ if (!resizingRef.current) return;
1645
+ resizingRef.current = false;
1646
+ document.body.style.userSelect = "";
1647
+ window.removeEventListener("pointermove", handleResize);
1648
+ window.removeEventListener("pointerup", stopResize);
1649
+ }, [handleResize]);
1650
+
1651
+ const startResize = useCallback(
1652
+ (e) => {
1653
+ if (!modal) return;
1654
+ resizingRef.current = true;
1655
+ document.body.style.userSelect = "none";
1656
+ window.addEventListener("pointermove", handleResize);
1657
+ window.addEventListener("pointerup", stopResize);
1658
+ e.preventDefault();
1659
+ },
1660
+ [handleResize, modal, stopResize]
1661
+ );
1662
+
1663
+ useEffect(() => () => stopResize(), [stopResize]);
1664
+
1665
+ const closeModal = useCallback(() => {
1666
+ setModal(null);
1667
+ setError("");
1668
+ clearDrawerState();
1669
+ }, [clearDrawerState]);
1670
+
1671
+ useLayoutEffect(() => {
1672
+ const shellEl = shellRef.current;
1673
+ const drawerEl = drawerRef.current;
1674
+ if (!shellEl) return undefined;
1675
+ const hasResizeObserver = typeof ResizeObserver !== "undefined";
1676
+
1677
+ const computePadding = () => {
1678
+ if (!drawerEl || !modal) {
1679
+ shellEl.style.removeProperty("--wf-shell-padding-right");
1680
+ return;
1681
+ }
1682
+ const shellRect = shellEl.getBoundingClientRect();
1683
+ const drawerRect = drawerEl.getBoundingClientRect();
1684
+ // Space already available to the right of the shell (e.g. due to sidebars)
1685
+ const naturalGap = Math.max(0, window.innerWidth - shellRect.right);
1686
+ const needed = Math.max(0, drawerRect.width - naturalGap) + 4;
1687
+ shellEl.style.setProperty("--wf-shell-padding-right", `${needed}px`);
1688
+ };
1689
+
1690
+ computePadding();
1691
+
1692
+ const resizeObserver = hasResizeObserver
1693
+ ? new ResizeObserver(() => computePadding())
1694
+ : null;
1695
+ if (resizeObserver) {
1696
+ resizeObserver.observe(shellEl);
1697
+ if (drawerEl) resizeObserver.observe(drawerEl);
1698
+ }
1699
+ window.addEventListener("resize", computePadding);
1700
+
1701
+ return () => {
1702
+ if (resizeObserver) resizeObserver.disconnect();
1703
+ window.removeEventListener("resize", computePadding);
1704
+ };
1705
+ }, [drawerWidth, modal]);
1706
+
1707
+ return (
1708
+ <div className={shellClass} style={shellStyle} ref={shellRef}>
1709
+ <div className="wf-toolbar">
1710
+ <div className="wf-toolbar__left">
1711
+ <strong>{strings.configure}</strong>
1712
+ {message ? (
1713
+ <span className="text-success ms-2">{message}</span>
1714
+ ) : null}
1715
+ {error ? <span className="text-danger ms-2">{error}</span> : null}
1716
+ {/* {savingPositions ? (
1717
+ <span className="text-muted ms-2">
1718
+ {strings.loading || "Saving positions..."}
1719
+ </span>
1720
+ ) : null} */}
1721
+ </div>
1722
+ <div className="wf-toolbar__actions">
1723
+ <button
1724
+ className="btn btn-sm btn-outline-primary"
1725
+ onClick={() => openStepForm({ initial_step: !hasSteps })}
1726
+ >
1727
+ {strings.addStep}
1728
+ </button>
1729
+ <button
1730
+ className="btn btn-sm btn-outline-secondary"
1731
+ onClick={reload}
1732
+ disabled={loading}
1733
+ >
1734
+ {loading ? strings.loading : strings.refresh}
1735
+ </button>
1736
+ <a className="btn btn-sm btn-outline-info" href={data.urls.runs}>
1737
+ {strings.runs}
1738
+ </a>
1739
+ </div>
1740
+ </div>
1741
+ {!hasSteps ? <div className="wf-empty">{strings.empty}</div> : null}
1742
+ <div className="wf-canvas">
1743
+ <ReactFlow
1744
+ nodesFocusable={true}
1745
+ edgesFocusable={true}
1746
+ disableKeyboardA11y={false}
1747
+ nodes={nodes}
1748
+ edges={edges}
1749
+ onNodesChange={onNodesChangeWrapped}
1750
+ onEdgesChange={onEdgesChange}
1751
+ onConnect={onConnect}
1752
+ onConnectStart={onConnectStart}
1753
+ onConnectEnd={onConnectEnd}
1754
+ onEdgeClick={onEdgeClick}
1755
+ onSelectionChange={handleSelectionChange}
1756
+ nodeTypes={nodeTypes}
1757
+ edgeTypes={edgeTypes}
1758
+ onInit={onInit}
1759
+ defaultViewport={pendingViewportRef.current || undefined}
1760
+ fitView={shouldFitView}
1761
+ proOptions={{ hideAttribution: true }}
1762
+ defaultEdgeOptions={{ animated: true }}
1763
+ colorMode={window._sc_lightmode || "light"}
1764
+ onMoveEnd={(_, viewport) => persistViewport(viewport)}
1765
+ >
1766
+ {showMiniMap && <MiniMap pannable zoomable />}
1767
+ <Controls />
1768
+ <Background gap={16} />
1769
+ </ReactFlow>
1770
+ </div>
1771
+ <StepDrawer
1772
+ modal={modal}
1773
+ innerRef={modalRef}
1774
+ outerRef={drawerRef}
1775
+ onClose={closeModal}
1776
+ submitting={savingModal}
1777
+ error={error && modal ? error : ""}
1778
+ data={data}
1779
+ onDelete={onDelete}
1780
+ drawerWidth={drawerWidth}
1781
+ onResizeStart={startResize}
1782
+ />
1783
+ </div>
1784
+ );
1785
+ };
1786
+
1787
+ export default WorkflowEditor;