@saltcorn/workflow-editor 1.6.0-alpha.10 → 1.6.0-alpha.11
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/dist/workflow_bundle.js +2 -53598
- package/dist/workflow_bundle.js.map +1 -1
- package/package.json +7 -5
- package/src/WorkflowEditor.jsx +395 -38
- package/src/workflow.css +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saltcorn/workflow-editor",
|
|
3
|
-
"version": "1.6.0-alpha.
|
|
3
|
+
"version": "1.6.0-alpha.11",
|
|
4
4
|
"description": "React Flow-based workflow editor for Saltcorn",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"homepage": "https://saltcorn.com",
|
|
@@ -17,12 +17,12 @@
|
|
|
17
17
|
"@babel/core": "7.25.2",
|
|
18
18
|
"@babel/preset-env": "7.25.2",
|
|
19
19
|
"@babel/preset-react": "7.24.7",
|
|
20
|
-
"babel-loader": "
|
|
20
|
+
"babel-loader": "9.1.3",
|
|
21
21
|
"classnames": "2.2.6",
|
|
22
22
|
"css-loader": "6.11.0",
|
|
23
23
|
"process": "0.11.10",
|
|
24
|
-
"react": "18.
|
|
25
|
-
"react-dom": "18.
|
|
24
|
+
"react": "18.2.0",
|
|
25
|
+
"react-dom": "18.2.0",
|
|
26
26
|
"@xyflow/react": "^12.10.0",
|
|
27
27
|
"style-loader": "3.3.4",
|
|
28
28
|
"webpack": "5.97.1",
|
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
"glob-parent": "^5.1.2",
|
|
34
34
|
"undici": "7.12.0",
|
|
35
35
|
"parse5": "7.2.1",
|
|
36
|
-
"parse5-htmlparser2-tree-adapter": "7.0.0"
|
|
36
|
+
"parse5-htmlparser2-tree-adapter": "7.0.0",
|
|
37
|
+
"react": "18.2.0",
|
|
38
|
+
"react-dom": "18.2.0"
|
|
37
39
|
}
|
|
38
40
|
}
|
package/src/WorkflowEditor.jsx
CHANGED
|
@@ -487,9 +487,16 @@ const buildGraph = (
|
|
|
487
487
|
id: `e-start-${initial.id}`,
|
|
488
488
|
source: "start",
|
|
489
489
|
target: String(initial.id),
|
|
490
|
-
type: "
|
|
490
|
+
type: "workflow-main",
|
|
491
491
|
animated: true,
|
|
492
492
|
markerEnd: makeMarker(),
|
|
493
|
+
data: {
|
|
494
|
+
startLabel: strings.start,
|
|
495
|
+
sourceStepName: "start",
|
|
496
|
+
sourceId: "start",
|
|
497
|
+
targetId: String(initial.id),
|
|
498
|
+
canInsertBetween: true,
|
|
499
|
+
},
|
|
493
500
|
});
|
|
494
501
|
else {
|
|
495
502
|
const addId = "add-start";
|
|
@@ -620,7 +627,7 @@ const buildGraph = (
|
|
|
620
627
|
});
|
|
621
628
|
|
|
622
629
|
const addNodes = [];
|
|
623
|
-
if (!
|
|
630
|
+
if (!initial) {
|
|
624
631
|
const addId = "add-start";
|
|
625
632
|
addNodes.push({
|
|
626
633
|
id: addId,
|
|
@@ -706,10 +713,16 @@ const StepDrawer = ({
|
|
|
706
713
|
error,
|
|
707
714
|
data,
|
|
708
715
|
onDelete,
|
|
716
|
+
onCopy,
|
|
709
717
|
drawerWidth,
|
|
710
718
|
onResizeStart,
|
|
711
719
|
}) => {
|
|
712
720
|
const isOpen = !!modal;
|
|
721
|
+
const stopDrawerKeyPropagation = useCallback((e) => {
|
|
722
|
+
e.stopPropagation();
|
|
723
|
+
if (e?.nativeEvent?.stopImmediatePropagation)
|
|
724
|
+
e.nativeEvent.stopImmediatePropagation();
|
|
725
|
+
}, []);
|
|
713
726
|
return (
|
|
714
727
|
<div
|
|
715
728
|
ref={outerRef}
|
|
@@ -719,6 +732,8 @@ const StepDrawer = ({
|
|
|
719
732
|
aria-hidden={!isOpen}
|
|
720
733
|
aria-label={modal?.title || data?.strings?.configure}
|
|
721
734
|
style={{ "--wf-drawer-width": `${drawerWidth}px` }}
|
|
735
|
+
onKeyDown={stopDrawerKeyPropagation}
|
|
736
|
+
onKeyUp={stopDrawerKeyPropagation}
|
|
722
737
|
>
|
|
723
738
|
{modal ? (
|
|
724
739
|
<>
|
|
@@ -752,6 +767,17 @@ const StepDrawer = ({
|
|
|
752
767
|
>
|
|
753
768
|
{data.strings.deleteStep}
|
|
754
769
|
</button>
|
|
770
|
+
{modal.stepId ? (
|
|
771
|
+
<button
|
|
772
|
+
className="btn btn-sm btn-outline-secondary"
|
|
773
|
+
onClick={(e) => {
|
|
774
|
+
e.stopPropagation();
|
|
775
|
+
onCopy(modal.stepId);
|
|
776
|
+
}}
|
|
777
|
+
>
|
|
778
|
+
{data.strings.copyStep || "Copy"}
|
|
779
|
+
</button>
|
|
780
|
+
) : null}
|
|
755
781
|
<button className="btn btn-secondary" onClick={onClose}>
|
|
756
782
|
Close
|
|
757
783
|
</button>
|
|
@@ -807,6 +833,9 @@ const WorkflowEditor = ({ data }) => {
|
|
|
807
833
|
const resizingRef = useRef(false);
|
|
808
834
|
const connectSourceRef = useRef(null);
|
|
809
835
|
const [selectedEdgeId, setSelectedEdgeId] = useState(null);
|
|
836
|
+
const autoSaveTimerRef = useRef(null);
|
|
837
|
+
const autoSavingRef = useRef(false);
|
|
838
|
+
const nodesRef = useRef([]);
|
|
810
839
|
|
|
811
840
|
const drawerStorageKey = useMemo(
|
|
812
841
|
() => `wf-drawer:${data?.trigger?.id || "default"}`,
|
|
@@ -914,10 +943,12 @@ const WorkflowEditor = ({ data }) => {
|
|
|
914
943
|
)
|
|
915
944
|
return;
|
|
916
945
|
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
|
917
|
-
const
|
|
918
|
-
|
|
946
|
+
const stepIds = selectedNodes
|
|
947
|
+
.filter((n) => n.type === "step")
|
|
948
|
+
.map((n) => n.id);
|
|
949
|
+
if (!stepIds.length) return;
|
|
919
950
|
e.preventDefault();
|
|
920
|
-
onDelete(
|
|
951
|
+
onDelete(stepIds);
|
|
921
952
|
},
|
|
922
953
|
[modal, onDelete, selectedNodes]
|
|
923
954
|
);
|
|
@@ -953,6 +984,10 @@ const WorkflowEditor = ({ data }) => {
|
|
|
953
984
|
refreshGraph(steps);
|
|
954
985
|
}, [steps, refreshGraph]);
|
|
955
986
|
|
|
987
|
+
useEffect(() => {
|
|
988
|
+
nodesRef.current = nodes;
|
|
989
|
+
}, [nodes]);
|
|
990
|
+
|
|
956
991
|
useEffect(() => {
|
|
957
992
|
if (!rfInstanceRef.current) return;
|
|
958
993
|
if (!pendingViewportRef.current) return;
|
|
@@ -1070,25 +1105,33 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1070
1105
|
setError("");
|
|
1071
1106
|
try {
|
|
1072
1107
|
let fresh = await fetchJson(data.urls.data);
|
|
1108
|
+
let updatedSteps = null;
|
|
1073
1109
|
|
|
1074
1110
|
// If a step was just added via an adder node, wire up next_step
|
|
1075
1111
|
if (pendingAddRef.current) {
|
|
1076
|
-
const { afterStepId, prevIds, insertBetween } =
|
|
1112
|
+
const { afterStepId, prevIds, insertBetween, fromStart } =
|
|
1113
|
+
pendingAddRef.current;
|
|
1077
1114
|
const newSteps = (fresh.steps || []).filter(
|
|
1078
1115
|
(s) => !prevIds.has(String(s.id))
|
|
1079
1116
|
);
|
|
1080
1117
|
if (newSteps.length === 1) {
|
|
1081
1118
|
const newStep = newSteps[0];
|
|
1082
1119
|
if (insertBetween && insertBetween.originalTargetName) {
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1120
|
+
if (fromStart) {
|
|
1121
|
+
await updateConnection({
|
|
1122
|
+
step_id: newStep.id,
|
|
1123
|
+
initial_step: true,
|
|
1124
|
+
});
|
|
1125
|
+
} else {
|
|
1126
|
+
await updateConnection({
|
|
1127
|
+
step_id: newStep.id,
|
|
1128
|
+
next_step: insertBetween.originalTargetName,
|
|
1129
|
+
});
|
|
1130
|
+
await updateConnection({
|
|
1131
|
+
step_id: afterStepId,
|
|
1132
|
+
next_step: newStep.name,
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1092
1135
|
} else {
|
|
1093
1136
|
await updateConnection({
|
|
1094
1137
|
step_id: afterStepId,
|
|
@@ -1098,20 +1141,32 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1098
1141
|
|
|
1099
1142
|
// Re-fetch so we get the updated connections reflected in steps
|
|
1100
1143
|
fresh = await fetchJson(data.urls.data);
|
|
1144
|
+
|
|
1145
|
+
if (insertBetween && insertBetween.originalTargetName) {
|
|
1146
|
+
updatedSteps = await applyInsertBetweenLayout(fresh.steps || [], {
|
|
1147
|
+
newStep,
|
|
1148
|
+
afterStepId,
|
|
1149
|
+
targetName: insertBetween.originalTargetName,
|
|
1150
|
+
targetId: insertBetween.targetId,
|
|
1151
|
+
snapshot: insertBetween.positionsSnapshot,
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1101
1154
|
}
|
|
1102
1155
|
pendingAddRef.current = null;
|
|
1103
1156
|
}
|
|
1104
1157
|
|
|
1105
|
-
|
|
1158
|
+
const nextSteps = updatedSteps || fresh.steps || [];
|
|
1159
|
+
setSteps(nextSteps);
|
|
1106
1160
|
|
|
1107
1161
|
setMessage(strings.refresh);
|
|
1108
1162
|
setTimeout(() => setMessage(""), 1200);
|
|
1163
|
+
return nextSteps;
|
|
1109
1164
|
} catch (e) {
|
|
1110
1165
|
setError(e.message);
|
|
1111
1166
|
} finally {
|
|
1112
1167
|
setLoading(false);
|
|
1113
1168
|
}
|
|
1114
|
-
}, [data.urls.data, fetchJson, strings.refresh, updateConnection]);
|
|
1169
|
+
}, [applyInsertBetweenLayout, data.urls.data, fetchJson, strings.refresh, updateConnection]);
|
|
1115
1170
|
|
|
1116
1171
|
const persistPositions = useCallback(
|
|
1117
1172
|
async (positions = []) => {
|
|
@@ -1165,6 +1220,98 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1165
1220
|
[persistPositions]
|
|
1166
1221
|
);
|
|
1167
1222
|
|
|
1223
|
+
const applyLocalPositionUpdates = useCallback(
|
|
1224
|
+
(updates = []) => {
|
|
1225
|
+
if (!updates.length) return;
|
|
1226
|
+
setNodes((nds) =>
|
|
1227
|
+
nds.map((n) => {
|
|
1228
|
+
const hit = updates.find((u) => String(u.id) === String(n.id));
|
|
1229
|
+
return hit
|
|
1230
|
+
? { ...n, position: { ...(n.position || {}), x: hit.x, y: hit.y } }
|
|
1231
|
+
: n;
|
|
1232
|
+
})
|
|
1233
|
+
);
|
|
1234
|
+
},
|
|
1235
|
+
[setNodes]
|
|
1236
|
+
);
|
|
1237
|
+
|
|
1238
|
+
const applyInsertBetweenLayout = useCallback(
|
|
1239
|
+
async (freshSteps, { newStep, afterStepId, targetName, targetId, snapshot }) => {
|
|
1240
|
+
if (!newStep || !snapshot) return null;
|
|
1241
|
+
const snap = snapshot || {};
|
|
1242
|
+
const newStepSize =
|
|
1243
|
+
getWorkflowSize(newStep) || {
|
|
1244
|
+
width: DEFAULT_NODE_WIDTH,
|
|
1245
|
+
height: DEFAULT_NODE_HEIGHT,
|
|
1246
|
+
};
|
|
1247
|
+
const sourceSnap = snap[String(afterStepId)];
|
|
1248
|
+
const resolvedTargetId =
|
|
1249
|
+
targetId || freshSteps.find((s) => s.name === targetName)?.id;
|
|
1250
|
+
const targetSnap = resolvedTargetId ? snap[String(resolvedTargetId)] : null;
|
|
1251
|
+
const shiftAmount = newStepSize.height + V_GAP;
|
|
1252
|
+
const fallbackY = sourceSnap?.y ?? 0;
|
|
1253
|
+
const fallbackX = sourceSnap?.x ?? targetSnap?.x ?? 0;
|
|
1254
|
+
const newY = targetSnap?.y ?? fallbackY;
|
|
1255
|
+
const newX =
|
|
1256
|
+
sourceSnap && targetSnap
|
|
1257
|
+
? (sourceSnap.x + targetSnap.x) / 2
|
|
1258
|
+
: targetSnap?.x ?? sourceSnap?.x ?? fallbackX;
|
|
1259
|
+
|
|
1260
|
+
const newHalfWidth = (newStepSize.width || DEFAULT_NODE_WIDTH) / 2;
|
|
1261
|
+
const bandPadding = H_GAP / 2;
|
|
1262
|
+
const bandMinX = newX - newHalfWidth - bandPadding;
|
|
1263
|
+
const bandMaxX = newX + newHalfWidth + bandPadding;
|
|
1264
|
+
|
|
1265
|
+
const updates = [];
|
|
1266
|
+
freshSteps.forEach((step) => {
|
|
1267
|
+
const idStr = String(step.id);
|
|
1268
|
+
const nodeSnap = snap[idStr];
|
|
1269
|
+
|
|
1270
|
+
if (idStr === String(newStep.id)) {
|
|
1271
|
+
updates.push({ id: idStr, x: newX, y: newY });
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
if (!nodeSnap) return;
|
|
1276
|
+
|
|
1277
|
+
const nodeWidth = nodeSnap.width || DEFAULT_NODE_WIDTH;
|
|
1278
|
+
const nodeHalfWidth = nodeWidth / 2;
|
|
1279
|
+
const nodeMinX = nodeSnap.x - nodeHalfWidth;
|
|
1280
|
+
const nodeMaxX = nodeSnap.x + nodeHalfWidth;
|
|
1281
|
+
const overlapsBand = !(nodeMaxX < bandMinX || nodeMinX > bandMaxX);
|
|
1282
|
+
const shouldShift =
|
|
1283
|
+
nodeSnap.y >= newY &&
|
|
1284
|
+
idStr !== String(afterStepId) &&
|
|
1285
|
+
overlapsBand;
|
|
1286
|
+
|
|
1287
|
+
if (shouldShift) {
|
|
1288
|
+
updates.push({ id: idStr, x: nodeSnap.x, y: nodeSnap.y + shiftAmount });
|
|
1289
|
+
} else {
|
|
1290
|
+
updates.push({ id: idStr, x: nodeSnap.x, y: nodeSnap.y });
|
|
1291
|
+
}
|
|
1292
|
+
});
|
|
1293
|
+
if (!updates.length) return freshSteps;
|
|
1294
|
+
|
|
1295
|
+
applyLocalPositionUpdates(updates);
|
|
1296
|
+
|
|
1297
|
+
const stepsWithPositions = freshSteps.map((s) => {
|
|
1298
|
+
const hit = updates.find((u) => String(u.id) === String(s.id));
|
|
1299
|
+
if (!hit) return s;
|
|
1300
|
+
return {
|
|
1301
|
+
...s,
|
|
1302
|
+
configuration: {
|
|
1303
|
+
...(s.configuration || {}),
|
|
1304
|
+
workflow_position: { x: hit.x, y: hit.y },
|
|
1305
|
+
},
|
|
1306
|
+
};
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
await persistPositions(updates);
|
|
1310
|
+
return stepsWithPositions;
|
|
1311
|
+
},
|
|
1312
|
+
[applyLocalPositionUpdates, persistPositions]
|
|
1313
|
+
);
|
|
1314
|
+
|
|
1168
1315
|
useEffect(() => {
|
|
1169
1316
|
return () => {
|
|
1170
1317
|
if (positionDebounceRef.current)
|
|
@@ -1232,8 +1379,8 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1232
1379
|
[clearDrawerState, data.urls.stepForm, fetchJson, persistDrawerState]
|
|
1233
1380
|
);
|
|
1234
1381
|
|
|
1235
|
-
const
|
|
1236
|
-
async (formEl) => {
|
|
1382
|
+
const submitStepForm = useCallback(
|
|
1383
|
+
async (formEl, { closeOnSuccess = true } = {}) => {
|
|
1237
1384
|
if (!formEl) return;
|
|
1238
1385
|
setSavingModal(true);
|
|
1239
1386
|
setError("");
|
|
@@ -1249,9 +1396,12 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1249
1396
|
});
|
|
1250
1397
|
const json = await res.json();
|
|
1251
1398
|
if (json.error) throw new Error(json.error);
|
|
1252
|
-
|
|
1253
|
-
|
|
1399
|
+
if (closeOnSuccess) {
|
|
1400
|
+
setModal(null);
|
|
1401
|
+
clearDrawerState();
|
|
1402
|
+
}
|
|
1254
1403
|
await reload();
|
|
1404
|
+
setSavingModal(false);
|
|
1255
1405
|
},
|
|
1256
1406
|
[clearDrawerState, data.csrfToken, data.urls.stepForm, reload]
|
|
1257
1407
|
);
|
|
@@ -1270,6 +1420,7 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1270
1420
|
}, 0);
|
|
1271
1421
|
|
|
1272
1422
|
const actionSelect = formEl.querySelector('[name="wf_action_name"]');
|
|
1423
|
+
const allowAutoSave = !!modal?.stepId; // avoid auto-save for brand new steps to prevent insert errors
|
|
1273
1424
|
const handleActionChange = () => {
|
|
1274
1425
|
if (typeof window !== "undefined" && window.apply_showif)
|
|
1275
1426
|
window.apply_showif();
|
|
@@ -1277,10 +1428,29 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1277
1428
|
if (actionSelect)
|
|
1278
1429
|
actionSelect.addEventListener("change", handleActionChange);
|
|
1279
1430
|
|
|
1431
|
+
const scheduleAutoSave = allowAutoSave
|
|
1432
|
+
? () => {
|
|
1433
|
+
if (autoSavingRef.current) return;
|
|
1434
|
+
if (autoSaveTimerRef.current)
|
|
1435
|
+
clearTimeout(autoSaveTimerRef.current);
|
|
1436
|
+
autoSaveTimerRef.current = setTimeout(async () => {
|
|
1437
|
+
autoSavingRef.current = true;
|
|
1438
|
+
try {
|
|
1439
|
+
await submitStepForm(formEl, { closeOnSuccess: false });
|
|
1440
|
+
} catch (err) {
|
|
1441
|
+
setError(err.message);
|
|
1442
|
+
} finally {
|
|
1443
|
+
autoSavingRef.current = false;
|
|
1444
|
+
setSavingModal(false);
|
|
1445
|
+
}
|
|
1446
|
+
}, 800);
|
|
1447
|
+
}
|
|
1448
|
+
: null;
|
|
1449
|
+
|
|
1280
1450
|
const submitHandler = async (e) => {
|
|
1281
1451
|
e.preventDefault();
|
|
1282
1452
|
try {
|
|
1283
|
-
await
|
|
1453
|
+
await submitStepForm(formEl);
|
|
1284
1454
|
} catch (err) {
|
|
1285
1455
|
setError(err.message);
|
|
1286
1456
|
} finally {
|
|
@@ -1288,12 +1458,22 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1288
1458
|
}
|
|
1289
1459
|
};
|
|
1290
1460
|
formEl.addEventListener("submit", submitHandler);
|
|
1461
|
+
if (scheduleAutoSave) {
|
|
1462
|
+
formEl.addEventListener("input", scheduleAutoSave);
|
|
1463
|
+
formEl.addEventListener("change", scheduleAutoSave);
|
|
1464
|
+
}
|
|
1291
1465
|
return () => {
|
|
1466
|
+
if (autoSaveTimerRef.current)
|
|
1467
|
+
clearTimeout(autoSaveTimerRef.current);
|
|
1292
1468
|
formEl.removeEventListener("submit", submitHandler);
|
|
1469
|
+
if (scheduleAutoSave) {
|
|
1470
|
+
formEl.removeEventListener("input", scheduleAutoSave);
|
|
1471
|
+
formEl.removeEventListener("change", scheduleAutoSave);
|
|
1472
|
+
}
|
|
1293
1473
|
if (actionSelect)
|
|
1294
1474
|
actionSelect.removeEventListener("change", handleActionChange);
|
|
1295
1475
|
};
|
|
1296
|
-
}, [
|
|
1476
|
+
}, [modal, submitStepForm]);
|
|
1297
1477
|
|
|
1298
1478
|
useEffect(() => {
|
|
1299
1479
|
if (typeof window === "undefined") return undefined;
|
|
@@ -1400,11 +1580,23 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1400
1580
|
if (targetEl?.classList?.contains("react-flow__pane")) {
|
|
1401
1581
|
// Dropped on empty space: clear next_step mark as final/unconnected
|
|
1402
1582
|
if (pending.nodeId && pending.handleId !== "loop-out") {
|
|
1403
|
-
|
|
1583
|
+
if (pending.nodeId === "start") {
|
|
1584
|
+
const currentInitial = steps.find((s) => s.initial_step);
|
|
1585
|
+
if (currentInitial) {
|
|
1586
|
+
updateConnection({
|
|
1587
|
+
step_id: currentInitial.id,
|
|
1588
|
+
initial_step: false,
|
|
1589
|
+
})
|
|
1590
|
+
.then(() => reload())
|
|
1591
|
+
.catch((e) => setError(e.message));
|
|
1592
|
+
}
|
|
1593
|
+
} else {
|
|
1594
|
+
onClearNext(pending.nodeId);
|
|
1595
|
+
}
|
|
1404
1596
|
}
|
|
1405
1597
|
}
|
|
1406
1598
|
},
|
|
1407
|
-
[onClearNext]
|
|
1599
|
+
[onClearNext, reload, steps, updateConnection]
|
|
1408
1600
|
);
|
|
1409
1601
|
|
|
1410
1602
|
const onEdgesChange = useCallback(
|
|
@@ -1494,15 +1686,46 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1494
1686
|
|
|
1495
1687
|
const onInsertBetween = useCallback(
|
|
1496
1688
|
({ source, target }) => {
|
|
1497
|
-
if (!source || !target
|
|
1689
|
+
if (!source || !target) return;
|
|
1498
1690
|
const originalTargetName = nameById[target];
|
|
1499
1691
|
if (!originalTargetName) return;
|
|
1692
|
+
const fromStart = source === "start";
|
|
1693
|
+
const sizeById = new Map(
|
|
1694
|
+
steps.map((s) => [
|
|
1695
|
+
String(s.id),
|
|
1696
|
+
getWorkflowSize(s) || {
|
|
1697
|
+
width: DEFAULT_NODE_WIDTH,
|
|
1698
|
+
height: DEFAULT_NODE_HEIGHT,
|
|
1699
|
+
},
|
|
1700
|
+
])
|
|
1701
|
+
);
|
|
1702
|
+
const positionsSnapshot = {};
|
|
1703
|
+
nodesRef.current
|
|
1704
|
+
.filter((n) => n.type === "step")
|
|
1705
|
+
.forEach((n) => {
|
|
1706
|
+
const size =
|
|
1707
|
+
sizeById.get(String(n.id)) ||
|
|
1708
|
+
{ width: DEFAULT_NODE_WIDTH, height: DEFAULT_NODE_HEIGHT };
|
|
1709
|
+
positionsSnapshot[String(n.id)] = {
|
|
1710
|
+
x: n.position?.x ?? 0,
|
|
1711
|
+
y: n.position?.y ?? 0,
|
|
1712
|
+
width: size.width || DEFAULT_NODE_WIDTH,
|
|
1713
|
+
height: size.height || DEFAULT_NODE_HEIGHT,
|
|
1714
|
+
};
|
|
1715
|
+
});
|
|
1500
1716
|
pendingAddRef.current = {
|
|
1501
|
-
afterStepId: source,
|
|
1717
|
+
afterStepId: fromStart ? "start" : source,
|
|
1502
1718
|
prevIds: new Set(steps.map((s) => String(s.id))),
|
|
1503
|
-
|
|
1719
|
+
fromStart,
|
|
1720
|
+
insertBetween: {
|
|
1721
|
+
originalTargetName,
|
|
1722
|
+
targetId: target,
|
|
1723
|
+
positionsSnapshot,
|
|
1724
|
+
},
|
|
1504
1725
|
};
|
|
1505
|
-
openStepForm(
|
|
1726
|
+
openStepForm(
|
|
1727
|
+
fromStart ? { initial_step: true } : { after_step: source }
|
|
1728
|
+
);
|
|
1506
1729
|
},
|
|
1507
1730
|
[nameById, openStepForm, steps]
|
|
1508
1731
|
);
|
|
@@ -1515,6 +1738,118 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1515
1738
|
[reload, updateConnection]
|
|
1516
1739
|
);
|
|
1517
1740
|
|
|
1741
|
+
const onCopy = useCallback(
|
|
1742
|
+
async (id) => {
|
|
1743
|
+
try {
|
|
1744
|
+
setError("");
|
|
1745
|
+
const res = await fetchJson(data.urls.copy, {
|
|
1746
|
+
method: "POST",
|
|
1747
|
+
body: JSON.stringify({ step_id: id }),
|
|
1748
|
+
headers: {
|
|
1749
|
+
"X-CSRF-Token": data.csrfToken || "",
|
|
1750
|
+
},
|
|
1751
|
+
});
|
|
1752
|
+
|
|
1753
|
+
if (!res || res.success !== "ok" || !res.step) {
|
|
1754
|
+
await reload();
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
const copiedStep = res.step;
|
|
1759
|
+
const copiedName = copiedStep.name;
|
|
1760
|
+
|
|
1761
|
+
// Reload full workflow data so the new step has
|
|
1762
|
+
// the same serialized shape as all other steps
|
|
1763
|
+
const freshSteps = (await reload()) || [];
|
|
1764
|
+
if (!freshSteps.length) return;
|
|
1765
|
+
|
|
1766
|
+
const sourceStep = freshSteps.find(
|
|
1767
|
+
(s) => String(s.id) === String(id)
|
|
1768
|
+
);
|
|
1769
|
+
const newStep = freshSteps.find((s) => s.name === copiedName);
|
|
1770
|
+
|
|
1771
|
+
if (!sourceStep || !newStep || !newStep.id) return;
|
|
1772
|
+
|
|
1773
|
+
const sourceSize =
|
|
1774
|
+
getWorkflowSize(sourceStep) || {
|
|
1775
|
+
width: DEFAULT_NODE_WIDTH,
|
|
1776
|
+
height: DEFAULT_NODE_HEIGHT,
|
|
1777
|
+
};
|
|
1778
|
+
const newSize =
|
|
1779
|
+
getWorkflowSize(newStep) || {
|
|
1780
|
+
width: DEFAULT_NODE_WIDTH,
|
|
1781
|
+
height: DEFAULT_NODE_HEIGHT,
|
|
1782
|
+
};
|
|
1783
|
+
|
|
1784
|
+
const sourcePos = sourceStep.configuration?.workflow_position || {
|
|
1785
|
+
x: 0,
|
|
1786
|
+
y: 0,
|
|
1787
|
+
};
|
|
1788
|
+
|
|
1789
|
+
// Desired location: directly to the right of the source step
|
|
1790
|
+
const targetX = sourcePos.x + sourceSize.width + ADD_GAP;
|
|
1791
|
+
const targetY = sourcePos.y;
|
|
1792
|
+
|
|
1793
|
+
const updates = [];
|
|
1794
|
+
|
|
1795
|
+
// Push steps that are to the right of the insertion band
|
|
1796
|
+
const bandMinX = targetX - newSize.width * 0.25;
|
|
1797
|
+
const shiftX = newSize.width + ADD_GAP;
|
|
1798
|
+
|
|
1799
|
+
freshSteps.forEach((s) => {
|
|
1800
|
+
const cfgPos = s.configuration?.workflow_position;
|
|
1801
|
+
if (!cfgPos) return;
|
|
1802
|
+
if (String(s.id) === String(newStep.id)) return;
|
|
1803
|
+
if (cfgPos.x >= bandMinX) {
|
|
1804
|
+
updates.push({
|
|
1805
|
+
id: String(s.id),
|
|
1806
|
+
x: cfgPos.x + shiftX,
|
|
1807
|
+
y: cfgPos.y,
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
// Position for the new copied step
|
|
1813
|
+
updates.push({
|
|
1814
|
+
id: String(newStep.id),
|
|
1815
|
+
x: targetX,
|
|
1816
|
+
y: targetY,
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
if (!updates.length) return;
|
|
1820
|
+
|
|
1821
|
+
// Move nodes immediately in the UI
|
|
1822
|
+
applyLocalPositionUpdates(updates);
|
|
1823
|
+
|
|
1824
|
+
// Update local steps state with new positions
|
|
1825
|
+
const stepsWithPositions = freshSteps.map((s) => {
|
|
1826
|
+
const hit = updates.find((u) => u.id === String(s.id));
|
|
1827
|
+
if (!hit) return s;
|
|
1828
|
+
return {
|
|
1829
|
+
...s,
|
|
1830
|
+
configuration: {
|
|
1831
|
+
...(s.configuration || {}),
|
|
1832
|
+
workflow_position: { x: hit.x, y: hit.y },
|
|
1833
|
+
},
|
|
1834
|
+
};
|
|
1835
|
+
});
|
|
1836
|
+
|
|
1837
|
+
setSteps(stepsWithPositions);
|
|
1838
|
+
await persistPositions(updates);
|
|
1839
|
+
} catch (e) {
|
|
1840
|
+
setError(e.message);
|
|
1841
|
+
}
|
|
1842
|
+
},
|
|
1843
|
+
[
|
|
1844
|
+
data.csrfToken,
|
|
1845
|
+
data.urls.copy,
|
|
1846
|
+
fetchJson,
|
|
1847
|
+
applyLocalPositionUpdates,
|
|
1848
|
+
persistPositions,
|
|
1849
|
+
reload,
|
|
1850
|
+
]
|
|
1851
|
+
);
|
|
1852
|
+
|
|
1518
1853
|
const onClearNext = useCallback(
|
|
1519
1854
|
async (id) => {
|
|
1520
1855
|
await updateConnection({ step_id: id, next_step: "" });
|
|
@@ -1524,15 +1859,29 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1524
1859
|
);
|
|
1525
1860
|
|
|
1526
1861
|
const onDelete = useCallback(
|
|
1527
|
-
async (
|
|
1528
|
-
|
|
1862
|
+
async (ids) => {
|
|
1863
|
+
const normalizedIds = Array.isArray(ids) ? ids : [ids];
|
|
1864
|
+
const uniqueIds = [...new Set(normalizedIds.map((id) => String(id)))];
|
|
1865
|
+
if (!uniqueIds.length) return;
|
|
1866
|
+
|
|
1867
|
+
const singleConfirm = strings.confirmDelete || "Delete step?";
|
|
1868
|
+
const confirmMessage =
|
|
1869
|
+
uniqueIds.length > 1
|
|
1870
|
+
? strings.confirmDeleteMany ||
|
|
1871
|
+
`${singleConfirm} (${uniqueIds.length} steps)`
|
|
1872
|
+
: singleConfirm;
|
|
1873
|
+
|
|
1874
|
+
if (!window.confirm(confirmMessage)) return;
|
|
1875
|
+
|
|
1529
1876
|
try {
|
|
1530
|
-
const
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1877
|
+
for (const id of uniqueIds) {
|
|
1878
|
+
const formData = new FormData();
|
|
1879
|
+
formData.append("_csrf", data.csrfToken || "");
|
|
1880
|
+
await fetchJson(`${data.urls.deleteStep}/${id}`, {
|
|
1881
|
+
method: "POST",
|
|
1882
|
+
body: formData,
|
|
1883
|
+
});
|
|
1884
|
+
}
|
|
1536
1885
|
setModal(null);
|
|
1537
1886
|
setError("");
|
|
1538
1887
|
clearDrawerState();
|
|
@@ -1541,7 +1890,14 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1541
1890
|
setError(e.message);
|
|
1542
1891
|
}
|
|
1543
1892
|
},
|
|
1544
|
-
[
|
|
1893
|
+
[
|
|
1894
|
+
clearDrawerState,
|
|
1895
|
+
data.csrfToken,
|
|
1896
|
+
fetchJson,
|
|
1897
|
+
reload,
|
|
1898
|
+
strings.confirmDelete,
|
|
1899
|
+
strings.confirmDeleteMany,
|
|
1900
|
+
]
|
|
1545
1901
|
);
|
|
1546
1902
|
|
|
1547
1903
|
useEffect(() => {
|
|
@@ -1777,6 +2133,7 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1777
2133
|
error={error && modal ? error : ""}
|
|
1778
2134
|
data={data}
|
|
1779
2135
|
onDelete={onDelete}
|
|
2136
|
+
onCopy={onCopy}
|
|
1780
2137
|
drawerWidth={drawerWidth}
|
|
1781
2138
|
onResizeStart={startResize}
|
|
1782
2139
|
/>
|