@visns-studio/visns-components 6.0.5 → 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.
@@ -50,18 +50,125 @@ import Breadcrumb from '../Breadcrumb';
50
50
  import CustomFetch from '../Fetch';
51
51
  import SketchConfig from '../sketch/json/config.json';
52
52
  import ConditionalDisplayEditor from './ConditionalDisplayEditor';
53
+ import {
54
+ OutstandingOptionToggle,
55
+ OutstandingRuleHint,
56
+ applyOutstandingRule,
57
+ deriveOutstandingSelection,
58
+ isOutstandingConfigurable,
59
+ remapOutstandingWhen,
60
+ staleOutstandingIds,
61
+ summariseOutstandingRule,
62
+ } from './OutstandingRuleEditor';
53
63
 
54
64
  import styles from '../styles/GenericFormBuilder.module.scss'; // Import the CSS module
55
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
+
56
132
  function GenericFormBuilder({ setting, urlParam, userProfile }) {
57
133
  const editorRef = useRef(null);
58
134
  const routeParams = useParams();
59
135
 
60
- const { dynamicDropdowns, fetchUrl, fields, parentUrl, formTitle } =
61
- setting;
136
+ const {
137
+ dynamicDropdowns,
138
+ fetchUrl,
139
+ fields,
140
+ parentUrl,
141
+ formTitle,
142
+ /**
143
+ * Opt-in, per-question "outstanding item" rules. Absent for every app
144
+ * that does not model outstanding items, in which case none of the
145
+ * `outstanding_when` handling below does anything at all.
146
+ */
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,
155
+ } = setting;
62
156
 
63
157
  const { dataId } = useParams();
64
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;
65
172
  const [dataField, setDataField] = useState({
66
173
  id: '',
67
174
  label: '',
@@ -115,6 +222,17 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
115
222
  key: '',
116
223
  });
117
224
  const [modalFormShow, setModalFormShow] = useState(false);
225
+ /**
226
+ * The open field modal's outstanding-item switch. `touched` is false until
227
+ * the author actually flips one, and until then the displayed state is
228
+ * derived from the field on every render — so a brand-new question picks up
229
+ * the standard rule as soon as its answers exist, rather than being frozen
230
+ * at whatever was true when the modal opened.
231
+ */
232
+ const [outstandingRule, setOutstandingRule] = useState({
233
+ touched: false,
234
+ id: null,
235
+ });
118
236
  const [roles, setRoles] = useState([]);
119
237
  const [hoveredField, setHoveredField] = useState(null);
120
238
  const [activeId, setActiveId] = useState(null);
@@ -148,6 +266,37 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
148
266
  */
149
267
  const dynamicFields = data.field_source === 'dynamic';
150
268
 
269
+ /** Does the open field carry an editable outstanding-item rule at all? */
270
+ const outstandingEnabled = isOutstandingConfigurable(
271
+ outstandingItems,
272
+ dataField.type
273
+ );
274
+
275
+ /**
276
+ * Which answer currently has its "Outstanding" switch on. Always sanitised
277
+ * against the answers the field offers right now, so renaming or deleting
278
+ * an answer can never leave a dangling id to be saved.
279
+ */
280
+ const outstandingSelection = useMemo(() => {
281
+ if (!outstandingEnabled) return null;
282
+
283
+ const candidate = outstandingRule.touched
284
+ ? outstandingRule.id
285
+ : deriveOutstandingSelection(dataField);
286
+
287
+ return (dataField.options || []).some(
288
+ (option) => option && option.id === candidate
289
+ )
290
+ ? candidate
291
+ : null;
292
+ }, [outstandingEnabled, outstandingRule, dataField]);
293
+
294
+ /** Stored ids the field no longer offers — drives the amber warning. */
295
+ const outstandingStaleIds = useMemo(
296
+ () => (outstandingEnabled ? staleOutstandingIds(dataField) : []),
297
+ [outstandingEnabled, dataField]
298
+ );
299
+
151
300
  // Generate compact field info for display in the center top
152
301
  const getFieldInfo = (field) => {
153
302
  const fieldTypeLabel =
@@ -182,6 +331,14 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
182
331
  info.push('Conditional');
183
332
  }
184
333
 
334
+ // Only surfaced for apps that opted into outstanding-item rules.
335
+ if (outstandingItems?.enabled) {
336
+ const outstandingSummary = summariseOutstandingRule(field);
337
+ if (outstandingSummary) {
338
+ info.push(outstandingSummary);
339
+ }
340
+ }
341
+
185
342
  return info.join(' • ');
186
343
  };
