@saltcorn/workflow-editor 1.6.0-alpha.9 → 1.6.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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: loopPos.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: "smoothstep",
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 (!steps.length) {
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
- <button
747
- className="btn btn-sm btn-outline-danger"
748
- onClick={(e) => {
749
- e.stopPropagation();
750
- onDelete(modal.stepId);
751
- }}
752
- >
753
- {data.strings.deleteStep}
754
- </button>
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 stepNode = selectedNodes.find((n) => n.type === "step");
918
- if (!stepNode) return;
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(stepNode.id);
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 } = pendingAddRef.current;
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
- // Insert new step between source and its former target
1084
- await updateConnection({
1085
- step_id: newStep.id,
1086
- next_step: insertBetween.originalTargetName,
1087
- });
1088
- await updateConnection({
1089
- step_id: afterStepId,
1090
- next_step: newStep.name,
1091
- });
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
- setSteps(fresh.steps || []);
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
- }, [data.urls.data, fetchJson, strings.refresh, updateConnection]);
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 handleModalSubmit = useCallback(
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
- setModal(null);
1253
- clearDrawerState();
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
- await handleModalSubmit(formEl);
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
- }, [handleModalSubmit, modal]);
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
- onClearNext(pending.nodeId);
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
- const removed = changes.filter((c) => c.type === "remove");
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(changes);
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(changes);
1817
+ onNodesChange(safeChanges);
1476
1818
  },
1477
- [onNodesChange, schedulePersistPositions, setAddPositions, setStartPosition]
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 || source === "start") return;
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
- insertBetween: { originalTargetName },
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 (id) => {
1528
- if (!window.confirm(strings.confirmDelete)) return;
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 formData = new FormData();
1531
- formData.append("_csrf", data.csrfToken || "");
1532
- await fetchJson(`${data.urls.deleteStep}/${id}`, {
1533
- method: "POST",
1534
- body: formData,
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
- [clearDrawerState, data.csrfToken, fetchJson, reload, strings.confirmDelete]
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 = { "--wf-drawer-width": `${drawerWidth}px` };
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
- return (
1708
- <div className={shellClass} style={shellStyle} ref={shellRef}>
1709
- <div className="wf-toolbar">
1710
- <div className="wf-toolbar__left">
1711
- <strong>{strings.configure}</strong>
1712
- {message ? (
1713
- <span className="text-success ms-2">{message}</span>
1714
- ) : null}
1715
- {error ? <span className="text-danger ms-2">{error}</span> : null}
1716
- {/* {savingPositions ? (
1717
- <span className="text-muted ms-2">
1718
- {strings.loading || "Saving positions..."}
1719
- </span>
1720
- ) : null} */}
1721
- </div>
1722
- <div className="wf-toolbar__actions">
1723
- <button
1724
- className="btn btn-sm btn-outline-primary"
1725
- onClick={() => openStepForm({ initial_step: !hasSteps })}
1726
- >
1727
- {strings.addStep}
1728
- </button>
1729
- <button
1730
- className="btn btn-sm btn-outline-secondary"
1731
- onClick={reload}
1732
- disabled={loading}
1733
- >
1734
- {loading ? strings.loading : strings.refresh}
1735
- </button>
1736
- <a className="btn btn-sm btn-outline-info" href={data.urls.runs}>
1737
- {strings.runs}
1738
- </a>
1739
- </div>
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
- {!hasSteps ? <div className="wf-empty">{strings.empty}</div> : null}
1742
- <div className="wf-canvas">
1743
- <ReactFlow
1744
- nodesFocusable={true}
1745
- edgesFocusable={true}
1746
- disableKeyboardA11y={false}
1747
- nodes={nodes}
1748
- edges={edges}
1749
- onNodesChange={onNodesChangeWrapped}
1750
- onEdgesChange={onEdgesChange}
1751
- onConnect={onConnect}
1752
- onConnectStart={onConnectStart}
1753
- onConnectEnd={onConnectEnd}
1754
- onEdgeClick={onEdgeClick}
1755
- onSelectionChange={handleSelectionChange}
1756
- nodeTypes={nodeTypes}
1757
- edgeTypes={edgeTypes}
1758
- onInit={onInit}
1759
- defaultViewport={pendingViewportRef.current || undefined}
1760
- fitView={shouldFitView}
1761
- proOptions={{ hideAttribution: true }}
1762
- defaultEdgeOptions={{ animated: true }}
1763
- colorMode={window._sc_lightmode || "light"}
1764
- onMoveEnd={(_, viewport) => persistViewport(viewport)}
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
- {showMiniMap && <MiniMap pannable zoomable />}
1767
- <Controls />
1768
- <Background gap={16} />
1769
- </ReactFlow>
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;