@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.
|
|
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:
|
|
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.
|
|
63
|
-
rgba(16, 25, 42, 0.
|
|
64
|
-
rgba(16, 25, 42, 0.
|
|
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
|
|