@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.
Files changed (83) hide show
  1. package/package.json +10 -7
  2. package/src/components/AssociationManager.jsx +344 -177
  3. package/src/components/AsyncSelect.jsx +2 -2
  4. package/src/components/Autocomplete.jsx +57 -12
  5. package/src/components/Breadcrumb.jsx +23 -4
  6. package/src/components/ClientAssociationManager.jsx +321 -145
  7. package/src/components/DataGrid.jsx +625 -20
  8. package/src/components/DropZone.jsx +4 -1
  9. package/src/components/Form.jsx +233 -6
  10. package/src/components/MultiSelect.jsx +2 -2
  11. package/src/components/Navigation.jsx +39 -23
  12. package/src/components/TableFilter.jsx +172 -12
  13. package/src/components/UniversalSearch.jsx +390 -0
  14. package/src/components/auth/Login.jsx +164 -8
  15. package/src/components/auth/Profile.jsx +209 -22
  16. package/src/components/auth/authEndpoints.js +4 -0
  17. package/src/components/auth/webauthn.js +194 -0
  18. package/src/components/columns/ColumnRenderers.jsx +6 -1
  19. package/src/components/controls/DataGridSearch.jsx +11 -5
  20. package/src/components/generic/GenericAuth.jsx +6 -0
  21. package/src/components/generic/GenericDashboard.jsx +93 -36
  22. package/src/components/generic/GenericDetail.jsx +146 -61
  23. package/src/components/generic/GenericIndex.jsx +142 -38
  24. package/src/components/generic/GenericSettings.jsx +220 -0
  25. package/src/components/generic/StandardModal.jsx +165 -8
  26. package/src/components/generic/dashboardChartDefaults.js +230 -0
  27. package/src/components/generic/shared/indexLayout.js +38 -0
  28. package/src/components/settings/IntegrationsManager.jsx +474 -0
  29. package/src/components/sms/SmsLineSettings.jsx +16 -12
  30. package/src/components/styles/AssociationManager.module.scss +4 -363
  31. package/src/components/styles/AuditLog.module.scss +6 -6
  32. package/src/components/styles/AuditLogs.module.scss +3 -3
  33. package/src/components/styles/Autocomplete.module.scss +7 -7
  34. package/src/components/styles/BusinessCardOcr.module.scss +6 -1
  35. package/src/components/styles/CallQueueSettings.module.scss +4 -1
  36. package/src/components/styles/CameraPlacement.module.scss +19 -19
  37. package/src/components/styles/CategorizedDropZone.module.scss +6 -1
  38. package/src/components/styles/ClientAssociationManager.module.scss +5 -289
  39. package/src/components/styles/ClientAuth.module.scss +1 -1
  40. package/src/components/styles/DataGrid.module.scss +455 -8
  41. package/src/components/styles/Field.module.scss +32 -24
  42. package/src/components/styles/Form.module.scss +123 -3
  43. package/src/components/styles/GenericDashboard.module.scss +612 -825
  44. package/src/components/styles/GenericDetail.module.scss +406 -61
  45. package/src/components/styles/GenericDynamic.module.scss +25 -25
  46. package/src/components/styles/GenericEditableTable.module.scss +33 -33
  47. package/src/components/styles/GenericFormBuilder.module.scss +119 -119
  48. package/src/components/styles/GenericIndex.module.scss +483 -38
  49. package/src/components/styles/GenericMain.module.scss +6 -3
  50. package/src/components/styles/GenericQuote.module.scss +399 -81
  51. package/src/components/styles/GenericReport.module.scss +8 -8
  52. package/src/components/styles/GenericSettings.module.scss +209 -0
  53. package/src/components/styles/GenericSort.module.scss +9 -9
  54. package/src/components/styles/Integrations.module.scss +321 -0
  55. package/src/components/styles/List.module.scss +4 -4
  56. package/src/components/styles/Login.module.scss +57 -0
  57. package/src/components/styles/MultiSelect.module.scss +4 -4
  58. package/src/components/styles/Navigation.module.scss +12 -12
  59. package/src/components/styles/Notification.module.scss +12 -8
  60. package/src/components/styles/NotificationList.module.scss +5 -5
  61. package/src/components/styles/Profile.module.scss +5 -5
  62. package/src/components/styles/QrCode.module.scss +4 -4
  63. package/src/components/styles/QuickAction.module.scss +20 -20
  64. package/src/components/styles/SortableQuoteItems.module.scss +4 -4
  65. package/src/components/styles/TableFilter.module.scss +128 -17
  66. package/src/components/styles/TicketConversation.module.scss +497 -0
  67. package/src/components/styles/UniversalSearch.module.scss +229 -0
  68. package/src/components/styles/Vault.module.scss +413 -0
  69. package/src/components/styles/_associationManager.scss +467 -0
  70. package/src/components/styles/_authBrandPanel.scss +15 -9
  71. package/src/components/styles/_controls.scss +43 -39
  72. package/src/components/styles/global-datagrid.css +196 -0
  73. package/src/components/styles/global.css +168 -17
  74. package/src/components/tickets/TicketConversation.jsx +455 -0
  75. package/src/components/utils/rowActionSettings.js +117 -0
  76. package/src/components/vault/VaultClientPicker.jsx +183 -0
  77. package/src/components/vault/VaultEntryForm.jsx +23 -0
  78. package/src/components/vault/VaultManager.jsx +382 -34
  79. package/src/components/vault/VaultShareModal.jsx +454 -0
  80. package/src/components/vault/vaultEndpoints.js +16 -2
  81. package/src/components/vault/vaultShares.js +247 -0
  82. package/src/index.js +35 -0
  83. 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
- const executeRowAction = async (config, row) => {
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
- const handleRowAction = (config, row) => {
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
- handleRowAction(config, row);
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
- {memoizedSettings
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
- const gridEl = root.querySelector('.InovuaReactDataGrid');
5300
- const body = root.querySelector('.InovuaReactDataGrid__body');
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
- if (!gridEl || !body) {
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 chrome =
5307
- gridEl.getBoundingClientRect().height -
5308
- body.getBoundingClientRect().height;
5699
+ const rows = root.querySelectorAll('.InovuaReactDataGrid__row');
5309
5700
 
5310
- return chrome > 0 ? chrome : null;
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
- const getMinimumHeight = () => {
5315
- if (resolvedHeight > 0) return resolvedHeight;
5316
- return fitToRows(dataCount, window.innerHeight);
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
- const minHeight = getMinimumHeight();
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: minHeight,
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