@visns-studio/visns-components 6.1.0 → 6.1.1

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.1.0",
94
+ "version": "6.1.1",
95
95
  "description": "Various packages to assist in the development of our Custom Applications.",
96
96
  "main": "src/index.js",
97
97
  "files": [
@@ -1823,6 +1823,23 @@ const DataGrid = forwardRef(
1823
1823
  )
1824
1824
  );
1825
1825
 
1826
+ /**
1827
+ * A settings entry reserved for the platform owner.
1828
+ *
1829
+ * `roles` cannot express this: it matches Spatie role *names*, and
1830
+ * super admin is deliberately not a role — roles are handed out from
1831
+ * the console by the very administrators this privilege sits above.
1832
+ * Navigation.jsx already reads `superAdminOnly` on menu entries the
1833
+ * same way; this is the row-action twin of it.
1834
+ *
1835
+ * Presentation only, like every check in this file: it takes the icon
1836
+ * away, it does not protect the endpoint behind it. A setting without
1837
+ * the flag is unaffected, so this is dormant for every config that
1838
+ * does not opt in.
1839
+ */
1840
+ const settingAllowedBySuperAdmin = (s) =>
1841
+ s?.superAdminOnly !== true || Boolean(userProfile?.is_super_admin);
1842
+
1826
1843
  const shouldShowGroupAction = (iconConfig, groupValue) => {
1827
1844
  // `showAll` is the every-row form of `show`: the icon appears only
1828
1845
  // when the condition holds for the whole group (e.g. close a tag
@@ -2934,6 +2951,12 @@ const DataGrid = forwardRef(
2934
2951
  }
2935
2952
  }
2936
2953
 
