@visns-studio/visns-components 6.17.2 → 6.20.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 +10 -7
- package/src/components/AssociationManager.jsx +344 -177
- package/src/components/AsyncSelect.jsx +2 -2
- package/src/components/Autocomplete.jsx +57 -12
- package/src/components/Breadcrumb.jsx +23 -4
- package/src/components/ClientAssociationManager.jsx +321 -145
- package/src/components/DataGrid.jsx +625 -20
- package/src/components/DropZone.jsx +4 -1
- package/src/components/Form.jsx +233 -6
- package/src/components/MultiSelect.jsx +2 -2
- package/src/components/Navigation.jsx +39 -23
- package/src/components/TableFilter.jsx +172 -12
- package/src/components/UniversalSearch.jsx +390 -0
- package/src/components/auth/Login.jsx +164 -8
- package/src/components/auth/Profile.jsx +209 -22
- package/src/components/auth/authEndpoints.js +4 -0
- package/src/components/auth/webauthn.js +194 -0
- package/src/components/columns/ColumnRenderers.jsx +6 -1
- package/src/components/controls/DataGridSearch.jsx +11 -5
- package/src/components/generic/GenericAuth.jsx +6 -0
- package/src/components/generic/GenericDashboard.jsx +93 -36
- package/src/components/generic/GenericDetail.jsx +146 -61
- package/src/components/generic/GenericIndex.jsx +134 -38
- package/src/components/generic/GenericSettings.jsx +220 -0
- package/src/components/generic/StandardModal.jsx +165 -8
- package/src/components/generic/dashboardChartDefaults.js +230 -0
- package/src/components/generic/shared/indexLayout.js +38 -0
- package/src/components/settings/IntegrationsManager.jsx +474 -0
- package/src/components/sms/SmsLineSettings.jsx +16 -12
- package/src/components/styles/AssociationManager.module.scss +4 -363
- package/src/components/styles/AuditLog.module.scss +6 -6
- package/src/components/styles/AuditLogs.module.scss +3 -3
- package/src/components/styles/Autocomplete.module.scss +7 -7
- package/src/components/styles/BusinessCardOcr.module.scss +6 -1
- package/src/components/styles/CallQueueSettings.module.scss +4 -1
- package/src/components/styles/CameraPlacement.module.scss +19 -19
- package/src/components/styles/CategorizedDropZone.module.scss +6 -1
- package/src/components/styles/ClientAssociationManager.module.scss +5 -289
- package/src/components/styles/ClientAuth.module.scss +1 -1
- package/src/components/styles/DataGrid.module.scss +455 -8
- package/src/components/styles/Field.module.scss +32 -24
- package/src/components/styles/Form.module.scss +123 -3
- package/src/components/styles/GenericDashboard.module.scss +612 -825
- package/src/components/styles/GenericDetail.module.scss +406 -61
- package/src/components/styles/GenericDynamic.module.scss +25 -25
- package/src/components/styles/GenericEditableTable.module.scss +33 -33
- package/src/components/styles/GenericFormBuilder.module.scss +119 -119
- package/src/components/styles/GenericIndex.module.scss +483 -38
- package/src/components/styles/GenericMain.module.scss +6 -3
- package/src/components/styles/GenericQuote.module.scss +399 -81
- package/src/components/styles/GenericReport.module.scss +8 -8
- package/src/components/styles/GenericSettings.module.scss +209 -0
- package/src/components/styles/GenericSort.module.scss +9 -9
- package/src/components/styles/Integrations.module.scss +321 -0
- package/src/components/styles/List.module.scss +4 -4
- package/src/components/styles/Login.module.scss +57 -0
- package/src/components/styles/MultiSelect.module.scss +4 -4
- package/src/components/styles/Navigation.module.scss +12 -12
- package/src/components/styles/Notification.module.scss +12 -8
- package/src/components/styles/NotificationList.module.scss +5 -5
- package/src/components/styles/Profile.module.scss +5 -5
- package/src/components/styles/QrCode.module.scss +4 -4
- package/src/components/styles/QuickAction.module.scss +20 -20
- package/src/components/styles/SortableQuoteItems.module.scss +4 -4
- package/src/components/styles/TableFilter.module.scss +128 -17
- package/src/components/styles/TicketConversation.module.scss +497 -0
- package/src/components/styles/UniversalSearch.module.scss +229 -0
- package/src/components/styles/Vault.module.scss +413 -0
- package/src/components/styles/_associationManager.scss +467 -0
- package/src/components/styles/_authBrandPanel.scss +15 -9
- package/src/components/styles/_controls.scss +43 -39
- package/src/components/styles/global-datagrid.css +196 -0
- package/src/components/styles/global.css +168 -17
- package/src/components/tickets/TicketConversation.jsx +455 -0
- package/src/components/utils/rowActionSettings.js +117 -0
- package/src/components/vault/VaultClientPicker.jsx +183 -0
- package/src/components/vault/VaultEntryForm.jsx +23 -0
- package/src/components/vault/VaultManager.jsx +382 -34
- package/src/components/vault/VaultShareModal.jsx +454 -0
- package/src/components/vault/vaultEndpoints.js +16 -2
- package/src/components/vault/vaultShares.js +247 -0
- package/src/index.js +35 -0
- package/src/utils/fetchUtil.js +38 -14
|
@@ -52,7 +52,10 @@ const getFileIcon = (extension) => {
|
|
|
52
52
|
return File;
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
// `settings` is optional in every documented usage but had no default, and
|
|
56
|
+
// the URL resolver reads `settings.url` unguarded - so a consumer that passes
|
|
57
|
+
// only `url` crashed on mount rather than using the url it passed.
|
|
58
|
+
function VisnsDropZone({ fetchData, files, settings = {}, url, urlKey, deleteUrl, entityData = {}, routeParams = {}, dataKey }) {
|
|
56
59
|
console.log('DropZone: Component initialized with props:', {
|
|
57
60
|
hasFetchData: typeof fetchData === 'function',
|
|
58
61
|
files,
|
package/src/components/Form.jsx
CHANGED
|
@@ -156,6 +156,13 @@ function Form({
|
|
|
156
156
|
fetchTable,
|
|
157
157
|
mapbox,
|
|
158
158
|
modalOpen,
|
|
159
|
+
/**
|
|
160
|
+
* `{ url, key }` for the detail page of a record created by this form,
|
|
161
|
+
* derived by <DataGrid> from the columns' own `link` config. Only
|
|
162
|
+
* consulted when `formSettings.openOnCreate` is set, and only as a
|
|
163
|
+
* fallback: an explicit `openOnCreate: { url, key }` wins.
|
|
164
|
+
*/
|
|
165
|
+
openOnCreateLink,
|
|
159
166
|
paramValue,
|
|
160
167
|
style,
|
|
161
168
|
type,
|
|
@@ -163,8 +170,65 @@ function Form({
|
|
|
163
170
|
userProfile,
|
|
164
171
|
bulkEditMode = false,
|
|
165
172
|
bulkEditGroupData = null,
|
|
173
|
+
/**
|
|
174
|
+
* Hands the parent this form's guarded close.
|
|
175
|
+
*
|
|
176
|
+
* The dialog shell owns the backdrop and the Escape key, but only the form
|
|
177
|
+
* knows whether anything has been typed. Rather than lift the dirty flag
|
|
178
|
+
* up (and re-render the form on every keystroke), the form publishes the
|
|
179
|
+
* one function that knows what to do.
|
|
180
|
+
*/
|
|
181
|
+
registerClose,
|
|
166
182
|
}) {
|
|
167
183
|
const navigate = useNavigate();
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Has the user typed anything since this form opened?
|
|
187
|
+
*
|
|
188
|
+
* A ref, not state: nothing renders differently because the form is dirty,
|
|
189
|
+
* and making it state would re-render the whole form on the first
|
|
190
|
+
* keystroke in every field.
|
|
191
|
+
*
|
|
192
|
+
* It exists for one reason — closing a half-filled form by clicking the
|
|
193
|
+
* backdrop, or pressing Escape, silently discarded the work. That is the
|
|
194
|
+
* single most common way a person loses data in a CRM, and it is entirely
|
|
195
|
+
* preventable.
|
|
196
|
+
*/
|
|
197
|
+
const dirtyRef = useRef(false);
|
|
198
|
+
|
|
199
|
+
const markDirty = useCallback(() => {
|
|
200
|
+
dirtyRef.current = true;
|
|
201
|
+
}, []);
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Close, asking first if there is anything to lose.
|
|
205
|
+
*
|
|
206
|
+
* `window.confirm` deliberately: it is synchronous, it cannot be missed,
|
|
207
|
+
* and it works identically from the backdrop, the Escape key and the
|
|
208
|
+
* Cancel button. A prettier in-app dialog would be a second modal stacked
|
|
209
|
+
* on the first, which is its own problem.
|
|
210
|
+
*/
|
|
211
|
+
const guardedClose = useCallback(() => {
|
|
212
|
+
if (
|
|
213
|
+
dirtyRef.current &&
|
|
214
|
+
typeof window !== 'undefined' &&
|
|
215
|
+
!window.confirm(
|
|
216
|
+
'You have unsaved changes. Close this form and discard them?'
|
|
217
|
+
)
|
|
218
|
+
) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
dirtyRef.current = false;
|
|
223
|
+
closeModal?.();
|
|
224
|
+
}, [closeModal]);
|
|
225
|
+
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
registerClose?.(guardedClose);
|
|
228
|
+
|
|
229
|
+
return () => registerClose?.(null);
|
|
230
|
+
}, [registerClose, guardedClose]);
|
|
231
|
+
|
|
168
232
|
const [fetchTrigger, setFetchTrigger] = useState(false);
|
|
169
233
|
const [formData, setFormData] = useState({});
|
|
170
234
|
const [inputClass, setInputClass] = useState({});
|
|
@@ -230,6 +294,8 @@ function Form({
|
|
|
230
294
|
};
|
|
231
295
|
|
|
232
296
|
const handleChange = (e) => {
|
|
297
|
+
markDirty();
|
|
298
|
+
|
|
233
299
|
const id = e.target.dataset.jsonkey || e.target.dataset.name;
|
|
234
300
|
const value = e.target.value;
|
|
235
301
|
const parent = e.target.dataset.parent ? e.target.dataset.parent : '';
|
|
@@ -950,13 +1016,41 @@ function Form({
|
|
|
950
1016
|
: address
|
|
951
1017
|
: text;
|
|
952
1018
|
const address2 = '';
|
|
953
|
-
const suburb = context[1]?.text || '';
|
|
954
|
-
const postcode = context[0]?.text || '';
|
|
955
1019
|
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1020
|
+
/*
|
|
1021
|
+
* Mapbox's `context` is a list, not a record: its entries vary with
|
|
1022
|
+
* the kind of place. A POI carries one more than a street address, an
|
|
1023
|
+
* address with no suburb one fewer. Reading it by position therefore
|
|
1024
|
+
* filed the state under "suburb" (and vice versa) on anything but the
|
|
1025
|
+
* shape the indices were written against. Each entry does carry a
|
|
1026
|
+
* typed `id` — `postcode.123`, `region.456` — so match on that.
|
|
1027
|
+
*/
|
|
1028
|
+
const contexts = Array.isArray(context) ? context : [];
|
|
1029
|
+
const contextOf = (...types) => {
|
|
1030
|
+
for (const type of types) {
|
|
1031
|
+
const found = contexts.find(
|
|
1032
|
+
(entry) =>
|
|
1033
|
+
typeof entry?.id === 'string' &&
|
|
1034
|
+
entry.id.split('.')[0] === type
|
|
1035
|
+
);
|
|
1036
|
+
|
|
1037
|
+
if (found) {
|
|
1038
|
+
return found;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
return undefined;
|
|
1043
|
+
};
|
|
1044
|
+
|
|
1045
|
+
const postcode = contextOf('postcode')?.text || '';
|
|
1046
|
+
// `locality` is the suburb; `place` is the city it sits in. Australian
|
|
1047
|
+
// addresses carry both, and the suburb is the one on the envelope.
|
|
1048
|
+
const suburb = contextOf('locality', 'place')?.text || '';
|
|
1049
|
+
const region = contextOf('region');
|
|
1050
|
+
const state = region?.text || '';
|
|
1051
|
+
// ISO 3166-2, e.g. "AU-SA" — the last segment is the state's initials.
|
|
1052
|
+
const stateInitials = region?.short_code?.split('-').pop() || '';
|
|
1053
|
+
const country = contextOf('country')?.text || '';
|
|
960
1054
|
|
|
961
1055
|
// Determine the prefix based on the id parameter
|
|
962
1056
|
let prefix = '';
|
|
@@ -970,6 +1064,11 @@ function Form({
|
|
|
970
1064
|
|
|
971
1065
|
// Create the formDataUpdate object with the appropriate field names
|
|
972
1066
|
const formDataUpdate = {
|
|
1067
|
+
// The field that was typed into always gets the street line, even
|
|
1068
|
+
// if it is not named `<prefix>address1` and the prefix guess above
|
|
1069
|
+
// came out empty — otherwise the box the user is looking at is the
|
|
1070
|
+
// one field the pick does not fill.
|
|
1071
|
+
[id]: address1,
|
|
973
1072
|
[`${prefix}address1`]: address1,
|
|
974
1073
|
[`${prefix}address2`]: address2,
|
|
975
1074
|
[`${prefix}suburb`]: suburb,
|
|
@@ -978,6 +1077,47 @@ function Form({
|
|
|
978
1077
|
[`${prefix}country`]: country,
|
|
979
1078
|
};
|
|
980
1079
|
|
|
1080
|
+
/*
|
|
1081
|
+
* A form that stores the state as a foreign key (`state_id`, an
|
|
1082
|
+
* ajax dropdown) cannot use the name — and being required, it left
|
|
1083
|
+
* the form unsaveable after a pick, which is how "the address
|
|
1084
|
+
* autocomplete doesn't work" looked from the outside. The options
|
|
1085
|
+
* live inside that <Field>, so resolve the id from the same endpoint
|
|
1086
|
+
* it reads, matching on the state's name or its initials.
|
|
1087
|
+
*/
|
|
1088
|
+
const stateIdField = formSettings.fields?.find(
|
|
1089
|
+
(item) => item.id === `${prefix}state_id`
|
|
1090
|
+
);
|
|
1091
|
+
|
|
1092
|
+
if (stateIdField?.url && (state || stateInitials)) {
|
|
1093
|
+
const normalise = (value) =>
|
|
1094
|
+
String(value ?? '')
|
|
1095
|
+
.trim()
|
|
1096
|
+
.toLowerCase();
|
|
1097
|
+
|
|
1098
|
+
CustomFetch(stateIdField.url, 'POST', {}, (result) => {
|
|
1099
|
+
const options = Array.isArray(result?.data) ? result.data : [];
|
|
1100
|
+
const match =
|
|
1101
|
+
options.find(
|
|
1102
|
+
(option) => normalise(option.label) === normalise(state)
|
|
1103
|
+
) ||
|
|
1104
|
+
(stateInitials
|
|
1105
|
+
? options.find(
|
|
1106
|
+
(option) =>
|
|
1107
|
+
normalise(option.label) ===
|
|
1108
|
+
normalise(stateInitials)
|
|
1109
|
+
)
|
|
1110
|
+
: undefined);
|
|
1111
|
+
|
|
1112
|
+
if (match) {
|
|
1113
|
+
setFormData((prevState) => ({
|
|
1114
|
+
...prevState,
|
|
1115
|
+
[stateIdField.id]: match.id,
|
|
1116
|
+
}));
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
|
|
981
1121
|
// If same_address is true and we're updating the primary address,
|
|
982
1122
|
// also update the postal address fields
|
|
983
1123
|
if (formData.same_address === true && id === 'address1') {
|
|
@@ -2316,6 +2456,71 @@ function Form({
|
|
|
2316
2456
|
);
|
|
2317
2457
|
}
|
|
2318
2458
|
|
|
2459
|
+
/**
|
|
2460
|
+
* Open a just-created record instead of dropping
|
|
2461
|
+
* the user back on the list.
|
|
2462
|
+
*
|
|
2463
|
+
* Creating a customer from the index used to leave
|
|
2464
|
+
* you looking at a refreshed table, with the record
|
|
2465
|
+
* you just filled in somewhere on page four — so
|
|
2466
|
+
* the next thing anyone did was search for it. A
|
|
2467
|
+
* view opts in with `form.openOnCreate`; without
|
|
2468
|
+
* the flag nothing here runs and the old behaviour
|
|
2469
|
+
* stands.
|
|
2470
|
+
*
|
|
2471
|
+
* Creates only: an update already has you on the
|
|
2472
|
+
* record, and navigating out of it would be a
|
|
2473
|
+
* regression rather than a shortcut.
|
|
2474
|
+
*/
|
|
2475
|
+
if (formType === 'create' && formSettings.openOnCreate) {
|
|
2476
|
+
const openOnCreate =
|
|
2477
|
+
formSettings.openOnCreate === true
|
|
2478
|
+
? {}
|
|
2479
|
+
: formSettings.openOnCreate;
|
|
2480
|
+
|
|
2481
|
+
const detailUrl =
|
|
2482
|
+
openOnCreate.url || openOnCreateLink?.url;
|
|
2483
|
+
const idKey =
|
|
2484
|
+
openOnCreate.key ||
|
|
2485
|
+
openOnCreateLink?.key ||
|
|
2486
|
+
formSettings.primaryKey ||
|
|
2487
|
+
'id';
|
|
2488
|
+
|
|
2489
|
+
// The endpoints disagree on shape: some return
|
|
2490
|
+
// the saved record under `data`, some return
|
|
2491
|
+
// the id at the top level. Take the first that
|
|
2492
|
+
// actually carries one.
|
|
2493
|
+
const record = res.data.data;
|
|
2494
|
+
const newId =
|
|
2495
|
+
(record && typeof record === 'object'
|
|
2496
|
+
? record[idKey] ?? record.id
|
|
2497
|
+
: record) ??
|
|
2498
|
+
res.data[idKey] ??
|
|
2499
|
+
res.data.id;
|
|
2500
|
+
|
|
2501
|
+
if (
|
|
2502
|
+
detailUrl &&
|
|
2503
|
+
newId != null &&
|
|
2504
|
+
newId !== '' &&
|
|
2505
|
+
typeof newId !== 'object'
|
|
2506
|
+
) {
|
|
2507
|
+
// The column links carry their own trailing
|
|
2508
|
+
// slash (`"/customers/"`); an explicit url
|
|
2509
|
+
// may not. Normalise rather than trust it.
|
|
2510
|
+
navigate(
|
|
2511
|
+
`${detailUrl.replace(
|
|
2512
|
+
/\/+$/,
|
|
2513
|
+
''
|
|
2514
|
+
)}/${newId}`
|
|
2515
|
+
);
|
|
2516
|
+
} else if (detailUrl) {
|
|
2517
|
+
console.warn(
|
|
2518
|
+
'openOnCreate: the create response carried no record id, staying on the list.',
|
|
2519
|
+
res.data
|
|
2520
|
+
);
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2319
2524
|
if (formSettings.redirect && res.data.data?.id) {
|
|
2320
2525
|
navigate(
|
|
2321
2526
|
`${formSettings.redirect}${res.data.data.id}`
|
|
@@ -3901,6 +4106,17 @@ function Form({
|
|
|
3901
4106
|
<div
|
|
3902
4107
|
className={`${styles.formItem} ${styles.fwItem} ${styles.buttonContainer}`}
|
|
3903
4108
|
>
|
|
4109
|
+
<button
|
|
4110
|
+
className={styles.btnSecondary}
|
|
4111
|
+
onClick={(e) => {
|
|
4112
|
+
e.preventDefault();
|
|
4113
|
+
guardedClose();
|
|
4114
|
+
}}
|
|
4115
|
+
type="button"
|
|
4116
|
+
data-type="cancel"
|
|
4117
|
+
>
|
|
4118
|
+
Cancel
|
|
4119
|
+
</button>
|
|
3904
4120
|
<button
|
|
3905
4121
|
className={styles.btn}
|
|
3906
4122
|
onClick={handleSubmit}
|
|
@@ -3965,6 +4181,17 @@ function Form({
|
|
|
3965
4181
|
<div
|
|
3966
4182
|
className={`${styles.formItem} ${styles.fwItem} ${styles.buttonContainer}`}
|
|
3967
4183
|
>
|
|
4184
|
+
<button
|
|
4185
|
+
className={styles.btnSecondary}
|
|
4186
|
+
onClick={(e) => {
|
|
4187
|
+
e.preventDefault();
|
|
4188
|
+
guardedClose();
|
|
4189
|
+
}}
|
|
4190
|
+
type="button"
|
|
4191
|
+
data-type="cancel"
|
|
4192
|
+
>
|
|
4193
|
+
Cancel
|
|
4194
|
+
</button>
|
|
3968
4195
|
<button
|
|
3969
4196
|
className={styles.btn}
|
|
3970
4197
|
onClick={handleSubmit}
|
|
@@ -350,12 +350,12 @@ function MultiSelect({
|
|
|
350
350
|
? style.multi_select_height
|
|
351
351
|
: isTouchDevice()
|
|
352
352
|
? '44px'
|
|
353
|
-
: '
|
|
353
|
+
: 'var(--field-height, 51px)', // Apple's recommended minimum touch target size
|
|
354
354
|
height: 'auto', // Allow height to grow with content
|
|
355
355
|
}),
|
|
356
356
|
valueContainer: (base) => ({
|
|
357
357
|
...base,
|
|
358
|
-
padding: '
|
|
358
|
+
padding: '0 1rem',
|
|
359
359
|
display: 'flex',
|
|
360
360
|
alignItems: 'flex-start',
|
|
361
361
|
flexWrap: 'wrap',
|
|
@@ -348,8 +348,9 @@ function Navigation({
|
|
|
348
348
|
const renderSetting = (n) => {
|
|
349
349
|
const iconComponents = SETTING_ICONS;
|
|
350
350
|
|
|
351
|
-
//
|
|
352
|
-
|
|
351
|
+
// Same rule as renderRailSetting: an item stays current on the pages
|
|
352
|
+
// beneath it, so the cog remains lit across /settings/*.
|
|
353
|
+
const isActive = n.url && isNavActive(n.url, [], currentPage);
|
|
353
354
|
|
|
354
355
|
// Add active class if the setting is active
|
|
355
356
|
const activeClass = isActive ? styles.active : '';
|
|
@@ -456,7 +457,13 @@ function Navigation({
|
|
|
456
457
|
* stylesheet to place. See Notification.module.scss / SwitchAccount.
|
|
457
458
|
*/
|
|
458
459
|
const renderRailSetting = (n) => {
|
|
459
|
-
|
|
460
|
+
// `isNavActive`, not an exact string match. The cog points at
|
|
461
|
+
// `/settings`, and the settings hub deep-links its panels as
|
|
462
|
+
// `/settings/integrations`, `/settings/vault` and so on — an equality
|
|
463
|
+
// test lights the cog on the hub's index and then drops it the moment
|
|
464
|
+
// you open a section, which is exactly when you most want to know
|
|
465
|
+
// where you are.
|
|
466
|
+
const isActive = n.url && isNavActive(n.url, [], currentPage);
|
|
460
467
|
const activeClass = isActive ? styles.active : '';
|
|
461
468
|
const label = actionLabel(n);
|
|
462
469
|
// A CSS string, quoted and escaped, for `content`.
|
|
@@ -640,27 +647,36 @@ function Navigation({
|
|
|
640
647
|
);
|
|
641
648
|
};
|
|
642
649
|
|
|
650
|
+
/**
|
|
651
|
+
* Every permission this user holds, however they hold it.
|
|
652
|
+
*
|
|
653
|
+
* A UNION, and that is the whole point. This used to return direct
|
|
654
|
+
* permissions if there were ANY, and only fall back to role permissions
|
|
655
|
+
* otherwise — so granting one permission directly to a user who held
|
|
656
|
+
* nineteen through a role left them with exactly one, and every gated item
|
|
657
|
+
* in the navigation disappeared. Spatie treats the two as additive and so
|
|
658
|
+
* must this.
|
|
659
|
+
*
|
|
660
|
+
* It also read `roles[0]` alone, which silently dropped the permissions of
|
|
661
|
+
* every role after the first.
|
|
662
|
+
*/
|
|
643
663
|
const getUserPermissions = () => {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
userProfile.roles.
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
(permission) => permission.name
|
|
661
|
-
);
|
|
662
|
-
}
|
|
663
|
-
return [];
|
|
664
|
+
const names = (list) =>
|
|
665
|
+
Array.isArray(list)
|
|
666
|
+
? list
|
|
667
|
+
.map((entry) =>
|
|
668
|
+
typeof entry === 'string' ? entry : entry?.name
|
|
669
|
+
)
|
|
670
|
+
.filter(Boolean)
|
|
671
|
+
: [];
|
|
672
|
+
|
|
673
|
+
const direct = names(userProfile?.permissions);
|
|
674
|
+
|
|
675
|
+
const viaRoles = Array.isArray(userProfile?.roles)
|
|
676
|
+
? userProfile.roles.flatMap((role) => names(role?.permissions))
|
|
677
|
+
: [];
|
|
678
|
+
|
|
679
|
+
return [...new Set([...direct, ...viaRoles])];
|
|
664
680
|
};
|
|
665
681
|
|
|
666
682
|
/**
|
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
import React, { useState, useEffect, useRef } from 'react';
|
|
2
2
|
import styles from './styles/TableFilter.module.scss';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Is this rail a flat list of tabs, or a two-level menu?
|
|
6
|
+
*
|
|
7
|
+
* A flat rail is one row of labels, so it can be laid out as a horizontal tab
|
|
8
|
+
* strip; a rail with expandable parents has a submenu under each item and has
|
|
9
|
+
* no single row to pan. Exported because GenericDetail has to answer the same
|
|
10
|
+
* question one level up — it decides whether the tabs sit ABOVE the content
|
|
11
|
+
* panel or beside it, and that has to agree with what this component actually
|
|
12
|
+
* renders, or a nested rail ends up in a strip-shaped container.
|
|
13
|
+
*/
|
|
14
|
+
export const isFlatFilterList = (filters) =>
|
|
15
|
+
!(filters || []).some(
|
|
16
|
+
(filter) =>
|
|
17
|
+
filter &&
|
|
18
|
+
filter.isParent === true &&
|
|
19
|
+
Array.isArray(filter.children) &&
|
|
20
|
+
filter.children.length > 0
|
|
21
|
+
);
|
|
22
|
+
|
|
4
23
|
function TableFilter({
|
|
5
24
|
filters,
|
|
6
25
|
setFilters,
|
|
@@ -14,9 +33,14 @@ function TableFilter({
|
|
|
14
33
|
// treatment narrow viewports already get for a flat rail (see the
|
|
15
34
|
// `.flatFilter` block in the stylesheet), promoted to an opt-in.
|
|
16
35
|
//
|
|
17
|
-
// A vertical rail spends a fifth of the page on navigation
|
|
18
|
-
//
|
|
19
|
-
// default is unchanged.
|
|
36
|
+
// A vertical rail spends a fifth of the page on navigation. Detail pages
|
|
37
|
+
// now ask for the strip unconditionally (see GenericDetail); index pages
|
|
38
|
+
// and Profile still get the rail, so the default here is unchanged.
|
|
39
|
+
//
|
|
40
|
+
// In this variant the strip also carries real tab semantics — tablist /
|
|
41
|
+
// tab / aria-selected, one tab stop for the whole strip, and arrow keys to
|
|
42
|
+
// move between tabs — because a horizontal row of labels with one marked
|
|
43
|
+
// is what a screen reader and a keyboard both expect a tablist to be.
|
|
20
44
|
variant,
|
|
21
45
|
}) {
|
|
22
46
|
const [visibleChildren, setVisibleChildren] = useState({});
|
|
@@ -525,6 +549,22 @@ function TableFilter({
|
|
|
525
549
|
const childClass = (child) =>
|
|
526
550
|
`${styles.childLink} ${styles[child.class] || ''}`;
|
|
527
551
|
|
|
552
|
+
const isSelected = d.show === true;
|
|
553
|
+
|
|
554
|
+
// Tab semantics only in the underline strip, where the markup really is
|
|
555
|
+
// a tablist: one tab stop for the row, arrow keys between tabs (see
|
|
556
|
+
// `handleStripKeyDown`), `aria-selected` on the chosen one. Elsewhere
|
|
557
|
+
// the rail is a list of links and `aria-current` is the honest word for
|
|
558
|
+
// "this is the one you are on" — announcing a half-implemented tablist
|
|
559
|
+
// to a screen reader is worse than announcing nothing.
|
|
560
|
+
const a11y = isUnderline
|
|
561
|
+
? {
|
|
562
|
+
role: 'tab',
|
|
563
|
+
'aria-selected': isSelected ? 'true' : 'false',
|
|
564
|
+
tabIndex: isSelected ? 0 : -1,
|
|
565
|
+
}
|
|
566
|
+
: { 'aria-current': isSelected ? 'page' : undefined };
|
|
567
|
+
|
|
528
568
|
if (type === 'simple') {
|
|
529
569
|
return (
|
|
530
570
|
<li className={styles[d.class]}>
|
|
@@ -533,8 +573,9 @@ function TableFilter({
|
|
|
533
573
|
data-filtertype="parent"
|
|
534
574
|
data-url={d.url || ''}
|
|
535
575
|
data-value={d.hasOwnProperty('value') ? d.value : ''}
|
|
536
|
-
data-active={
|
|
576
|
+
data-active={isSelected ? 'true' : undefined}
|
|
537
577
|
onClick={filterTable}
|
|
578
|
+
{...a11y}
|
|
538
579
|
>
|
|
539
580
|
{d.label}
|
|
540
581
|
</button>
|
|
@@ -549,9 +590,10 @@ function TableFilter({
|
|
|
549
590
|
data-filtertype="parent"
|
|
550
591
|
data-url={d.url || ''}
|
|
551
592
|
data-value={d.hasOwnProperty('value') ? d.value : ''}
|
|
552
|
-
data-active={
|
|
593
|
+
data-active={isSelected ? 'true' : undefined}
|
|
553
594
|
onClick={filterTable}
|
|
554
595
|
className={parentClass}
|
|
596
|
+
{...a11y}
|
|
555
597
|
>
|
|
556
598
|
{d.label}
|
|
557
599
|
{isParent && d.children && d.children.length > 0 && (
|
|
@@ -642,13 +684,7 @@ function TableFilter({
|
|
|
642
684
|
// is only styled inside that media query, and `.mobileToggle` is
|
|
643
685
|
// `display: none` at every width above it, so leaving it out of the tree
|
|
644
686
|
// for a flat rail is invisible on desktop and tablet.
|
|
645
|
-
const isFlatStrip =
|
|
646
|
-
(filter) =>
|
|
647
|
-
filter &&
|
|
648
|
-
filter.isParent === true &&
|
|
649
|
-
Array.isArray(filter.children) &&
|
|
650
|
-
filter.children.length > 0
|
|
651
|
-
);
|
|
687
|
+
const isFlatStrip = isFlatFilterList(filters);
|
|
652
688
|
|
|
653
689
|
const activeId = activeFilter ? activeFilter.id : null;
|
|
654
690
|
|
|
@@ -674,6 +710,127 @@ function TableFilter({
|
|
|
674
710
|
// expandable parents has no single row to lay out.
|
|
675
711
|
const isUnderline = variant === 'underline' && isFlatStrip;
|
|
676
712
|
|
|
713
|
+
/**
|
|
714
|
+
* Which edges of the strip have tabs hidden past them: 'none' | 'start' |
|
|
715
|
+
* 'end' | 'both'.
|
|
716
|
+
*
|
|
717
|
+
* The strip hides its scrollbar (see `@mixin tab-strip`), which is right —
|
|
718
|
+
* a scrollbar under a two-tab row is noise — but it leaves a strip that is
|
|
719
|
+
* three tabs wider than its box looking exactly like a strip that fits.
|
|
720
|
+
* The stylesheet reads this off `data-overflow` and fades the affected
|
|
721
|
+
* edge, so the cut-off tab says out loud that it is cut off.
|
|
722
|
+
*/
|
|
723
|
+
const [stripOverflow, setStripOverflow] = useState('none');
|
|
724
|
+
|
|
725
|
+
useEffect(() => {
|
|
726
|
+
if (!isFlatStrip) return undefined;
|
|
727
|
+
|
|
728
|
+
const list = listRef.current;
|
|
729
|
+
|
|
730
|
+
if (!list || typeof list.addEventListener !== 'function') {
|
|
731
|
+
return undefined;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const measure = () => {
|
|
735
|
+
const max = list.scrollWidth - list.clientWidth;
|
|
736
|
+
|
|
737
|
+
// A sub-pixel difference is a rounding artefact, not a hidden tab.
|
|
738
|
+
if (!(max > 1)) {
|
|
739
|
+
setStripOverflow('none');
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const atStart = list.scrollLeft <= 1;
|
|
744
|
+
const atEnd = list.scrollLeft >= max - 1;
|
|
745
|
+
|
|
746
|
+
setStripOverflow(
|
|
747
|
+
atStart && atEnd
|
|
748
|
+
? 'none'
|
|
749
|
+
: atStart
|
|
750
|
+
? 'end'
|
|
751
|
+
: atEnd
|
|
752
|
+
? 'start'
|
|
753
|
+
: 'both'
|
|
754
|
+
);
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
measure();
|
|
758
|
+
|
|
759
|
+
list.addEventListener('scroll', measure, { passive: true });
|
|
760
|
+
|
|
761
|
+
if (typeof window !== 'undefined' && window.addEventListener) {
|
|
762
|
+
window.addEventListener('resize', measure);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return () => {
|
|
766
|
+
list.removeEventListener('scroll', measure);
|
|
767
|
+
|
|
768
|
+
if (typeof window !== 'undefined' && window.removeEventListener) {
|
|
769
|
+
window.removeEventListener('resize', measure);
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
// `filters` because adding or removing a tab changes whether the row
|
|
773
|
+
// overflows; `activeId` because selecting a tab scrolls it into view.
|
|
774
|
+
}, [isFlatStrip, filters, activeId]);
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Arrow-key movement between tabs.
|
|
778
|
+
*
|
|
779
|
+
* A tablist is one tab stop, not one per tab — `renderContent` gives the
|
|
780
|
+
* selected tab `tabIndex=0` and the rest `-1` — so without this the strip
|
|
781
|
+
* would be reachable by keyboard and impossible to move around in. Home
|
|
782
|
+
* and End jump to the ends. Activation follows focus, which is the
|
|
783
|
+
* expected behaviour for a tablist whose panels are already loaded.
|
|
784
|
+
*/
|
|
785
|
+
const handleStripKeyDown = (e) => {
|
|
786
|
+
if (!isUnderline) return;
|
|
787
|
+
|
|
788
|
+
const step =
|
|
789
|
+
e.key === 'ArrowRight'
|
|
790
|
+
? 1
|
|
791
|
+
: e.key === 'ArrowLeft'
|
|
792
|
+
? -1
|
|
793
|
+
: e.key === 'Home'
|
|
794
|
+
? 'first'
|
|
795
|
+
: e.key === 'End'
|
|
796
|
+
? 'last'
|
|
797
|
+
: null;
|
|
798
|
+
|
|
799
|
+
if (step === null) return;
|
|
800
|
+
|
|
801
|
+
const list = listRef.current;
|
|
802
|
+
|
|
803
|
+
if (!list || typeof list.querySelectorAll !== 'function') return;
|
|
804
|
+
|
|
805
|
+
const items = Array.from(
|
|
806
|
+
list.querySelectorAll(':scope > li > [data-filtertype]')
|
|
807
|
+
);
|
|
808
|
+
|
|
809
|
+
if (items.length === 0) return;
|
|
810
|
+
|
|
811
|
+
const current = items.indexOf(e.target);
|
|
812
|
+
|
|
813
|
+
if (current === -1) return;
|
|
814
|
+
|
|
815
|
+
e.preventDefault();
|
|
816
|
+
|
|
817
|
+
const next =
|
|
818
|
+
step === 'first'
|
|
819
|
+
? 0
|
|
820
|
+
: step === 'last'
|
|
821
|
+
? items.length - 1
|
|
822
|
+
: (current + step + items.length) % items.length;
|
|
823
|
+
|
|
824
|
+
const target = items[next];
|
|
825
|
+
|
|
826
|
+
if (!target) return;
|
|
827
|
+
|
|
828
|
+
if (typeof target.focus === 'function') target.focus();
|
|
829
|
+
// Goes through the same `filterTable` path a mouse click does, rather
|
|
830
|
+
// than a second copy of the selection logic.
|
|
831
|
+
if (typeof target.click === 'function') target.click();
|
|
832
|
+
};
|
|
833
|
+
|
|
677
834
|
return (
|
|
678
835
|
<div
|
|
679
836
|
className={`${styles.tableFilterContainer} ${
|
|
@@ -701,6 +858,9 @@ function TableFilter({
|
|
|
701
858
|
className={`${styles.tableFilter} ${
|
|
702
859
|
mobileMenuOpen ? styles.mobileOpen : ''
|
|
703
860
|
}`}
|
|
861
|
+
role={isUnderline ? 'tablist' : undefined}
|
|
862
|
+
data-overflow={isFlatStrip ? stripOverflow : undefined}
|
|
863
|
+
onKeyDown={isUnderline ? handleStripKeyDown : undefined}
|
|
704
864
|
>
|
|
705
865
|
{filters.map((item, key) => (
|
|
706
866
|
<React.Fragment key={'table-filter-' + key}>
|