@visns-studio/visns-components 6.2.1 → 6.3.0

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
@@ -91,7 +91,7 @@
91
91
  "react-dom": "^17.0.0 || ^18.0.0"
92
92
  },
93
93
  "name": "@visns-studio/visns-components",
94
- "version": "6.2.1",
94
+ "version": "6.3.0",
95
95
  "description": "Various packages to assist in the development of our Custom Applications.",
96
96
  "main": "src/index.js",
97
97
  "files": [
@@ -168,6 +168,24 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
168
168
  * tab itself has already replaced.
169
169
  */
170
170
  const expectedDetailHashRef = useRef(null);
171
+
172
+ /**
173
+ * The `{label, detail}` the server is believed to hold, as a comparable
174
+ * string.
175
+ *
176
+ * Every authoring action on this screen — adding a question, rewording an
177
+ * option, changing a size — only moves React state. Nothing reaches the
178
+ * server until the template itself is saved, and until this ref existed
179
+ * there was no way to tell "on screen" from "stored": a tab could be closed
180
+ * on an afternoon's work and say nothing about it.
181
+ *
182
+ * Null until the first load lands. Before that there is no baseline, and
183
+ * everything would compare as changed.
184
+ */
185
+ const savedSnapshotRef = useRef(null);
186
+ const [unsavedChanges, setUnsavedChanges] = useState(false);
187
+ /** Bumped whenever the baseline moves, so the comparison below re-runs. */
188
+ const [savedBaselineTick, setSavedBaselineTick] = useState(0);
171
189
  const lockEnabled = Boolean(optimisticLock);
172
190
  const lockSchemaVersion =
173
191
  optimisticLock?.schemaVersion ?? DEFAULT_LOCK_SCHEMA_VERSION;
@@ -1397,6 +1415,20 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1397
1415
  (!field.options || field.options.length === 0)
1398
1416
  ) {
1399
1417
  errors.push('Please add options for the dropdown.');
1418
+ } else if (
1419
+ /**
1420
+ * A checkbox with no options renders as a bare label with nothing
1421
+ * to tick. Harmless-looking in the builder, and fatal in the field:
1422
+ * marked required, it cannot be answered, so the form it belongs to
1423
+ * can never be submitted. Dropdowns have always been held to this;
1424
+ * checkboxes were simply missed.
1425
+ */
1426
+ field.type === 'checkbox' &&
1427
+ (!field.options || field.options.length === 0)
1428
+ ) {
1429
+ errors.push(
1430
+ 'Please add at least one option for the checkbox — a checkbox with no options cannot be answered.'
1431
+ );
1400
1432
  } else if (
1401
1433
  field.type === 'table_text' &&
1402
1434
  (!field.options || field.options.length === 0)
@@ -1814,6 +1846,34 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1814
1846
  );
1815
1847
  };
1816
1848
 
1849
+ /** The comparable form of the two things this screen actually stores. */
1850
+ const snapshotOf = (label, detail) =>
1851
+ JSON.stringify({ label: label ?? '', detail: detail ?? [] });
1852
+
1853
+ /**
1854
+ * Re-anchor the unsaved-changes baseline on what the server now holds.
1855
+ *
1856
+ * Takes a partial: the sort endpoint writes `detail` alone and leaves the
1857
+ * label as it was, so rebuilding the whole baseline from the current
1858
+ * on-screen `data` would quietly adopt an unsaved rename as "stored".
1859
+ */
1860
+ const markSaved = ({ label, detail } = {}) => {
1861
+ const previous = savedSnapshotRef.current
1862
+ ? JSON.parse(savedSnapshotRef.current)
1863
+ : {};
1864
+
1865
+ savedSnapshotRef.current = snapshotOf(
1866
+ label === undefined ? previous.label : label,
1867
+ detail === undefined ? previous.detail : detail
1868
+ );
1869
+
1870
+ // Never `setUnsavedChanges(false)` directly: a partial save can leave
1871
+ // something else still unsaved. Re-ordering writes `detail` and not the
1872
+ // label, so declaring the screen clean here would lose a pending
1873
+ // rename. Let the one comparison decide.
1874
+ setSavedBaselineTick((tick) => tick + 1);
1875
+ };
1876
+
1817
1877
  /**
1818
1878
  * "Somebody else got here first", in the only words that help.
1819
1879
  *
@@ -1877,6 +1937,10 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1877
1937
 
1878
1938
  if (res.data.error === '') {
1879
1939
  await rememberDetailHash(data.id, savedDetail);
1940
+ markSaved({
1941
+ label: data.label,
1942
+ detail: savedDetail,
1943
+ });
1880
1944
 
1881
1945
  toast.success(
1882
1946
  "You have successfully updated the form's detail."
@@ -1912,7 +1976,31 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1912
1976
  return;
1913
1977
  }
1914
1978
 
1979
+ /**
1980
+ * Suppressing the shared handler's toast made this branch the
1981
+ * only voice the user has, so it must never be silent. It used
1982
+ * to `return` here, which turned an expired session (401
1983
+ * `Unauthenticated.`) — or any error body carrying neither
1984
+ * `errors` nor `message` — into a save that looked like it had
1985
+ * worked: nothing stored, and nothing said about it.
1986
+ */
1915
1987
  if (err?.response) {
1988
+ const status = err.response.status;
1989
+ const expired =
1990
+ status === 401 || message === 'Unauthenticated.';
1991
+
1992
+ toast.error(
1993
+ <div>
1994
+ <strong>Nothing was saved.</strong>{' '}
1995
+ {expired
1996
+ ? 'Your session has expired. Sign in again in another tab, then come back and save — your changes are still on screen.'
1997
+ : `The server refused the save${
1998
+ status ? ` (error ${status})` : ''
1999
+ }. Your changes are still on screen — try again, and report this if it keeps happening.`}
2000
+ </div>,
2001
+ { autoClose: false }
2002
+ );
2003
+
1916
2004
  return;
1917
2005
  }
