@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.
- package/package.json +1 -1
- package/src/components/DataGrid.jsx +40 -0
- package/src/components/Navigation.jsx +59 -3
- package/src/components/auth/Login.jsx +2 -2
- package/src/components/generic/GenericDashboard.jsx +181 -1
- package/src/components/generic/GenericFormBuilder.jsx +374 -14
- package/src/components/generic/OutstandingRuleEditor.jsx +313 -0
- package/src/components/styles/GenericDashboard.module.scss +155 -0
- package/src/components/styles/GenericFormBuilder.module.scss +60 -0
- package/src/components/styles/Login.module.scss +29 -3
|
@@ -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 {
|
|
61
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
2742
|
+
nextOptionId;
|
|
2430
2743
|
setDataField(
|
|
2431
2744
|
(
|
|
2432
2745
|
items
|
|
2433
|
-
) =>
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
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
|