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