1918
2006
  }
@@ -1934,6 +2022,10 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1934
2022
  // The version this tab is editing from. Everything the guard does
1935
2023
  // is measured against this moment.
1936
2024
  await rememberDetailHash(res.data?.id, res.data?.detail);
2025
+ markSaved({
2026
+ label: res.data?.label,
2027
+ detail: res.data?.detail,
2028
+ });
1937
2029
  } catch (err) {
1938
2030
  toast.error(`Error: ${err}`);
1939
2031
  }
@@ -1961,6 +2053,10 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1961
2053
  // here — but leaving the hash behind would make the next Save
1962
2054
  // report a collision this tab caused itself.
1963
2055
  await rememberDetailHash(data.id, savedDetail);
2056
+
2057
+ // Re-ordering is the one authoring action that reaches the server
2058
+ // on its own, so the unsaved-changes baseline has to follow it too.
2059
+ markSaved({ detail: savedDetail });
1964
2060
  } catch (err) {
1965
2061
  toast.error(`Error: ${err}`);
1966
2062
  }
@@ -1992,6 +2088,47 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1992
2088
  fetchRoles();
1993
2089
  }, []);
1994
2090
 
2091
+ /**
2092
+ * Is what is on screen different from what is stored?
2093
+ *
2094
+ * Compared against the baseline rather than tracked per-action, so it
2095
+ * cannot drift: undoing a change by hand correctly reports "all changes
2096
+ * saved" again, and no authoring path can forget to raise the flag.
2097
+ */
2098
+ useEffect(() => {
2099
+ if (savedSnapshotRef.current === null) {
2100
+ return;
2101
+ }
2102
+
2103
+ setUnsavedChanges(
2104
+ snapshotOf(data.label, data.detail) !== savedSnapshotRef.current
2105
+ );
2106
+ }, [data, savedBaselineTick]);
2107
+
2108
+ /**
2109
+ * The browser's own "leave site?" prompt, and only while there is something
2110
+ * to lose. Last line of defence for the failure this screen is prone to: a
2111
+ * question window's button reads as final, so a template can be closed in
2112
+ * the belief that its questions were filed.
2113
+ */
2114
+ useEffect(() => {
2115
+ if (!unsavedChanges) {
2116
+ return undefined;
2117
+ }
2118
+
2119
+ const warnBeforeLeaving = (event) => {
2120
+ event.preventDefault();
2121
+ event.returnValue = '';
2122
+
2123
+ return '';
2124
+ };
2125
+
2126
+ window.addEventListener('beforeunload', warnBeforeLeaving);
2127
+
2128
+ return () =>
2129
+ window.removeEventListener('beforeunload', warnBeforeLeaving);
2130
+ }, [unsavedChanges]);
2131
+
1995
2132
  /* ---------------------------------------------------------------- */
1996
2133
  /* Guided help */
1997
2134
  /* ---------------------------------------------------------------- */
