@saltcorn/workflow-editor 1.6.0-alpha.10 → 1.6.0-alpha.12

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,
@@ -487,9 +488,16 @@ const buildGraph = (
487
488
  id: `e-start-${initial.id}`,
488
489
  source: "start",
489
490
  target: String(initial.id),
490
- type: "smoothstep",
491
+ type: "workflow-main",
491
492
  animated: true,
492
493
  markerEnd: makeMarker(),
494
+ data: {
495
+ startLabel: strings.start,
496
+ sourceStepName: "start",
497
+ sourceId: "start",
498
+ targetId: String(initial.id),
499
+ canInsertBetween: true,
500
+ },
493
501
  });
494
502
  else {
495
503
  const addId = "add-start";
@@ -620,7 +628,7 @@ const buildGraph = (
620
628
  });
621
629
 
622
630
  const addNodes = [];
623
- if (!steps.length) {
631
+ if (!initial) {
624
632
  const addId = "add-start";
625
633
  addNodes.push({
626
634
  id: addId,
@@ -706,10 +714,16 @@ const StepDrawer = ({
706
714
  error,
707
715
  data,
708
716
  onDelete,
717
+ onCopy,
709
718
  drawerWidth,
710
719
  onResizeStart,
711
720
  }) => {
712
721
  const isOpen = !!modal;
722
+ const stopDrawerKeyPropagation = useCallback((e) => {
723
+ e.stopPropagation();
724
+ if (e?.nativeEvent?.stopImmediatePropagation)
725
+ e.nativeEvent.stopImmediatePropagation();
726
+ }, []);
713
727
  return (
714
728
  <div
715
729
  ref={outerRef}
@@ -719,6 +733,8 @@ const StepDrawer = ({
719
733
  aria-hidden={!isOpen}
720
734
  aria-label={modal?.title || data?.strings?.configure}
721
735
  style={{ "--wf-drawer-width": `${drawerWidth}px` }}
736
+ onKeyDown={stopDrawerKeyPropagation}
737
+ onKeyUp={stopDrawerKeyPropagation}
722
738
  >
723
739
  {modal ? (
724
740
  <>
@@ -743,31 +759,46 @@ const StepDrawer = ({
743
759
  ) : null}
744
760
  </div>
745
761
  <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>
762
+ {modal.stepId && (
763
+ <button
764
+ className="btn btn-sm btn-outline-danger"
765
+ onClick={(e) => {
766
+ e.stopPropagation();
767
+ onDelete(modal.stepId);
768
+ }}
769
+ >
770
+ {data.strings.deleteStep}
771
+ </button>
772
+ )}
773
+ {modal.stepId ? (
774
+ <button
775
+ className="btn btn-sm btn-outline-secondary"
776
+ onClick={(e) => {
777
+ e.stopPropagation();
778
+ onCopy(modal.stepId);
779
+ }}
780
+ >
781
+ {data.strings.copyStep || "Copy"}
782
+ </button>
783
+ ) : null}
755
784
  <button className="btn btn-secondary" onClick={onClose}>
756
785
  Close
757
786
  </button>
758
- <button
759
- className="btn btn-primary"
760
- onClick={() =>
761
- innerRef.current
762
- ?.querySelector("form")
763
- ?.dispatchEvent(
764
- new Event("submit", { cancelable: true, bubbles: true })
765
- )
766
- }
767
- disabled={submitting}
768
- >
769
- {submitting ? "Saving..." : "Save"}
770
- </button>
787
+ {!modal.stepId && (
788
+ <button
789
+ className="btn btn-primary"
790
+ onClick={() =>
791
+ innerRef.current
792
+ ?.querySelector("form")
793
+ ?.dispatchEvent(
794
+ new Event("submit", { cancelable: true, bubbles: true })
795
+ )
796
+ }
797
+ disabled={submitting}
798
+ >
799
+ {submitting ? "Saving..." : "Save"}
800
+ </button>
801
+ )}
771
802
  </div>
772
803
  </>
773
804
  ) : null}
@@ -807,6 +838,13 @@ const WorkflowEditor = ({ data }) => {
807
838
  const resizingRef = useRef(false);
808
839
  const connectSourceRef = useRef(null);
809
840
  const [selectedEdgeId, setSelectedEdgeId] = useState(null);
841
+ const autoSaveTimerRef = useRef(null);
842
+ const autoSavingRef = useRef(false);
843
+ const nodesRef = useRef([]);
844
+ const [headerHost, setHeaderHost] = useState(null);
845
+ const [shellHeight, setShellHeight] = useState(null);
846
+ const lastHeightRef = useRef(null);
847
+ const heightDebounceRef = useRef(null);
810
848
 
811
849
  const drawerStorageKey = useMemo(
812
850
  () => `wf-drawer:${data?.trigger?.id || "default"}`,
@@ -819,6 +857,57 @@ const WorkflowEditor = ({ data }) => {
819
857
  );
820
858
 
821
859
  const rfInstanceRef = useRef(null);
860
+ const headerHtmlRef = useRef("");
861
+
862
+ const recomputeShellHeight = useCallback(() => {
863
+ if (typeof window === "undefined") return;
864
+ const shellEl = shellRef.current;
865
+ if (!shellEl) return;
866
+
867
+ const wrapperEl = shellEl.closest(".workflow-editor-wrapper");
868
+ if (!wrapperEl) return;
869
+
870
+ const afterEl = wrapperEl.nextElementSibling;
871
+ const cardBodyEl = shellEl.closest(".card-body");
872
+ if (!cardBodyEl) return;
873
+
874
+ const minHeight = 320;
875
+ const bottomMargin = 28;
876
+
877
+ const cardBodyRect = cardBodyEl.getBoundingClientRect();
878
+ const styles = window.getComputedStyle(cardBodyEl);
879
+ const cardBodyPaddingTop = parseFloat(styles.paddingTop) || 0;
880
+ const cardBodyPaddingBottom = parseFloat(styles.paddingBottom) || 0;
881
+
882
+ const effectiveTop = Math.max(0, cardBodyRect.top);
883
+
884
+ let footerHeight = 0;
885
+ if (afterEl && typeof afterEl.getBoundingClientRect === "function") {
886
+ const afterRect = afterEl.getBoundingClientRect();
887
+ const afterStyles = window.getComputedStyle(afterEl);
888
+ const mt = parseFloat(afterStyles.marginTop) || 0;
889
+ const mb = parseFloat(afterStyles.marginBottom) || 0;
890
+ footerHeight = afterRect.height + mt + mb;
891
+ }
892
+
893
+ const availableHeight =
894
+ window.innerHeight -
895
+ effectiveTop -
896
+ bottomMargin -
897
+ cardBodyPaddingTop -
898
+ cardBodyPaddingBottom -
899
+ footerHeight;
900
+
901
+ const wrapperHeight = Math.max(minHeight, availableHeight);
902
+
903
+ if (lastHeightRef.current !== wrapperHeight) {
904
+ lastHeightRef.current = wrapperHeight;
905
+ if (wrapperEl) {
906
+ wrapperEl.style.height = `${wrapperHeight}px`;
907
+ }
908
+ if (Number.isFinite(wrapperHeight)) setShellHeight(wrapperHeight);
909
+ }
910
+ }, []);
822
911
 
823
912
  const persistDrawerState = useCallback(
824
913
  (payload) => {
@@ -914,10 +1003,12 @@ const WorkflowEditor = ({ data }) => {
914
1003
  )
915
1004
  return;
916
1005
  if (e.key !== "Delete" && e.key !== "Backspace") return;
917
- const stepNode = selectedNodes.find((n) => n.type === "step");
918
- if (!stepNode) return;
1006
+ const stepIds = selectedNodes
1007
+ .filter((n) => n.type === "step")
1008
+ .map((n) => n.id);
1009
+ if (!stepIds.length) return;
919
1010
  e.preventDefault();
920
- onDelete(stepNode.id);
1011
+ onDelete(stepIds);
921
1012
  },
922
1013
  [modal, onDelete, selectedNodes]
923
1014
  );
@@ -953,6 +1044,10 @@ const WorkflowEditor = ({ data }) => {
953
1044
  refreshGraph(steps);
954
1045
  }, [steps, refreshGraph]);
955
1046
 
1047
+ useEffect(() => {
1048
+ nodesRef.current = nodes;
1049
+ }, [nodes]);
1050
+
956
1051
  useEffect(() => {
957
1052
  if (!rfInstanceRef.current) return;
958
1053
  if (!pendingViewportRef.current) return;
@@ -1070,25 +1165,33 @@ const WorkflowEditor = ({ data }) => {
1070
1165
  setError("");
1071
1166
  try {
1072
1167
  let fresh = await fetchJson(data.urls.data);
1168
+ let updatedSteps = null;
1073
1169
 
1074
1170
  // If a step was just added via an adder node, wire up next_step
1075
1171
  if (pendingAddRef.current) {
1076
- const { afterStepId, prevIds, insertBetween } = pendingAddRef.current;
1172
+ const { afterStepId, prevIds, insertBetween, fromStart } =
1173
+ pendingAddRef.current;
1077
1174
  const newSteps = (fresh.steps || []).filter(
1078
1175
  (s) => !prevIds.has(String(s.id))
1079
1176
  );
1080
1177
  if (newSteps.length === 1) {
1081
1178
  const newStep = newSteps[0];
1082
1179
  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
- });
1180
+ if (fromStart) {
1181
+ await updateConnection({
1182
+ step_id: newStep.id,
1183
+ initial_step: true,
1184
+ });
1185
+ } else {
1186
+ await updateConnection({
1187
+ step_id: newStep.id,
1188
+ next_step: insertBetween.originalTargetName,
1189
+ });
1190
+ await updateConnection({
1191
+ step_id: afterStepId,
1192
+ next_step: newStep.name,
1193
+ });
1194
+ }
1092
1195
  } else {
1093
1196
  await updateConnection({
1094
1197
  step_id: afterStepId,
@@ -1098,20 +1201,38 @@ const WorkflowEditor = ({ data }) => {
1098
1201
 
1099
1202
  // Re-fetch so we get the updated connections reflected in steps
1100
1203
  fresh = await fetchJson(data.urls.data);
1204
+
1205
+ if (insertBetween && insertBetween.originalTargetName) {
1206
+ updatedSteps = await applyInsertBetweenLayout(fresh.steps || [], {
1207
+ newStep,
1208
+ afterStepId,
1209
+ targetName: insertBetween.originalTargetName,
1210
+ targetId: insertBetween.targetId,
1211
+ snapshot: insertBetween.positionsSnapshot,
1212
+ });
1213
+ }
1101
1214
  }
1102
1215
  pendingAddRef.current = null;
1103
1216
  }
1104
1217
 
1105
- setSteps(fresh.steps || []);
1218
+ const nextSteps = updatedSteps || fresh.steps || [];
1219
+ setSteps(nextSteps);
1106
1220
 
1107
1221
  setMessage(strings.refresh);
1108
1222
  setTimeout(() => setMessage(""), 1200);
1223
+ return nextSteps;
1109
1224
  } catch (e) {
1110
1225
  setError(e.message);
1111
1226
  } finally {
1112
1227
  setLoading(false);
1113
1228
  }
1114
- }, [data.urls.data, fetchJson, strings.refresh, updateConnection]);
1229
+ }, [
1230
+ applyInsertBetweenLayout,
1231
+ data.urls.data,
1232
+ fetchJson,
1233
+ strings.refresh,
1234
+ updateConnection,
1235
+ ]);
1115
1236
 
1116
1237
  const persistPositions = useCallback(
1117
1238
  async (positions = []) => {
@@ -1165,6 +1286,104 @@ const WorkflowEditor = ({ data }) => {
1165
1286
  [persistPositions]
1166
1287
  );
1167
1288
 
1289
+ const applyLocalPositionUpdates = useCallback(
1290
+ (updates = []) => {
1291
+ if (!updates.length) return;
1292
+ setNodes((nds) =>
1293
+ nds.map((n) => {
1294
+ const hit = updates.find((u) => String(u.id) === String(n.id));
1295
+ return hit
1296
+ ? { ...n, position: { ...(n.position || {}), x: hit.x, y: hit.y } }
1297
+ : n;
1298
+ })
1299
+ );
1300
+ },
1301
+ [setNodes]
1302
+ );
1303
+
1304
+ const applyInsertBetweenLayout = useCallback(
1305
+ async (
1306
+ freshSteps,
1307
+ { newStep, afterStepId, targetName, targetId, snapshot }
1308
+ ) => {
1309
+ if (!newStep || !snapshot) return null;
1310
+ const snap = snapshot || {};
1311
+ const newStepSize = getWorkflowSize(newStep) || {
1312
+ width: DEFAULT_NODE_WIDTH,
1313
+ height: DEFAULT_NODE_HEIGHT,
1314
+ };
1315
+ const sourceSnap = snap[String(afterStepId)];
1316
+ const resolvedTargetId =
1317
+ targetId || freshSteps.find((s) => s.name === targetName)?.id;
1318
+ const targetSnap = resolvedTargetId
1319
+ ? snap[String(resolvedTargetId)]
1320
+ : null;
1321
+ const shiftAmount = newStepSize.height + V_GAP;
1322
+ const fallbackY = sourceSnap?.y ?? 0;
1323
+ const fallbackX = sourceSnap?.x ?? targetSnap?.x ?? 0;
1324
+ const newY = targetSnap?.y ?? fallbackY;
1325
+ const newX =
1326
+ sourceSnap && targetSnap
1327
+ ? (sourceSnap.x + targetSnap.x) / 2
1328
+ : (targetSnap?.x ?? sourceSnap?.x ?? fallbackX);
1329
+
1330
+ const newHalfWidth = (newStepSize.width || DEFAULT_NODE_WIDTH) / 2;
1331
+ const bandPadding = H_GAP / 2;
1332
+ const bandMinX = newX - newHalfWidth - bandPadding;
1333
+ const bandMaxX = newX + newHalfWidth + bandPadding;
1334
+
1335
+ const updates = [];
1336
+ freshSteps.forEach((step) => {
1337
+ const idStr = String(step.id);
1338
+ const nodeSnap = snap[idStr];
1339
+
1340
+ if (idStr === String(newStep.id)) {
1341
+ updates.push({ id: idStr, x: newX, y: newY });
1342
+ return;
1343
+ }
1344
+
1345
+ if (!nodeSnap) return;
1346
+
1347
+ const nodeWidth = nodeSnap.width || DEFAULT_NODE_WIDTH;
1348
+ const nodeHalfWidth = nodeWidth / 2;
1349
+ const nodeMinX = nodeSnap.x - nodeHalfWidth;
1350
+ const nodeMaxX = nodeSnap.x + nodeHalfWidth;
1351
+ const overlapsBand = !(nodeMaxX < bandMinX || nodeMinX > bandMaxX);
1352
+ const shouldShift =
1353
+ nodeSnap.y >= newY && idStr !== String(afterStepId) && overlapsBand;
1354
+
1355
+ if (shouldShift) {
1356
+ updates.push({
1357
+ id: idStr,
1358
+ x: nodeSnap.x,
1359
+ y: nodeSnap.y + shiftAmount,
1360
+ });
1361
+ } else {
1362
+ updates.push({ id: idStr, x: nodeSnap.x, y: nodeSnap.y });
1363
+ }
1364
+ });
1365
+ if (!updates.length) return freshSteps;
1366
+
1367
+ applyLocalPositionUpdates(updates);
1368
+
1369
+ const stepsWithPositions = freshSteps.map((s) => {
1370
+ const hit = updates.find((u) => String(u.id) === String(s.id));
1371
+ if (!hit) return s;
1372
+ return {
1373
+ ...s,
1374
+ configuration: {
1375
+ ...(s.configuration || {}),
1376
+ workflow_position: { x: hit.x, y: hit.y },
1377
+ },
1378
+ };
1379
+ });
1380
+
1381
+ await persistPositions(updates);
1382
+ return stepsWithPositions;
1383
+ },
1384
+ [applyLocalPositionUpdates, persistPositions]
1385
+ );
1386
+
1168
1387
  useEffect(() => {
1169
1388
  return () => {
1170
1389
  if (positionDebounceRef.current)
@@ -1220,6 +1439,7 @@ const WorkflowEditor = ({ data }) => {
1220
1439
  const base = `${data.urls.stepForm}${stepId ? `/${stepId}` : ""}`;
1221
1440
  const url = new URL(base, window.location.origin);
1222
1441
  url.searchParams.set("render", "dialog");
1442
+ url.searchParams.set("vw", window.innerWidth);
1223
1443
  if (initial_step) url.searchParams.set("initial_step", "true");
1224
1444
  if (after_step) url.searchParams.set("after_step", after_step);
1225
1445
  const res = await fetchJson(url.toString());
@@ -1232,8 +1452,8 @@ const WorkflowEditor = ({ data }) => {
1232
1452
  [clearDrawerState, data.urls.stepForm, fetchJson, persistDrawerState]
1233
1453
  );
1234
1454
 
1235
- const handleModalSubmit = useCallback(
1236
- async (formEl) => {
1455
+ const submitStepForm = useCallback(
1456
+ async (formEl, { closeOnSuccess = true } = {}) => {
1237
1457
  if (!formEl) return;
1238
1458
  setSavingModal(true);
1239
1459
  setError("");
@@ -1249,9 +1469,12 @@ const WorkflowEditor = ({ data }) => {
1249
1469
  });
1250
1470
  const json = await res.json();
1251
1471
  if (json.error) throw new Error(json.error);
1252
- setModal(null);
1253
- clearDrawerState();
1472
+ if (closeOnSuccess) {
1473
+ setModal(null);
1474
+ clearDrawerState();
1475
+ }
1254
1476
  await reload();
1477
+ setSavingModal(false);
1255
1478
  },
1256
1479
  [clearDrawerState, data.csrfToken, data.urls.stepForm, reload]
1257
1480
  );
@@ -1270,6 +1493,7 @@ const WorkflowEditor = ({ data }) => {
1270
1493
  }, 0);
1271
1494
 
1272
1495
  const actionSelect = formEl.querySelector('[name="wf_action_name"]');
1496
+ const allowAutoSave = !!modal?.stepId; // avoid auto-save for brand new steps to prevent insert errors
1273
1497
  const handleActionChange = () => {
1274
1498
  if (typeof window !== "undefined" && window.apply_showif)
1275
1499
  window.apply_showif();
@@ -1277,10 +1501,28 @@ const WorkflowEditor = ({ data }) => {
1277
1501
  if (actionSelect)
1278
1502
  actionSelect.addEventListener("change", handleActionChange);
1279
1503
 
1504
+ const scheduleAutoSave = allowAutoSave
1505
+ ? () => {
1506
+ if (autoSavingRef.current) return;
1507
+ if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
1508
+ autoSaveTimerRef.current = setTimeout(async () => {
1509
+ autoSavingRef.current = true;
1510
+ try {
1511
+ await submitStepForm(formEl, { closeOnSuccess: false });
1512
+ } catch (err) {
1513
+ setError(err.message);
1514
+ } finally {
1515
+ autoSavingRef.current = false;
1516
+ setSavingModal(false);
1517
+ }
1518
+ }, 800);
1519
+ }
1520
+ : null;
1521
+
1280
1522
  const submitHandler = async (e) => {
1281
1523
  e.preventDefault();
1282
1524
  try {
1283
- await handleModalSubmit(formEl);
1525
+ await submitStepForm(formEl);
1284
1526
  } catch (err) {
1285
1527
  setError(err.message);
1286
1528
  } finally {
@@ -1288,12 +1530,21 @@ const WorkflowEditor = ({ data }) => {
1288
1530
  }
1289
1531
  };
1290
1532
  formEl.addEventListener("submit", submitHandler);
1533
+ if (scheduleAutoSave) {
1534
+ formEl.addEventListener("input", scheduleAutoSave);
1535
+ formEl.addEventListener("change", scheduleAutoSave);
1536
+ }
1291
1537
  return () => {
1538
+ if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current);
1292
1539
  formEl.removeEventListener("submit", submitHandler);
1540
+ if (scheduleAutoSave) {
1541
+ formEl.removeEventListener("input", scheduleAutoSave);
1542
+ formEl.removeEventListener("change", scheduleAutoSave);
1543
+ }
1293
1544
  if (actionSelect)
1294
1545
  actionSelect.removeEventListener("change", handleActionChange);
1295
1546
  };
1296
- }, [handleModalSubmit, modal]);
1547
+ }, [modal, submitStepForm]);
1297
1548
 
1298
1549
  useEffect(() => {
1299
1550
  if (typeof window === "undefined") return undefined;
@@ -1400,16 +1651,32 @@ const WorkflowEditor = ({ data }) => {
1400
1651
  if (targetEl?.classList?.contains("react-flow__pane")) {
1401
1652
  // Dropped on empty space: clear next_step mark as final/unconnected
1402
1653
  if (pending.nodeId && pending.handleId !== "loop-out") {
1403
- onClearNext(pending.nodeId);
1654
+ if (pending.nodeId === "start") {
1655
+ const currentInitial = steps.find((s) => s.initial_step);
1656
+ if (currentInitial) {
1657
+ updateConnection({
1658
+ step_id: currentInitial.id,
1659
+ initial_step: false,
1660
+ })
1661
+ .then(() => reload())
1662
+ .catch((e) => setError(e.message));
1663
+ }
1664
+ } else {
1665
+ onClearNext(pending.nodeId);
1666
+ }
1404
1667
  }
1405
1668
  }
1406
1669
  },
1407
- [onClearNext]
1670
+ [onClearNext, reload, steps, updateConnection]
1408
1671
  );
1409
1672
 
1410
1673
  const onEdgesChange = useCallback(
1411
1674
  (changes) => {
1412
- const removed = changes.filter((c) => c.type === "remove");
1675
+ // Block edge deletions while a modal is open so ReactFlow cannot remove links under dialogs
1676
+ const safeChanges = modal
1677
+ ? changes.filter((c) => c.type !== "remove")
1678
+ : changes;
1679
+ const removed = safeChanges.filter((c) => c.type === "remove");
1413
1680
  if (removed.length) {
1414
1681
  const updates = [];
1415
1682
  removed.forEach((chg) => {
@@ -1438,13 +1705,18 @@ const WorkflowEditor = ({ data }) => {
1438
1705
  .then(() => reload())
1439
1706
  .catch((e) => setError(e.message));
1440
1707
  }
1441
- rfOnEdgesChange(changes);
1708
+ rfOnEdgesChange(safeChanges);
1442
1709
  },
1443
- [edges, reload, rfOnEdgesChange, updateConnection]
1710
+ [edges, modal, reload, rfOnEdgesChange, updateConnection]
1444
1711
  );
1445
1712
 
1446
1713
  const onNodesChangeWrapped = useCallback(
1447
1714
  (changes) => {
1715
+ // Prevent node deletion by reactflow while drawer is open
1716
+ const safeChanges = modal
1717
+ ? changes.filter((c) => c.type !== "remove")
1718
+ : changes;
1719
+
1448
1720
  const finishedPositions = changes
1449
1721
  .filter((c) => c.type === "position" && c.position && c.dragging)
1450
1722
  .map((c) => ({ id: c.id, x: c.position.x, y: c.position.y }));
@@ -1472,9 +1744,15 @@ const WorkflowEditor = ({ data }) => {
1472
1744
  );
1473
1745
  if (stepPositions.length) schedulePersistPositions(stepPositions);
1474
1746
  }
1475
- onNodesChange(changes);
1747
+ onNodesChange(safeChanges);
1476
1748
  },
1477
- [onNodesChange, schedulePersistPositions, setAddPositions, setStartPosition]
1749
+ [
1750
+ modal,
1751
+ onNodesChange,
1752
+ schedulePersistPositions,
1753
+ setAddPositions,
1754
+ setStartPosition,
1755
+ ]
1478
1756
  );
1479
1757
 
1480
1758
  const onAddAfter = useCallback(
@@ -1494,15 +1772,45 @@ const WorkflowEditor = ({ data }) => {
1494
1772
 
1495
1773
  const onInsertBetween = useCallback(
1496
1774
  ({ source, target }) => {
1497
- if (!source || !target || source === "start") return;
1775
+ if (!source || !target) return;
1498
1776
  const originalTargetName = nameById[target];
1499
1777
  if (!originalTargetName) return;
1778
+ const fromStart = source === "start";
1779
+ const sizeById = new Map(
1780
+ steps.map((s) => [
1781
+ String(s.id),
1782
+ getWorkflowSize(s) || {
1783
+ width: DEFAULT_NODE_WIDTH,
1784
+ height: DEFAULT_NODE_HEIGHT,
1785
+ },
1786
+ ])
1787
+ );
1788
+ const positionsSnapshot = {};
1789
+ nodesRef.current
1790
+ .filter((n) => n.type === "step")
1791
+ .forEach((n) => {
1792
+ const size = sizeById.get(String(n.id)) || {
1793
+ width: DEFAULT_NODE_WIDTH,
1794
+ height: DEFAULT_NODE_HEIGHT,
1795
+ };
1796
+ positionsSnapshot[String(n.id)] = {
1797
+ x: n.position?.x ?? 0,
1798
+ y: n.position?.y ?? 0,
1799
+ width: size.width || DEFAULT_NODE_WIDTH,
1800
+ height: size.height || DEFAULT_NODE_HEIGHT,
1801
+ };
1802
+ });
1500
1803
  pendingAddRef.current = {
1501
- afterStepId: source,
1804
+ afterStepId: fromStart ? "start" : source,
1502
1805
  prevIds: new Set(steps.map((s) => String(s.id))),
1503
- insertBetween: { originalTargetName },
1806
+ fromStart,
1807
+ insertBetween: {
1808
+ originalTargetName,
1809
+ targetId: target,
1810
+ positionsSnapshot,
1811
+ },
1504
1812
  };
1505
- openStepForm({ after_step: source });
1813
+ openStepForm(fromStart ? { initial_step: true } : { after_step: source });
1506
1814
  },
1507
1815
  [nameById, openStepForm, steps]
1508
1816
  );
@@ -1515,6 +1823,127 @@ const WorkflowEditor = ({ data }) => {
1515
1823
  [reload, updateConnection]
1516
1824
  );
1517
1825
 
1826
+ const onCopy = useCallback(
1827
+ async (id) => {
1828
+ try {
1829
+ setError("");
1830
+ const res = await fetchJson(data.urls.copy, {
1831
+ method: "POST",
1832
+ body: JSON.stringify({ step_id: id }),
1833
+ headers: {
1834
+ "X-CSRF-Token": data.csrfToken || "",
1835
+ },
1836
+ });
1837
+
1838
+ if (!res || res.success !== "ok" || !res.step) {
1839
+ await reload();
1840
+ return;
1841
+ }
1842
+
1843
+ const copiedStep = res.step;
1844
+ const copiedName = copiedStep.name;
1845
+
1846
+ // Reload full workflow data so the new step has
1847
+ // the same serialized shape as all other steps
1848
+ const freshSteps = (await reload()) || [];
1849
+ if (!freshSteps.length) return;
1850
+
1851
+ const sourceStep = freshSteps.find((s) => String(s.id) === String(id));
1852
+ const newStep = freshSteps.find((s) => s.name === copiedName);
1853
+
1854
+ if (!sourceStep || !newStep || !newStep.id) return;
1855
+
1856
+ const sourceSize = getWorkflowSize(sourceStep) || {
1857
+ width: DEFAULT_NODE_WIDTH,
1858
+ height: DEFAULT_NODE_HEIGHT,
1859
+ };
1860
+ const newSize = getWorkflowSize(newStep) || {
1861
+ width: DEFAULT_NODE_WIDTH,
1862
+ height: DEFAULT_NODE_HEIGHT,
1863
+ };
1864
+
1865
+ const sourcePos = sourceStep.configuration?.workflow_position || {
1866
+ x: 0,
1867
+ y: 0,
1868
+ };
1869
+
1870
+ // Desired location: directly to the right of the source step
1871
+ const targetX = sourcePos.x + sourceSize.width + ADD_GAP;
1872
+ const targetY = sourcePos.y;
1873
+
1874
+ const updates = [];
1875
+
1876
+ // Push steps that are to the right of the insertion band
1877
+ const bandMinX = targetX - newSize.width * 0.25;
1878
+ const shiftX = newSize.width + ADD_GAP;
1879
+ const newHalfHeight = (newSize.height || DEFAULT_NODE_HEIGHT) / 2;
1880
+ const bandPaddingY = V_GAP / 2;
1881
+ const bandMinY = targetY - newHalfHeight - bandPaddingY;
1882
+ const bandMaxY = targetY + newHalfHeight + bandPaddingY;
1883
+
1884
+ freshSteps.forEach((s) => {
1885
+ const cfgPos = s.configuration?.workflow_position;
1886
+ if (!cfgPos) return;
1887
+ if (String(s.id) === String(newStep.id)) return;
1888
+ const stepSize = getWorkflowSize(s) || {
1889
+ width: DEFAULT_NODE_WIDTH,
1890
+ height: DEFAULT_NODE_HEIGHT,
1891
+ };
1892
+ const halfHeight = (stepSize.height || DEFAULT_NODE_HEIGHT) / 2;
1893
+ const nodeMinY = cfgPos.y - halfHeight;
1894
+ const nodeMaxY = cfgPos.y + halfHeight;
1895
+ const overlapsBandY = !(nodeMaxY < bandMinY || nodeMinY > bandMaxY);
1896
+
1897
+ if (cfgPos.x >= bandMinX && overlapsBandY) {
1898
+ updates.push({
1899
+ id: String(s.id),
1900
+ x: cfgPos.x + shiftX,
1901
+ y: cfgPos.y,
1902
+ });
1903
+ }
1904
+ });
1905
+
1906
+ // Position for the new copied step
1907
+ updates.push({
1908
+ id: String(newStep.id),
1909
+ x: targetX,
1910
+ y: targetY,
1911
+ });
1912
+
1913
+ if (!updates.length) return;
1914
+
1915
+ // Move nodes immediately in the UI
1916
+ applyLocalPositionUpdates(updates);
1917
+
1918
+ // Update local steps state with new positions
1919
+ const stepsWithPositions = freshSteps.map((s) => {
1920
+ const hit = updates.find((u) => u.id === String(s.id));
1921
+ if (!hit) return s;
1922
+ return {
1923
+ ...s,
1924
+ configuration: {
1925
+ ...(s.configuration || {}),
1926
+ workflow_position: { x: hit.x, y: hit.y },
1927
+ },
1928
+ };
1929
+ });
1930
+
1931
+ setSteps(stepsWithPositions);
1932
+ await persistPositions(updates);
1933
+ } catch (e) {
1934
+ setError(e.message);
1935
+ }
1936
+ },
1937
+ [
1938
+ data.csrfToken,
1939
+ data.urls.copy,
1940
+ fetchJson,
1941
+ applyLocalPositionUpdates,
1942
+ persistPositions,
1943
+ reload,
1944
+ ]
1945
+ );
1946
+
1518
1947
  const onClearNext = useCallback(
1519
1948
  async (id) => {
1520
1949
  await updateConnection({ step_id: id, next_step: "" });
@@ -1524,15 +1953,29 @@ const WorkflowEditor = ({ data }) => {
1524
1953
  );
1525
1954
 
1526
1955
  const onDelete = useCallback(
1527
- async (id) => {
1528
- if (!window.confirm(strings.confirmDelete)) return;
1956
+ async (ids) => {
1957
+ const normalizedIds = Array.isArray(ids) ? ids : [ids];
1958
+ const uniqueIds = [...new Set(normalizedIds.map((id) => String(id)))];
1959
+ if (!uniqueIds.length) return;
1960
+
1961
+ const singleConfirm = strings.confirmDelete || "Delete step?";
1962
+ const confirmMessage =
1963
+ uniqueIds.length > 1
1964
+ ? strings.confirmDeleteMany ||
1965
+ `${singleConfirm} (${uniqueIds.length} steps)`
1966
+ : singleConfirm;
1967
+
1968
+ if (!window.confirm(confirmMessage)) return;
1969
+
1529
1970
  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
- });
1971
+ for (const id of uniqueIds) {
1972
+ const formData = new FormData();
1973
+ formData.append("_csrf", data.csrfToken || "");
1974
+ await fetchJson(`${data.urls.deleteStep}/${id}`, {
1975
+ method: "POST",
1976
+ body: formData,
1977
+ });
1978
+ }
1536
1979
  setModal(null);
1537
1980
  setError("");
1538
1981
  clearDrawerState();
@@ -1541,7 +1984,14 @@ const WorkflowEditor = ({ data }) => {
1541
1984
  setError(e.message);
1542
1985
  }
1543
1986
  },
1544
- [clearDrawerState, data.csrfToken, fetchJson, reload, strings.confirmDelete]
1987
+ [
1988
+ clearDrawerState,
1989
+ data.csrfToken,
1990
+ fetchJson,
1991
+ reload,
1992
+ strings.confirmDelete,
1993
+ strings.confirmDeleteMany,
1994
+ ]
1545
1995
  );
1546
1996
 
1547
1997
  useEffect(() => {
@@ -1607,7 +2057,11 @@ const WorkflowEditor = ({ data }) => {
1607
2057
 
1608
2058
  const hasSteps = steps.length > 0;
1609
2059
  const shellClass = modal ? "wf-shell wf-shell--drawer-open" : "wf-shell";
1610
- const shellStyle = { "--wf-drawer-width": `${drawerWidth}px` };
2060
+ const shellStyle = {
2061
+ "--wf-drawer-width": `${drawerWidth}px`,
2062
+ height: shellHeight ? `${shellHeight}px` : undefined,
2063
+ maxHeight: shellHeight ? `${shellHeight}px` : undefined,
2064
+ };
1611
2065
  const showMiniMap = !modal;
1612
2066
  const shouldFitView = !pendingViewportRef.current;
1613
2067
 
@@ -1704,84 +2158,132 @@ const WorkflowEditor = ({ data }) => {
1704
2158
  };
1705
2159
  }, [drawerWidth, modal]);
1706
2160
 
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>
2161
+ useEffect(() => {
2162
+ const shellEl = shellRef.current;
2163
+ if (!shellEl) return;
2164
+ const cardEl = shellEl.closest(".card");
2165
+ const headerEl = cardEl?.querySelector?.(".card-header");
2166
+ if (headerEl && headerEl !== headerHost) {
2167
+ headerHtmlRef.current = headerEl.innerHTML || "";
2168
+ headerEl.dataset.wfHeaderInjected = "true";
2169
+ headerEl.classList.add("d-flex", "align-items-center");
2170
+ headerEl.innerHTML = "";
2171
+ setHeaderHost(headerEl);
2172
+ }
2173
+ // recalc height once we know our real position
2174
+ requestAnimationFrame(() => recomputeShellHeight());
2175
+ }, [headerHost, recomputeShellHeight]);
2176
+
2177
+ useLayoutEffect(() => {
2178
+ recomputeShellHeight();
2179
+
2180
+ // Debounce resize events to avoid jumpiness
2181
+ const handleResize = () => {
2182
+ if (heightDebounceRef.current) clearTimeout(heightDebounceRef.current);
2183
+ heightDebounceRef.current = setTimeout(() => {
2184
+ recomputeShellHeight();
2185
+ }, 100);
2186
+ };
2187
+
2188
+ window.addEventListener("resize", handleResize);
2189
+ return () => {
2190
+ window.removeEventListener("resize", handleResize);
2191
+ if (heightDebounceRef.current) clearTimeout(heightDebounceRef.current);
2192
+ };
2193
+ }, [recomputeShellHeight]);
2194
+
2195
+ useLayoutEffect(() => {
2196
+ requestAnimationFrame(() => recomputeShellHeight());
2197
+ }, [steps.length, recomputeShellHeight]);
2198
+
2199
+ const headerContent = (
2200
+ <div className="d-flex align-items-center w-100">
2201
+ <div className="d-flex align-items-center flex-wrap">
2202
+ {headerHtmlRef.current ? (
2203
+ <span
2204
+ className="d-flex align-items-center flex-wrap text-muted"
2205
+ dangerouslySetInnerHTML={{ __html: headerHtmlRef.current }}
2206
+ />
2207
+ ) : (
2208
+ <h5 className="m-0 fw-bold text-primary d-inline">
2209
+ {strings.configure}
2210
+ </h5>
2211
+ )}
2212
+ {message ? <span className="text-success ms-2">{message}</span> : null}
2213
+ {error ? <span className="text-danger ms-2">{error}</span> : null}
1740
2214
  </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)}
2215
+ <div className="wf-toolbar__actions ms-auto d-flex flex-wrap">
2216
+ <button
2217
+ className="btn btn-sm btn-outline-primary"
2218
+ onClick={() => openStepForm({ initial_step: !hasSteps })}
2219
+ >
2220
+ {strings.addStep}
2221
+ </button>
2222
+ <button
2223
+ className="btn btn-sm btn-outline-secondary ms-2"
2224
+ onClick={reload}
2225
+ disabled={loading}
1765
2226
  >
1766
- {showMiniMap && <MiniMap pannable zoomable />}
1767
- <Controls />
1768
- <Background gap={16} />
1769
- </ReactFlow>
2227
+ {loading ? strings.loading : strings.refresh}
2228
+ </button>
1770
2229
  </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
2230
  </div>
1784
2231
  );
2232
+
2233
+ return (
2234
+ <>
2235
+ {headerHost ? (
2236
+ createPortal(headerContent, headerHost)
2237
+ ) : (
2238
+ <div className="wf-toolbar">{headerContent}</div>
2239
+ )}
2240
+ <div className={shellClass} style={shellStyle} ref={shellRef}>
2241
+ {!hasSteps ? <div className="wf-empty">{strings.empty}</div> : null}
2242
+ <div className="wf-canvas">
2243
+ <ReactFlow
2244
+ nodesFocusable={true}
2245
+ edgesFocusable={true}
2246
+ disableKeyboardA11y={false}
2247
+ nodes={nodes}
2248
+ edges={edges}
2249
+ onNodesChange={onNodesChangeWrapped}
2250
+ onEdgesChange={onEdgesChange}
2251
+ onConnect={onConnect}
2252
+ onConnectStart={onConnectStart}
2253
+ onConnectEnd={onConnectEnd}
2254
+ onEdgeClick={onEdgeClick}
2255
+ onSelectionChange={handleSelectionChange}
2256
+ nodeTypes={nodeTypes}
2257
+ edgeTypes={edgeTypes}
2258
+ onInit={onInit}
2259
+ defaultViewport={pendingViewportRef.current || undefined}
2260
+ fitView={shouldFitView}
2261
+ proOptions={{ hideAttribution: true }}
2262
+ defaultEdgeOptions={{ animated: true }}
2263
+ colorMode={window._sc_lightmode || "light"}
2264
+ onMoveEnd={(_, viewport) => persistViewport(viewport)}
2265
+ >
2266
+ {showMiniMap && <MiniMap pannable zoomable />}
2267
+ <Controls />
2268
+ <Background gap={16} />
2269
+ </ReactFlow>
2270
+ </div>
2271
+ <StepDrawer
2272
+ modal={modal}
2273
+ innerRef={modalRef}
2274
+ outerRef={drawerRef}
2275
+ onClose={closeModal}
2276
+ submitting={savingModal}
2277
+ error={error && modal ? error : ""}
2278
+ data={data}
2279
+ onDelete={onDelete}
2280
+ onCopy={onCopy}
2281
+ drawerWidth={drawerWidth}
2282
+ onResizeStart={startResize}
2283
+ />
2284
+ </div>
2285
+ </>
2286
+ );
1785
2287
  };
1786
2288
 
1787
2289
  export default WorkflowEditor;