187
344
 
@@ -229,6 +386,13 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
229
386
  tooltip += `Show when "${field.conditional_field}" = "${field.conditional_value}"\n`;
230
387
  }
231
388
 
389
+ if (outstandingItems?.enabled) {
390
+ const outstandingSummary = summariseOutstandingRule(field);
391
+ if (outstandingSummary) {
392
+ tooltip += `\nOutstanding rule: ${outstandingSummary}\n`;
393
+ }
394
+ }
395
+
232
396
  // Add other conditional properties if they exist
233
397
  if (field.conditionalOperator) {
234
398
  tooltip += `Operator: ${field.conditionalOperator}\n`;
@@ -1027,7 +1191,11 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1027
1191
  textareaHeight: 'normal',
1028
1192
  conditional_field: '',
1029
1193
  conditional_value: '',
1194
+ // `outstanding_when` is deliberately NOT seeded here: absent must
1195
+ // stay absent so a field that was never configured never gains the
1196
+ // key just by being opened.
1030
1197
  }));
1198
+ setOutstandingRule({ touched: false, id: null });
1031
1199
  setModalType(() => ({
1032
1200
  type: 'create',
1033
1201
  key: '',
@@ -1047,10 +1215,28 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1047
1215
  const { name, value } = e.target;
1048
1216
  const updatedValue = name === 'id' ? slugify(value) : value;
1049
1217
 
1050
- setDataField((items) => ({
1051
- ...items,
1052
- [name]: updatedValue,
1053
- }));
1218
+ setDataField((items) => {
1219
+ const next = { ...items, [name]: updatedValue };
1220
+
1221
+ // Switching to a type that cannot carry an outstanding rule
1222
+ // would strand the key with no UI left to edit it.
1223
+ if (
1224
+ name === 'type' &&
1225
+ 'outstanding_when' in next &&
1226
+ !isOutstandingConfigurable(outstandingItems, updatedValue)
1227
+ ) {
1228
+ delete next.outstanding_when;
1229
+ }
1230
+
1231
+ return next;
1232
+ });
1233
+
1234
+ if (
1235
+ name === 'type' &&
1236
+ !isOutstandingConfigurable(outstandingItems, updatedValue)
1237
+ ) {
1238
+ setOutstandingRule({ touched: false, id: null });
1239
+ }
1054
1240
 
1055
1241
  if (name === 'label') {
1056
1242
  setDataField((items) => ({
@@ -1080,6 +1266,10 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1080
1266
  ...detail[key],
1081
1267
  }));
1082
1268
 
1269
+ // Untouched: the switch shows whatever the stored rule — or the
1270
+ // standard rule — implies, recomputed as the author edits.
1271
+ setOutstandingRule({ touched: false, id: null });
1272
+
1083
1273
  setModalType(() => ({
1084
1274
  type: 'update',
1085
1275
  key: key,
@@ -1158,6 +1348,11 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1158
1348
 
1159
1349
  field.id = formatFieldId(field.id);
1160
1350
 
1351
+ // No-op unless the consuming app enabled outstanding-item rules. Writes
1352
+ // `outstanding_when` only when the switch differs from the standard
1353
+ // rule, so opening and saving an untouched field changes nothing.
1354
+ field = applyOutstandingRule(field, outstandingSelection, outstandingItems);
1355
+
1161
1356
  let errorMessage = validateField(field);
1162
1357
 
1163
1358
  if (errorMessage === '') {
@@ -1598,6 +1793,49 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1598
1793
  }
1599
1794
  };
1600
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
+
1601
1839
  const handleSubmit = async (e) => {
1602
1840
  try {
1603
1841
  if (e) {
@@ -1610,15 +1848,34 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1610
1848
  }
1611
1849
 
1612
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.
1613
1857
  const res = await CustomFetch(
1614
1858
  `${fetchUrl}/${dataId}`,
1615
1859
  'PUT',
1616
1860
  {
1617
1861
  ...data,
1618
- }
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
1619
1874
  );
1620
1875
 
1621
1876
  if (res.data.error === '') {
1877
+ await rememberDetailHash(data.id, savedDetail);
1878
+
1622
1879
  toast.success(
1623
1880
  "You have successfully updated the form's detail."
1624
1881
  );
@@ -1632,6 +1889,32 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1632
1889
  }
1633
1890
  }
1634
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
+
1635
1918
  toast.error(`Error: ${err}`);
1636
1919
  }
1637
1920
  };
@@ -1645,6 +1928,10 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1645
1928
  );
1646
1929
 
1647
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);
1648
1935
  } catch (err) {
1649
1936
  toast.error(`Error: ${err}`);
1650
1937
  }
@@ -1652,17 +1939,26 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1652
1939
 
1653
1940
  const saveOnSort = async () => {
1654
1941
  try {
1942
+ const savedDetail = data.detail;
1655
1943
  const res = await CustomFetch(
1656
1944
  `${fetchUrl}/sort/${dataId}`,
1657
1945
  'POST',
1658
1946
  {
1659
- detail: data.detail,
1947
+ detail: savedDetail,
1660
1948
  }
1661
1949
  );
1662
1950
 
1663
1951
  if (res.data.error !== '') {
1664
1952
  toast.error(String(res.data.error));
1953
+
1954
+ return;
1665
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);
1666
1962
  } catch (err) {
1667
1963
  toast.error(`Error: ${err}`);
1668
1964
  }
@@ -2378,6 +2674,14 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
2378
2674
  </span>
2379
2675
  )}
2380
2676
  </div>
2677
+ {outstandingEnabled ? (
2678
+ <OutstandingRuleHint
2679
+ config={outstandingItems}
2680
+ staleIds={
2681
+ outstandingStaleIds
2682
+ }
2683
+ />
2684
+ ) : null}
2381
2685
  {dataField.options.length >
2382
2686
  0 ? (
2383
2687
  <div
@@ -2415,6 +2719,15 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
2415
2719
  onChange={(
2416
2720
  e
2417
2721
  ) => {
2722
+ // Captured before the id is regenerated so any
2723
+ // outstanding rule pointing at it can follow.
2724
+ const previousOptionId =
2725
+ dataField
2726
+ .options[
2727
+ optionKey
2728
+ ]
2729
+ ?.id;
2730
+ const nextOptionId = `${slugify(e.target.value)}-`;
2418
2731
  const newOptions =
2419
2732
  [
2420
2733
  ...dataField.options,
@@ -2426,18 +2739,65 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
2426
2739
  newOptions[
2427
2740
  optionKey
2428
2741
  ].id =
2429
- `${slugify(e.target.value)}-`;
2742
+ nextOptionId;
2430
2743
  setDataField(
2431
2744
  (
2432
2745
  items
2433
- ) => ({
2434
- ...items,
2435
- options:
2436
- newOptions,
2437
- })
2746
+ ) =>
2747
+ remapOutstandingWhen(
2748
+ {
2749
+ ...items,
2750
+ options:
2751
+ newOptions,
2752
+ },
2753
+ previousOptionId,
2754
+ nextOptionId
2755
+ )
2756
+ );
2757
+
2758
+ // An unsaved switch has to follow the
2759
+ // rename too, or it would be sanitised
2760
+ // away as a dangling id.
2761
+ setOutstandingRule(
2762
+ (
2763
+ rule
2764
+ ) =>
2765
+ rule.touched &&
2766
+ rule.id ===
2767
+ previousOptionId
2768
+ ? {
2769
+ ...rule,
2770
+ id: nextOptionId,
2771
+ }
2772
+ : rule
2438
2773
  );
2439
2774
  }}
2440
2775
  />
2776
+ {outstandingEnabled ? (
2777
+ <OutstandingOptionToggle
2778
+ optionId={
2779
+ option.id
2780
+ }
2781
+ label={
2782
+ option.label ||
2783
+ option.id
2784
+ }
2785
+ checked={
2786
+ outstandingSelection ===
2787
+ option.id
2788
+ }
2789
+ onToggle={(
2790
+ id
2791
+ ) =>
2792
+ setOutstandingRule(
2793
+ {
2794
+ touched: true,
2795
+ id,
2796
+ }
2797
+ )
2798
+ }
2799
+ />
2800
+ ) : null}
2441
2801
  <button
2442
2802
  className={
2443
2803
  styles.optionDelete