@@ -2201,8 +2338,10 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
2201
2338
  border: '#dc2626',
2202
2339
  text: '#991b1b',
2203
2340
  items: [
2204
- '<strong>Save</strong> inside a question window only adds it to the list on screen — nothing is stored yet',
2205
- 'To store the template, click <strong>Edit Form</strong> (bottom right) and then <strong>Save</strong>',
2341
+ '<strong>Apply</strong> inside a question window only adds it to the list on screen — nothing is stored yet',
2342
+ 'To store the template, click <strong>Save Template</strong> (bottom right)',
2343
+ 'The label beside those buttons reads <strong>Unsaved changes</strong> until you do, and the browser warns you if you try to leave',
2344
+ '<strong>Edit Form</strong> is only for renaming the template',
2206
2345
  'Moving a question saves the whole template straight away, so the order is never lost',
2207
2346
  'If someone else changed this template while you had it open, you are told nothing was saved — reload the page before saving again, or your changes would undo theirs',
2208
2347
  ],
@@ -2566,6 +2705,20 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
2566
2705
  )}
2567
2706
  </div>
2568
2707
  <div className={styles.polActions}>
2708
+ {/* The only honest answer to "have my changes been
2709
+ stored?". Everything else on this screen edits a
2710
+ copy held in the browser. */}
2711
+ <span
2712
+ className={`${styles.saveState} ${
2713
+ unsavedChanges
2714
+ ? styles.saveStateDirty
2715
+ : styles.saveStateClean
2716
+ }`}
2717
+ >
2718
+ {unsavedChanges
2719
+ ? 'Unsaved changes'
2720
+ : 'All changes saved'}
2721
+ </span>
2569
2722
  {/* Nothing to author on a dynamic template, but
2570
2723
  Edit Form stays so it can still be renamed. */}
