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

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