@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.
|
|
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>
|
|
2205
|
-
'To store the template, click <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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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;
|