@saltcorn/workflow-editor 1.6.0-alpha.7 → 1.6.0-alpha.8

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.7",
3
+ "version": "1.6.0-alpha.8",
4
4
  "description": "React Flow-based workflow editor for Saltcorn",
5
5
  "main": "index.js",
6
6
  "homepage": "https://saltcorn.com",
@@ -4,6 +4,7 @@ import React, {
4
4
  useRef,
5
5
  useState,
6
6
  useLayoutEffect,
7
+ useMemo,
7
8
  } from "react";
8
9
  import {
9
10
  Background,
@@ -699,6 +700,7 @@ const buildGraph = (
699
700
  const StepDrawer = ({
700
701
  modal,
701
702
  innerRef,
703
+ outerRef,
702
704
  onClose,
703
705
  submitting,
704
706
  error,
@@ -710,6 +712,7 @@ const StepDrawer = ({
710
712
  const isOpen = !!modal;
711
713
  return (
712
714
  <div
715
+ ref={outerRef}
713
716
  className={`wf-drawer${isOpen ? " wf-drawer--open" : ""}`}
714
717
  role="dialog"
715
718
  aria-modal="false"
@@ -799,17 +802,99 @@ const WorkflowEditor = ({ data }) => {
799
802
  return 600;
800
803
  });
801
804
  const modalRef = useRef(null);
805
+ const drawerRef = useRef(null);
806
+ const shellRef = useRef(null);
802
807
  const resizingRef = useRef(false);
803
808
  const connectSourceRef = useRef(null);
804
809
  const [selectedEdgeId, setSelectedEdgeId] = useState(null);
805
810
 
811
+ const drawerStorageKey = useMemo(
812
+ () => `wf-drawer:${data?.trigger?.id || "default"}`,
813
+ [data?.trigger?.id]
814
+ );
815
+
816
+ const viewportStorageKey = useMemo(
817
+ () => `wf-viewport:${data?.trigger?.id || "default"}`,
818
+ [data?.trigger?.id]
819
+ );
820
+
806
821
  const rfInstanceRef = useRef(null);
807
822
 
823
+ const persistDrawerState = useCallback(
824
+ (payload) => {
825
+ try {
826
+ if (typeof window === "undefined") return;
827
+ window.sessionStorage.setItem(
828
+ drawerStorageKey,
829
+ JSON.stringify(payload || {})
830
+ );
831
+ } catch (e) {
832
+ // ignore storage errors
833
+ }
834
+ },
835
+ [drawerStorageKey]
836
+ );
837
+
838
+ const clearDrawerState = useCallback(() => {
839
+ try {
840
+ if (typeof window === "undefined") return;
841
+ window.sessionStorage.removeItem(drawerStorageKey);
842
+ } catch (e) {
843
+ // ignore storage errors
844
+ }
845
+ }, [drawerStorageKey]);
846
+
847
+ const loadViewport = useCallback(() => {
848
+ if (typeof window === "undefined") return null;
849
+ try {
850
+ const raw = window.sessionStorage.getItem(viewportStorageKey);
851
+ if (!raw) return null;
852
+ const parsed = JSON.parse(raw);
853
+ if (!parsed || typeof parsed !== "object") return null;
854
+ const { x, y, zoom } = parsed;
855
+ if (
856
+ Number.isFinite(Number(x)) &&
857
+ Number.isFinite(Number(y)) &&
858
+ Number.isFinite(Number(zoom))
859
+ ) {
860
+ return { x: Number(x), y: Number(y), zoom: Number(zoom) };
861
+ }
862
+ } catch (e) {
863
+ // ignore storage errors
864
+ }
865
+ return null;
866
+ }, [viewportStorageKey]);
867
+
868
+ const persistViewport = useCallback(
869
+ (viewport) => {
870
+ if (!viewport) return;
871
+ const { x, y, zoom } = viewport;
872
+ if (
873
+ !Number.isFinite(Number(x)) ||
874
+ !Number.isFinite(Number(y)) ||
875
+ !Number.isFinite(Number(zoom))
876
+ )
877
+ return;
878
+ try {
879
+ if (typeof window === "undefined") return;
880
+ window.sessionStorage.setItem(
881
+ viewportStorageKey,
882
+ JSON.stringify({ x: Number(x), y: Number(y), zoom: Number(zoom) })
883
+ );
884
+ } catch (e) {
885
+ // ignore storage errors
886
+ }
887
+ },
888
+ [viewportStorageKey]
889
+ );
890
+
808
891
  const onInit = useCallback((instance) => {
809
892
  rfInstanceRef.current = instance;
810
893
  }, []);
811
894
 
812
895
  const [selectedNodes, setSelectedNodes] = useState([]);
896
+ const pendingViewportRef = useRef(loadViewport());
897
+ const appliedViewportRef = useRef(false);
813
898
 
814
899
  const handleSelectionChange = useCallback(({ nodes = [], edges = [] }) => {
815
900
  setSelectedNodes(nodes);
@@ -868,6 +953,16 @@ const WorkflowEditor = ({ data }) => {
868
953
  refreshGraph(steps);
869
954
  }, [steps, refreshGraph]);
870
955
 
956
+ useEffect(() => {
957
+ if (!rfInstanceRef.current) return;
958
+ if (!pendingViewportRef.current) return;
959
+ if (appliedViewportRef.current) return;
960
+ rfInstanceRef.current.setViewport(pendingViewportRef.current, {
961
+ duration: 0,
962
+ });
963
+ appliedViewportRef.current = true;
964
+ }, [nodes]);
965
+
871
966
  useEffect(() => {
872
967
  window.addEventListener("keydown", handleKeyDelete);
873
968
  return () => window.removeEventListener("keydown", handleKeyDelete);
@@ -972,7 +1067,6 @@ const WorkflowEditor = ({ data }) => {
972
1067
 
973
1068
  const reload = useCallback(async () => {
974
1069
  setLoading(true);
975
- setModal(null);
976
1070
  setError("");
977
1071
  try {
978
1072
  let fresh = await fetchJson(data.urls.data);
@@ -1117,6 +1211,12 @@ const WorkflowEditor = ({ data }) => {
1117
1211
  async ({ stepId, initial_step, after_step } = {}) => {
1118
1212
  try {
1119
1213
  setError("");
1214
+ const descriptor = {
1215
+ stepId: stepId || null,
1216
+ initial_step: !!initial_step,
1217
+ after_step: after_step || null,
1218
+ };
1219
+ persistDrawerState(descriptor);
1120
1220
  const base = `${data.urls.stepForm}${stepId ? `/${stepId}` : ""}`;
1121
1221
  const url = new URL(base, window.location.origin);
1122
1222
  url.searchParams.set("render", "dialog");
@@ -1125,10 +1225,11 @@ const WorkflowEditor = ({ data }) => {
1125
1225
  const res = await fetchJson(url.toString());
1126
1226
  setModal({ title: res.title, body: res.form, stepId: stepId || null });
1127
1227
  } catch (e) {
1228
+ clearDrawerState();
1128
1229
  setError(e.message);
1129
1230
  }
1130
1231
  },
1131
- [data.urls.stepForm, fetchJson]
1232
+ [clearDrawerState, data.urls.stepForm, fetchJson, persistDrawerState]
1132
1233
  );
1133
1234
 
1134
1235
  const handleModalSubmit = useCallback(
@@ -1149,9 +1250,10 @@ const WorkflowEditor = ({ data }) => {
1149
1250
  const json = await res.json();
1150
1251
  if (json.error) throw new Error(json.error);
1151
1252
  setModal(null);
1253
+ clearDrawerState();
1152
1254
  await reload();
1153
1255
  },
1154
- [data.csrfToken, data.urls.stepForm, reload]
1256
+ [clearDrawerState, data.csrfToken, data.urls.stepForm, reload]
1155
1257
  );
1156
1258
 
1157
1259
  useEffect(() => {
@@ -1193,6 +1295,19 @@ const WorkflowEditor = ({ data }) => {
1193
1295
  };
1194
1296
  }, [handleModalSubmit, modal]);
1195
1297
 
1298
+ useEffect(() => {
1299
+ if (typeof window === "undefined") return undefined;
1300
+ const raw = window.sessionStorage.getItem(drawerStorageKey);
1301
+ if (!raw) return undefined;
1302
+ try {
1303
+ const parsed = JSON.parse(raw);
1304
+ if (parsed && typeof parsed === "object") openStepForm(parsed);
1305
+ } catch (e) {
1306
+ clearDrawerState();
1307
+ }
1308
+ return undefined;
1309
+ }, [clearDrawerState, drawerStorageKey, openStepForm]);
1310
+
1196
1311
  const updateConnection = useCallback(
1197
1312
  async (payload) => {
1198
1313
  const body = new URLSearchParams();
@@ -1243,7 +1358,9 @@ const WorkflowEditor = ({ data }) => {
1243
1358
  : "";
1244
1359
  const simpleNextId = rawNext && idByName[rawNext];
1245
1360
  const targetHasNext = !!(
1246
- tgtStep && tgtStep.next_step && String(tgtStep.next_step).trim()
1361
+ tgtStep &&
1362
+ tgtStep.next_step &&
1363
+ String(tgtStep.next_step).trim()
1247
1364
  );
1248
1365
 
1249
1366
  if (
@@ -1409,15 +1526,22 @@ const WorkflowEditor = ({ data }) => {
1409
1526
  const onDelete = useCallback(
1410
1527
  async (id) => {
1411
1528
  if (!window.confirm(strings.confirmDelete)) return;
1412
- const formData = new FormData();
1413
- formData.append("_csrf", data.csrfToken || "");
1414
- await fetchJson(`${data.urls.deleteStep}/${id}`, {
1415
- method: "POST",
1416
- body: formData,
1417
- });
1418
- await reload();
1529
+ 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
+ });
1536
+ setModal(null);
1537
+ setError("");
1538
+ clearDrawerState();
1539
+ await reload();
1540
+ } catch (e) {
1541
+ setError(e.message);
1542
+ }
1419
1543
  },
1420
- [data.csrfToken, fetchJson, reload, strings.confirmDelete]
1544
+ [clearDrawerState, data.csrfToken, fetchJson, reload, strings.confirmDelete]
1421
1545
  );
1422
1546
 
1423
1547
  useEffect(() => {
@@ -1485,6 +1609,7 @@ const WorkflowEditor = ({ data }) => {
1485
1609
  const shellClass = modal ? "wf-shell wf-shell--drawer-open" : "wf-shell";
1486
1610
  const shellStyle = { "--wf-drawer-width": `${drawerWidth}px` };
1487
1611
  const showMiniMap = !modal;
1612
+ const shouldFitView = !pendingViewportRef.current;
1488
1613
 
1489
1614
  const onEdgeClick = useCallback((_, edge) => {
1490
1615
  setSelectedEdgeId(edge.id);
@@ -1537,8 +1662,50 @@ const WorkflowEditor = ({ data }) => {
1537
1662
 
1538
1663
  useEffect(() => () => stopResize(), [stopResize]);
1539
1664
 
1665
+ const closeModal = useCallback(() => {
1666
+ setModal(null);
1667
+ setError("");
1668
+ clearDrawerState();
1669
+ }, [clearDrawerState]);
1670
+
1671
+ useLayoutEffect(() => {
1672
+ const shellEl = shellRef.current;
1673
+ const drawerEl = drawerRef.current;
1674
+ if (!shellEl) return undefined;
1675
+ const hasResizeObserver = typeof ResizeObserver !== "undefined";
1676
+
1677
+ const computePadding = () => {
1678
+ if (!drawerEl || !modal) {
1679
+ shellEl.style.removeProperty("--wf-shell-padding-right");
1680
+ return;
1681
+ }
1682
+ const shellRect = shellEl.getBoundingClientRect();
1683
+ const drawerRect = drawerEl.getBoundingClientRect();
1684
+ // Space already available to the right of the shell (e.g. due to sidebars)
1685
+ const naturalGap = Math.max(0, window.innerWidth - shellRect.right);
1686
+ const needed = Math.max(0, drawerRect.width - naturalGap) + 4;
1687
+ shellEl.style.setProperty("--wf-shell-padding-right", `${needed}px`);
1688
+ };
1689
+
1690
+ computePadding();
1691
+
1692
+ const resizeObserver = hasResizeObserver
1693
+ ? new ResizeObserver(() => computePadding())
1694
+ : null;
1695
+ if (resizeObserver) {
1696
+ resizeObserver.observe(shellEl);
1697
+ if (drawerEl) resizeObserver.observe(drawerEl);
1698
+ }
1699
+ window.addEventListener("resize", computePadding);
1700
+
1701
+ return () => {
1702
+ if (resizeObserver) resizeObserver.disconnect();
1703
+ window.removeEventListener("resize", computePadding);
1704
+ };
1705
+ }, [drawerWidth, modal]);
1706
+
1540
1707
  return (
1541
- <div className={shellClass} style={shellStyle}>
1708
+ <div className={shellClass} style={shellStyle} ref={shellRef}>
1542
1709
  <div className="wf-toolbar">
1543
1710
  <div className="wf-toolbar__left">
1544
1711
  <strong>{strings.configure}</strong>
@@ -1589,10 +1756,12 @@ const WorkflowEditor = ({ data }) => {
1589
1756
  nodeTypes={nodeTypes}
1590
1757
  edgeTypes={edgeTypes}
1591
1758
  onInit={onInit}
1592
- fitView
1759
+ defaultViewport={pendingViewportRef.current || undefined}
1760
+ fitView={shouldFitView}
1593
1761
  proOptions={{ hideAttribution: true }}
1594
1762
  defaultEdgeOptions={{ animated: true }}
1595
1763
  colorMode={window._sc_lightmode || "light"}
1764
+ onMoveEnd={(_, viewport) => persistViewport(viewport)}
1596
1765
  >
1597
1766
  {showMiniMap && <MiniMap pannable zoomable />}
1598
1767
  <Controls />
@@ -1602,10 +1771,8 @@ const WorkflowEditor = ({ data }) => {
1602
1771
  <StepDrawer
1603
1772
  modal={modal}
1604
1773
  innerRef={modalRef}
1605
- onClose={() => {
1606
- setModal(null);
1607
- setError("");
1608
- }}
1774
+ outerRef={drawerRef}
1775
+ onClose={closeModal}
1609
1776
  submitting={savingModal}
1610
1777
  error={error && modal ? error : ""}
1611
1778
  data={data}
package/src/workflow.css CHANGED
@@ -1,5 +1,6 @@
1
1
  .wf-shell {
2
2
  padding: 0;
3
+ padding-right: var(--wf-shell-padding-right, 0px);
3
4
  height: 100%;
4
5
  min-height: 0;
5
6
  display: flex;
@@ -14,10 +15,7 @@
14
15
  --wf-handle-bg: #fff;
15
16
  --wf-handle-source-bg: #5a5a5a;
16
17
  --wf-drawer-width: 50vw;
17
- }
18
-
19
- .wf-shell--drawer-open {
20
- padding-right: calc(var(--wf-drawer-width) - 44px);
18
+ --wf-drawer-offset: -4px;
21
19
  }
22
20
 
23
21
  .wf-toolbar {
@@ -49,6 +47,10 @@
49
47
  .wf-shell .react-flow__edge-labels {
50
48
  z-index: 2;
51
49
  }
50
+ .react-flow .react-flow__edges svg {
51
+ overflow: visible !important;
52
+ z-index: 1040;
53
+ }
52
54
 
53
55
  .wf-empty {
54
56
  padding: 12px;
@@ -256,7 +258,7 @@
256
258
 
257
259
  @media (max-width: 768px) {
258
260
  .wf-shell--drawer-open {
259
- padding-right: 0;
261
+ padding-right: var(--wf-shell-padding-right, 0px);
260
262
  }
261
263
 
262
264
  .wf-drawer {