@saltcorn/workflow-editor 1.6.0-alpha.8 → 1.6.0-beta.1
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/package.json +7 -5
- package/src/WorkflowEditor.jsx +706 -134
- package/src/workflow.css +2 -0
- package/dist/workflow_bundle.js.LICENSE.txt +0 -61
- package/dist/workflow_bundle.js.map +0 -1
package/src/WorkflowEditor.jsx
CHANGED
|
@@ -6,6 +6,7 @@ import React, {
|
|
|
6
6
|
useLayoutEffect,
|
|
7
7
|
useMemo,
|
|
8
8
|
} from "react";
|
|
9
|
+
import { createPortal } from "react-dom";
|
|
9
10
|
import {
|
|
10
11
|
Background,
|
|
11
12
|
Controls,
|
|
@@ -302,6 +303,11 @@ const buildGraph = (
|
|
|
302
303
|
const allStepNames = steps.map((s) => s.name).filter(Boolean);
|
|
303
304
|
const orderIndex = new Map(steps.map((s, idx) => [String(s.id), idx]));
|
|
304
305
|
|
|
306
|
+
const hasAnyWorkflowPosition = steps.some(
|
|
307
|
+
(s) => s?.configuration?.workflow_position
|
|
308
|
+
);
|
|
309
|
+
const shouldIndentLoopBodies = steps.length > 0 && !hasAnyWorkflowPosition;
|
|
310
|
+
|
|
305
311
|
const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
306
312
|
|
|
307
313
|
const extractNextStepNames = (rawNextStep) => {
|
|
@@ -415,6 +421,9 @@ const buildGraph = (
|
|
|
415
421
|
width: DEFAULT_NODE_WIDTH,
|
|
416
422
|
height: DEFAULT_NODE_HEIGHT,
|
|
417
423
|
};
|
|
424
|
+
const bodyX = shouldIndentLoopBodies
|
|
425
|
+
? (loopPos.x || 0) + (loopSize.width || DEFAULT_NODE_WIDTH) + H_GAP
|
|
426
|
+
: loopPos.x;
|
|
418
427
|
let yCursor = loopPos.y + loopSize.height + V_GAP;
|
|
419
428
|
|
|
420
429
|
body.forEach((s) => {
|
|
@@ -424,7 +433,7 @@ const buildGraph = (
|
|
|
424
433
|
height: DEFAULT_NODE_HEIGHT,
|
|
425
434
|
};
|
|
426
435
|
positionOverrides[String(s.id)] = {
|
|
427
|
-
x:
|
|
436
|
+
x: bodyX,
|
|
428
437
|
y: yCursor,
|
|
429
438
|
};
|
|
430
439
|
yCursor += bodySize.height + V_GAP;
|
|
@@ -487,9 +496,16 @@ const buildGraph = (
|
|
|
487
496
|
id: `e-start-${initial.id}`,
|
|
488
497
|
source: "start",
|
|
489
498
|
target: String(initial.id),
|
|
490
|
-
type: "
|
|
499
|
+
type: "workflow-main",
|
|
491
500
|
animated: true,
|
|
492
501
|
markerEnd: makeMarker(),
|
|
502
|
+
data: {
|
|
503
|
+
startLabel: strings.start,
|
|
504
|
+
sourceStepName: "start",
|
|
505
|
+
sourceId: "start",
|
|
506
|
+
targetId: String(initial.id),
|
|
507
|
+
canInsertBetween: true,
|
|
508
|
+
},
|
|
493
509
|
});
|
|
494
510
|
else {
|
|
495
511
|
const addId = "add-start";
|
|
@@ -620,7 +636,7 @@ const buildGraph = (
|
|
|
620
636
|
});
|
|
621
637
|
|
|
622
638
|
const addNodes = [];
|
|
623
|
-
if (!
|
|
639
|
+
if (!initial) {
|
|
624
640
|
const addId = "add-start";
|
|
625
641
|
addNodes.push({
|
|
626
642
|
id: addId,
|
|
@@ -706,10 +722,25 @@ const StepDrawer = ({
|
|
|
706
722
|
error,
|
|
707
723
|
data,
|
|
708
724
|
onDelete,
|
|
725
|
+
onCopy,
|
|
709
726
|
drawerWidth,
|
|
710
727
|
onResizeStart,
|
|
711
728
|
}) => {
|
|
712
729
|
const isOpen = !!modal;
|
|
730
|
+
const stopDrawerKeyPropagation = useCallback((e) => {
|
|
731
|
+
e.stopPropagation();
|
|
732
|
+
if (e?.nativeEvent?.stopImmediatePropagation)
|
|
733
|
+
e.nativeEvent.stopImmediatePropagation();
|
|
734
|
+
}, []);
|
|
735
|
+
const handleSaveClick = useCallback(() => {
|
|
736
|
+
const form = innerRef?.current?.querySelector("form");
|
|
737
|
+
if (!form) return;
|
|
738
|
+
if (typeof form.requestSubmit === "function") form.requestSubmit();
|
|
739
|
+
else
|
|
740
|
+
form.dispatchEvent(
|
|
741
|
+
new Event("submit", { cancelable: true, bubbles: true })
|
|
742
|
+
);
|
|
743
|
+
}, [innerRef]);
|
|
713
744
|
return (
|
|
714
745
|
<div
|
|
715
746
|
ref={outerRef}
|
|
@@ -719,6 +750,8 @@ const StepDrawer = ({
|
|
|
719
750
|
aria-hidden={!isOpen}
|
|
720
751
|
aria-label={modal?.title || data?.strings?.configure}
|
|
721
752
|
style={{ "--wf-drawer-width": `${drawerWidth}px` }}
|
|
753
|
+
onKeyDown={stopDrawerKeyPropagation}
|
|
754
|
+
onKeyUp={stopDrawerKeyPropagation}
|
|
722
755
|
>
|
|
723
756
|
{modal ? (
|
|
724
757
|
<>
|
|
@@ -743,27 +776,34 @@ const StepDrawer = ({
|
|
|
743
776
|
) : null}
|
|
744
777
|
</div>
|
|
745
778
|
<div className="wf-drawer__footer d-flex justify-content-end gap-2">
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
e
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
779
|
+
{modal.stepId && (
|
|
780
|
+
<button
|
|
781
|
+
className="btn btn-sm btn-outline-danger"
|
|
782
|
+
onClick={(e) => {
|
|
783
|
+
e.stopPropagation();
|
|
784
|
+
onDelete(modal.stepId);
|
|
785
|
+
}}
|
|
786
|
+
>
|
|
787
|
+
{data.strings.deleteStep}
|
|
788
|
+
</button>
|
|
789
|
+
)}
|
|
790
|
+
{modal.stepId ? (
|
|
791
|
+
<button
|
|
792
|
+
className="btn btn-sm btn-outline-secondary"
|
|
793
|
+
onClick={(e) => {
|
|
794
|
+
e.stopPropagation();
|
|
795
|
+
onCopy(modal.stepId);
|
|
796
|
+
}}
|
|
797
|
+
>
|
|
798
|
+
{data.strings.copyStep || "Copy"}
|
|
799
|
+
</button>
|
|
800
|
+
) : null}
|
|
755
801
|
<button className="btn btn-secondary" onClick={onClose}>
|
|
756
802
|
Close
|
|
757
803
|
</button>
|
|
758
804
|
<button
|
|
759
805
|
className="btn btn-primary"
|
|
760
|
-
onClick={
|
|
761
|
-
innerRef.current
|
|
762
|
-
?.querySelector("form")
|
|
763
|
-
?.dispatchEvent(
|
|
764
|
-
new Event("submit", { cancelable: true, bubbles: true })
|
|
765
|
-
)
|
|
766
|
-
}
|
|
806
|
+
onClick={handleSaveClick}
|
|
767
807
|
disabled={submitting}
|
|
768
808
|
>
|
|
769
809
|
{submitting ? "Saving..." : "Save"}
|
|
@@ -807,6 +847,15 @@ const WorkflowEditor = ({ data }) => {
|
|
|
807
847
|
const resizingRef = useRef(false);
|
|
808
848
|
const connectSourceRef = useRef(null);
|
|
809
849
|
const [selectedEdgeId, setSelectedEdgeId] = useState(null);
|
|
850
|
+
const autoSaveTimerRef = useRef(null);
|
|
851
|
+
const autoSavingRef = useRef(false);
|
|
852
|
+
const lastAutosaveSnapshotRef = useRef("");
|
|
853
|
+
const pendingAutosaveSnapshotRef = useRef("");
|
|
854
|
+
const nodesRef = useRef([]);
|
|
855
|
+
const [headerHost, setHeaderHost] = useState(null);
|
|
856
|
+
const [shellHeight, setShellHeight] = useState(null);
|
|
857
|
+
const lastHeightRef = useRef(null);
|
|
858
|
+
const heightDebounceRef = useRef(null);
|
|
810
859
|
|
|
811
860
|
const drawerStorageKey = useMemo(
|
|
812
861
|
() => `wf-drawer:${data?.trigger?.id || "default"}`,
|
|
@@ -819,6 +868,57 @@ const WorkflowEditor = ({ data }) => {
|
|
|
819
868
|
);
|
|
820
869
|
|
|
821
870
|
const rfInstanceRef = useRef(null);
|
|
871
|
+
const headerHtmlRef = useRef("");
|
|
872
|
+
|
|
873
|
+
const recomputeShellHeight = useCallback(() => {
|
|
874
|
+
if (typeof window === "undefined") return;
|
|
875
|
+
const shellEl = shellRef.current;
|
|
876
|
+
if (!shellEl) return;
|
|
877
|
+
|
|
878
|
+
const wrapperEl = shellEl.closest(".workflow-editor-wrapper");
|
|
879
|
+
if (!wrapperEl) return;
|
|
880
|
+
|
|
881
|
+
const afterEl = wrapperEl.nextElementSibling;
|
|
882
|
+
const cardBodyEl = shellEl.closest(".card-body");
|
|
883
|
+
if (!cardBodyEl) return;
|
|
884
|
+
|
|
885
|
+
const minHeight = 400;
|
|
886
|
+
const bottomMargin = 28;
|
|
887
|
+
|
|
888
|
+
const cardBodyRect = cardBodyEl.getBoundingClientRect();
|
|
889
|
+
const styles = window.getComputedStyle(cardBodyEl);
|
|
890
|
+
const cardBodyPaddingTop = parseFloat(styles.paddingTop) || 0;
|
|
891
|
+
const cardBodyPaddingBottom = parseFloat(styles.paddingBottom) || 0;
|
|
892
|
+
|
|
893
|
+
const effectiveTop = Math.max(0, cardBodyRect.top);
|
|
894
|
+
|
|
895
|
+
let footerHeight = 0;
|
|
896
|
+
if (afterEl && typeof afterEl.getBoundingClientRect === "function") {
|
|
897
|
+
const afterRect = afterEl.getBoundingClientRect();
|
|
898
|
+
const afterStyles = window.getComputedStyle(afterEl);
|
|
899
|
+
const mt = parseFloat(afterStyles.marginTop) || 0;
|
|
900
|
+
const mb = parseFloat(afterStyles.marginBottom) || 0;
|
|
901
|
+
footerHeight = afterRect.height + mt + mb;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const availableHeight =
|
|
905
|
+
window.innerHeight -
|
|
906
|
+
effectiveTop -
|
|
907
|
+
bottomMargin -
|
|
908
|
+
cardBodyPaddingTop -
|
|
909
|
+
cardBodyPaddingBottom -
|
|
910
|
+
footerHeight;
|
|
911
|
+
|
|
912
|
+
const wrapperHeight = Math.max(minHeight, availableHeight);
|
|
913
|
+
|
|
914
|
+
if (lastHeightRef.current !== wrapperHeight) {
|
|
915
|
+
lastHeightRef.current = wrapperHeight;
|
|
916
|
+
if (wrapperEl) {
|
|
917
|
+
wrapperEl.style.height = `${wrapperHeight}px`;
|
|
918
|
+
}
|
|
919
|
+
if (Number.isFinite(wrapperHeight)) setShellHeight(wrapperHeight);
|
|
920
|
+
}
|
|
921
|
+
}, []);
|
|
822
922
|
|
|
823
923
|
const persistDrawerState = useCallback(
|
|
824
924
|
(payload) => {
|
|
@@ -888,6 +988,24 @@ const WorkflowEditor = ({ data }) => {
|
|
|
888
988
|
[viewportStorageKey]
|
|
889
989
|
);
|
|
890
990
|
|
|
991
|
+
const serializeForm = useCallback((formEl) => {
|
|
992
|
+
if (!formEl) return "";
|
|
993
|
+
const fd = new FormData(formEl);
|
|
994
|
+
const entries = [];
|
|
995
|
+
for (const [key, value] of fd.entries()) {
|
|
996
|
+
if (value instanceof File) {
|
|
997
|
+
entries.push([key, `file:${value.name}:${value.size}`]);
|
|
998
|
+
} else {
|
|
999
|
+
entries.push([key, `${value}`]);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
entries.sort((a, b) => {
|
|
1003
|
+
if (a[0] === b[0]) return a[1].localeCompare(b[1]);
|
|
1004
|
+
return a[0].localeCompare(b[0]);
|
|
1005
|
+
});
|
|
1006
|
+
return JSON.stringify(entries);
|
|
1007
|
+
}, []);
|
|
1008
|
+
|
|
891
1009
|
const onInit = useCallback((instance) => {
|
|
892
1010
|
rfInstanceRef.current = instance;
|
|
893
1011
|
}, []);
|
|
@@ -914,10 +1032,12 @@ const WorkflowEditor = ({ data }) => {
|
|
|
914
1032
|
)
|
|
915
1033
|
return;
|
|
916
1034
|
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
|
917
|
-
const
|
|
918
|
-
|
|
1035
|
+
const stepIds = selectedNodes
|
|
1036
|
+
.filter((n) => n.type === "step")
|
|
1037
|
+
.map((n) => n.id);
|
|
1038
|
+
if (!stepIds.length) return;
|
|
919
1039
|
e.preventDefault();
|
|
920
|
-
onDelete(
|
|
1040
|
+
onDelete(stepIds);
|
|
921
1041
|
},
|
|
922
1042
|
[modal, onDelete, selectedNodes]
|
|
923
1043
|
);
|
|
@@ -953,6 +1073,10 @@ const WorkflowEditor = ({ data }) => {
|
|
|
953
1073
|
refreshGraph(steps);
|
|
954
1074
|
}, [steps, refreshGraph]);
|
|
955
1075
|
|
|
1076
|
+
useEffect(() => {
|
|
1077
|
+
nodesRef.current = nodes;
|
|
1078
|
+
}, [nodes]);
|
|
1079
|
+
|
|
956
1080
|
useEffect(() => {
|
|
957
1081
|
if (!rfInstanceRef.current) return;
|
|
958
1082
|
if (!pendingViewportRef.current) return;
|
|
@@ -1070,25 +1194,33 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1070
1194
|
setError("");
|
|
1071
1195
|
try {
|
|
1072
1196
|
let fresh = await fetchJson(data.urls.data);
|
|
1197
|
+
let updatedSteps = null;
|
|
1073
1198
|
|
|
1074
1199
|
// If a step was just added via an adder node, wire up next_step
|
|
1075
1200
|
if (pendingAddRef.current) {
|
|
1076
|
-
const { afterStepId, prevIds, insertBetween } =
|
|
1201
|
+
const { afterStepId, prevIds, insertBetween, fromStart } =
|
|
1202
|
+
pendingAddRef.current;
|
|
1077
1203
|
const newSteps = (fresh.steps || []).filter(
|
|
1078
1204
|
(s) => !prevIds.has(String(s.id))
|
|
1079
1205
|
);
|
|
1080
1206
|
if (newSteps.length === 1) {
|
|
1081
1207
|
const newStep = newSteps[0];
|
|
1082
1208
|
if (insertBetween && insertBetween.originalTargetName) {
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1209
|
+
if (fromStart) {
|
|
1210
|
+
await updateConnection({
|
|
1211
|
+
step_id: newStep.id,
|
|
1212
|
+
initial_step: true,
|
|
1213
|
+
});
|
|
1214
|
+
} else {
|
|
1215
|
+
await updateConnection({
|
|
1216
|
+
step_id: newStep.id,
|
|
1217
|
+
next_step: insertBetween.originalTargetName,
|
|
1218
|
+
});
|
|
1219
|
+
await updateConnection({
|
|
1220
|
+
step_id: afterStepId,
|
|
1221
|
+
next_step: newStep.name,
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1092
1224
|
} else {
|
|
1093
1225
|
await updateConnection({
|
|
1094
1226
|
step_id: afterStepId,
|
|
@@ -1098,20 +1230,38 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1098
1230
|
|
|
1099
1231
|
// Re-fetch so we get the updated connections reflected in steps
|
|
1100
1232
|
fresh = await fetchJson(data.urls.data);
|
|
1233
|
+
|
|
1234
|
+
if (insertBetween && insertBetween.originalTargetName) {
|
|
1235
|
+
updatedSteps = await applyInsertBetweenLayout(fresh.steps || [], {
|
|
1236
|
+
newStep,
|
|
1237
|
+
afterStepId,
|
|
1238
|
+
targetName: insertBetween.originalTargetName,
|
|
1239
|
+
targetId: insertBetween.targetId,
|
|
1240
|
+
snapshot: insertBetween.positionsSnapshot,
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1101
1243
|
}
|
|
1102
1244
|
pendingAddRef.current = null;
|
|
1103
1245
|
}
|
|
1104
1246
|
|
|
1105
|
-
|
|
1247
|
+
const nextSteps = updatedSteps || fresh.steps || [];
|
|
1248
|
+
setSteps(nextSteps);
|
|
1106
1249
|
|
|
1107
1250
|
setMessage(strings.refresh);
|
|
1108
1251
|
setTimeout(() => setMessage(""), 1200);
|
|
1252
|
+
return nextSteps;
|
|
1109
1253
|
} catch (e) {
|
|
1110
1254
|
setError(e.message);
|
|
1111
1255
|
} finally {
|
|
1112
1256
|
setLoading(false);
|
|
1113
1257
|
}
|
|
1114
|
-
}, [
|
|
1258
|
+
}, [
|
|
1259
|
+
applyInsertBetweenLayout,
|
|
1260
|
+
data.urls.data,
|
|
1261
|
+
fetchJson,
|
|
1262
|
+
strings.refresh,
|
|
1263
|
+
updateConnection,
|
|
1264
|
+
]);
|
|
1115
1265
|
|
|
1116
1266
|
const persistPositions = useCallback(
|
|
1117
1267
|
async (positions = []) => {
|
|
@@ -1165,6 +1315,104 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1165
1315
|
[persistPositions]
|
|
1166
1316
|
);
|
|
1167
1317
|
|
|
1318
|
+
const applyLocalPositionUpdates = useCallback(
|
|
1319
|
+
(updates = []) => {
|
|
1320
|
+
if (!updates.length) return;
|
|
1321
|
+
setNodes((nds) =>
|
|
1322
|
+
nds.map((n) => {
|
|
1323
|
+
const hit = updates.find((u) => String(u.id) === String(n.id));
|
|
1324
|
+
return hit
|
|
1325
|
+
? { ...n, position: { ...(n.position || {}), x: hit.x, y: hit.y } }
|
|
1326
|
+
: n;
|
|
1327
|
+
})
|
|
1328
|
+
);
|
|
1329
|
+
},
|
|
1330
|
+
[setNodes]
|
|
1331
|
+
);
|
|
1332
|
+
|
|
1333
|
+
const applyInsertBetweenLayout = useCallback(
|
|
1334
|
+
async (
|
|
1335
|
+
freshSteps,
|
|
1336
|
+
{ newStep, afterStepId, targetName, targetId, snapshot }
|
|
1337
|
+
) => {
|
|
1338
|
+
if (!newStep || !snapshot) return null;
|
|
1339
|
+
const snap = snapshot || {};
|
|
1340
|
+
const newStepSize = getWorkflowSize(newStep) || {
|
|
1341
|
+
width: DEFAULT_NODE_WIDTH,
|
|
1342
|
+
height: DEFAULT_NODE_HEIGHT,
|
|
1343
|
+
};
|
|
1344
|
+
const sourceSnap = snap[String(afterStepId)];
|
|
1345
|
+
const resolvedTargetId =
|
|
1346
|
+
targetId || freshSteps.find((s) => s.name === targetName)?.id;
|
|
1347
|
+
const targetSnap = resolvedTargetId
|
|
1348
|
+
? snap[String(resolvedTargetId)]
|
|
1349
|
+
: null;
|
|
1350
|
+
const shiftAmount = newStepSize.height + V_GAP;
|
|
1351
|
+
const fallbackY = sourceSnap?.y ?? 0;
|
|
1352
|
+
const fallbackX = sourceSnap?.x ?? targetSnap?.x ?? 0;
|
|
1353
|
+
const newY = targetSnap?.y ?? fallbackY;
|
|
1354
|
+
const newX =
|
|
1355
|
+
sourceSnap && targetSnap
|
|
1356
|
+
? (sourceSnap.x + targetSnap.x) / 2
|
|
1357
|
+
: (targetSnap?.x ?? sourceSnap?.x ?? fallbackX);
|
|
1358
|
+
|
|
1359
|
+
const newHalfWidth = (newStepSize.width || DEFAULT_NODE_WIDTH) / 2;
|
|
1360
|
+
const bandPadding = H_GAP / 2;
|
|
1361
|
+
const bandMinX = newX - newHalfWidth - bandPadding;
|
|
1362
|
+
const bandMaxX = newX + newHalfWidth + bandPadding;
|
|
1363
|
+
|
|
1364
|
+
const updates = [];
|
|
1365
|
+
freshSteps.forEach((step) => {
|
|
1366
|
+
const idStr = String(step.id);
|
|
1367
|
+
const nodeSnap = snap[idStr];
|
|
1368
|
+
|
|
1369
|
+
if (idStr === String(newStep.id)) {
|
|
1370
|
+
updates.push({ id: idStr, x: newX, y: newY });
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
if (!nodeSnap) return;
|
|
1375
|
+
|
|
1376
|
+
const nodeWidth = nodeSnap.width || DEFAULT_NODE_WIDTH;
|
|
1377
|
+
const nodeHalfWidth = nodeWidth / 2;
|
|
1378
|
+
const nodeMinX = nodeSnap.x - nodeHalfWidth;
|
|
1379
|
+
const nodeMaxX = nodeSnap.x + nodeHalfWidth;
|
|
1380
|
+
const overlapsBand = !(nodeMaxX < bandMinX || nodeMinX > bandMaxX);
|
|
1381
|
+
const shouldShift =
|
|
1382
|
+
nodeSnap.y >= newY && idStr !== String(afterStepId) && overlapsBand;
|
|
1383
|
+
|
|
1384
|
+
if (shouldShift) {
|
|
1385
|
+
updates.push({
|
|
1386
|
+
id: idStr,
|
|
1387
|
+
x: nodeSnap.x,
|
|
1388
|
+
y: nodeSnap.y + shiftAmount,
|
|
1389
|
+
});
|
|
1390
|
+
} else {
|
|
1391
|
+
updates.push({ id: idStr, x: nodeSnap.x, y: nodeSnap.y });
|
|
1392
|
+
}
|
|
1393
|
+
});
|
|
1394
|
+
if (!updates.length) return freshSteps;
|
|
1395
|
+
|
|
1396
|
+
applyLocalPositionUpdates(updates);
|
|
1397
|
+
|
|
1398
|
+
const stepsWithPositions = freshSteps.map((s) => {
|
|
1399
|
+
const hit = updates.find((u) => String(u.id) === String(s.id));
|
|
1400
|
+
if (!hit) return s;
|
|
1401
|
+
return {
|
|
1402
|
+
...s,
|
|
1403
|
+
configuration: {
|
|
1404
|
+
...(s.configuration || {}),
|
|
1405
|
+
workflow_position: { x: hit.x, y: hit.y },
|
|
1406
|
+
},
|
|
1407
|
+
};
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
await persistPositions(updates);
|
|
1411
|
+
return stepsWithPositions;
|
|
1412
|
+
},
|
|
1413
|
+
[applyLocalPositionUpdates, persistPositions]
|
|
1414
|
+
);
|
|
1415
|
+
|
|
1168
1416
|
useEffect(() => {
|
|
1169
1417
|
return () => {
|
|
1170
1418
|
if (positionDebounceRef.current)
|
|
@@ -1220,6 +1468,7 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1220
1468
|
const base = `${data.urls.stepForm}${stepId ? `/${stepId}` : ""}`;
|
|
1221
1469
|
const url = new URL(base, window.location.origin);
|
|
1222
1470
|
url.searchParams.set("render", "dialog");
|
|
1471
|
+
url.searchParams.set("vw", window.innerWidth);
|
|
1223
1472
|
if (initial_step) url.searchParams.set("initial_step", "true");
|
|
1224
1473
|
if (after_step) url.searchParams.set("after_step", after_step);
|
|
1225
1474
|
const res = await fetchJson(url.toString());
|
|
@@ -1232,8 +1481,8 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1232
1481
|
[clearDrawerState, data.urls.stepForm, fetchJson, persistDrawerState]
|
|
1233
1482
|
);
|
|
1234
1483
|
|
|
1235
|
-
const
|
|
1236
|
-
async (formEl) => {
|
|
1484
|
+
const submitStepForm = useCallback(
|
|
1485
|
+
async (formEl, { closeOnSuccess = true } = {}) => {
|
|
1237
1486
|
if (!formEl) return;
|
|
1238
1487
|
setSavingModal(true);
|
|
1239
1488
|
setError("");
|
|
@@ -1249,9 +1498,12 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1249
1498
|
});
|
|
1250
1499
|
const json = await res.json();
|
|
1251
1500
|
if (json.error) throw new Error(json.error);
|
|
1252
|
-
|
|
1253
|
-
|
|
1501
|
+
if (closeOnSuccess) {
|
|
1502
|
+
setModal(null);
|
|
1503
|
+
clearDrawerState();
|
|
1504
|
+
}
|
|
1254
1505
|
await reload();
|
|
1506
|
+
setSavingModal(false);
|
|
1255
1507
|
},
|
|
1256
1508
|
[clearDrawerState, data.csrfToken, data.urls.stepForm, reload]
|
|
1257
1509
|
);
|
|
@@ -1260,6 +1512,8 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1260
1512
|
if (!modal) return undefined;
|
|
1261
1513
|
const formEl = modalRef.current?.querySelector("form");
|
|
1262
1514
|
if (!formEl) return undefined;
|
|
1515
|
+
lastAutosaveSnapshotRef.current = serializeForm(formEl);
|
|
1516
|
+
pendingAutosaveSnapshotRef.current = "";
|
|
1263
1517
|
// Re-run show-if logic so action-specific fields render correctly
|
|
1264
1518
|
setTimeout(() => {
|
|
1265
1519
|
if (typeof window !== "undefined") {
|
|
@@ -1270,17 +1524,74 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1270
1524
|
}, 0);
|
|
1271
1525
|
|
|
1272
1526
|
const actionSelect = formEl.querySelector('[name="wf_action_name"]');
|
|
1527
|
+
const allowAutoSave = !!modal?.stepId; // avoid auto-save for brand new steps to prevent insert errors
|
|
1528
|
+
|
|
1529
|
+
const shouldAutoSaveForAction = () => !!allowAutoSave;
|
|
1530
|
+
|
|
1531
|
+
const cancelPendingAutoSave = () => {
|
|
1532
|
+
if (autoSaveTimerRef.current) {
|
|
1533
|
+
clearTimeout(autoSaveTimerRef.current);
|
|
1534
|
+
autoSaveTimerRef.current = null;
|
|
1535
|
+
}
|
|
1536
|
+
pendingAutosaveSnapshotRef.current = "";
|
|
1537
|
+
};
|
|
1538
|
+
|
|
1273
1539
|
const handleActionChange = () => {
|
|
1274
1540
|
if (typeof window !== "undefined" && window.apply_showif)
|
|
1275
1541
|
window.apply_showif();
|
|
1542
|
+
if (!shouldAutoSaveForAction()) cancelPendingAutoSave();
|
|
1276
1543
|
};
|
|
1277
1544
|
if (actionSelect)
|
|
1278
1545
|
actionSelect.addEventListener("change", handleActionChange);
|
|
1279
1546
|
|
|
1547
|
+
const scheduleAutoSave = allowAutoSave
|
|
1548
|
+
? () => {
|
|
1549
|
+
if (!shouldAutoSaveForAction()) return;
|
|
1550
|
+
if (autoSavingRef.current) return;
|
|
1551
|
+
const snapshot = serializeForm(formEl);
|
|
1552
|
+
if (!snapshot) return;
|
|
1553
|
+
if (snapshot === lastAutosaveSnapshotRef.current) return;
|
|
1554
|
+
if (snapshot === pendingAutosaveSnapshotRef.current) return;
|
|
1555
|
+
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
|
|
1556
|
+
pendingAutosaveSnapshotRef.current = snapshot;
|
|
1557
|
+
autoSaveTimerRef.current = setTimeout(async () => {
|
|
1558
|
+
pendingAutosaveSnapshotRef.current = "";
|
|
1559
|
+
const currentSnapshot = serializeForm(formEl);
|
|
1560
|
+
if (!currentSnapshot) return;
|
|
1561
|
+
if (currentSnapshot === lastAutosaveSnapshotRef.current) return;
|
|
1562
|
+
autoSavingRef.current = true;
|
|
1563
|
+
try {
|
|
1564
|
+
await submitStepForm(formEl, { closeOnSuccess: false });
|
|
1565
|
+
lastAutosaveSnapshotRef.current = currentSnapshot;
|
|
1566
|
+
} catch (err) {
|
|
1567
|
+
setError(err.message);
|
|
1568
|
+
} finally {
|
|
1569
|
+
autoSavingRef.current = false;
|
|
1570
|
+
setSavingModal(false);
|
|
1571
|
+
}
|
|
1572
|
+
}, 800);
|
|
1573
|
+
}
|
|
1574
|
+
: null;
|
|
1575
|
+
|
|
1280
1576
|
const submitHandler = async (e) => {
|
|
1281
1577
|
e.preventDefault();
|
|
1578
|
+
const currentSnapshot = serializeForm(formEl);
|
|
1579
|
+
const alreadySaved =
|
|
1580
|
+
shouldAutoSaveForAction() &&
|
|
1581
|
+
!!lastAutosaveSnapshotRef.current &&
|
|
1582
|
+
currentSnapshot === lastAutosaveSnapshotRef.current;
|
|
1583
|
+
|
|
1584
|
+
if (alreadySaved) {
|
|
1585
|
+
setModal(null);
|
|
1586
|
+
clearDrawerState();
|
|
1587
|
+
setSavingModal(false);
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1282
1591
|
try {
|
|
1283
|
-
|
|
1592
|
+
cancelPendingAutoSave();
|
|
1593
|
+
await submitStepForm(formEl);
|
|
1594
|
+
lastAutosaveSnapshotRef.current = currentSnapshot;
|
|
1284
1595
|
} catch (err) {
|
|
1285
1596
|
setError(err.message);
|
|
1286
1597
|
} finally {
|
|
@@ -1288,12 +1599,22 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1288
1599
|
}
|
|
1289
1600
|
};
|
|
1290
1601
|
formEl.addEventListener("submit", submitHandler);
|
|
1602
|
+
if (scheduleAutoSave) {
|
|
1603
|
+
formEl.addEventListener("input", scheduleAutoSave);
|
|
1604
|
+
formEl.addEventListener("change", scheduleAutoSave);
|
|
1605
|
+
}
|
|
1291
1606
|
return () => {
|
|
1607
|
+
cancelPendingAutoSave();
|
|
1292
1608
|
formEl.removeEventListener("submit", submitHandler);
|
|
1609
|
+
if (scheduleAutoSave) {
|
|
1610
|
+
formEl.removeEventListener("input", scheduleAutoSave);
|
|
1611
|
+
formEl.removeEventListener("change", scheduleAutoSave);
|
|
1612
|
+
}
|
|
1293
1613
|
if (actionSelect)
|
|
1294
1614
|
actionSelect.removeEventListener("change", handleActionChange);
|
|
1615
|
+
lastAutosaveSnapshotRef.current = "";
|
|
1295
1616
|
};
|
|
1296
|
-
}, [
|
|
1617
|
+
}, [modal, serializeForm, submitStepForm, clearDrawerState, setModal]);
|
|
1297
1618
|
|
|
1298
1619
|
useEffect(() => {
|
|
1299
1620
|
if (typeof window === "undefined") return undefined;
|
|
@@ -1400,16 +1721,32 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1400
1721
|
if (targetEl?.classList?.contains("react-flow__pane")) {
|
|
1401
1722
|
// Dropped on empty space: clear next_step mark as final/unconnected
|
|
1402
1723
|
if (pending.nodeId && pending.handleId !== "loop-out") {
|
|
1403
|
-
|
|
1724
|
+
if (pending.nodeId === "start") {
|
|
1725
|
+
const currentInitial = steps.find((s) => s.initial_step);
|
|
1726
|
+
if (currentInitial) {
|
|
1727
|
+
updateConnection({
|
|
1728
|
+
step_id: currentInitial.id,
|
|
1729
|
+
initial_step: false,
|
|
1730
|
+
})
|
|
1731
|
+
.then(() => reload())
|
|
1732
|
+
.catch((e) => setError(e.message));
|
|
1733
|
+
}
|
|
1734
|
+
} else {
|
|
1735
|
+
onClearNext(pending.nodeId);
|
|
1736
|
+
}
|
|
1404
1737
|
}
|
|
1405
1738
|
}
|
|
1406
1739
|
},
|
|
1407
|
-
[onClearNext]
|
|
1740
|
+
[onClearNext, reload, steps, updateConnection]
|
|
1408
1741
|
);
|
|
1409
1742
|
|
|
1410
1743
|
const onEdgesChange = useCallback(
|
|
1411
1744
|
(changes) => {
|
|
1412
|
-
|
|
1745
|
+
// Block edge deletions while a modal is open so ReactFlow cannot remove links under dialogs
|
|
1746
|
+
const safeChanges = modal
|
|
1747
|
+
? changes.filter((c) => c.type !== "remove")
|
|
1748
|
+
: changes;
|
|
1749
|
+
const removed = safeChanges.filter((c) => c.type === "remove");
|
|
1413
1750
|
if (removed.length) {
|
|
1414
1751
|
const updates = [];
|
|
1415
1752
|
removed.forEach((chg) => {
|
|
@@ -1438,13 +1775,18 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1438
1775
|
.then(() => reload())
|
|
1439
1776
|
.catch((e) => setError(e.message));
|
|
1440
1777
|
}
|
|
1441
|
-
rfOnEdgesChange(
|
|
1778
|
+
rfOnEdgesChange(safeChanges);
|
|
1442
1779
|
},
|
|
1443
|
-
[edges, reload, rfOnEdgesChange, updateConnection]
|
|
1780
|
+
[edges, modal, reload, rfOnEdgesChange, updateConnection]
|
|
1444
1781
|
);
|
|
1445
1782
|
|
|
1446
1783
|
const onNodesChangeWrapped = useCallback(
|
|
1447
1784
|
(changes) => {
|
|
1785
|
+
// Prevent node deletion by reactflow while drawer is open
|
|
1786
|
+
const safeChanges = modal
|
|
1787
|
+
? changes.filter((c) => c.type !== "remove")
|
|
1788
|
+
: changes;
|
|
1789
|
+
|
|
1448
1790
|
const finishedPositions = changes
|
|
1449
1791
|
.filter((c) => c.type === "position" && c.position && c.dragging)
|
|
1450
1792
|
.map((c) => ({ id: c.id, x: c.position.x, y: c.position.y }));
|
|
@@ -1472,9 +1814,15 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1472
1814
|
);
|
|
1473
1815
|
if (stepPositions.length) schedulePersistPositions(stepPositions);
|
|
1474
1816
|
}
|
|
1475
|
-
onNodesChange(
|
|
1817
|
+
onNodesChange(safeChanges);
|
|
1476
1818
|
},
|
|
1477
|
-
[
|
|
1819
|
+
[
|
|
1820
|
+
modal,
|
|
1821
|
+
onNodesChange,
|
|
1822
|
+
schedulePersistPositions,
|
|
1823
|
+
setAddPositions,
|
|
1824
|
+
setStartPosition,
|
|
1825
|
+
]
|
|
1478
1826
|
);
|
|
1479
1827
|
|
|
1480
1828
|
const onAddAfter = useCallback(
|
|
@@ -1494,15 +1842,45 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1494
1842
|
|
|
1495
1843
|
const onInsertBetween = useCallback(
|
|
1496
1844
|
({ source, target }) => {
|
|
1497
|
-
if (!source || !target
|
|
1845
|
+
if (!source || !target) return;
|
|
1498
1846
|
const originalTargetName = nameById[target];
|
|
1499
1847
|
if (!originalTargetName) return;
|
|
1848
|
+
const fromStart = source === "start";
|
|
1849
|
+
const sizeById = new Map(
|
|
1850
|
+
steps.map((s) => [
|
|
1851
|
+
String(s.id),
|
|
1852
|
+
getWorkflowSize(s) || {
|
|
1853
|
+
width: DEFAULT_NODE_WIDTH,
|
|
1854
|
+
height: DEFAULT_NODE_HEIGHT,
|
|
1855
|
+
},
|
|
1856
|
+
])
|
|
1857
|
+
);
|
|
1858
|
+
const positionsSnapshot = {};
|
|
1859
|
+
nodesRef.current
|
|
1860
|
+
.filter((n) => n.type === "step")
|
|
1861
|
+
.forEach((n) => {
|
|
1862
|
+
const size = sizeById.get(String(n.id)) || {
|
|
1863
|
+
width: DEFAULT_NODE_WIDTH,
|
|
1864
|
+
height: DEFAULT_NODE_HEIGHT,
|
|
1865
|
+
};
|
|
1866
|
+
positionsSnapshot[String(n.id)] = {
|
|
1867
|
+
x: n.position?.x ?? 0,
|
|
1868
|
+
y: n.position?.y ?? 0,
|
|
1869
|
+
width: size.width || DEFAULT_NODE_WIDTH,
|
|
1870
|
+
height: size.height || DEFAULT_NODE_HEIGHT,
|
|
1871
|
+
};
|
|
1872
|
+
});
|
|
1500
1873
|
pendingAddRef.current = {
|
|
1501
|
-
afterStepId: source,
|
|
1874
|
+
afterStepId: fromStart ? "start" : source,
|
|
1502
1875
|
prevIds: new Set(steps.map((s) => String(s.id))),
|
|
1503
|
-
|
|
1876
|
+
fromStart,
|
|
1877
|
+
insertBetween: {
|
|
1878
|
+
originalTargetName,
|
|
1879
|
+
targetId: target,
|
|
1880
|
+
positionsSnapshot,
|
|
1881
|
+
},
|
|
1504
1882
|
};
|
|
1505
|
-
openStepForm({ after_step: source });
|
|
1883
|
+
openStepForm(fromStart ? { initial_step: true } : { after_step: source });
|
|
1506
1884
|
},
|
|
1507
1885
|
[nameById, openStepForm, steps]
|
|
1508
1886
|
);
|
|
@@ -1515,6 +1893,127 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1515
1893
|
[reload, updateConnection]
|
|
1516
1894
|
);
|
|
1517
1895
|
|
|
1896
|
+
const onCopy = useCallback(
|
|
1897
|
+
async (id) => {
|
|
1898
|
+
try {
|
|
1899
|
+
setError("");
|
|
1900
|
+
const res = await fetchJson(data.urls.copy, {
|
|
1901
|
+
method: "POST",
|
|
1902
|
+
body: JSON.stringify({ step_id: id }),
|
|
1903
|
+
headers: {
|
|
1904
|
+
"X-CSRF-Token": data.csrfToken || "",
|
|
1905
|
+
},
|
|
1906
|
+
});
|
|
1907
|
+
|
|
1908
|
+
if (!res || res.success !== "ok" || !res.step) {
|
|
1909
|
+
await reload();
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
const copiedStep = res.step;
|
|
1914
|
+
const copiedName = copiedStep.name;
|
|
1915
|
+
|
|
1916
|
+
// Reload full workflow data so the new step has
|
|
1917
|
+
// the same serialized shape as all other steps
|
|
1918
|
+
const freshSteps = (await reload()) || [];
|
|
1919
|
+
if (!freshSteps.length) return;
|
|
1920
|
+
|
|
1921
|
+
const sourceStep = freshSteps.find((s) => String(s.id) === String(id));
|
|
1922
|
+
const newStep = freshSteps.find((s) => s.name === copiedName);
|
|
1923
|
+
|
|
1924
|
+
if (!sourceStep || !newStep || !newStep.id) return;
|
|
1925
|
+
|
|
1926
|
+
const sourceSize = getWorkflowSize(sourceStep) || {
|
|
1927
|
+
width: DEFAULT_NODE_WIDTH,
|
|
1928
|
+
height: DEFAULT_NODE_HEIGHT,
|
|
1929
|
+
};
|
|
1930
|
+
const newSize = getWorkflowSize(newStep) || {
|
|
1931
|
+
width: DEFAULT_NODE_WIDTH,
|
|
1932
|
+
height: DEFAULT_NODE_HEIGHT,
|
|
1933
|
+
};
|
|
1934
|
+
|
|
1935
|
+
const sourcePos = sourceStep.configuration?.workflow_position || {
|
|
1936
|
+
x: 0,
|
|
1937
|
+
y: 0,
|
|
1938
|
+
};
|
|
1939
|
+
|
|
1940
|
+
// Desired location: directly to the right of the source step
|
|
1941
|
+
const targetX = sourcePos.x + sourceSize.width + ADD_GAP;
|
|
1942
|
+
const targetY = sourcePos.y;
|
|
1943
|
+
|
|
1944
|
+
const updates = [];
|
|
1945
|
+
|
|
1946
|
+
// Push steps that are to the right of the insertion band
|
|
1947
|
+
const bandMinX = targetX - newSize.width * 0.25;
|
|
1948
|
+
const shiftX = newSize.width + ADD_GAP;
|
|
1949
|
+
const newHalfHeight = (newSize.height || DEFAULT_NODE_HEIGHT) / 2;
|
|
1950
|
+
const bandPaddingY = V_GAP / 2;
|
|
1951
|
+
const bandMinY = targetY - newHalfHeight - bandPaddingY;
|
|
1952
|
+
const bandMaxY = targetY + newHalfHeight + bandPaddingY;
|
|
1953
|
+
|
|
1954
|
+
freshSteps.forEach((s) => {
|
|
1955
|
+
const cfgPos = s.configuration?.workflow_position;
|
|
1956
|
+
if (!cfgPos) return;
|
|
1957
|
+
if (String(s.id) === String(newStep.id)) return;
|
|
1958
|
+
const stepSize = getWorkflowSize(s) || {
|
|
1959
|
+
width: DEFAULT_NODE_WIDTH,
|
|
1960
|
+
height: DEFAULT_NODE_HEIGHT,
|
|
1961
|
+
};
|
|
1962
|
+
const halfHeight = (stepSize.height || DEFAULT_NODE_HEIGHT) / 2;
|
|
1963
|
+
const nodeMinY = cfgPos.y - halfHeight;
|
|
1964
|
+
const nodeMaxY = cfgPos.y + halfHeight;
|
|
1965
|
+
const overlapsBandY = !(nodeMaxY < bandMinY || nodeMinY > bandMaxY);
|
|
1966
|
+
|
|
1967
|
+
if (cfgPos.x >= bandMinX && overlapsBandY) {
|
|
1968
|
+
updates.push({
|
|
1969
|
+
id: String(s.id),
|
|
1970
|
+
x: cfgPos.x + shiftX,
|
|
1971
|
+
y: cfgPos.y,
|
|
1972
|
+
});
|
|
1973
|
+
}
|
|
1974
|
+
});
|
|
1975
|
+
|
|
1976
|
+
// Position for the new copied step
|
|
1977
|
+
updates.push({
|
|
1978
|
+
id: String(newStep.id),
|
|
1979
|
+
x: targetX,
|
|
1980
|
+
y: targetY,
|
|
1981
|
+
});
|
|
1982
|
+
|
|
1983
|
+
if (!updates.length) return;
|
|
1984
|
+
|
|
1985
|
+
// Move nodes immediately in the UI
|
|
1986
|
+
applyLocalPositionUpdates(updates);
|
|
1987
|
+
|
|
1988
|
+
// Update local steps state with new positions
|
|
1989
|
+
const stepsWithPositions = freshSteps.map((s) => {
|
|
1990
|
+
const hit = updates.find((u) => u.id === String(s.id));
|
|
1991
|
+
if (!hit) return s;
|
|
1992
|
+
return {
|
|
1993
|
+
...s,
|
|
1994
|
+
configuration: {
|
|
1995
|
+
...(s.configuration || {}),
|
|
1996
|
+
workflow_position: { x: hit.x, y: hit.y },
|
|
1997
|
+
},
|
|
1998
|
+
};
|
|
1999
|
+
});
|
|
2000
|
+
|
|
2001
|
+
setSteps(stepsWithPositions);
|
|
2002
|
+
await persistPositions(updates);
|
|
2003
|
+
} catch (e) {
|
|
2004
|
+
setError(e.message);
|
|
2005
|
+
}
|
|
2006
|
+
},
|
|
2007
|
+
[
|
|
2008
|
+
data.csrfToken,
|
|
2009
|
+
data.urls.copy,
|
|
2010
|
+
fetchJson,
|
|
2011
|
+
applyLocalPositionUpdates,
|
|
2012
|
+
persistPositions,
|
|
2013
|
+
reload,
|
|
2014
|
+
]
|
|
2015
|
+
);
|
|
2016
|
+
|
|
1518
2017
|
const onClearNext = useCallback(
|
|
1519
2018
|
async (id) => {
|
|
1520
2019
|
await updateConnection({ step_id: id, next_step: "" });
|
|
@@ -1524,15 +2023,29 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1524
2023
|
);
|
|
1525
2024
|
|
|
1526
2025
|
const onDelete = useCallback(
|
|
1527
|
-
async (
|
|
1528
|
-
|
|
2026
|
+
async (ids) => {
|
|
2027
|
+
const normalizedIds = Array.isArray(ids) ? ids : [ids];
|
|
2028
|
+
const uniqueIds = [...new Set(normalizedIds.map((id) => String(id)))];
|
|
2029
|
+
if (!uniqueIds.length) return;
|
|
2030
|
+
|
|
2031
|
+
const singleConfirm = strings.confirmDelete || "Delete step?";
|
|
2032
|
+
const confirmMessage =
|
|
2033
|
+
uniqueIds.length > 1
|
|
2034
|
+
? strings.confirmDeleteMany ||
|
|
2035
|
+
`${singleConfirm} (${uniqueIds.length} steps)`
|
|
2036
|
+
: singleConfirm;
|
|
2037
|
+
|
|
2038
|
+
if (!window.confirm(confirmMessage)) return;
|
|
2039
|
+
|
|
1529
2040
|
try {
|
|
1530
|
-
const
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
2041
|
+
for (const id of uniqueIds) {
|
|
2042
|
+
const formData = new FormData();
|
|
2043
|
+
formData.append("_csrf", data.csrfToken || "");
|
|
2044
|
+
await fetchJson(`${data.urls.deleteStep}/${id}`, {
|
|
2045
|
+
method: "POST",
|
|
2046
|
+
body: formData,
|
|
2047
|
+
});
|
|
2048
|
+
}
|
|
1536
2049
|
setModal(null);
|
|
1537
2050
|
setError("");
|
|
1538
2051
|
clearDrawerState();
|
|
@@ -1541,7 +2054,14 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1541
2054
|
setError(e.message);
|
|
1542
2055
|
}
|
|
1543
2056
|
},
|
|
1544
|
-
[
|
|
2057
|
+
[
|
|
2058
|
+
clearDrawerState,
|
|
2059
|
+
data.csrfToken,
|
|
2060
|
+
fetchJson,
|
|
2061
|
+
reload,
|
|
2062
|
+
strings.confirmDelete,
|
|
2063
|
+
strings.confirmDeleteMany,
|
|
2064
|
+
]
|
|
1545
2065
|
);
|
|
1546
2066
|
|
|
1547
2067
|
useEffect(() => {
|
|
@@ -1607,7 +2127,11 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1607
2127
|
|
|
1608
2128
|
const hasSteps = steps.length > 0;
|
|
1609
2129
|
const shellClass = modal ? "wf-shell wf-shell--drawer-open" : "wf-shell";
|
|
1610
|
-
const shellStyle = {
|
|
2130
|
+
const shellStyle = {
|
|
2131
|
+
"--wf-drawer-width": `${drawerWidth}px`,
|
|
2132
|
+
height: shellHeight ? `${shellHeight}px` : undefined,
|
|
2133
|
+
maxHeight: shellHeight ? `${shellHeight}px` : undefined,
|
|
2134
|
+
};
|
|
1611
2135
|
const showMiniMap = !modal;
|
|
1612
2136
|
const shouldFitView = !pendingViewportRef.current;
|
|
1613
2137
|
|
|
@@ -1704,84 +2228,132 @@ const WorkflowEditor = ({ data }) => {
|
|
|
1704
2228
|
};
|
|
1705
2229
|
}, [drawerWidth, modal]);
|
|
1706
2230
|
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
2231
|
+
useEffect(() => {
|
|
2232
|
+
const shellEl = shellRef.current;
|
|
2233
|
+
if (!shellEl) return;
|
|
2234
|
+
const cardEl = shellEl.closest(".card");
|
|
2235
|
+
const headerEl = cardEl?.querySelector?.(".card-header");
|
|
2236
|
+
if (headerEl && headerEl !== headerHost) {
|
|
2237
|
+
headerHtmlRef.current = headerEl.innerHTML || "";
|
|
2238
|
+
headerEl.dataset.wfHeaderInjected = "true";
|
|
2239
|
+
headerEl.classList.add("d-flex", "align-items-center");
|
|
2240
|
+
headerEl.innerHTML = "";
|
|
2241
|
+
setHeaderHost(headerEl);
|
|
2242
|
+
}
|
|
2243
|
+
// recalc height once we know our real position
|
|
2244
|
+
requestAnimationFrame(() => recomputeShellHeight());
|
|
2245
|
+
}, [headerHost, recomputeShellHeight]);
|
|
2246
|
+
|
|
2247
|
+
useLayoutEffect(() => {
|
|
2248
|
+
recomputeShellHeight();
|
|
2249
|
+
|
|
2250
|
+
// Debounce resize events to avoid jumpiness
|
|
2251
|
+
const handleResize = () => {
|
|
2252
|
+
if (heightDebounceRef.current) clearTimeout(heightDebounceRef.current);
|
|
2253
|
+
heightDebounceRef.current = setTimeout(() => {
|
|
2254
|
+
recomputeShellHeight();
|
|
2255
|
+
}, 100);
|
|
2256
|
+
};
|
|
2257
|
+
|
|
2258
|
+
window.addEventListener("resize", handleResize);
|
|
2259
|
+
return () => {
|
|
2260
|
+
window.removeEventListener("resize", handleResize);
|
|
2261
|
+
if (heightDebounceRef.current) clearTimeout(heightDebounceRef.current);
|
|
2262
|
+
};
|
|
2263
|
+
}, [recomputeShellHeight]);
|
|
2264
|
+
|
|
2265
|
+
useLayoutEffect(() => {
|
|
2266
|
+
requestAnimationFrame(() => recomputeShellHeight());
|
|
2267
|
+
}, [steps.length, recomputeShellHeight]);
|
|
2268
|
+
|
|
2269
|
+
const headerContent = (
|
|
2270
|
+
<div className="d-flex align-items-center w-100">
|
|
2271
|
+
<div className="d-flex align-items-center flex-wrap">
|
|
2272
|
+
{headerHtmlRef.current ? (
|
|
2273
|
+
<span
|
|
2274
|
+
className="d-flex align-items-center flex-wrap text-muted"
|
|
2275
|
+
dangerouslySetInnerHTML={{ __html: headerHtmlRef.current }}
|
|
2276
|
+
/>
|
|
2277
|
+
) : (
|
|
2278
|
+
<h5 className="m-0 fw-bold text-primary d-inline">
|
|
2279
|
+
{strings.configure}
|
|
2280
|
+
</h5>
|
|
2281
|
+
)}
|
|
2282
|
+
{message ? <span className="text-success ms-2">{message}</span> : null}
|
|
2283
|
+
{error ? <span className="text-danger ms-2">{error}</span> : null}
|
|
1740
2284
|
</div>
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
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)}
|
|
2285
|
+
<div className="wf-toolbar__actions ms-auto d-flex flex-wrap">
|
|
2286
|
+
<button
|
|
2287
|
+
className="btn btn-sm btn-outline-primary"
|
|
2288
|
+
onClick={() => openStepForm({ initial_step: !hasSteps })}
|
|
1765
2289
|
>
|
|
1766
|
-
{
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
2290
|
+
{strings.addStep}
|
|
2291
|
+
</button>
|
|
2292
|
+
<button
|
|
2293
|
+
className="btn btn-sm btn-outline-secondary ms-2"
|
|
2294
|
+
onClick={reload}
|
|
2295
|
+
disabled={loading}
|
|
2296
|
+
>
|
|
2297
|
+
{loading ? strings.loading : strings.refresh}
|
|
2298
|
+
</button>
|
|
1770
2299
|
</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
2300
|
</div>
|
|
1784
2301
|
);
|
|
2302
|
+
|
|
2303
|
+
return (
|
|
2304
|
+
<>
|
|
2305
|
+
{headerHost ? (
|
|
2306
|
+
createPortal(headerContent, headerHost)
|
|
2307
|
+
) : (
|
|
2308
|
+
<div className="wf-toolbar">{headerContent}</div>
|
|
2309
|
+
)}
|
|
2310
|
+
<div className={shellClass} style={shellStyle} ref={shellRef}>
|
|
2311
|
+
{!hasSteps ? <div className="wf-empty">{strings.empty}</div> : null}
|
|
2312
|
+
<div className="wf-canvas">
|
|
2313
|
+
<ReactFlow
|
|
2314
|
+
nodesFocusable={true}
|
|
2315
|
+
edgesFocusable={true}
|
|
2316
|
+
disableKeyboardA11y={false}
|
|
2317
|
+
nodes={nodes}
|
|
2318
|
+
edges={edges}
|
|
2319
|
+
onNodesChange={onNodesChangeWrapped}
|
|
2320
|
+
onEdgesChange={onEdgesChange}
|
|
2321
|
+
onConnect={onConnect}
|
|
2322
|
+
onConnectStart={onConnectStart}
|
|
2323
|
+
onConnectEnd={onConnectEnd}
|
|
2324
|
+
onEdgeClick={onEdgeClick}
|
|
2325
|
+
onSelectionChange={handleSelectionChange}
|
|
2326
|
+
nodeTypes={nodeTypes}
|
|
2327
|
+
edgeTypes={edgeTypes}
|
|
2328
|
+
onInit={onInit}
|
|
2329
|
+
defaultViewport={pendingViewportRef.current || undefined}
|
|
2330
|
+
fitView={shouldFitView}
|
|
2331
|
+
proOptions={{ hideAttribution: true }}
|
|
2332
|
+
defaultEdgeOptions={{ animated: true }}
|
|
2333
|
+
colorMode={window._sc_lightmode || "light"}
|
|
2334
|
+
onMoveEnd={(_, viewport) => persistViewport(viewport)}
|
|
2335
|
+
>
|
|
2336
|
+
{showMiniMap && <MiniMap pannable zoomable />}
|
|
2337
|
+
<Controls />
|
|
2338
|
+
<Background gap={16} />
|
|
2339
|
+
</ReactFlow>
|
|
2340
|
+
</div>
|
|
2341
|
+
<StepDrawer
|
|
2342
|
+
modal={modal}
|
|
2343
|
+
innerRef={modalRef}
|
|
2344
|
+
outerRef={drawerRef}
|
|
2345
|
+
onClose={closeModal}
|
|
2346
|
+
submitting={savingModal}
|
|
2347
|
+
error={error && modal ? error : ""}
|
|
2348
|
+
data={data}
|
|
2349
|
+
onDelete={onDelete}
|
|
2350
|
+
onCopy={onCopy}
|
|
2351
|
+
drawerWidth={drawerWidth}
|
|
2352
|
+
onResizeStart={startResize}
|
|
2353
|
+
/>
|
|
2354
|
+
</div>
|
|
2355
|
+
</>
|
|
2356
|
+
);
|
|
1785
2357
|
};
|
|
1786
2358
|
|
|
1787
2359
|
export default WorkflowEditor;
|