@visns-studio/visns-components 6.3.0 → 6.3.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.3.
|
|
94
|
+
"version": "6.3.1",
|
|
95
95
|
"description": "Various packages to assist in the development of our Custom Applications.",
|
|
96
96
|
"main": "src/index.js",
|
|
97
97
|
"files": [
|
|
@@ -184,6 +184,15 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
184
184
|
*/
|
|
185
185
|
const savedSnapshotRef = useRef(null);
|
|
186
186
|
const [unsavedChanges, setUnsavedChanges] = useState(false);
|
|
187
|
+
/** How many questions differ from the stored version, for the indicator. */
|
|
188
|
+
const [unsavedCount, setUnsavedCount] = useState(0);
|
|
189
|
+
/**
|
|
190
|
+
* Whether the "this is not stored yet" notice has already been given since
|
|
191
|
+
* the last successful save. Once per unsaved streak: the first Apply is
|
|
192
|
+
* where the misunderstanding happens, and a toast on every Apply after that
|
|
193
|
+
* would train people to dismiss it without reading.
|
|
194
|
+
*/
|
|
195
|
+
const stagedNoticeShownRef = useRef(false);
|
|
187
196
|
/** Bumped whenever the baseline moves, so the comparison below re-runs. */
|
|
188
197
|
const [savedBaselineTick, setSavedBaselineTick] = useState(0);
|
|
189
198
|
const lockEnabled = Boolean(optimisticLock);
|
|
@@ -1376,8 +1385,11 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
1376
1385
|
let errorMessage = validateField(field);
|
|
1377
1386
|
|
|
1378
1387
|
if (errorMessage === '') {
|
|
1388
|
+
const isNew = modalType.type === 'create';
|
|
1389
|
+
|
|
1379
1390
|
saveFieldData(field);
|
|
1380
1391
|
handleCloseModal();
|
|
1392
|
+
announceStagedChange(isNew);
|
|
1381
1393
|
} else {
|
|
1382
1394
|
displayErrorMessage(errorMessage);
|
|
1383
1395
|
}
|
|
@@ -1850,6 +1862,68 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
1850
1862
|
const snapshotOf = (label, detail) =>
|
|
1851
1863
|
JSON.stringify({ label: label ?? '', detail: detail ?? [] });
|
|
1852
1864
|
|
|
1865
|
+
/**
|
|
1866
|
+
* How many separate edits are waiting to be stored, counted by question.
|
|
1867
|
+
*
|
|
1868
|
+
* A number is worth more than "Unsaved changes" alone — it tells somebody
|
|
1869
|
+
* who stepped away how much they stand to lose. Counted by field id, so an
|
|
1870
|
+
* added, a reworded and a deleted question are three, while re-ordering the
|
|
1871
|
+
* same questions is none of them. That last case is not a miscount: the
|
|
1872
|
+
* order is written to the server the moment it changes, so a pure re-order
|
|
1873
|
+
* shows as dirty for only as long as that request is in flight, and the
|
|
1874
|
+
* wording below falls back to the unnumbered phrase.
|
|
1875
|
+
*/
|
|
1876
|
+
const countUnsavedChanges = (baseline, label, detail) => {
|
|
1877
|
+
let count = (baseline.label ?? '') === (label ?? '') ? 0 : 1;
|
|
1878
|
+
|
|
1879
|
+
const stored = new Map(
|
|
1880
|
+
(baseline.detail ?? []).map((field) => [
|
|
1881
|
+
field.id,
|
|
1882
|
+
JSON.stringify(field),
|
|
1883
|
+
])
|
|
1884
|
+
);
|
|
1885
|
+
const present = new Set();
|
|
1886
|
+
|
|
1887
|
+
(detail ?? []).forEach((field) => {
|
|
1888
|
+
present.add(field.id);
|
|
1889
|
+
|
|
1890
|
+
if (stored.get(field.id) !== JSON.stringify(field)) {
|
|
1891
|
+
count += 1;
|
|
1892
|
+
}
|
|
1893
|
+
});
|
|
1894
|
+
|
|
1895
|
+
stored.forEach((_encoded, id) => {
|
|
1896
|
+
if (!present.has(id)) {
|
|
1897
|
+
count += 1;
|
|
1898
|
+
}
|
|
1899
|
+
});
|
|
1900
|
+
|
|
1901
|
+
return count;
|
|
1902
|
+
};
|
|
1903
|
+
|
|
1904
|
+
/**
|
|
1905
|
+
* Say, at the moment it is least obvious, that Apply stored nothing.
|
|
1906
|
+
*
|
|
1907
|
+
* The question window closes and the new question appears in the list,
|
|
1908
|
+
* which looks exactly like a save. This is the one place where saying so
|
|
1909
|
+
* costs nothing and lands while the user is still looking.
|
|
1910
|
+
*/
|
|
1911
|
+
const announceStagedChange = (isNew) => {
|
|
1912
|
+
if (stagedNoticeShownRef.current) {
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
stagedNoticeShownRef.current = true;
|
|
1917
|
+
|
|
1918
|
+
toast.info(
|
|
1919
|
+
<div>
|
|
1920
|
+
{isNew ? 'Question added' : 'Question updated'} — this is on
|
|
1921
|
+
screen only. Tap <strong>Save Template</strong> to store it.
|
|
1922
|
+
</div>,
|
|
1923
|
+
{ toastId: 'fb-staged-change', autoClose: 6000 }
|
|
1924
|
+
);
|
|
1925
|
+
};
|
|
1926
|
+
|
|
1853
1927
|
/**
|
|
1854
1928
|
* Re-anchor the unsaved-changes baseline on what the server now holds.
|
|
1855
1929
|
*
|
|
@@ -1867,6 +1941,11 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
1867
1941
|
detail === undefined ? previous.detail : detail
|
|
1868
1942
|
);
|
|
1869
1943
|
|
|
1944
|
+
// The reminder is earned again by the next unsaved streak. Anyone who
|
|
1945
|
+
// has just stored their work has demonstrably understood the flow, and
|
|
1946
|
+
// will still be told the next time they walk into it.
|
|
1947
|
+
stagedNoticeShownRef.current = false;
|
|
1948
|
+
|
|
1870
1949
|
// Never `setUnsavedChanges(false)` directly: a partial save can leave
|
|
1871
1950
|
// something else still unsaved. Re-ordering writes `detail` and not the
|
|
1872
1951
|
// label, so declaring the screen clean here would lose a pending
|
|
@@ -2100,8 +2179,20 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
2100
2179
|
return;
|
|
2101
2180
|
}
|
|
2102
2181
|
|
|
2103
|
-
|
|
2104
|
-
snapshotOf(data.label, data.detail) !== savedSnapshotRef.current
|
|
2182
|
+
const dirty =
|
|
2183
|
+
snapshotOf(data.label, data.detail) !== savedSnapshotRef.current;
|
|
2184
|
+
|
|
2185
|
+
setUnsavedChanges(dirty);
|
|
2186
|
+
// Only worth counting when something is outstanding, so a clean screen
|
|
2187
|
+
// never pays for parsing the baseline.
|
|
2188
|
+
setUnsavedCount(
|
|
2189
|
+
dirty
|
|
2190
|
+
? countUnsavedChanges(
|
|
2191
|
+
JSON.parse(savedSnapshotRef.current),
|
|
2192
|
+
data.label,
|
|
2193
|
+
data.detail
|
|
2194
|
+
)
|
|
2195
|
+
: 0
|
|
2105
2196
|
);
|
|
2106
2197
|
}, [data, savedBaselineTick]);
|
|
2107
2198
|
|
|
@@ -2340,7 +2431,7 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
2340
2431
|
items: [
|
|
2341
2432
|
'<strong>Apply</strong> inside a question window only adds it to the list on screen — nothing is stored yet',
|
|
2342
2433
|
'To store the template, click <strong>Save Template</strong> (bottom right)',
|
|
2343
|
-
'
|
|
2434
|
+
'While anything is waiting, the button turns amber and the label beside it counts what you would lose — the browser warns you as well if you try to leave',
|
|
2344
2435
|
'<strong>Edit Form</strong> is only for renaming the template',
|
|
2345
2436
|
'Moving a question saves the whole template straight away, so the order is never lost',
|
|
2346
2437
|
'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',
|
|
@@ -2714,10 +2805,15 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
2714
2805
|
? styles.saveStateDirty
|
|
2715
2806
|
: styles.saveStateClean
|
|
2716
2807
|
}`}
|
|
2808
|
+
aria-live="polite"
|
|
2717
2809
|
>
|
|
2718
|
-
{unsavedChanges
|
|
2719
|
-
? '
|
|
2720
|
-
:
|
|
2810
|
+
{!unsavedChanges
|
|
2811
|
+
? 'All changes saved'
|
|
2812
|
+
: unsavedCount > 0
|
|
2813
|
+
? `${unsavedCount} unsaved change${
|
|
2814
|
+
unsavedCount === 1 ? '' : 's'
|
|
2815
|
+
}`
|
|
2816
|
+
: 'Unsaved changes'}
|
|
2721
2817
|
</span>
|
|
2722
2818
|
{/* Nothing to author on a dynamic template, but
|
|
2723
2819
|
Edit Form stays so it can still be renamed. */}
|
|
@@ -2741,10 +2837,20 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
|
|
|
2741
2837
|
questions looked like the one that renames the
|
|
2742
2838
|
form. It belongs out here, beside the work. */}
|
|
2743
2839
|
<button
|
|
2744
|
-
className={`${styles.btn} ${
|
|
2840
|
+
className={`${styles.btn} ${
|
|
2841
|
+
styles.btnPrimary
|
|
2842
|
+
} ${
|
|
2843
|
+
unsavedChanges
|
|
2844
|
+
? styles.btnSaveDirty
|
|
2845
|
+
: styles.btnSaveClean
|
|
2846
|
+
}`}
|
|
2745
2847
|
onClick={handleSubmit}
|
|
2746
2848
|
data-tooltip-id="system-tooltip"
|
|
2747
|
-
data-tooltip-content=
|
|
2849
|
+
data-tooltip-content={
|
|
2850
|
+
unsavedChanges
|
|
2851
|
+
? 'You have changes that are only on screen. Tap to store them.'
|
|
2852
|
+
: 'Everything on this screen is stored.'
|
|
2853
|
+
}
|
|
2748
2854
|
>
|
|
2749
2855
|
Save Template
|
|
2750
2856
|
</button>
|
|
@@ -833,8 +833,22 @@ select:not(:placeholder-shown) + .fi__span {
|
|
|
833
833
|
}
|
|
834
834
|
}
|
|
835
835
|
|
|
836
|
+
/* Amber, pilled and bolder than its calm twin. The indicator, the dot and the
|
|
837
|
+
Save Template button all sit on the same warning hue on purpose: one state,
|
|
838
|
+
said three times, so it cannot be read as decoration. */
|
|
836
839
|
.saveStateDirty {
|
|
837
|
-
color: #
|
|
840
|
+
color: #92400e;
|
|
841
|
+
font-weight: 700;
|
|
842
|
+
padding: 3px 10px;
|
|
843
|
+
border-radius: 999px;
|
|
844
|
+
background: rgba(245, 158, 11, 0.16);
|
|
845
|
+
border: 1px solid rgba(180, 83, 9, 0.35);
|
|
846
|
+
|
|
847
|
+
&::before {
|
|
848
|
+
background: #d97706;
|
|
849
|
+
box-shadow: 0 0 0 0 rgba(217, 119, 6, 0.6);
|
|
850
|
+
animation: fbSaveDot 2s ease-in-out infinite;
|
|
851
|
+
}
|
|
838
852
|
}
|
|
839
853
|
|
|
840
854
|
.saveStateClean {
|
|
@@ -846,6 +860,70 @@ select:not(:placeholder-shown) + .fi__span {
|
|
|
846
860
|
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.35);
|
|
847
861
|
}
|
|
848
862
|
|
|
863
|
+
/* Nothing to save: present and findable, but not competing for attention. */
|
|
864
|
+
.btnSaveClean {
|
|
865
|
+
opacity: 0.9;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Something to save. Amber overrides the shared primary fill, and the ring
|
|
870
|
+
* breathes so the button reads as waiting on the user rather than as one more
|
|
871
|
+
* blue button in a row of blue buttons.
|
|
872
|
+
*/
|
|
873
|
+
.btnSaveDirty {
|
|
874
|
+
background: #d97706 !important;
|
|
875
|
+
border-color: #b45309 !important;
|
|
876
|
+
color: #fff !important;
|
|
877
|
+
font-weight: 700;
|
|
878
|
+
box-shadow:
|
|
879
|
+
0 0 0 2px rgba(217, 119, 6, 0.45),
|
|
880
|
+
0 4px 14px rgba(180, 83, 9, 0.35);
|
|
881
|
+
animation: fbSaveNudge 2s ease-in-out infinite;
|
|
882
|
+
|
|
883
|
+
&:hover:not(:disabled) {
|
|
884
|
+
background: #b45309 !important;
|
|
885
|
+
border-color: #92400e !important;
|
|
886
|
+
color: #fff !important;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
@keyframes fbSaveNudge {
|
|
891
|
+
0%,
|
|
892
|
+
100% {
|
|
893
|
+
box-shadow:
|
|
894
|
+
0 0 0 2px rgba(217, 119, 6, 0.45),
|
|
895
|
+
0 4px 14px rgba(180, 83, 9, 0.35);
|
|
896
|
+
transform: scale(1);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
50% {
|
|
900
|
+
box-shadow:
|
|
901
|
+
0 0 0 7px rgba(217, 119, 6, 0),
|
|
902
|
+
0 6px 18px rgba(180, 83, 9, 0.45);
|
|
903
|
+
transform: scale(1.035);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
@keyframes fbSaveDot {
|
|
908
|
+
0%,
|
|
909
|
+
100% {
|
|
910
|
+
box-shadow: 0 0 0 0 rgba(217, 119, 6, 0.6);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
50% {
|
|
914
|
+
box-shadow: 0 0 0 5px rgba(217, 119, 6, 0);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/* Movement is the enhancement, never the message: the amber fill, the ring and
|
|
919
|
+
the wording all survive on their own for anyone who asked for stillness. */
|
|
920
|
+
@media (prefers-reduced-motion: reduce) {
|
|
921
|
+
.btnSaveDirty,
|
|
922
|
+
.saveStateDirty::before {
|
|
923
|
+
animation: none;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
849
927
|
/* Sits under the question window's Apply, where the misunderstanding happens. */
|
|
850
928
|
.applyHint {
|
|
851
929
|
flex: 1;
|