@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.
- package/.babelrc +3 -0
- package/dist/workflow_bundle.js +53599 -0
- package/dist/workflow_bundle.js.LICENSE.txt +61 -0
- package/dist/workflow_bundle.js.map +1 -0
- package/package.json +38 -0
- package/src/WorkflowEdge.jsx +69 -0
- package/src/WorkflowEditor.jsx +1787 -0
- package/src/index.js +31 -0
- package/src/workflow.css +355 -0
- package/webpack.config.js +39 -0
|
@@ -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;
|