2954
+ // Owner-only actions (import a form template, etc.) — see
2955
+ // settingAllowedBySuperAdmin
2956
+ if (!settingAllowedBySuperAdmin(s)) {
2957
+ allow = false;
2958
+ }
2959
+
2937
2960
  if (s.active) {
2938
2961
  // Helper function to get nested values for main active condition (searches for target value)
2939
2962
  const getNestedValueForMain = (data, path, targetValue) => {
@@ -4186,6 +4209,16 @@ const DataGrid = forwardRef(
4186
4209
  if (actionColumnSettingIds.has(setting.id)) {
4187
4210
  return false;
4188
4211
  }
4212
+ // Owner-only actions never render for anyone else,
4213
+ // so they must not reserve width either — a grid
4214
+ // whose only actions are super-admin ones gets no
4215
+ // Action column at all.
4216
+ if (
4217
+ setting.superAdminOnly === true &&
4218
+ !userProfile?.is_super_admin
4219
+ ) {
4220
+ return false;
4221
+ }
4189
4222
  if (
4190
4223
  setting.roles &&
4191
4224
  Array.isArray(setting.roles) &&
@@ -63,6 +63,72 @@ import {
63
63
 
64
64
  import styles from '../styles/GenericFormBuilder.module.scss'; // Import the CSS module
65
65
 
66
+ /**
67
+ * Optimistic locking for the builder — opt-in, and dormant without it.
68
+ *
69
+ * The builder's PUT has always been a blind overwrite: whatever the tab holds
70
+ * wins, however old it is. That is survivable while the only way to change a
71
+ * template is the builder itself, and stops being survivable the moment
72
+ * something else can rewrite one — a spreadsheet import, say — because a tab
73
+ * left open across the import silently undoes it on the next Save.
74
+ *
75
+ * The handle is a content hash of `detail`, declared by the writer as "this is
76
+ * the version I believe I am overwriting". Backends that understand it refuse
77
+ * the write with 409 when the row has moved on; backends that do not simply
78
+ * ignore an unknown field, which is why this ships behind a config flag rather
79
+ * than on for everybody: a model with `$guarded = []` would try to write it as
80
+ * a column.
81
+ *
82
+ * @see FormTemplate::expectDetailHash() and FormTemplateEnvelope::hashDetail()
83
+ * in the Prime backend — the canonical encoding below is that method's
84
+ * twin and must not drift from it.
85
+ */
86
+ const DEFAULT_LOCK_SCHEMA_VERSION = 1;
87
+
88
+ /**
89
+ * The content hash of a template's `detail`, computed exactly as the server
90
+ * computes it.
91
+ *
92
+ * The server hashes `json_encode(['schema_version' => N, 'id' => id,
93
+ * 'detail' => detail], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)`.
94
+ * `JSON.stringify` of the same values is byte-identical to that: both leave
95
+ * slashes and non-ASCII raw, both use the same short escapes, and `detail`
96
+ * arrived here as JSON so its key order and value types are already whatever
97
+ * PHP encoded. Verified against all 35 live templates before this shipped —
98
+ * every hash matched.
99
+ *
100
+ * Returns null rather than guessing when it cannot be computed (no SubtleCrypto
101
+ * outside a secure context, no numeric id yet). A null hash is simply not sent,
102
+ * which leaves the save exactly as unguarded as it was before — the wrong
103
+ * hash would reject every save, and a missing one rejects none.
104
+ */
105
+ const detailContentHash = async (templateId, detail, schemaVersion) => {
106
+ const subtle = globalThis.crypto?.subtle;
107
+
108
+ if (!subtle || typeof templateId !== 'number') {
109
+ return null;
110
+ }
111
+
112
+ const canonical = JSON.stringify({
113
+ schema_version: schemaVersion,
114
+ id: templateId,
115
+ detail: detail ?? [],
116
+ });
117
+
118
+ try {
119
+ const digest = await subtle.digest(
120
+ 'SHA-256',
121
+ new TextEncoder().encode(canonical)
122
+ );
123
+
124
+ return [...new Uint8Array(digest)]
125
+ .map((byte) => byte.toString(16).padStart(2, '0'))
126
+ .join('');
127
+ } catch (error) {
128
+ return null;
129
+ }
130
+ };
131
+
66
132
  function GenericFormBuilder({ setting, urlParam, userProfile }) {
67
133
  const editorRef = useRef(null);
68
134
  const routeParams = useParams();
@@ -79,10 +145,30 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
79
145
  * `outstanding_when` handling below does anything at all.
80
146
  */
81
147
  outstandingItems,
148
+ /**
149
+ * Opt-in collision guard. `true`, or `{ schemaVersion: n }` when the
150
+ * backend's envelope version is not 1. Absent for every app whose
151
+ * backend does not understand `expected_detail_hash`, in which case
152
+ * nothing below sends one and the save is unchanged.
153
+ */
154
+ optimisticLock,
82
155
  } = setting;
83
156
 
84
157
  const { dataId } = useParams();
85
158
  const [data, setData] = useState({});
159
+
160
+ /**
161
+ * The hash of the `detail` the server is believed to hold right now.
162
+ *
163
+ * Set from the load, and re-anchored after every save this tab makes — a
164
+ * hash describes one write, so leaving the load-time value in place would
165
+ * make the second save of a session compare against a version that this
166
+ * tab itself has already replaced.
167
+ */
168
+ const expectedDetailHashRef = useRef(null);
169
+ const lockEnabled = Boolean(optimisticLock);
170
+ const lockSchemaVersion =
171
+ optimisticLock?.schemaVersion ?? DEFAULT_LOCK_SCHEMA_VERSION;
86
172
  const [dataField, setDataField] = useState({
87
173
  id: '',
88
174
  label: '',
@@ -1707,6 +1793,49 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1707
1793
  }
1708
1794
  };
1709
1795
 
1796
+ /**
1797
+ * Re-anchor the collision guard on the version just written.
1798
+ *
1799
+ * Called after a save rather than after a re-fetch: the payload that was
1800
+ * accepted IS what the row now holds, and asking the server again would
1801
+ * cost a round trip to learn something already known.
1802
+ */
1803
+ const rememberDetailHash = async (templateId, detail) => {
1804
+ if (!lockEnabled) {
1805
+ return;
1806
+ }
1807
+
1808
+ expectedDetailHashRef.current = await detailContentHash(
1809
+ templateId,
1810
+ detail,
1811
+ lockSchemaVersion
1812
+ );
1813
+ };
1814
+
1815
+ /**
1816
+ * "Somebody else got here first", in the only words that help.
1817
+ *
1818
+ * No silent retry, ever. A retry would re-send this tab's whole `detail`
1819
+ * over the top of whatever arrived in the meantime, which is precisely the
1820
+ * overwrite the guard exists to prevent.
1821
+ */
1822
+ const handleStaleTemplate = (payload) => {
1823
+ const versions =
1824
+ payload?.expected_version && payload?.current_version
1825
+ ? ` (you have ${payload.expected_version}, the system now has ${payload.current_version})`
1826
+ : '';
1827
+
1828
+ toast.error(
1829
+ <div>
1830
+ <strong>Nothing was saved.</strong> This template was changed
1831
+ elsewhere — imported from a spreadsheet, or edited in another
1832
+ tab — after you opened it{versions}. Reload the page before
1833
+ saving, or your changes would undo theirs.
1834
+ </div>,
1835
+ { autoClose: false }
1836
+ );
1837
+ };
1838
+
1710
1839
  const handleSubmit = async (e) => {
1711
1840
  try {
1712
1841
  if (e) {
@@ -1719,15 +1848,34 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1719
1848
  }
1720
1849
 
1721
1850
  if (_error === '') {
1851
+ const savedDetail = data.detail;
1852
+
1853
+ // The guard answers a 409 in its own words, so the shared
1854
+ // handler's toast is suppressed and replayed in the catch
1855
+ // for every other failure — same message, same behaviour,
1856
+ // just deferred until the status is known.
1722
1857
  const res = await CustomFetch(
1723
1858
  `${fetchUrl}/${dataId}`,
1724
1859
  'PUT',
1725
1860
  {
1726
1861
  ...data,
1727
- }
1862
+ // Only when the backend understands it, and only
1863
+ // when a hash could actually be computed. Absent,
1864
+ // the server saves exactly as it always has.
1865
+ ...(lockEnabled && expectedDetailHashRef.current
1866
+ ? {
1867
+ expected_detail_hash:
1868
+ expectedDetailHashRef.current,
1869
+ }
1870
+ : {}),
1871
+ },
1872
+ null,
1873
+ lockEnabled ? () => {} : null
1728
1874
  );
1729
1875
 
1730
1876
  if (res.data.error === '') {
1877
+ await rememberDetailHash(data.id, savedDetail);
1878
+
1731
1879
  toast.success(
1732
1880
  "You have successfully updated the form's detail."
1733
1881
  );
@@ -1741,6 +1889,32 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1741
1889
  }
1742
1890
  }
1743
1891
  } catch (err) {
1892
+ if (err?.response?.status === 409) {
1893
+ handleStaleTemplate(err.response.data);
1894
+
1895
+ return;
1896
+ }
1897
+
1898
+ if (lockEnabled) {
1899
+ // Replay what the shared handler would have said, in the same
1900
+ // order it would have said it — field errors before the
1901
+ // envelope's generic message.
1902
+ const payload = err?.response?.data;
1903
+ const message = payload?.errors
1904
+ ? Object.values(payload.errors).flat().join('<br />')
1905
+ : payload?.message || null;
1906
+
1907
+ if (message && message !== 'Unauthenticated.') {
1908
+ toast.error(<div>{parse(String(message))}</div>);
1909
+
1910
+ return;
1911
+ }
1912
+
1913
+ if (err?.response) {
1914
+ return;
1915
+ }
1916
+ }
1917
+
1744
1918
  toast.error(`Error: ${err}`);
1745
1919
  }
1746
1920
  };
@@ -1754,6 +1928,10 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1754
1928
  );
1755
1929
 
1756
1930
  setData(res.data);
1931
+
1932
+ // The version this tab is editing from. Everything the guard does
1933
+ // is measured against this moment.
1934
+ await rememberDetailHash(res.data?.id, res.data?.detail);
1757
1935
  } catch (err) {
1758
1936
  toast.error(`Error: ${err}`);
1759
1937
  }
@@ -1761,17 +1939,26 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1761
1939
 
1762
1940
  const saveOnSort = async () => {
1763
1941
  try {
1942
+ const savedDetail = data.detail;
1764
1943
  const res = await CustomFetch(
1765
1944
  `${fetchUrl}/sort/${dataId}`,
1766
1945
  'POST',
1767
1946
  {
1768
- detail: data.detail,
1947
+ detail: savedDetail,
1769
1948
  }
1770
1949
  );
1771
1950
 
1772
1951
  if (res.data.error !== '') {
1773
1952
  toast.error(String(res.data.error));
1953
+
1954
+ return;
1774
1955
  }
1956
+
1957
+ // Re-ordering writes `detail` too, so the guard has to follow it.
1958
+ // The sort endpoint reads only `detail` and cannot be guarded from
1959
+ // here — but leaving the hash behind would make the next Save
1960
+ // report a collision this tab caused itself.
1961
+ await rememberDetailHash(data.id, savedDetail);
1775
1962
  } catch (err) {
1776
1963
  toast.error(`Error: ${err}`);
1777
1964
  }
@@ -52,6 +52,17 @@
52
52
  // A scrim anchored to the bottom-left, where the copy sits — not a flat
53
53
  // wash over the whole image, which would dull the dusk light that makes
54
54
  // the photograph worth using.
55
+ //
56
+ // The foot is a near-solid plate rather than a fade. The brand line has to
57
+ // hold against whatever the photograph is doing behind it, and this one is
58
+ // bright exactly where the type sits, which left "Prime Builders" washing
59
+ // into the render. Held high — 0.88 still at 18%, which is where the ink
60
+ // block ends — so the whole block reads on one ground, then released
61
+ // quickly to 0.06 at the top, so the sky above is still a photograph and
62
+ // not a tint.
63
+ //
64
+ // Shared verbatim with the supervisor PWA
65
+ // (prime-web-nextjs/app/login/Login.module.scss). If one moves, move both.
55
66
  &::after {
56
67
  content: '';
57
68
  position: absolute;
@@ -59,9 +70,11 @@
59
70
  background:
60
71
  linear-gradient(
61
72
  to top,
62
- rgba(16, 25, 42, 0.92) 0%,
63
- rgba(16, 25, 42, 0.55) 32%,
64
- rgba(16, 25, 42, 0.08) 62%
73
+ rgba(16, 25, 42, 0.94) 0%,
74
+ rgba(16, 25, 42, 0.88) 18%,
75
+ rgba(16, 25, 42, 0.62) 42%,
76
+ rgba(16, 25, 42, 0.22) 70%,
77
+ rgba(16, 25, 42, 0.06) 100%
65
78
  ),
66
79
  linear-gradient(
67
80
  to right,
@@ -90,6 +103,11 @@
90
103
  font-size: 0.72rem;
91
104
  font-weight: 700;
92
105
  letter-spacing: 0.22em;
106
+ // Stated, not left to the cascade: global.css sets
107
+ // `p { line-height: var(--para-height) }`, which is 1.6 here. That put the
108
+ // kicker on a taller line than the same kicker in the PWA, so the red dash
109
+ // sat at a different height and the gap beneath it read as wider.
110
+ line-height: 1.4;
93
111
  text-transform: uppercase;
94
112
  color: rgba(255, 255, 255, 0.75);
95
113
  // The single use of the logo's red in the whole screen.
@@ -114,6 +132,10 @@
114
132
  line-height: 1;
115
133
  // Tight tracking at display size — Barlow opens up as it scales.
116
134
  letter-spacing: -0.02em;
135
+ // Stated rather than inherited from .brandInk: this is the one line on the
136
+ // screen that has to be white, and finding that out should not mean
137
+ // reading the parent.
138
+ color: #fff;
117
139
  text-wrap: balance;
118
140
  }
119
141
 
@@ -123,6 +145,10 @@
123
145
  font-size: 1rem;
124
146
  font-weight: 300;
125
147
  line-height: 1.5;
148
+ // No-op against this app's globals, but the PWA's own `p` rule tracks body
149
+ // copy at 0.015rem, so the value is pinned in both files rather than left
150
+ // to whichever global layer the screen happens to be rendered under.
151
+ letter-spacing: 0;
126
152
  color: rgba(255, 255, 255, 0.82);
127
153
  }
128
154