@visns-studio/visns-components 6.17.2 → 6.20.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 +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 +142 -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
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
isColumnSortable,
|
|
57
57
|
} from '../utils/columnsMetadataUtils';
|
|
58
58
|
import {
|
|
59
|
+
Ban,
|
|
59
60
|
AlarmClock,
|
|
60
61
|
RotateCcw,
|
|
61
62
|
RefreshCw,
|
|
@@ -90,8 +91,20 @@ import {
|
|
|
90
91
|
// affordance, which a bare Plus does not say on its own.
|
|
91
92
|
ChevronDown,
|
|
92
93
|
ListPlus,
|
|
94
|
+
// Quick-assign: a person plus a plus sign says "hand this to someone" in a
|
|
95
|
+
// way a bare User glyph (which reads as "the person who holds it") does not.
|
|
96
|
+
UserPlus,
|
|
97
|
+
Search,
|
|
98
|
+
X,
|
|
93
99
|
} from 'lucide-react';
|
|
94
100
|
import styles from './styles/DataGrid.module.scss';
|
|
101
|
+
import {
|
|
102
|
+
filterPickerOptions,
|
|
103
|
+
isPickerRowAction,
|
|
104
|
+
normalisePickerOptions,
|
|
105
|
+
pickerOptionsCacheKey,
|
|
106
|
+
splitSettingEntries,
|
|
107
|
+
} from './utils/rowActionSettings';
|
|
95
108
|
|
|
96
109
|
/**
|
|
97
110
|
* `ajaxSetting.rowStyles[].style` tokens → the module class that draws them.
|
|
@@ -276,6 +289,25 @@ const loadData = async (
|
|
|
276
289
|
const ROW_HEIGHT = 34;
|
|
277
290
|
const GRID_CHROME = ROW_HEIGHT * 2 + 60; // header + filter row + footer
|
|
278
291
|
|
|
292
|
+
/**
|
|
293
|
+
* The fewest data rows' worth of body the grid will render.
|
|
294
|
+
*
|
|
295
|
+
* A table with two rows in it should not collapse to a sliver: the empty-state
|
|
296
|
+
* panel, a loading spinner and the "no records" message all need somewhere to
|
|
297
|
+
* sit. Five rows is enough for those without being the several-hundred-pixel
|
|
298
|
+
* void a window-sized grid leaves under a short table.
|
|
299
|
+
*/
|
|
300
|
+
const MIN_VISIBLE_ROWS = 5;
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The shortest a data row is assumed to be when sizing the grid to its content.
|
|
304
|
+
*
|
|
305
|
+
* Measured at 44px in the default density. Used as a FLOOR rather than a value,
|
|
306
|
+
* so a density that renders taller rows still gets enough room and a stale or
|
|
307
|
+
* unavailable measurement can only ever over-allocate.
|
|
308
|
+
*/
|
|
309
|
+
const ROW_PITCH_FLOOR = 44;
|
|
310
|
+
|
|
279
311
|
/**
|
|
280
312
|
* Column types whose renderer names the grid column `${type}.${id}` instead of
|
|
281
313
|
* the plain field path. Their filter entries must use the same name, otherwise
|
|
@@ -303,7 +335,12 @@ const DataGrid = forwardRef(
|
|
|
303
335
|
ajaxSetting,
|
|
304
336
|
api,
|
|
305
337
|
columns,
|
|
306
|
-
form,
|
|
338
|
+
// 24 unguarded `form.*` reads below, the first of them
|
|
339
|
+
// `form.searchDisable`, so a consumer that omits this optional-
|
|
340
|
+
// looking prop crashed on mount instead of rendering a read-only
|
|
341
|
+
// grid. An empty object gives every one of those reads undefined,
|
|
342
|
+
// which is what they already treat as "not set".
|
|
343
|
+
form = {},
|
|
307
344
|
gridHeight,
|
|
308
345
|
hoverColor,
|
|
309
346
|
legend,
|
|
@@ -463,6 +500,56 @@ const DataGrid = forwardRef(
|
|
|
463
500
|
]
|
|
464
501
|
);
|
|
465
502
|
|
|
503
|
+
/**
|
|
504
|
+
* The open quick-assign menu, or null.
|
|
505
|
+
*
|
|
506
|
+
* `{ config, row, anchor }` — anchor is the clicked icon's viewport
|
|
507
|
+
* rect, so the menu can sit under it. Held as one object rather than
|
|
508
|
+
* three pieces of state so that opening, moving between rows and
|
|
509
|
+
* closing are each a single update and the menu can never render
|
|
510
|
+
* against a row it was not opened from.
|
|
511
|
+
*/
|
|
512
|
+
const [actionPicker, setActionPicker] = useState(null);
|
|
513
|
+
const [pickerOptions, setPickerOptions] = useState([]);
|
|
514
|
+
const [pickerLoading, setPickerLoading] = useState(false);
|
|
515
|
+
const [pickerSearch, setPickerSearch] = useState('');
|
|
516
|
+
// Options live for the life of the grid mount: the user list behind a
|
|
517
|
+
// quick-assign menu does not change between two clicks, and refetching
|
|
518
|
+
// it made the menu flash empty every time it opened.
|
|
519
|
+
const pickerOptionsCache = useRef({});
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Where a record created from this grid should open, for
|
|
523
|
+
* `form.openOnCreate`.
|
|
524
|
+
*
|
|
525
|
+
* A view that already links its rows to a detail page has told us the
|
|
526
|
+
* url once, in the column's `link` config — repeating it under the
|
|
527
|
+
* form would be a second copy to keep in step. So the first column
|
|
528
|
+
* with a plain `{ key, url }` link wins. Row links that open a file
|
|
529
|
+
* (`folder`) or a popup are not detail pages, so they are skipped.
|
|
530
|
+
*
|
|
531
|
+
* A grid whose columns carry no link can still opt in by giving
|
|
532
|
+
* `openOnCreate` its own `{ url, key }`; <Form> prefers that over
|
|
533
|
+
* whatever is derived here.
|
|
534
|
+
*/
|
|
535
|
+
const openOnCreateLink = useMemo(() => {
|
|
536
|
+
if (!form?.openOnCreate) {
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const linked = (columns || []).find(
|
|
541
|
+
(column) =>
|
|
542
|
+
column?.link?.url &&
|
|
543
|
+
column.link.key &&
|
|
544
|
+
!column.link.folder &&
|
|
545
|
+
!column.link.popup
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
return linked
|
|
549
|
+
? { url: linked.link.url, key: linked.link.key }
|
|
550
|
+
: null;
|
|
551
|
+
}, [columns, form?.openOnCreate]);
|
|
552
|
+
|
|
466
553
|
/** Group expansion state */
|
|
467
554
|
const [collapsedGroups, setCollapsedGroups] = useState({});
|
|
468
555
|
|
|
@@ -1712,6 +1799,7 @@ const DataGrid = forwardRef(
|
|
|
1712
1799
|
case 'activate':
|
|
1713
1800
|
case 'arrowCycle':
|
|
1714
1801
|
case 'cloudUpload':
|
|
1802
|
+
case 'ban':
|
|
1715
1803
|
case 'envelope':
|
|
1716
1804
|
case 'file':
|
|
1717
1805
|
case 'image':
|
|
@@ -2112,6 +2200,8 @@ const DataGrid = forwardRef(
|
|
|
2112
2200
|
return Edit;
|
|
2113
2201
|
case 'envelope':
|
|
2114
2202
|
return Mail;
|
|
2203
|
+
case 'ban':
|
|
2204
|
+
return Ban;
|
|
2115
2205
|
case 'file':
|
|
2116
2206
|
return File;
|
|
2117
2207
|
case 'image':
|
|
@@ -2435,6 +2525,7 @@ const DataGrid = forwardRef(
|
|
|
2435
2525
|
|
|
2436
2526
|
/** Lucide icons an inline row action may name via `icon` */
|
|
2437
2527
|
const rowActionIcons = {
|
|
2528
|
+
ban: Ban,
|
|
2438
2529
|
check: Check,
|
|
2439
2530
|
copy: Copy,
|
|
2440
2531
|
delete: Trash2,
|
|
@@ -2443,6 +2534,7 @@ const DataGrid = forwardRef(
|
|
|
2443
2534
|
plus: Plus,
|
|
2444
2535
|
trash: Trash2,
|
|
2445
2536
|
undo: RotateCcw,
|
|
2537
|
+
userPlus: UserPlus,
|
|
2446
2538
|
};
|
|
2447
2539
|
|
|
2448
2540
|
/**
|
|
@@ -2462,6 +2554,21 @@ const DataGrid = forwardRef(
|
|
|
2462
2554
|
return false;
|
|
2463
2555
|
}
|
|
2464
2556
|
|
|
2557
|
+
// An inline action declared in the ROOT `settings` array sits in
|
|
2558
|
+
// the same cell as update/delete, so it answers to the same two
|
|
2559
|
+
// gates those do. Dormant for any config that names neither.
|
|
2560
|
+
if (!settingAllowedBySuperAdmin(config)) {
|
|
2561
|
+
return false;
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
if (
|
|
2565
|
+
Array.isArray(config.roles) &&
|
|
2566
|
+
config.roles.length > 0 &&
|
|
2567
|
+
!userHasAnyRole(config.roles)
|
|
2568
|
+
) {
|
|
2569
|
+
return false;
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2465
2572
|
if (
|
|
2466
2573
|
Array.isArray(config.show) &&
|
|
2467
2574
|
!config.show.every((condition) =>
|
|
@@ -2493,7 +2600,12 @@ const DataGrid = forwardRef(
|
|
|
2493
2600
|
: String(value);
|
|
2494
2601
|
});
|
|
2495
2602
|
|
|
2496
|
-
|
|
2603
|
+
/**
|
|
2604
|
+
* `extraBody` carries the value a picker action collected (e.g.
|
|
2605
|
+
* `{ user_id: 7 }` from the quick-assign menu). A plain confirm action
|
|
2606
|
+
* passes nothing and behaves exactly as before.
|
|
2607
|
+
*/
|
|
2608
|
+
const executeRowAction = async (config, row, extraBody = null) => {
|
|
2497
2609
|
const fetchConfig = config.fetch;
|
|
2498
2610
|
|
|
2499
2611
|
if (!fetchConfig?.url) {
|
|
@@ -2514,7 +2626,7 @@ const DataGrid = forwardRef(
|
|
|
2514
2626
|
const result = await CustomFetch(
|
|
2515
2627
|
url,
|
|
2516
2628
|
fetchConfig.method || 'POST',
|
|
2517
|
-
{ ...fetchConfig.data }
|
|
2629
|
+
{ ...fetchConfig.data, ...(extraBody || {}) }
|
|
2518
2630
|
);
|
|
2519
2631
|
|
|
2520
2632
|
// These endpoints answer 200 with an `error` string rather
|
|
@@ -2539,7 +2651,100 @@ const DataGrid = forwardRef(
|
|
|
2539
2651
|
}
|
|
2540
2652
|
};
|
|
2541
2653
|
|
|
2542
|
-
|
|
2654
|
+
/** Open (or re-target, or toggle shut) the quick-assign menu */
|
|
2655
|
+
const openActionPicker = (config, row, anchor) => {
|
|
2656
|
+
setActionPicker((current) => {
|
|
2657
|
+
// Clicking the same row's icon again closes the menu
|
|
2658
|
+
if (
|
|
2659
|
+
current &&
|
|
2660
|
+
current.config?.id === config.id &&
|
|
2661
|
+
current.row === row
|
|
2662
|
+
) {
|
|
2663
|
+
return null;
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
return { config, row, anchor };
|
|
2667
|
+
});
|
|
2668
|
+
setPickerSearch('');
|
|
2669
|
+
};
|
|
2670
|
+
|
|
2671
|
+
const closeActionPicker = () => {
|
|
2672
|
+
setActionPicker(null);
|
|
2673
|
+
setPickerSearch('');
|
|
2674
|
+
};
|
|
2675
|
+
|
|
2676
|
+
/**
|
|
2677
|
+
* Fixed-position box under the clicked icon.
|
|
2678
|
+
*
|
|
2679
|
+
* Fixed rather than absolute because the menu is rendered at the root
|
|
2680
|
+
* of the component, outside the grid's own scrolling/overflow-hidden
|
|
2681
|
+
* cells — an absolutely positioned panel would be clipped by the row.
|
|
2682
|
+
* It flips above the icon when there is no room below (the last rows
|
|
2683
|
+
* of a full page) and is clamped inside the viewport horizontally.
|
|
2684
|
+
*/
|
|
2685
|
+
const actionPickerStyle = () => {
|
|
2686
|
+
const anchor = actionPicker?.anchor;
|
|
2687
|
+
|
|
2688
|
+
if (!anchor) {
|
|
2689
|
+
return { display: 'none' };
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
const width = 240;
|
|
2693
|
+
const maxHeight = 300;
|
|
2694
|
+
const viewportWidth =
|
|
2695
|
+
typeof window === 'undefined' ? width + 16 : window.innerWidth;
|
|
2696
|
+
const viewportHeight =
|
|
2697
|
+
typeof window === 'undefined' ? maxHeight + 16 : window.innerHeight;
|
|
2698
|
+
// Right-aligned to the icon: the action cell is the last column,
|
|
2699
|
+
// so growing leftwards keeps the menu on screen.
|
|
2700
|
+
const left = Math.max(
|
|
2701
|
+
8,
|
|
2702
|
+
Math.min(anchor.right - width, viewportWidth - width - 8)
|
|
2703
|
+
);
|
|
2704
|
+
const openUpwards =
|
|
2705
|
+
anchor.bottom + 6 + maxHeight > viewportHeight - 8 &&
|
|
2706
|
+
anchor.top > maxHeight;
|
|
2707
|
+
|
|
2708
|
+
return {
|
|
2709
|
+
position: 'fixed',
|
|
2710
|
+
zIndex: 10000,
|
|
2711
|
+
width: `${width}px`,
|
|
2712
|
+
maxHeight: `${maxHeight}px`,
|
|
2713
|
+
left: `${left}px`,
|
|
2714
|
+
...(openUpwards
|
|
2715
|
+
? { bottom: `${viewportHeight - anchor.top + 6}px` }
|
|
2716
|
+
: { top: `${anchor.bottom + 6}px` }),
|
|
2717
|
+
};
|
|
2718
|
+
};
|
|
2719
|
+
|
|
2720
|
+
/**
|
|
2721
|
+
* A chosen option becomes the action's body and goes through the same
|
|
2722
|
+
* executeRowAction every other inline action uses — so the toast, the
|
|
2723
|
+
* `error`-in-a-200 handling and the grid reload are one implementation.
|
|
2724
|
+
* `option` is null for the "Unassign" entry, which posts an explicit
|
|
2725
|
+
* null (the endpoint validates `present, nullable`).
|
|
2726
|
+
*/
|
|
2727
|
+
const handleActionPickerChoice = (option) => {
|
|
2728
|
+
if (!actionPicker) {
|
|
2729
|
+
return;
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
const { config, row } = actionPicker;
|
|
2733
|
+
const bodyKey = config.fetch?.bodyKey || 'value';
|
|
2734
|
+
|
|
2735
|
+
closeActionPicker();
|
|
2736
|
+
executeRowAction(config, row, { [bodyKey]: option?.id ?? null });
|
|
2737
|
+
};
|
|
2738
|
+
|
|
2739
|
+
const handleRowAction = (config, row, anchor) => {
|
|
2740
|
+
// A picker action collects a value first — there is nothing to
|
|
2741
|
+
// confirm until one has been chosen
|
|
2742
|
+
if (isPickerRowAction(config)) {
|
|
2743
|
+
openActionPicker(config, row, anchor);
|
|
2744
|
+
|
|
2745
|
+
return;
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2543
2748
|
if (!config.confirm) {
|
|
2544
2749
|
executeRowAction(config, row);
|
|
2545
2750
|
|
|
@@ -2576,10 +2781,22 @@ const DataGrid = forwardRef(
|
|
|
2576
2781
|
return (
|
|
2577
2782
|
<span
|
|
2578
2783
|
key={`row-action-${config.id}`}
|
|
2784
|
+
// Named so the picker's outside-click dismissal can tell a
|
|
2785
|
+
// click on the trigger from a click on the page: without
|
|
2786
|
+
// it, mousedown closes the menu a moment before click
|
|
2787
|
+
// reopens it and the icon can never toggle shut.
|
|
2788
|
+
className={styles.rowActionTrigger}
|
|
2579
2789
|
onClick={(e) => {
|
|
2580
2790
|
e.preventDefault();
|
|
2581
2791
|
e.stopPropagation();
|
|
2582
|
-
|
|
2792
|
+
// The menu variant needs to know where the icon is;
|
|
2793
|
+
// currentTarget is the icon's own wrapper, so its rect
|
|
2794
|
+
// survives a click that landed on the svg inside it.
|
|
2795
|
+
handleRowAction(
|
|
2796
|
+
config,
|
|
2797
|
+
row,
|
|
2798
|
+
e.currentTarget.getBoundingClientRect()
|
|
2799
|
+
);
|
|
2583
2800
|
}}
|
|
2584
2801
|
>
|
|
2585
2802
|
<IconComponent
|
|
@@ -2594,6 +2811,103 @@ const DataGrid = forwardRef(
|
|
|
2594
2811
|
);
|
|
2595
2812
|
};
|
|
2596
2813
|
|
|
2814
|
+
/**
|
|
2815
|
+
* Load the open picker's options, once per (url, params) per mount.
|
|
2816
|
+
*
|
|
2817
|
+
* Keyed on the cache key rather than on `actionPicker` itself, so
|
|
2818
|
+
* moving the menu from one row to the next does not refetch — the list
|
|
2819
|
+
* of people you can assign to is the same list on every row.
|
|
2820
|
+
*/
|
|
2821
|
+
const pickerCacheKey = actionPicker
|
|
2822
|
+
? pickerOptionsCacheKey(actionPicker.config)
|
|
2823
|
+
: null;
|
|
2824
|
+
|
|
2825
|
+
useEffect(() => {
|
|
2826
|
+
if (!pickerCacheKey || !actionPicker?.config?.optionsUrl) {
|
|
2827
|
+
return;
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
const cached = pickerOptionsCache.current[pickerCacheKey];
|
|
2831
|
+
|
|
2832
|
+
if (cached) {
|
|
2833
|
+
setPickerOptions(cached);
|
|
2834
|
+
setPickerLoading(false);
|
|
2835
|
+
|
|
2836
|
+
return;
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
let cancelled = false;
|
|
2840
|
+
|
|
2841
|
+
setPickerLoading(true);
|
|
2842
|
+
|
|
2843
|
+
CustomFetch(
|
|
2844
|
+
actionPicker.config.optionsUrl,
|
|
2845
|
+
actionPicker.config.optionsMethod || 'POST',
|
|
2846
|
+
{ ...(actionPicker.config.optionsParams || {}) }
|
|
2847
|
+
)
|
|
2848
|
+
.then((res) => {
|
|
2849
|
+
const options = normalisePickerOptions(res?.data ?? res);
|
|
2850
|
+
|
|
2851
|
+
pickerOptionsCache.current[pickerCacheKey] = options;
|
|
2852
|
+
|
|
2853
|
+
if (!cancelled) {
|
|
2854
|
+
setPickerOptions(options);
|
|
2855
|
+
setPickerLoading(false);
|
|
2856
|
+
}
|
|
2857
|
+
})
|
|
2858
|
+
.catch((error) => {
|
|
2859
|
+
console.error('Row action picker options failed:', error);
|
|
2860
|
+
|
|
2861
|
+
if (!cancelled) {
|
|
2862
|
+
setPickerOptions([]);
|
|
2863
|
+
setPickerLoading(false);
|
|
2864
|
+
}
|
|
2865
|
+
});
|
|
2866
|
+
|
|
2867
|
+
return () => {
|
|
2868
|
+
cancelled = true;
|
|
2869
|
+
};
|
|
2870
|
+
}, [pickerCacheKey]);
|
|
2871
|
+
|
|
2872
|
+
// The menu is a floating panel with no backdrop, so a click anywhere
|
|
2873
|
+
// else — or Escape, or a scroll that would leave it stranded beside
|
|
2874
|
+
// nothing — dismisses it.
|
|
2875
|
+
useEffect(() => {
|
|
2876
|
+
if (!actionPicker) {
|
|
2877
|
+
return;
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2880
|
+
const onKeyDown = (event) => {
|
|
2881
|
+
if (event.key === 'Escape') {
|
|
2882
|
+
closeActionPicker();
|
|
2883
|
+
}
|
|
2884
|
+
};
|
|
2885
|
+
|
|
2886
|
+
const onDismiss = (event) => {
|
|
2887
|
+
if (
|
|
2888
|
+
event?.target?.closest?.(
|
|
2889
|
+
`.${styles.rowActionPicker}, .${styles.rowActionTrigger}`
|
|
2890
|
+
)
|
|
2891
|
+
) {
|
|
2892
|
+
return;
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
closeActionPicker();
|
|
2896
|
+
};
|
|
2897
|
+
|
|
2898
|
+
document.addEventListener('keydown', onKeyDown);
|
|
2899
|
+
document.addEventListener('mousedown', onDismiss);
|
|
2900
|
+
window.addEventListener('scroll', closeActionPicker, true);
|
|
2901
|
+
window.addEventListener('resize', closeActionPicker);
|
|
2902
|
+
|
|
2903
|
+
return () => {
|
|
2904
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
2905
|
+
document.removeEventListener('mousedown', onDismiss);
|
|
2906
|
+
window.removeEventListener('scroll', closeActionPicker, true);
|
|
2907
|
+
window.removeEventListener('resize', closeActionPicker);
|
|
2908
|
+
};
|
|
2909
|
+
}, [actionPicker]);
|
|
2910
|
+
|
|
2597
2911
|
// Touch handlers for tooltips
|
|
2598
2912
|
const handleTouchStart = (e, buttonId, label) => {
|
|
2599
2913
|
// Clear any existing timer
|
|
@@ -2867,6 +3181,17 @@ const DataGrid = forwardRef(
|
|
|
2867
3181
|
);
|
|
2868
3182
|
}
|
|
2869
3183
|
}
|
|
3184
|
+
} else if (
|
|
3185
|
+
rowData === null ||
|
|
3186
|
+
rowData === undefined ||
|
|
3187
|
+
rowData === ''
|
|
3188
|
+
) {
|
|
3189
|
+
// A link column whose key is empty on
|
|
3190
|
+
// THIS row has nowhere to go — a task
|
|
3191
|
+
// that belongs to a customer directly
|
|
3192
|
+
// has no ticket_id, and following the
|
|
3193
|
+
// link would open "/tickets/null".
|
|
3194
|
+
// The cell is simply inert.
|
|
2870
3195
|
} else {
|
|
2871
3196
|
navigate(`${linkUrl}${rowData}`);
|
|
2872
3197
|
}
|
|
@@ -4063,6 +4388,24 @@ const DataGrid = forwardRef(
|
|
|
4063
4388
|
break;
|
|
4064
4389
|
case 'select':
|
|
4065
4390
|
filterEditor = SelectFilter;
|
|
4391
|
+
|
|
4392
|
+
// Inline options: `filter.options` as
|
|
4393
|
+
// [{ id, label }].
|
|
4394
|
+
//
|
|
4395
|
+
// `selectedDataSource` is only ever filled
|
|
4396
|
+
// from a URL fetch, so a filter over a
|
|
4397
|
+
// fixed set — a status of Active/Completed,
|
|
4398
|
+
// a yes/no flag — had nowhere to get its
|
|
4399
|
+
// choices and rendered an empty dropdown.
|
|
4400
|
+
// Fetching two known values over HTTP would
|
|
4401
|
+
// be silly; this lets the column carry them.
|
|
4402
|
+
if (
|
|
4403
|
+
!selectedDataSource &&
|
|
4404
|
+
Array.isArray(column.filter.options)
|
|
4405
|
+
) {
|
|
4406
|
+
selectedDataSource = column.filter.options;
|
|
4407
|
+
}
|
|
4408
|
+
|
|
4066
4409
|
// Only set filter editor if we have data or it's not a URL-based filter
|
|
4067
4410
|
if (selectedDataSource || !column.filter.url) {
|
|
4068
4411
|
filterEditorProps = {
|
|
@@ -5033,9 +5376,18 @@ const DataGrid = forwardRef(
|
|
|
5033
5376
|
return null;
|
|
5034
5377
|
}
|
|
5035
5378
|
|
|
5379
|
+
// Inline actions first, so update/delete keep
|
|
5380
|
+
// the end-of-row position muscle memory
|
|
5381
|
+
// expects them in.
|
|
5382
|
+
const { inlineActions, standard } =
|
|
5383
|
+
splitSettingEntries(memoizedSettings);
|
|
5384
|
+
|
|
5036
5385
|
return (
|
|
5037
5386
|
<div className={styles.tdactions}>
|
|
5038
|
-
{
|
|
5387
|
+
{inlineActions.map((config) =>
|
|
5388
|
+
renderRowAction(config, data)
|
|
5389
|
+
)}
|
|
5390
|
+
{standard
|
|
5039
5391
|
.filter(
|
|
5040
5392
|
(setting) =>
|
|
5041
5393
|
!actionColumnSettingIds.has(
|
|
@@ -5248,6 +5600,15 @@ const DataGrid = forwardRef(
|
|
|
5248
5600
|
// after it — which used to be a flat 28px guess that was wrong for any
|
|
5249
5601
|
// page nesting its grid differently.
|
|
5250
5602
|
const containerRef = useRef(null);
|
|
5603
|
+
|
|
5604
|
+
/**
|
|
5605
|
+
* The form's own guarded close, published by <Form> once it mounts.
|
|
5606
|
+
*
|
|
5607
|
+
* The dialog shell owns the backdrop and the Escape key; only the form
|
|
5608
|
+
* knows whether anything has been typed. Null until a form is open, in
|
|
5609
|
+
* which case the plain close is correct.
|
|
5610
|
+
*/
|
|
5611
|
+
const formCloseRef = useRef(null);
|
|
5251
5612
|
const measuredHeight = useAvailableHeight(containerRef, {
|
|
5252
5613
|
bottomGutter: 0,
|
|
5253
5614
|
measureChrome: true,
|
|
@@ -5296,33 +5657,164 @@ const DataGrid = forwardRef(
|
|
|
5296
5657
|
return null;
|
|
5297
5658
|
}
|
|
5298
5659
|
|
|
5299
|
-
|
|
5300
|
-
|
|
5660
|
+
// Sum the parts, rather than subtracting the body from the grid.
|
|
5661
|
+
//
|
|
5662
|
+
// `.InovuaReactDataGrid__body` turns out to BE the whole grid in
|
|
5663
|
+
// this build — measured 448px against a 448px grid — so the old
|
|
5664
|
+
// subtraction returned 0, failed its `> 0` guard, and silently
|
|
5665
|
+
// handed back null every single time. The caller then used the
|
|
5666
|
+
// GRID_CHROME constant, and nothing ever revealed that the
|
|
5667
|
+
// measurement had never once worked.
|
|
5668
|
+
const heightOf = (selector) => {
|
|
5669
|
+
const el = root.querySelector(selector);
|
|
5670
|
+
|
|
5671
|
+
return el ? el.getBoundingClientRect().height : 0;
|
|
5672
|
+
};
|
|
5673
|
+
|
|
5674
|
+
const chrome =
|
|
5675
|
+
heightOf('.InovuaReactDataGrid__header-wrapper, .InovuaReactDataGrid__column-header') +
|
|
5676
|
+
heightOf('.InovuaReactDataGrid__column-header__filter-wrapper') +
|
|
5677
|
+
heightOf('.InovuaReactDataGrid__footer-wrapper, .InovuaReactDataGrid__footer') +
|
|
5678
|
+
heightOf('.InovuaReactDataGrid__pagination-toolbar');
|
|
5679
|
+
|
|
5680
|
+
return chrome > 0 ? chrome : null;
|
|
5681
|
+
};
|
|
5301
5682
|
|
|
5302
|
-
|
|
5683
|
+
/**
|
|
5684
|
+
* How tall a data row actually is, measured.
|
|
5685
|
+
*
|
|
5686
|
+
* The density table's `rowUnit` is what the grid is ASKED to use; what
|
|
5687
|
+
* it renders can differ — measured 44px against a rowUnit of 32. Ten
|
|
5688
|
+
* rows costed at 32px each under-allocated the body by 120px, so the
|
|
5689
|
+
* grid was sized too short and clipped its own last rows. Measuring
|
|
5690
|
+
* the rendered pitch removes the assumption entirely.
|
|
5691
|
+
*/
|
|
5692
|
+
const measureRowPitch = () => {
|
|
5693
|
+
const root = containerRef.current;
|
|
5694
|
+
|
|
5695
|
+
if (!root || typeof root.querySelectorAll !== 'function') {
|
|
5303
5696
|
return null;
|
|
5304
5697
|
}
|
|
5305
5698
|
|
|
5306
|
-
const
|
|
5307
|
-
gridEl.getBoundingClientRect().height -
|
|
5308
|
-
body.getBoundingClientRect().height;
|
|
5699
|
+
const rows = root.querySelectorAll('.InovuaReactDataGrid__row');
|
|
5309
5700
|
|
|
5310
|
-
|
|
5701
|
+
if (rows.length >= 2) {
|
|
5702
|
+
const pitch =
|
|
5703
|
+
rows[1].getBoundingClientRect().top -
|
|
5704
|
+
rows[0].getBoundingClientRect().top;
|
|
5705
|
+
|
|
5706
|
+
if (pitch > 0) {
|
|
5707
|
+
return pitch;
|
|
5708
|
+
}
|
|
5709
|
+
}
|
|
5710
|
+
|
|
5711
|
+
if (rows.length === 1) {
|
|
5712
|
+
const single = rows[0].getBoundingClientRect().height;
|
|
5713
|
+
|
|
5714
|
+
if (single > 0) {
|
|
5715
|
+
return single;
|
|
5716
|
+
}
|
|
5717
|
+
}
|
|
5718
|
+
|
|
5719
|
+
return null;
|
|
5311
5720
|
};
|
|
5312
5721
|
|
|
5313
5722
|
useEffect(() => {
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5723
|
+
// TWO heights, and they must not be conflated.
|
|
5724
|
+
//
|
|
5725
|
+
// `availableHeight` is what the window allows, and it is what the
|
|
5726
|
+
// page size is derived from below. `renderedHeight` is what the
|
|
5727
|
+
// grid is actually drawn at, which is the smaller of that and what
|
|
5728
|
+
// the rows need.
|
|
5729
|
+
//
|
|
5730
|
+
// Deriving the page size from the *rendered* height instead would
|
|
5731
|
+
// feed back on itself: a short table shrinks the grid, a shorter
|
|
5732
|
+
// grid asks for fewer rows, and the grid can then never grow back
|
|
5733
|
+
// to discover there was more data. So the request stays sized to
|
|
5734
|
+
// the window and only the painting follows the content.
|
|
5735
|
+
const availableHeight =
|
|
5736
|
+
resolvedHeight > 0
|
|
5737
|
+
? resolvedHeight
|
|
5738
|
+
: fitToRows(dataCount, window.innerHeight);
|
|
5739
|
+
|
|
5740
|
+
// A window-sized grid asks for as many rows as fit — 37 on a 1440p
|
|
5741
|
+
// screen. A table holding four of them rendered the other 33 as
|
|
5742
|
+
// empty body, which is the single most dated thing about an index
|
|
5743
|
+
// page: a thousand pixels of nothing under the last row, with the
|
|
5744
|
+
// pager stranded at the bottom of it.
|
|
5745
|
+
const contentHeight = () => {
|
|
5746
|
+
const chrome = measureGridChrome() ?? GRID_CHROME;
|
|
5747
|
+
|
|
5748
|
+
// ERR HIGH, DELIBERATELY.
|
|
5749
|
+
//
|
|
5750
|
+
// The two errors here are not symmetrical. Over-allocating
|
|
5751
|
+
// leaves a little empty body under the last row, which nobody
|
|
5752
|
+
// notices. Under-allocating CLIPS rows behind the pager, which
|
|
5753
|
+
// everybody notices and which looks like data loss.
|
|
5754
|
+
//
|
|
5755
|
+
// The density table's `rowUnit` is what the grid is asked to
|
|
5756
|
+
// use; measured, rows render at 44px against a rowUnit of 32.
|
|
5757
|
+
// Measurement is preferred when it is available, but it reads
|
|
5758
|
+
// the DOM and the DOM is not always there when this runs — so
|
|
5759
|
+
// there is a floor under whatever it returns.
|
|
5760
|
+
const pitch = Math.max(
|
|
5761
|
+
measureRowPitch() ?? 0,
|
|
5762
|
+
grid.rowUnit ?? 0,
|
|
5763
|
+
ROW_PITCH_FLOOR
|
|
5764
|
+
);
|
|
5765
|
+
|
|
5766
|
+
const rows = Math.max(dataCount || 0, MIN_VISIBLE_ROWS);
|
|
5767
|
+
|
|
5768
|
+
// Plus one row of slack, because sub-pixel heights round down
|
|
5769
|
+
// and rounding down is what clips the last row.
|
|
5770
|
+
return chrome + rows * pitch + pitch;
|
|
5317
5771
|
};
|
|
5318
5772
|
|
|
5319
|
-
|
|
5773
|
+
// An explicit `gridHeight` is a CEILING, not an exact size.
|
|
5774
|
+
//
|
|
5775
|
+
// This first exempted it, on the reasoning that a page asking for a
|
|
5776
|
+
// height should get it. But look at what the only caller actually
|
|
5777
|
+
// passes: GenericDetail hands every tab `windowHeight * 0.65`, and
|
|
5778
|
+
// its own comment says why — "a detail page stacks several grids
|
|
5779
|
+
// down one tab and they cannot all own the viewport". That is a cap
|
|
5780
|
+
// being expressed, not a measurement. Honouring it literally put
|
|
5781
|
+
// ~975px of empty body under the two rows on a customer's Sites
|
|
5782
|
+
// tab.
|
|
5783
|
+
const renderedHeight = Math.min(availableHeight, contentHeight());
|
|
5784
|
+
|
|
5785
|
+
// Re-measure ONCE MORE after the browser has painted.
|
|
5786
|
+
//
|
|
5787
|
+
// Everything above runs in the same tick as the data change, which
|
|
5788
|
+
// is before the new rows exist in the DOM — so `measureRowPitch`
|
|
5789
|
+
// and `measureGridChrome` are reading the PREVIOUS render. With 10
|
|
5790
|
+
// rows arriving into a grid that had none, the first pass costed
|
|
5791
|
+
// them at the fallback unit and sized the body ~130px short, which
|
|
5792
|
+
// clipped the last two rows behind the pager.
|
|
5793
|
+
//
|
|
5794
|
+
// rAF is the earliest point the real geometry exists. The guard
|
|
5795
|
+
// stops it looping: it only writes when the corrected height
|
|
5796
|
+
// actually differs.
|
|
5797
|
+
const frame = requestAnimationFrame(() => {
|
|
5798
|
+
const corrected = Math.min(availableHeight, contentHeight());
|
|
5799
|
+
|
|
5800
|
+
if (Math.abs(corrected - renderedHeight) > 1) {
|
|
5801
|
+
setGridStyle((prev) =>
|
|
5802
|
+
prev.height === corrected
|
|
5803
|
+
? prev
|
|
5804
|
+
: { ...prev, height: corrected, minHeight: corrected }
|
|
5805
|
+
);
|
|
5806
|
+
}
|
|
5807
|
+
});
|
|
5808
|
+
|
|
5320
5809
|
setGridStyle((prevState) => ({
|
|
5321
5810
|
...prevState,
|
|
5322
|
-
minHeight,
|
|
5323
|
-
height:
|
|
5811
|
+
minHeight: renderedHeight,
|
|
5812
|
+
height: renderedHeight,
|
|
5324
5813
|
}));
|
|
5325
5814
|
|
|
5815
|
+
// Everything below sizes the REQUEST, so it reads availableHeight.
|
|
5816
|
+
const minHeight = availableHeight;
|
|
5817
|
+
|
|
5326
5818
|
// How many rows to ask for.
|
|
5327
5819
|
//
|
|
5328
5820
|
// The rule used to be "if the profile has a rows_per_page, never
|
|
@@ -5375,6 +5867,7 @@ const DataGrid = forwardRef(
|
|
|
5375
5867
|
|
|
5376
5868
|
setLimit(rowsForHeight(minHeight, chrome, grid.rowUnit));
|
|
5377
5869
|
}
|
|
5870
|
+
return () => cancelAnimationFrame(frame);
|
|
5378
5871
|
}, [
|
|
5379
5872
|
dataCount,
|
|
5380
5873
|
gridHeight,
|
|
@@ -6261,12 +6754,23 @@ const DataGrid = forwardRef(
|
|
|
6261
6754
|
<AudioPlayer audioSource={audioSource} />
|
|
6262
6755
|
<StandardModal
|
|
6263
6756
|
isOpen={modalShow}
|
|
6264
|
-
onClose={modalClose}
|
|
6757
|
+
onClose={() => (formCloseRef.current ?? modalClose)()}
|
|
6265
6758
|
title={formData.title || 'Edit Data'}
|
|
6266
6759
|
closeOnDocumentClick={false}
|
|
6267
6760
|
verticalAlign={formData.verticalAlign || 'center'}
|
|
6761
|
+
/* A record form is the case a side sheet is for: the list
|
|
6762
|
+
stays visible behind it, a long form scrolls naturally
|
|
6763
|
+
instead of stretching the dialog, and the width is fixed
|
|
6764
|
+
so the fields keep one measure on every monitor. A view
|
|
6765
|
+
that genuinely wants the centred dialog sets
|
|
6766
|
+
`form.layout: "modal"`. */
|
|
6767
|
+
variant={formData.layout === 'modal' ? 'modal' : 'sheet'}
|
|
6768
|
+
size={formData.modalSize || 'medium'}
|
|
6268
6769
|
>
|
|
6269
6770
|
<Form
|
|
6771
|
+
registerClose={(fn) => {
|
|
6772
|
+
formCloseRef.current = fn;
|
|
6773
|
+
}}
|
|
6270
6774
|
ajaxSetting={ajaxSetting}
|
|
6271
6775
|
api={api}
|
|
6272
6776
|
closeModal={modalClose}
|
|
@@ -6277,6 +6781,7 @@ const DataGrid = forwardRef(
|
|
|
6277
6781
|
gridRef={gridRef}
|
|
6278
6782
|
mapbox={mapbox}
|
|
6279
6783
|
modalOpen={modalOpen}
|
|
6784
|
+
openOnCreateLink={openOnCreateLink}
|
|
6280
6785
|
paramValue={paramValue}
|
|
6281
6786
|
style={style}
|
|
6282
6787
|
updateForm={setFormData}
|
|
@@ -6298,6 +6803,106 @@ const DataGrid = forwardRef(
|
|
|
6298
6803
|
setLightboxIndex={setLightboxIndex}
|
|
6299
6804
|
/>
|
|
6300
6805
|
|
|
6806
|
+
{/* Quick-assign menu for a picker row action */}
|
|
6807
|
+
{actionPicker && (
|
|
6808
|
+
<div
|
|
6809
|
+
className={styles.rowActionPicker}
|
|
6810
|
+
style={actionPickerStyle()}
|
|
6811
|
+
role="dialog"
|
|
6812
|
+
aria-label={actionPicker.config.label || 'Choose'}
|
|
6813
|
+
onClick={(e) => e.stopPropagation()}
|
|
6814
|
+
>
|
|
6815
|
+
<div className={styles.rowActionPickerHeader}>
|
|
6816
|
+
<Search size={14} strokeWidth={2} />
|
|
6817
|
+
<input
|
|
6818
|
+
autoFocus
|
|
6819
|
+
type="text"
|
|
6820
|
+
className={styles.rowActionPickerSearch}
|
|
6821
|
+
placeholder={
|
|
6822
|
+
actionPicker.config.searchPlaceholder ||
|
|
6823
|
+
'Search…'
|
|
6824
|
+
}
|
|
6825
|
+
value={pickerSearch}
|
|
6826
|
+
onChange={(e) =>
|
|
6827
|
+
setPickerSearch(e.target.value)
|
|
6828
|
+
}
|
|
6829
|
+
/>
|
|
6830
|
+
<button
|
|
6831
|
+
type="button"
|
|
6832
|
+
className={styles.rowActionPickerClose}
|
|
6833
|
+
aria-label="Close"
|
|
6834
|
+
onClick={closeActionPicker}
|
|
6835
|
+
>
|
|
6836
|
+
<X size={14} strokeWidth={2} />
|
|
6837
|
+
</button>
|
|
6838
|
+
</div>
|
|
6839
|
+
<div className={styles.rowActionPickerList}>
|
|
6840
|
+
{pickerLoading ? (
|
|
6841
|
+
<div className={styles.rowActionPickerEmpty}>
|
|
6842
|
+
Loading…
|
|
6843
|
+
</div>
|
|
6844
|
+
) : (
|
|
6845
|
+
(() => {
|
|
6846
|
+
const matches = filterPickerOptions(
|
|
6847
|
+
pickerOptions,
|
|
6848
|
+
pickerSearch
|
|
6849
|
+
);
|
|
6850
|
+
// The clear-it entry posts an explicit
|
|
6851
|
+
// null. Hidden while searching: it is not
|
|
6852
|
+
// a name and never matches the term.
|
|
6853
|
+
const clearLabel =
|
|
6854
|
+
actionPicker.config.emptyOptionLabel ??
|
|
6855
|
+
'Unassign';
|
|
6856
|
+
|
|
6857
|
+
return (
|
|
6858
|
+
<>
|
|
6859
|
+
{clearLabel &&
|
|
6860
|
+
!pickerSearch.trim() && (
|
|
6861
|
+
<button
|
|
6862
|
+
type="button"
|
|
6863
|
+
className={`${styles.rowActionPickerOption} ${styles.rowActionPickerClear}`}
|
|
6864
|
+
onClick={() =>
|
|
6865
|
+
handleActionPickerChoice(
|
|
6866
|
+
null
|
|
6867
|
+
)
|
|
6868
|
+
}
|
|
6869
|
+
>
|
|
6870
|
+
{clearLabel}
|
|
6871
|
+
</button>
|
|
6872
|
+
)}
|
|
6873
|
+
{matches.map((option) => (
|
|
6874
|
+
<button
|
|
6875
|
+
key={option.id}
|
|
6876
|
+
type="button"
|
|
6877
|
+
className={
|
|
6878
|
+
styles.rowActionPickerOption
|
|
6879
|
+
}
|
|
6880
|
+
onClick={() =>
|
|
6881
|
+
handleActionPickerChoice(
|
|
6882
|
+
option
|
|
6883
|
+
)
|
|
6884
|
+
}
|
|
6885
|
+
>
|
|
6886
|
+
{option.label}
|
|
6887
|
+
</button>
|
|
6888
|
+
))}
|
|
6889
|
+
{matches.length === 0 && (
|
|
6890
|
+
<div
|
|
6891
|
+
className={
|
|
6892
|
+
styles.rowActionPickerEmpty
|
|
6893
|
+
}
|
|
6894
|
+
>
|
|
6895
|
+
No matches
|
|
6896
|
+
</div>
|
|
6897
|
+
)}
|
|
6898
|
+
</>
|
|
6899
|
+
);
|
|
6900
|
+
})()
|
|
6901
|
+
)}
|
|
6902
|
+
</div>
|
|
6903
|
+
</div>
|
|
6904
|
+
)}
|
|
6905
|
+
|
|
6301
6906
|
{/* Touch Tooltip Overlay */}
|
|
6302
6907
|
{activeTooltip && (
|
|
6303
6908
|
<div
|