@saltcorn/workflow-editor 1.6.0-alpha.4

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,1368 @@
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ useLayoutEffect,
7
+ } from "react";
8
+ import {
9
+ Background,
10
+ Controls,
11
+ Handle,
12
+ MiniMap,
13
+ Position,
14
+ useEdgesState,
15
+ useNodesState,
16
+ ReactFlow,
17
+ } from "@xyflow/react";
18
+ import "@xyflow/react/dist/style.css";
19
+ import "./workflow.css";
20
+ import WorkflowEdge from "./WorkflowEdge";
21
+
22
+ const handleStyle = {
23
+ width: "10px",
24
+ height: "10px",
25
+ borderRadius: "50%",
26
+ border: "2px solid var(--wf-handle-border, #3f3f3f)",
27
+ background: "var(--wf-handle-bg, #fff)",
28
+ };
29
+
30
+ const DEFAULT_NODE_WIDTH = 220;
31
+ const DEFAULT_NODE_HEIGHT = 120;
32
+ const H_GAP = 80;
33
+ const V_GAP = 60;
34
+ const ADD_NODE_SIZE = 32;
35
+ const ADD_GAP = 32;
36
+
37
+ const normalizeSize = (size) => {
38
+ if (!size) return null;
39
+ const width = Number(size.width);
40
+ const height = Number(size.height);
41
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
42
+ return { width, height };
43
+ };
44
+
45
+ const getWorkflowSize = (step) =>
46
+ normalizeSize(step?.configuration?.workflow_size);
47
+
48
+ const StartNode = ({ data }) => (
49
+ <div className="wf-start-node">
50
+ <div className="wf-start-title">{data.strings.start}</div>
51
+ <Handle
52
+ style={handleStyle}
53
+ id="start"
54
+ type="source"
55
+ position={Position.Right}
56
+ />
57
+ </div>
58
+ );
59
+
60
+ const AddNode = ({ data }) => {
61
+ const handleClick = (e) => {
62
+ e.stopPropagation();
63
+ data.onAddAfter?.(data.afterStepId);
64
+ };
65
+ const handleKeyDown = (e) => {
66
+ if (e.key === "Enter" || e.key === " ") {
67
+ e.preventDefault();
68
+ data.onAddAfter?.(data.afterStepId);
69
+ }
70
+ };
71
+ return (
72
+ <div
73
+ className="wf-add-node"
74
+ role="button"
75
+ tabIndex={0}
76
+ aria-label={data.strings.addStep}
77
+ onClick={handleClick}
78
+ onKeyDown={handleKeyDown}
79
+ >
80
+ <span aria-hidden>+</span>
81
+ <Handle type="target" position={Position.Left} />
82
+ </div>
83
+ );
84
+ };
85
+
86
+ const StepNode = ({ data }) => {
87
+ const summaryLines = Array.isArray(data.actionSummary)
88
+ ? data.actionSummary
89
+ : data.actionSummary
90
+ ? String(data.actionSummary)
91
+ .split("\n")
92
+ .map((s) => s.trim())
93
+ .filter(Boolean)
94
+ : data.actionDescription
95
+ ? [data.actionDescription]
96
+ : [];
97
+
98
+ const handleClick = (e) => {
99
+ if (e.target?.closest(".react-flow__handle")) return;
100
+ e.stopPropagation();
101
+ if (data.onEdit) data.onEdit(data.id);
102
+ };
103
+
104
+ const handleKeyDown = (e) => {
105
+ if (e.key === "Enter" || e.key === " ") {
106
+ e.preventDefault();
107
+ if (data.onEdit) data.onEdit(data.id);
108
+ }
109
+ };
110
+
111
+ const isLoopBody = data.inLoopBody;
112
+ const useVerticalHandles =
113
+ isLoopBody && String(data.action_name) !== "ForLoop";
114
+ const targetPosition = useVerticalHandles ? Position.Top : Position.Left;
115
+ const sourcePosition = useVerticalHandles ? Position.Bottom : Position.Right;
116
+
117
+ return (
118
+ <div
119
+ className="wf-node"
120
+ onClick={handleClick}
121
+ role="button"
122
+ tabIndex={0}
123
+ onKeyDown={handleKeyDown}
124
+ >
125
+ <div className="wf-node__header">
126
+ <div className="wf-node__title-row">
127
+ <div className="wf-node__title">{data.name}</div>
128
+ <div className="wf-node__action">{data.action_name}</div>
129
+ </div>
130
+ </div>
131
+ {summaryLines.length ? (
132
+ <ul className="wf-node__summary">
133
+ {summaryLines.map((line, ix) => (
134
+ <li key={ix}>{line}</li>
135
+ ))}
136
+ </ul>
137
+ ) : null}
138
+ {data.only_if ? (
139
+ <div className="wf-node__meta">
140
+ <span className="wf-label">{data.strings.onlyIf}:</span>{" "}
141
+ {data.only_if}
142
+ </div>
143
+ ) : null}
144
+ {/* {data.next_step ? (
145
+ <div className="wf-node__meta">
146
+ <span className="wf-label">{data.strings.nextStep}:</span>{" "}
147
+ {data.next_step}
148
+ </div>
149
+ ) : null} */}
150
+ {/* {data.isLoop && data.loop_body_initial_step ? (
151
+ <div className="wf-node__meta">
152
+ <span className="wf-label">{data.strings.loopBody}:</span>{" "}
153
+ {data.loop_body_initial_step}
154
+ </div>
155
+ ) : null} */}
156
+ <Handle style={handleStyle} type="target" position={targetPosition} />
157
+ <Handle
158
+ style={handleStyle}
159
+ id="main"
160
+ type="source"
161
+ position={sourcePosition}
162
+ />
163
+ </div>
164
+ );
165
+ };
166
+
167
+ const nodeTypes = {
168
+ start: StartNode,
169
+ step: StepNode,
170
+ add: AddNode,
171
+ };
172
+
173
+ const edgeTypes = {
174
+ "workflow-main": WorkflowEdge,
175
+ };
176
+
177
+ const findLoopBackLinks = (steps) => {
178
+ const byName = new Map(steps.map((s) => [s.name, s]));
179
+ const loopBacks = {};
180
+ steps
181
+ .filter((s) => s.action_name === "ForLoop")
182
+ .forEach((forStep) => {
183
+ const visited = new Set();
184
+ let cursor = byName.get(forStep.configuration?.loop_body_initial_step);
185
+ let lastStep = null;
186
+ while (cursor && !visited.has(cursor.name)) {
187
+ visited.add(cursor.name);
188
+ lastStep = cursor;
189
+ if (!cursor.next_step || cursor.next_step === forStep.name) break;
190
+ cursor = byName.get(cursor.next_step);
191
+ }
192
+ if (lastStep && !lastStep.next_step) {
193
+ loopBacks[lastStep.name] = forStep.name;
194
+ }
195
+ });
196
+ return loopBacks;
197
+ };
198
+
199
+ const findLoopBodyStepIds = (steps) => {
200
+ const byName = new Map(steps.map((s) => [s.name, s]));
201
+ const loopBodyIds = new Set();
202
+ steps
203
+ .filter((s) => s.action_name === "ForLoop")
204
+ .forEach((loopStep) => {
205
+ const visited = new Set();
206
+ let cursor = byName.get(loopStep.configuration?.loop_body_initial_step);
207
+ while (cursor && !visited.has(cursor.name)) {
208
+ visited.add(cursor.name);
209
+ loopBodyIds.add(String(cursor.id));
210
+ if (!cursor.next_step || cursor.next_step === loopStep.name) break;
211
+ cursor = byName.get(cursor.next_step);
212
+ }
213
+ });
214
+ return loopBodyIds;
215
+ };
216
+
217
+ const buildGraph = (
218
+ steps,
219
+ strings,
220
+ actionExplainers,
221
+ { startPosition, addPositions } = {}
222
+ ) => {
223
+ const idByName = {};
224
+ const nameById = {};
225
+ const sizeById = {};
226
+ steps.forEach((s) => {
227
+ idByName[s.name] = String(s.id);
228
+ nameById[String(s.id)] = s.name;
229
+ const size = getWorkflowSize(s);
230
+ sizeById[String(s.id)] = size || {
231
+ width: DEFAULT_NODE_WIDTH,
232
+ height: DEFAULT_NODE_HEIGHT,
233
+ };
234
+ });
235
+
236
+ const allStepNames = steps.map((s) => s.name).filter(Boolean);
237
+
238
+ const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
239
+
240
+ const extractNextStepNames = (rawNextStep) => {
241
+ if (!rawNextStep) return [];
242
+ const trimmed = String(rawNextStep).trim();
243
+
244
+ // Simple case: next_step is directly a step name
245
+ if (idByName[trimmed]) return [trimmed];
246
+
247
+ // Expression case: find all step names referenced in the expression
248
+ const expr = trimmed;
249
+ const hits = new Set();
250
+ allStepNames.forEach((name) => {
251
+ if (!name) return;
252
+ try {
253
+ const re = new RegExp(`\\b${escapeRegExp(name)}\\b`);
254
+ if (re.test(expr)) hits.add(name);
255
+ } catch (e) {
256
+ // Fallback: simple substring match if regex construction fails
257
+ if (expr.includes(name)) hits.add(name);
258
+ }
259
+ });
260
+ return [...hits];
261
+ };
262
+
263
+ const initial = steps.find((s) => s.initial_step);
264
+ const loopBackLinks = findLoopBackLinks(steps);
265
+ const loopBodyIds = findLoopBodyStepIds(steps);
266
+
267
+ const adjacency = new Map();
268
+ steps.forEach((s) => {
269
+ const targets = [];
270
+ const nextNames = extractNextStepNames(s.next_step);
271
+ nextNames.forEach((name) => {
272
+ const id = idByName[name];
273
+ if (id) targets.push(id);
274
+ });
275
+ adjacency.set(String(s.id), [...new Set(targets)]);
276
+ });
277
+
278
+ const depths = {};
279
+ const queue = [];
280
+ if (initial) {
281
+ depths[String(initial.id)] = 1;
282
+ queue.push(String(initial.id));
283
+ }
284
+
285
+ while (queue.length) {
286
+ const current = queue.shift();
287
+ const d = depths[current];
288
+ (adjacency.get(current) || []).forEach((n) => {
289
+ if (depths[n] === undefined) {
290
+ depths[n] = d + 1;
291
+ queue.push(n);
292
+ }
293
+ });
294
+ }
295
+
296
+ let maxDepth = Object.values(depths).reduce((m, v) => Math.max(m, v), 0);
297
+ const sortedRemaining = steps
298
+ .map((s) => String(s.id))
299
+ .filter((id) => depths[id] === undefined)
300
+ .sort();
301
+ if (sortedRemaining.length) {
302
+ const orphanDepth = maxDepth + 1;
303
+ sortedRemaining.forEach((id) => {
304
+ depths[id] = orphanDepth;
305
+ });
306
+ maxDepth = orphanDepth;
307
+ }
308
+
309
+ const groupByDepth = new Map();
310
+ steps.forEach((s) => {
311
+ const d = depths[String(s.id)] || 1;
312
+ if (!groupByDepth.has(d)) groupByDepth.set(d, []);
313
+ groupByDepth.get(d).push(String(s.id));
314
+ });
315
+ [...groupByDepth.values()].forEach((arr) => arr.sort());
316
+
317
+ const positions = {};
318
+ const depthEntries = [...groupByDepth.entries()].sort(([a], [b]) => a - b);
319
+ const depthX = {};
320
+ let xCursor = 0;
321
+ depthEntries.forEach(([d, ids]) => {
322
+ const colWidth = Math.max(
323
+ ...ids.map((id) => sizeById[id]?.width || DEFAULT_NODE_WIDTH)
324
+ );
325
+ depthX[d] = xCursor;
326
+ xCursor += colWidth + H_GAP;
327
+ });
328
+ depthEntries.forEach(([d, ids]) => {
329
+ let yCursor = 0;
330
+ ids.forEach((id) => {
331
+ positions[id] = { x: depthX[d], y: yCursor };
332
+ yCursor += (sizeById[id]?.height || DEFAULT_NODE_HEIGHT) + V_GAP;
333
+ });
334
+ });
335
+
336
+ // Nudge ForLoop bodies to be vertical under their loop parent for readability
337
+ const positionOverrides = {};
338
+ steps
339
+ .filter((s) => s.action_name === "ForLoop")
340
+ .forEach((loopStep) => {
341
+ const loopPos =
342
+ loopStep.configuration?.workflow_position ||
343
+ positions[String(loopStep.id)];
344
+ if (!loopPos) return;
345
+ const byName = new Map(steps.map((s) => [s.name, s]));
346
+ const body = [];
347
+ const seen = new Set();
348
+ let cursor = byName.get(loopStep.configuration?.loop_body_initial_step);
349
+ while (cursor && !seen.has(cursor.name)) {
350
+ seen.add(cursor.name);
351
+ body.push(cursor);
352
+ if (!cursor.next_step || cursor.next_step === loopStep.name) break;
353
+ cursor = byName.get(cursor.next_step);
354
+ }
355
+
356
+ const loopSize = sizeById[String(loopStep.id)] || {
357
+ width: DEFAULT_NODE_WIDTH,
358
+ height: DEFAULT_NODE_HEIGHT,
359
+ };
360
+ let yCursor = loopPos.y + loopSize.height + V_GAP;
361
+
362
+ body.forEach((s) => {
363
+ if (s.configuration?.workflow_position) return; // respect saved positions
364
+ const bodySize = sizeById[String(s.id)] || {
365
+ width: DEFAULT_NODE_WIDTH,
366
+ height: DEFAULT_NODE_HEIGHT,
367
+ };
368
+ positionOverrides[String(s.id)] = {
369
+ x: loopPos.x,
370
+ y: yCursor,
371
+ };
372
+ yCursor += bodySize.height + V_GAP;
373
+ });
374
+ });
375
+
376
+ const nodePositions = {};
377
+ const startNodePosition =
378
+ startPosition ||
379
+ (initial
380
+ ? {
381
+ x: positions[String(initial.id)]?.x - 180 || -180,
382
+ y: positions[String(initial.id)]?.y || 40,
383
+ }
384
+ : { x: -180, y: 40 });
385
+
386
+ const nodes = [
387
+ {
388
+ id: "start",
389
+ type: "start",
390
+ data: { strings },
391
+ position: startNodePosition,
392
+ draggable: true,
393
+ },
394
+ ...steps.map((step, ix) => {
395
+ return {
396
+ id: String(step.id),
397
+ type: "step",
398
+ position: step.configuration?.workflow_position ||
399
+ positionOverrides[String(step.id)] ||
400
+ positions[String(step.id)] || {
401
+ x: (ix % 3) * 260,
402
+ y: Math.floor(ix / 3) * 180 + 10,
403
+ },
404
+ data: {
405
+ ...step,
406
+ id: String(step.id),
407
+ loop_body_initial_step: step.configuration?.loop_body_initial_step,
408
+ isLoop: step.action_name === "ForLoop",
409
+ inLoopBody: loopBodyIds.has(String(step.id)),
410
+ actionSummary:
411
+ step.summary ||
412
+ (actionExplainers[step.action_name]
413
+ ? [actionExplainers[step.action_name]]
414
+ : []),
415
+ actionDescription: actionExplainers[step.action_name],
416
+ strings,
417
+ },
418
+ };
419
+ }),
420
+ ];
421
+
422
+ nodes.forEach((n) => {
423
+ if (n.type === "step") nodePositions[n.id] = n.position;
424
+ });
425
+
426
+ const edges = [];
427
+ if (initial)
428
+ edges.push({
429
+ id: `e-start-${initial.id}`,
430
+ source: "start",
431
+ target: String(initial.id),
432
+ type: "smoothstep",
433
+ animated: true,
434
+ });
435
+ else {
436
+ const addId = "add-start";
437
+ edges.push({
438
+ id: "e-start-adder",
439
+ source: "start",
440
+ target: addId,
441
+ type: "bezier",
442
+ animated: false,
443
+ deletable: false,
444
+ style: {
445
+ strokeDasharray: "4 2",
446
+ stroke: "var(--wf-edge-adder, #0d6efd)",
447
+ },
448
+ });
449
+ }
450
+
451
+ steps.forEach((step) => {
452
+ if (step.next_step) {
453
+ const rawNext = String(step.next_step || "").trim();
454
+ const directTargetId = idByName[rawNext];
455
+ const nextNames = extractNextStepNames(step.next_step);
456
+ const isSimpleNextStep =
457
+ !!directTargetId && nextNames.length === 1 && nextNames[0] === rawNext;
458
+ nextNames.forEach((name) => {
459
+ const targetId = idByName[name];
460
+ if (!targetId) return;
461
+ edges.push({
462
+ id: `e-${step.id}-${name}`,
463
+ source: String(step.id),
464
+ target: targetId,
465
+ type: "workflow-main",
466
+ animated: true,
467
+ data: {
468
+ startLabel: step.name,
469
+ sourceStepName: step.name,
470
+ sourceId: String(step.id),
471
+ targetId,
472
+ canInsertBetween: isSimpleNextStep,
473
+ },
474
+ });
475
+ });
476
+ }
477
+ if (step.action_name === "ForLoop") {
478
+ const loopTarget = step.configuration?.loop_body_initial_step;
479
+ if (loopTarget) {
480
+ const loopId = idByName[loopTarget];
481
+ edges.push({
482
+ id: `loop-${step.id}-${loopTarget}`,
483
+ source: String(step.id),
484
+ target: loopId || String(step.id),
485
+ type: "smoothstep",
486
+ style: {
487
+ stroke: "var(--wf-edge-loop, #f59f00)",
488
+ strokeDasharray: "6 4",
489
+ },
490
+ label: strings.loopBody,
491
+ markerEnd: "arrowclosed",
492
+ data: { loop: true, missing: !loopId },
493
+ });
494
+ }
495
+ }
496
+
497
+ if (!step.next_step && loopBackLinks[step.name]) {
498
+ const forLoopName = loopBackLinks[step.name];
499
+ const loopId = idByName[forLoopName];
500
+ if (loopId) {
501
+ edges.push({
502
+ id: `loopback-${step.id}-${forLoopName}`,
503
+ source: String(step.id),
504
+ target: loopId,
505
+ type: "smoothstep",
506
+ style: {
507
+ stroke: "var(--wf-edge-loop, #f59f00)",
508
+ strokeDasharray: "6 4",
509
+ },
510
+ data: { loop: true, loopBack: true },
511
+ markerEnd: "arrowclosed",
512
+ deletable: false,
513
+ });
514
+ }
515
+ }
516
+ });
517
+
518
+ const addNodes = [];
519
+ if (!steps.length) {
520
+ const addId = "add-start";
521
+ addNodes.push({
522
+ id: addId,
523
+ type: "add",
524
+ position: addPositions?.[addId] || {
525
+ x: startNodePosition.x + 160,
526
+ y: startNodePosition.y,
527
+ },
528
+ data: { strings, afterStepId: "start" },
529
+ draggable: true,
530
+ selectable: false,
531
+ deletable: false,
532
+ });
533
+ }
534
+ steps.forEach((step) => {
535
+ const hasNextTargets = extractNextStepNames(step.next_step).length > 0;
536
+ if (hasNextTargets || loopBackLinks[step.name]) return;
537
+ const basePos = nodePositions[String(step.id)] || { x: 0, y: 0 };
538
+ const baseSize = sizeById[String(step.id)] || {
539
+ width: DEFAULT_NODE_WIDTH,
540
+ height: DEFAULT_NODE_HEIGHT,
541
+ };
542
+ const addId = `add-${step.id}`;
543
+ addNodes.push({
544
+ id: addId,
545
+ type: "add",
546
+ position: addPositions?.[addId] || {
547
+ x: basePos.x + baseSize.width + ADD_GAP,
548
+ y: basePos.y + baseSize.height / 2 - ADD_NODE_SIZE / 2,
549
+ },
550
+ data: { strings, afterStepId: String(step.id) },
551
+ draggable: true,
552
+ selectable: false,
553
+ deletable: false,
554
+ });
555
+ edges.push({
556
+ id: `e-${step.id}-adder`,
557
+ source: String(step.id),
558
+ target: addId,
559
+ type: "bezier",
560
+ animated: false,
561
+ deletable: false,
562
+ style: {
563
+ strokeDasharray: "4 2",
564
+ stroke: "var(--wf-edge-adder, #0d6efd)",
565
+ },
566
+ });
567
+ });
568
+
569
+ const allNodes = [...nodes, ...addNodes];
570
+
571
+ return { nodes: allNodes, edges, idByName, nameById };
572
+ };
573
+
574
+ const StepModal = ({
575
+ modal,
576
+ innerRef,
577
+ onClose,
578
+ submitting,
579
+ error,
580
+ data,
581
+ onDelete,
582
+ }) => {
583
+ if (!modal) return null;
584
+ return (
585
+ <div className="wf-modal-backdrop">
586
+ <div className="wf-modal card shadow">
587
+ <div className="card-header d-flex justify-content-between align-items-center">
588
+ <h5 className="mb-0">{modal.title}</h5>
589
+ <button className="btn-close" onClick={onClose} aria-label="Close" />
590
+ </div>
591
+ <div className="card-body">
592
+ <div
593
+ ref={innerRef}
594
+ dangerouslySetInnerHTML={{ __html: modal.body }}
595
+ />
596
+ {error ? (
597
+ <div className="alert alert-danger mt-3">{error}</div>
598
+ ) : null}
599
+ </div>
600
+ <div className="card-footer d-flex justify-content-end gap-2">
601
+ <button
602
+ className="btn btn-sm btn-outline-danger"
603
+ onClick={(e) => {
604
+ e.stopPropagation();
605
+ onDelete(modal.stepId);
606
+ }}
607
+ >
608
+ {data.strings.deleteStep}
609
+ </button>
610
+ <button className="btn btn-secondary" onClick={onClose}>
611
+ Close
612
+ </button>
613
+ <button
614
+ className="btn btn-primary"
615
+ onClick={() =>
616
+ innerRef.current
617
+ ?.querySelector("form")
618
+ ?.dispatchEvent(
619
+ new Event("submit", { cancelable: true, bubbles: true })
620
+ )
621
+ }
622
+ disabled={submitting}
623
+ >
624
+ {submitting ? "Saving..." : "Save"}
625
+ </button>
626
+ </div>
627
+ </div>
628
+ </div>
629
+ );
630
+ };
631
+
632
+ const WorkflowEditor = ({ data }) => {
633
+ const [steps, setSteps] = useState(data.steps || []);
634
+ const [nodes, setNodes, onNodesChange] = useNodesState([]);
635
+ const [edges, setEdges, rfOnEdgesChange] = useEdgesState([]);
636
+ const [nameById, setNameById] = useState({});
637
+ const [idByName, setIdByName] = useState({});
638
+ const [startPosition, setStartPosition] = useState(
639
+ data.config?.workflow_start_position || null
640
+ );
641
+ const [addPositions, setAddPositions] = useState({});
642
+ const pendingAddRef = useRef(null);
643
+ const positionDebounceRef = useRef(null);
644
+ const pendingPositionRef = useRef(new Map());
645
+ const [loading, setLoading] = useState(false);
646
+ const [message, setMessage] = useState("");
647
+ const [error, setError] = useState("");
648
+ const [modal, setModal] = useState(null);
649
+ const [savingModal, setSavingModal] = useState(false);
650
+ const [savingPositions, setSavingPositions] = useState(false);
651
+ const [sizeSyncing, setSizeSyncing] = useState(false);
652
+ const modalRef = useRef(null);
653
+
654
+ const rfInstanceRef = useRef(null);
655
+
656
+ const onInit = useCallback((instance) => {
657
+ rfInstanceRef.current = instance;
658
+ }, []);
659
+
660
+ const [selectedNodes, setSelectedNodes] = useState([]);
661
+
662
+ const handleSelectionChange = useCallback(({ nodes = [] }) => {
663
+ console.log("selection change", nodes);
664
+ setSelectedNodes(nodes);
665
+ }, []);
666
+ const handleKeyDelete = useCallback(
667
+ (e) => {
668
+ if (modal) return;
669
+ const tag = e.target?.tagName?.toLowerCase();
670
+ if (
671
+ tag === "input" ||
672
+ tag === "textarea" ||
673
+ tag === "select" ||
674
+ tag === "button" ||
675
+ e.target?.isContentEditable
676
+ )
677
+ return;
678
+ if (e.key !== "Delete" && e.key !== "Backspace") return;
679
+ const stepNode = selectedNodes.find((n) => n.type === "step");
680
+ if (!stepNode) return;
681
+ e.preventDefault();
682
+ onDelete(stepNode.id);
683
+ },
684
+ [modal, onDelete, selectedNodes]
685
+ );
686
+
687
+ const strings = data.strings || {};
688
+ const refreshGraph = useCallback(
689
+ (nextSteps) => {
690
+ const {
691
+ nodes: n,
692
+ edges: e,
693
+ idByName: idMap,
694
+ nameById: nameMap,
695
+ } = buildGraph(nextSteps, strings, data.actionExplainers || {}, {
696
+ startPosition,
697
+ addPositions,
698
+ });
699
+ setNodes(n);
700
+ setEdges(e);
701
+ setIdByName(idMap);
702
+ setNameById(nameMap);
703
+ },
704
+ [
705
+ addPositions,
706
+ data.actionExplainers,
707
+ setEdges,
708
+ setNodes,
709
+ startPosition,
710
+ strings,
711
+ ]
712
+ );
713
+
714
+ useEffect(() => {
715
+ refreshGraph(steps);
716
+ }, [steps, refreshGraph]);
717
+
718
+ useEffect(() => {
719
+ window.addEventListener("keydown", handleKeyDelete);
720
+ return () => window.removeEventListener("keydown", handleKeyDelete);
721
+ }, [handleKeyDelete]);
722
+
723
+ useEffect(() => {
724
+ if (!rfInstanceRef.current) return;
725
+
726
+ const currentNodes = rfInstanceRef.current.getNodes();
727
+
728
+ // Ensure all step nodes have dimensions
729
+ const stepNodes = currentNodes.filter(
730
+ (n) => n.type === "step" && n.width && n.height
731
+ );
732
+ if (!stepNodes.length) return;
733
+
734
+ const stepById = new Map(stepNodes.map((n) => [n.id, n]));
735
+ const updates = [];
736
+
737
+ currentNodes.forEach((node) => {
738
+ if (!node.id.startsWith("add-")) return;
739
+ // Respect user-dragged add nodes
740
+ if (addPositions?.[node.id]) return;
741
+
742
+ const ADD_GAP = 32;
743
+
744
+ const stepNode = stepById.get(node.id.replace("add-", ""));
745
+ if (!stepNode) return;
746
+
747
+ const nextX = stepNode.position.x + stepNode.width + ADD_GAP;
748
+
749
+ const nextY =
750
+ stepNode.position.y + stepNode.height / 2 - (node.height || 24) / 2;
751
+
752
+ const nextPosition = { x: nextX, y: nextY };
753
+
754
+ // Only update if the position actually changed to avoid infinite loops
755
+ if (
756
+ node.position &&
757
+ node.position.x === nextPosition.x &&
758
+ node.position.y === nextPosition.y
759
+ ) {
760
+ return;
761
+ }
762
+
763
+ updates.push({
764
+ id: node.id,
765
+ position: nextPosition,
766
+ });
767
+ });
768
+
769
+ if (!updates.length) return;
770
+
771
+ setNodes((nds) =>
772
+ nds.map((n) => {
773
+ const u = updates.find((x) => x.id === n.id);
774
+ return u ? { ...n, position: u.position } : n;
775
+ })
776
+ );
777
+ }, [nodes, addPositions, setNodes]);
778
+
779
+ useLayoutEffect(() => {
780
+ if (sizeSyncing) return;
781
+ if (!rfInstanceRef.current) return;
782
+ const currentNodes = rfInstanceRef.current.getNodes();
783
+ const stepNodes = currentNodes.filter(
784
+ (n) => n.type === "step" && n.width && n.height
785
+ );
786
+ if (!stepNodes.length) return;
787
+ const updates = [];
788
+ stepNodes.forEach((node) => {
789
+ const step = steps.find((s) => String(s.id) === String(node.id));
790
+ if (!step) return;
791
+ const stored = getWorkflowSize(step);
792
+ const width = Math.round(node.width);
793
+ const height = Math.round(node.height);
794
+ if (!stored || stored.width !== width || stored.height !== height) {
795
+ updates.push({ id: node.id, width, height });
796
+ }
797
+ });
798
+ if (updates.length) persistSizes(updates);
799
+ }, [nodes, persistSizes, sizeSyncing, steps]);
800
+
801
+ const fetchJson = useCallback(async (url, options = {}) => {
802
+ const baseHeaders = {
803
+ "x-requested-with": "XMLHttpRequest",
804
+ };
805
+ if (!(options.body instanceof FormData) && !options.skipJsonHeader)
806
+ baseHeaders["Content-Type"] = "application/json";
807
+ const res = await fetch(url, {
808
+ ...options,
809
+ headers: { ...baseHeaders, ...(options.headers || {}) },
810
+ });
811
+ const json = await res.json();
812
+ if (!res.ok || json.error) {
813
+ throw new Error(json.error || res.statusText);
814
+ }
815
+ return json;
816
+ }, []);
817
+
818
+ const reload = useCallback(async () => {
819
+ setLoading(true);
820
+ setModal(null);
821
+ setError("");
822
+ try {
823
+ let fresh = await fetchJson(data.urls.data);
824
+
825
+ // If a step was just added via an adder node, wire up next_step
826
+ if (pendingAddRef.current) {
827
+ const { afterStepId, prevIds, insertBetween } = pendingAddRef.current;
828
+ const newSteps = (fresh.steps || []).filter(
829
+ (s) => !prevIds.has(String(s.id))
830
+ );
831
+ if (newSteps.length === 1) {
832
+ const newStep = newSteps[0];
833
+ if (insertBetween && insertBetween.originalTargetName) {
834
+ // Insert new step between source and its former target
835
+ await updateConnection({
836
+ step_id: newStep.id,
837
+ next_step: insertBetween.originalTargetName,
838
+ });
839
+ await updateConnection({
840
+ step_id: afterStepId,
841
+ next_step: newStep.name,
842
+ });
843
+ } else {
844
+ await updateConnection({
845
+ step_id: afterStepId,
846
+ next_step: newStep.name,
847
+ });
848
+ }
849
+
850
+ // Re-fetch so we get the updated connections reflected in steps
851
+ fresh = await fetchJson(data.urls.data);
852
+ }
853
+ pendingAddRef.current = null;
854
+ }
855
+
856
+ setSteps(fresh.steps || []);
857
+
858
+ setMessage(strings.refresh);
859
+ setTimeout(() => setMessage(""), 1200);
860
+ } catch (e) {
861
+ setError(e.message);
862
+ } finally {
863
+ setLoading(false);
864
+ }
865
+ }, [data.urls.data, fetchJson, strings.refresh, updateConnection]);
866
+
867
+ const persistPositions = useCallback(
868
+ async (positions = []) => {
869
+ if (!positions.length) return;
870
+ try {
871
+ setSavingPositions(true);
872
+ await fetchJson(data.urls.positions, {
873
+ method: "POST",
874
+ body: JSON.stringify({ positions }),
875
+ headers: {
876
+ "X-CSRF-Token": data.csrfToken || "",
877
+ },
878
+ });
879
+ // keep local state in sync so refresh uses saved positions
880
+ setSteps((prev) =>
881
+ prev.map((s) => {
882
+ const hit = positions.find((p) => String(p.id) === String(s.id));
883
+ return hit
884
+ ? {
885
+ ...s,
886
+ configuration: {
887
+ ...(s.configuration || {}),
888
+ workflow_position: { x: hit.x, y: hit.y },
889
+ },
890
+ }
891
+ : s;
892
+ })
893
+ );
894
+ } catch (e) {
895
+ setError(e.message);
896
+ } finally {
897
+ setSavingPositions(false);
898
+ }
899
+ },
900
+ [data.urls.positions, fetchJson, setSteps]
901
+ );
902
+
903
+ const schedulePersistPositions = useCallback(
904
+ (positions = []) => {
905
+ if (!positions.length) return;
906
+ positions.forEach((p) => pendingPositionRef.current.set(String(p.id), p));
907
+ if (positionDebounceRef.current)
908
+ clearTimeout(positionDebounceRef.current);
909
+ positionDebounceRef.current = setTimeout(() => {
910
+ const toSave = Array.from(pendingPositionRef.current.values());
911
+ pendingPositionRef.current.clear();
912
+ positionDebounceRef.current = null;
913
+ persistPositions(toSave);
914
+ }, 300);
915
+ },
916
+ [persistPositions]
917
+ );
918
+
919
+ useEffect(() => {
920
+ return () => {
921
+ if (positionDebounceRef.current)
922
+ clearTimeout(positionDebounceRef.current);
923
+ };
924
+ }, []);
925
+
926
+ const persistSizes = useCallback(
927
+ async (sizes = []) => {
928
+ if (!sizes.length) return;
929
+ try {
930
+ setSizeSyncing(true);
931
+ await fetchJson(data.urls.sizes, {
932
+ method: "POST",
933
+ body: JSON.stringify({ sizes }),
934
+ headers: {
935
+ "X-CSRF-Token": data.csrfToken || "",
936
+ },
937
+ });
938
+ setSteps((prev) =>
939
+ prev.map((s) => {
940
+ const hit = sizes.find((p) => String(p.id) === String(s.id));
941
+ return hit
942
+ ? {
943
+ ...s,
944
+ configuration: {
945
+ ...(s.configuration || {}),
946
+ workflow_size: { width: hit.width, height: hit.height },
947
+ },
948
+ }
949
+ : s;
950
+ })
951
+ );
952
+ } catch (e) {
953
+ setError(e.message);
954
+ } finally {
955
+ setSizeSyncing(false);
956
+ }
957
+ },
958
+ [data.csrfToken, data.urls.sizes, fetchJson, setSteps]
959
+ );
960
+
961
+ const openStepForm = useCallback(
962
+ async ({ stepId, initial_step, after_step } = {}) => {
963
+ try {
964
+ setError("");
965
+ const base = `${data.urls.stepForm}${stepId ? `/${stepId}` : ""}`;
966
+ const url = new URL(base, window.location.origin);
967
+ url.searchParams.set("render", "dialog");
968
+ if (initial_step) url.searchParams.set("initial_step", "true");
969
+ if (after_step) url.searchParams.set("after_step", after_step);
970
+ const res = await fetchJson(url.toString());
971
+ setModal({ title: res.title, body: res.form, stepId: stepId || null });
972
+ } catch (e) {
973
+ setError(e.message);
974
+ }
975
+ },
976
+ [data.urls.stepForm, fetchJson]
977
+ );
978
+
979
+ const handleModalSubmit = useCallback(
980
+ async (formEl) => {
981
+ if (!formEl) return;
982
+ setSavingModal(true);
983
+ setError("");
984
+ const formData = new FormData(formEl);
985
+ if (!formData.get("_csrf") && data.csrfToken)
986
+ formData.append("_csrf", data.csrfToken);
987
+ const action = formEl.getAttribute("action") || data.urls.stepForm;
988
+ const method = (formEl.getAttribute("method") || "post").toUpperCase();
989
+ const res = await fetch(action, {
990
+ method,
991
+ body: formData,
992
+ headers: { "x-requested-with": "XMLHttpRequest" },
993
+ });
994
+ const json = await res.json();
995
+ if (json.error) throw new Error(json.error);
996
+ setModal(null);
997
+ await reload();
998
+ },
999
+ [data.csrfToken, data.urls.stepForm, reload]
1000
+ );
1001
+
1002
+ useEffect(() => {
1003
+ if (!modal) return undefined;
1004
+ const formEl = modalRef.current?.querySelector("form");
1005
+ if (!formEl) return undefined;
1006
+ // Re-run show-if logic so action-specific fields render correctly
1007
+ setTimeout(() => {
1008
+ if (typeof window !== "undefined" && window.apply_showif)
1009
+ window.apply_showif();
1010
+ }, 0);
1011
+
1012
+ const actionSelect = formEl.querySelector('[name="wf_action_name"]');
1013
+ const handleActionChange = () => {
1014
+ if (typeof window !== "undefined" && window.apply_showif)
1015
+ window.apply_showif();
1016
+ };
1017
+ if (actionSelect)
1018
+ actionSelect.addEventListener("change", handleActionChange);
1019
+
1020
+ const submitHandler = async (e) => {
1021
+ e.preventDefault();
1022
+ try {
1023
+ await handleModalSubmit(formEl);
1024
+ } catch (err) {
1025
+ setError(err.message);
1026
+ } finally {
1027
+ setSavingModal(false);
1028
+ }
1029
+ };
1030
+ formEl.addEventListener("submit", submitHandler);
1031
+ return () => {
1032
+ formEl.removeEventListener("submit", submitHandler);
1033
+ if (actionSelect)
1034
+ actionSelect.removeEventListener("change", handleActionChange);
1035
+ };
1036
+ }, [handleModalSubmit, modal]);
1037
+
1038
+ const updateConnection = useCallback(
1039
+ async (payload) => {
1040
+ const body = new URLSearchParams();
1041
+ Object.entries({ ...payload, _csrf: data.csrfToken }).forEach(
1042
+ ([k, v]) => {
1043
+ if (typeof v !== "undefined" && v !== null) body.append(k, v);
1044
+ }
1045
+ );
1046
+ await fetchJson(data.urls.connect, {
1047
+ method: "POST",
1048
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1049
+ body,
1050
+ });
1051
+ },
1052
+ [data.csrfToken, data.urls.connect, fetchJson]
1053
+ );
1054
+
1055
+ const onConnect = useCallback(
1056
+ async (connection) => {
1057
+ const loop = connection.sourceHandle === "loop";
1058
+ const targetName = nameById[connection.target];
1059
+ if (connection.source === "start") {
1060
+ const targetId = connection.target;
1061
+ await updateConnection({ step_id: targetId, initial_step: true });
1062
+ await reload();
1063
+ return;
1064
+ }
1065
+ const stepName = nameById[connection.source];
1066
+ if (!stepName) return;
1067
+ if (loop) {
1068
+ await updateConnection({
1069
+ step_id: connection.source,
1070
+ loop_body_step: targetName || "",
1071
+ });
1072
+ } else {
1073
+ // If this source already has a simple next_step, treat this as inserting the target step in between source and its former target.
1074
+ const srcStep = steps.find(
1075
+ (s) => String(s.id) === String(connection.source)
1076
+ );
1077
+ const rawNext = srcStep?.next_step
1078
+ ? String(srcStep.next_step).trim()
1079
+ : "";
1080
+ const simpleNextId = rawNext && idByName[rawNext];
1081
+
1082
+ if (simpleNextId && simpleNextId !== connection.target) {
1083
+ // Rewire: source -> new target, new target -> old target name
1084
+ await updateConnection({
1085
+ step_id: connection.target,
1086
+ next_step: rawNext,
1087
+ });
1088
+ }
1089
+
1090
+ await updateConnection({
1091
+ step_id: connection.source,
1092
+ next_step: targetName || "",
1093
+ });
1094
+ }
1095
+ await reload();
1096
+ },
1097
+ [idByName, nameById, reload, steps, updateConnection]
1098
+ );
1099
+
1100
+ const onEdgesChange = useCallback(
1101
+ (changes) => {
1102
+ const removed = changes.filter((c) => c.type === "remove");
1103
+ if (removed.length) {
1104
+ const updates = [];
1105
+ removed.forEach((chg) => {
1106
+ const edge = edges.find((e) => e.id === chg.id);
1107
+ if (!edge) return;
1108
+ if (edge.source === "start") {
1109
+ updates.push(
1110
+ updateConnection({
1111
+ step_id: edge.target,
1112
+ initial_step: false,
1113
+ })
1114
+ );
1115
+ return;
1116
+ }
1117
+ if (edge.data?.loop) {
1118
+ updates.push(
1119
+ updateConnection({ step_id: edge.source, loop_body_step: "" })
1120
+ );
1121
+ } else {
1122
+ updates.push(
1123
+ updateConnection({ step_id: edge.source, next_step: "" })
1124
+ );
1125
+ }
1126
+ });
1127
+ Promise.all(updates)
1128
+ .then(() => reload())
1129
+ .catch((e) => setError(e.message));
1130
+ }
1131
+ rfOnEdgesChange(changes);
1132
+ },
1133
+ [edges, reload, rfOnEdgesChange, updateConnection]
1134
+ );
1135
+
1136
+ const onNodesChangeWrapped = useCallback(
1137
+ (changes) => {
1138
+ const finishedPositions = changes
1139
+ .filter((c) => c.type === "position" && c.position && c.dragging)
1140
+ .map((c) => ({ id: c.id, x: c.position.x, y: c.position.y }));
1141
+
1142
+ if (finishedPositions.length) {
1143
+ finishedPositions
1144
+ .filter((p) => p.id === "start")
1145
+ .forEach((p) => setStartPosition({ x: p.x, y: p.y }));
1146
+
1147
+ const addUpdates = finishedPositions.filter((p) =>
1148
+ p.id.startsWith("add-")
1149
+ );
1150
+ if (addUpdates.length) {
1151
+ setAddPositions((prev) => {
1152
+ const next = { ...prev };
1153
+ addUpdates.forEach((p) => {
1154
+ next[p.id] = { x: p.x, y: p.y };
1155
+ });
1156
+ return next;
1157
+ });
1158
+ }
1159
+
1160
+ const stepPositions = finishedPositions.filter(
1161
+ (p) => /^[0-9]+$/.test(p.id) || p.id === "start"
1162
+ );
1163
+ if (stepPositions.length) schedulePersistPositions(stepPositions);
1164
+ }
1165
+ onNodesChange(changes);
1166
+ },
1167
+ [onNodesChange, schedulePersistPositions, setAddPositions, setStartPosition]
1168
+ );
1169
+
1170
+ const onAddAfter = useCallback(
1171
+ (id) => {
1172
+ if (id === "start") {
1173
+ openStepForm({ initial_step: true });
1174
+ return;
1175
+ }
1176
+ pendingAddRef.current = {
1177
+ afterStepId: id,
1178
+ prevIds: new Set(steps.map((s) => String(s.id))),
1179
+ };
1180
+ openStepForm({ after_step: id });
1181
+ },
1182
+ [openStepForm, steps]
1183
+ );
1184
+
1185
+ const onInsertBetween = useCallback(
1186
+ ({ source, target }) => {
1187
+ if (!source || !target || source === "start") return;
1188
+ const originalTargetName = nameById[target];
1189
+ if (!originalTargetName) return;
1190
+ pendingAddRef.current = {
1191
+ afterStepId: source,
1192
+ prevIds: new Set(steps.map((s) => String(s.id))),
1193
+ insertBetween: { originalTargetName },
1194
+ };
1195
+ openStepForm({ after_step: source });
1196
+ },
1197
+ [nameById, openStepForm, steps]
1198
+ );
1199
+
1200
+ const onSetStart = useCallback(
1201
+ async (id) => {
1202
+ await updateConnection({ step_id: id, initial_step: true });
1203
+ await reload();
1204
+ },
1205
+ [reload, updateConnection]
1206
+ );
1207
+
1208
+ const onClearNext = useCallback(
1209
+ async (id) => {
1210
+ await updateConnection({ step_id: id, next_step: "" });
1211
+ await reload();
1212
+ },
1213
+ [reload, updateConnection]
1214
+ );
1215
+
1216
+ const onDelete = useCallback(
1217
+ async (id) => {
1218
+ if (!window.confirm(strings.confirmDelete)) return;
1219
+ const formData = new FormData();
1220
+ formData.append("_csrf", data.csrfToken || "");
1221
+ await fetchJson(`${data.urls.deleteStep}/${id}`, {
1222
+ method: "POST",
1223
+ body: formData,
1224
+ });
1225
+ await reload();
1226
+ },
1227
+ [data.csrfToken, fetchJson, reload, strings.confirmDelete]
1228
+ );
1229
+
1230
+ useEffect(() => {
1231
+ setNodes((nds) =>
1232
+ nds.map((n) =>
1233
+ n.type === "start"
1234
+ ? {
1235
+ ...n,
1236
+ data: {
1237
+ ...n.data,
1238
+ onAddInitial: () => openStepForm({ initial_step: true }),
1239
+ },
1240
+ }
1241
+ : n.type === "add"
1242
+ ? {
1243
+ ...n,
1244
+ data: {
1245
+ ...n.data,
1246
+ onAddAfter: (afterId) => onAddAfter(afterId),
1247
+ },
1248
+ }
1249
+ : {
1250
+ ...n,
1251
+ data: {
1252
+ ...n.data,
1253
+ onEdit: (id) => openStepForm({ stepId: id }),
1254
+ onAddAfter,
1255
+ onSetStart,
1256
+ onClearNext,
1257
+ onDelete,
1258
+ },
1259
+ }
1260
+ )
1261
+ );
1262
+ }, [
1263
+ onAddAfter,
1264
+ onClearNext,
1265
+ onDelete,
1266
+ onSetStart,
1267
+ openStepForm,
1268
+ setNodes,
1269
+ steps,
1270
+ ]);
1271
+
1272
+ useEffect(() => {
1273
+ setEdges((eds) => {
1274
+ let changed = false;
1275
+ const nextEdges = eds.map((e) => {
1276
+ if (!e.data || !e.data.canInsertBetween) return e;
1277
+ if (e.data.onAddBetween === onInsertBetween) return e;
1278
+ changed = true;
1279
+ return {
1280
+ ...e,
1281
+ data: {
1282
+ ...e.data,
1283
+ onAddBetween: onInsertBetween,
1284
+ },
1285
+ };
1286
+ });
1287
+ return changed ? nextEdges : eds;
1288
+ });
1289
+ }, [onInsertBetween, setEdges, edges]);
1290
+
1291
+ const hasSteps = steps.length > 0;
1292
+
1293
+ return (
1294
+ <div className="wf-shell">
1295
+ <div className="wf-toolbar">
1296
+ <div className="wf-toolbar__left">
1297
+ <strong>{strings.configure}</strong>
1298
+ {message ? (
1299
+ <span className="text-success ms-2">{message}</span>
1300
+ ) : null}
1301
+ {error ? <span className="text-danger ms-2">{error}</span> : null}
1302
+ {/* {savingPositions ? (
1303
+ <span className="text-muted ms-2">
1304
+ {strings.loading || "Saving positions..."}
1305
+ </span>
1306
+ ) : null} */}
1307
+ </div>
1308
+ <div className="wf-toolbar__actions">
1309
+ <button
1310
+ className="btn btn-sm btn-outline-primary"
1311
+ onClick={() => openStepForm({ initial_step: !hasSteps })}
1312
+ >
1313
+ {strings.addStep}
1314
+ </button>
1315
+ <button
1316
+ className="btn btn-sm btn-outline-secondary"
1317
+ onClick={reload}
1318
+ disabled={loading}
1319
+ >
1320
+ {loading ? strings.loading : strings.refresh}
1321
+ </button>
1322
+ <a className="btn btn-sm btn-outline-info" href={data.urls.runs}>
1323
+ {strings.runs}
1324
+ </a>
1325
+ </div>
1326
+ </div>
1327
+ {!hasSteps ? <div className="wf-empty">{strings.empty}</div> : null}
1328
+ <div className="wf-canvas">
1329
+ <ReactFlow
1330
+ nodesFocusable={true}
1331
+ edgesFocusable={true}
1332
+ disableKeyboardA11y={false}
1333
+ nodes={nodes}
1334
+ edges={edges}
1335
+ onNodesChange={onNodesChangeWrapped}
1336
+ onEdgesChange={onEdgesChange}
1337
+ onConnect={onConnect}
1338
+ onSelectionChange={handleSelectionChange}
1339
+ nodeTypes={nodeTypes}
1340
+ edgeTypes={edgeTypes}
1341
+ onInit={onInit}
1342
+ fitView
1343
+ proOptions={{ hideAttribution: true }}
1344
+ defaultEdgeOptions={{ animated: true }}
1345
+ colorMode={window._sc_lightmode || "light"}
1346
+ >
1347
+ <MiniMap pannable zoomable />
1348
+ <Controls />
1349
+ <Background gap={16} />
1350
+ </ReactFlow>
1351
+ </div>
1352
+ <StepModal
1353
+ modal={modal}
1354
+ innerRef={modalRef}
1355
+ onClose={() => {
1356
+ setModal(null);
1357
+ setError("");
1358
+ }}
1359
+ submitting={savingModal}
1360
+ error={error && modal ? error : ""}
1361
+ data={data}
1362
+ onDelete={onDelete}
1363
+ />
1364
+ </div>
1365
+ );
1366
+ };
1367
+
1368
+ export default WorkflowEditor;