2571
2724
  {!dynamicFields && (
@@ -2582,6 +2735,19 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
2582
2735
  >
2583
2736
  Edit Form
2584
2737
  </button>
2738
+ {/* Was reachable only through Edit Form — a window
2739
+ titled "Update Form Detail" showing nothing but
2740
+ the label, so the action that stores the
2741
+ questions looked like the one that renames the
2742
+ form. It belongs out here, beside the work. */}
2743
+ <button
2744
+ className={`${styles.btn} ${styles.btnPrimary}`}
2745
+ onClick={handleSubmit}
2746
+ data-tooltip-id="system-tooltip"
2747
+ data-tooltip-content="Store this template. Question windows only stage changes on screen."
2748
+ >
2749
+ Save Template
2750
+ </button>
2585
2751
  </div>
2586
2752
  </div>
2587
2753
  </div>
@@ -3399,11 +3565,23 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
3399
3565
  <div
3400
3566
  className={`${styles.formItem} ${styles.fwItem} ${styles.lastItem}`}
3401
3567
  >
3568
+ {/* This window has never stored
3569
+ anything — it edits the copy held in
3570
+ the browser. Calling its button
3571
+ "Save" was the whole reason people
3572
+ left the screen believing their work
3573
+ had been filed. */}
3574
+ <p className={styles.applyHint}>
3575
+ Adds this question to the list on
3576
+ screen. Use{' '}
3577
+ <strong>Save Template</strong> to
3578
+ store it.
3579
+ </p>
3402
3580
  <button
3403
3581
  className={`${styles.btn}`}
3404
3582
  onClick={handleSaveField}
3405
3583
  >
3406
- Save
3584
+ Apply
3407
3585
  </button>
3408
3586
  </div>
3409
3587
  </form>
@@ -643,7 +643,8 @@ select:not(:placeholder-shown) + .fi__span {
643
643
  overflow: visible;
644
644
  position: relative;
645
645
  z-index: 100;
646
- box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15),
646
+ box-shadow:
647
+ 0 20px 60px rgba(0, 0, 0, 0.15),
647
648
  0 0 0 1px rgba(var(--primary-rgb), 0.06);
648
649
  }
649
650
 
@@ -786,9 +787,6 @@ select:not(:placeholder-shown) + .fi__span {
786
787
 
787
788
  /* Adding btn class */
788
789
 
789
-
790
-
791
-
792
790
  /* Floating action buttons */
793
791
  .polActions {
794
792
  position: fixed;
@@ -801,9 +799,11 @@ select:not(:placeholder-shown) + .fi__span {
801
799
  background: rgba(255, 255, 255, 0.95);
802
800
  padding: 10px 14px;
803
801
  border-radius: 12px;
804
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12),
802
+ box-shadow:
803
+ 0 4px 20px rgba(0, 0, 0, 0.12),
805
804
  0 0 0 1px rgba(var(--primary-rgb), 0.08);
806
805
  backdrop-filter: blur(8px);
806
+ align-items: center;
807
807
 
808
808
  button {
809
809
  display: block;
@@ -814,6 +814,50 @@ select:not(:placeholder-shown) + .fi__span {
814
814
  }
815
815
  }
816
816
 
817
+ /* Stored or not — the one thing this screen never used to say out loud. */
818
+ .saveState {
819
+ display: inline-flex;
820
+ align-items: center;
821
+ gap: 6px;
822
+ padding: 0 6px;
823
+ font-size: 0.8rem;
824
+ font-weight: 600;
825
+ white-space: nowrap;
826
+
827
+ &::before {
828
+ content: '';
829
+ width: 8px;
830
+ height: 8px;
831
+ border-radius: 50%;
832
+ background: currentColor;
833
+ }
834
+ }
835
+
836
+ .saveStateDirty {
837
+ color: #b45309;
838
+ }
839
+
840
+ .saveStateClean {
841
+ color: #15803d;
842
+ }
843
+
844
+ /* The save that actually writes, weighted so it reads as the end of the job. */
845
+ .btnPrimary {
846
+ box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.35);
847
+ }
848
+
849
+ /* Sits under the question window's Apply, where the misunderstanding happens. */
850
+ .applyHint {
851
+ flex: 1;
852
+ align-self: center;
853
+ margin: 0;
854
+ padding-right: 1rem;
855
+ font-size: 0.78rem;
856
+ line-height: 1.35;
857
+ color: var(--text-color-light, #6b7280);
858
+ text-align: left;
859
+ }
860
+
817
861
  .fwItem {
818
862
  flex-basis: 100%;
819
863
  }
@@ -1337,6 +1381,12 @@ select:not(:placeholder-shown) + .fi__span {
1337
1381
  .dragHandle {
1338
1382
  color: var(--primary-color) !important;
1339
1383
  cursor: grab;
1384
+ /* Same reason as .cGrip: on iPadOS the browser treats a press-and-move on
1385
+ this handle as a scroll unless the element opts out, so the drag never
1386
+ activates and reordering is silently unavailable. */
1387
+ touch-action: none;
1388
+ -webkit-user-select: none;
1389
+ user-select: none;
1340
1390
  padding: 4px;
1341
1391
  border-radius: 6px;
1342
1392
  background: white;
@@ -2047,7 +2097,8 @@ select:not(:placeholder-shown) + .fi__span {
2047
2097
  border-radius: 12px;
2048
2098
  padding: 1.25rem 1.5rem;
2049
2099
  width: 280px;
2050
- box-shadow: 0 16px 48px rgba(0, 0, 0, 0.18),
2100
+ box-shadow:
2101
+ 0 16px 48px rgba(0, 0, 0, 0.18),
2051
2102
  0 0 0 1px rgba(var(--primary-rgb), 0.08);
2052
2103
 
2053
2104
  h3 {
@@ -2472,7 +2523,6 @@ select:not(:placeholder-shown) + .fi__span {
2472
2523
  margin-right: -1.15rem !important;
2473
2524
  }
2474
2525
 
2475
-
2476
2526
  /* The header and search span both columns; only the rail and canvas sit
2477
2527
  side by side. */
2478
2528
 
@@ -2622,9 +2672,6 @@ select:not(:placeholder-shown) + .fi__span {
2622
2672
 
2623
2673
  /* ---- compact rows ---- */
2624
2674
 
2625
-
2626
-
2627
-
2628
2675
  .duplicateBtn {
2629
2676
  display: flex;
2630
2677
  align-items: center;
@@ -2705,6 +2752,12 @@ select:not(:placeholder-shown) + .fi__span {
2705
2752
  align-items: center;
2706
2753
  color: rgba(var(--paragraph-rgb), 0.4);
2707
2754
  cursor: grab;
2755
+ /* dnd-kit's PointerSensor cannot start a drag from an element the browser
2756
+ has already claimed for scrolling. Without this the grip does nothing at
2757
+ all on a touch screen — the page just scrolls under the finger. */
2758
+ touch-action: none;
2759
+ -webkit-user-select: none;
2760
+ user-select: none;
2708
2761
 
2709
2762
  &:active {
2710
2763
  cursor: grabbing;