@visns-studio/visns-components 6.6.3 → 6.15.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.
Files changed (100) hide show
  1. package/README.md +1658 -18
  2. package/package.json +10 -4
  3. package/src/components/DataGrid.jsx +619 -158
  4. package/src/components/Fetch.jsx +80 -3
  5. package/src/components/Form.jsx +9 -0
  6. package/src/components/Navigation.jsx +109 -50
  7. package/src/components/Notification.jsx +279 -9
  8. package/src/components/TableFilter.jsx +14 -1
  9. package/src/components/auth/AuthBrandPanel.jsx +43 -0
  10. package/src/components/auth/AuthLoading.jsx +78 -0
  11. package/src/components/auth/AuthShell.jsx +59 -0
  12. package/src/components/auth/ClientAuth.jsx +161 -0
  13. package/src/components/auth/ClientAuthFrame.jsx +59 -0
  14. package/src/components/auth/ClientLogin.jsx +266 -55
  15. package/src/components/auth/ClientOTPVerify.jsx +587 -115
  16. package/src/components/auth/ImpersonateGate.jsx +254 -0
  17. package/src/components/auth/Login.jsx +134 -41
  18. package/src/components/auth/LogoutScreen.jsx +112 -0
  19. package/src/components/auth/Reset.jsx +134 -77
  20. package/src/components/auth/TwoFactorAuth.jsx +475 -297
  21. package/src/components/auth/Verify.jsx +237 -126
  22. package/src/components/auth/authEndpoints.js +105 -0
  23. package/src/components/auth/authFont.js +23 -0
  24. package/src/components/auth/authHelpers.js +465 -0
  25. package/src/components/auth/clientAuthProtocols.js +240 -0
  26. package/src/components/auth/useOptionalRouter.js +49 -0
  27. package/src/components/callQueue/CallQueuePop.jsx +1502 -0
  28. package/src/components/callQueue/CallQueueSettings.jsx +508 -0
  29. package/src/components/callQueue/callQueueHelpers.js +280 -0
  30. package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
  31. package/src/components/columns/AutoGrowCell.jsx +141 -0
  32. package/src/components/columns/ColumnRenderers.jsx +53 -7
  33. package/src/components/generic/GenericAuth.jsx +163 -96
  34. package/src/components/generic/GenericDetail.jsx +215 -90
  35. package/src/components/generic/GenericIndex.jsx +20 -47
  36. package/src/components/generic/GenericMain.jsx +5 -0
  37. package/src/components/generic/GroupedReportRenderer.jsx +1 -5
  38. package/src/components/generic/StandardModal.jsx +17 -5
  39. package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
  40. package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
  41. package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
  42. package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
  43. package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
  44. package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
  45. package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
  46. package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
  47. package/src/components/navActive.js +60 -0
  48. package/src/components/notify/desktopNotifications.js +256 -0
  49. package/src/components/sketch/SketchField.jsx +12 -2
  50. package/src/components/sms/SmsComposeModal.jsx +495 -0
  51. package/src/components/sms/SmsInbox.jsx +596 -0
  52. package/src/components/sms/SmsInboxBadge.jsx +468 -0
  53. package/src/components/sms/SmsLineSettings.jsx +854 -0
  54. package/src/components/sms/SmsThreadPanel.jsx +1184 -0
  55. package/src/components/sms/smsEndpoints.js +56 -0
  56. package/src/components/sms/smsHelpers.js +1075 -0
  57. package/src/components/sms/smsLiveState.js +132 -0
  58. package/src/components/sms/useSmsLive.js +307 -0
  59. package/src/components/styles/CallQueuePop.module.scss +646 -0
  60. package/src/components/styles/CallQueueSettings.module.scss +460 -0
  61. package/src/components/styles/ClientAuth.module.scss +229 -0
  62. package/src/components/styles/DataGrid.module.scss +10 -0
  63. package/src/components/styles/GenericDetail.module.scss +153 -26
  64. package/src/components/styles/GenericIndex.module.scss +13 -0
  65. package/src/components/styles/GenericMain.module.scss +19 -2
  66. package/src/components/styles/ImpersonateGate.module.scss +85 -0
  67. package/src/components/styles/Login.module.scss +13 -122
  68. package/src/components/styles/LogoutScreen.module.scss +89 -0
  69. package/src/components/styles/Navigation.module.scss +509 -223
  70. package/src/components/styles/Notification.module.scss +103 -0
  71. package/src/components/styles/Reset.module.scss +52 -186
  72. package/src/components/styles/Sms.module.scss +2382 -0
  73. package/src/components/styles/TableFilter.module.scss +118 -98
  74. package/src/components/styles/TwoFactorAuth.module.scss +115 -176
  75. package/src/components/styles/Vault.module.scss +1983 -0
  76. package/src/components/styles/Verify.module.scss +57 -180
  77. package/src/components/styles/_authBrandPanel.scss +163 -0
  78. package/src/components/styles/_authShell.scss +369 -0
  79. package/src/components/styles/global-datagrid.css +29 -0
  80. package/src/components/utils/buildEnv.js +67 -0
  81. package/src/components/utils/displayValue.js +94 -0
  82. package/src/components/utils/useDensity.js +345 -9
  83. package/src/components/vault/OtpChip.jsx +208 -0
  84. package/src/components/vault/PasswordGenerator.jsx +145 -0
  85. package/src/components/vault/QrScanner.jsx +326 -0
  86. package/src/components/vault/VaultAccessLog.jsx +225 -0
  87. package/src/components/vault/VaultConfirmPanel.jsx +113 -0
  88. package/src/components/vault/VaultEntryForm.jsx +740 -0
  89. package/src/components/vault/VaultManager.jsx +1000 -0
  90. package/src/components/vault/VaultQuickSearch.jsx +681 -0
  91. package/src/components/vault/qrDecode.js +257 -0
  92. package/src/components/vault/useDebouncedValue.js +22 -0
  93. package/src/components/vault/useVaultReveal.js +160 -0
  94. package/src/components/vault/vaultClipboard.js +33 -0
  95. package/src/components/vault/vaultEndpoints.js +40 -0
  96. package/src/components/vault/vaultFit.js +116 -0
  97. package/src/components/vault/vaultHelpers.js +547 -0
  98. package/src/components/vault/vaultNavigation.jsx +58 -0
  99. package/src/components/vault/vaultOtp.js +105 -0
  100. package/src/index.js +233 -1
@@ -30,6 +30,7 @@ import React, {
30
30
  useState,
31
31
  } from 'react';
32
32
  import { useNavigate, useParams } from 'react-router-dom';
33
+ import { buildEnv } from './utils/buildEnv';
33
34
  import debounce from 'lodash.debounce';
34
35
  import moment from 'moment';
35
36
  import 'reactjs-popup/dist/index.css';
@@ -43,7 +44,11 @@ import NativeDateFilterEditor from './columns/NativeDateFilterEditor';
43
44
  import { toast } from 'react-toastify';
44
45
  import fetchUtil from '../utils/fetchUtil';
45
46
  import { confirmDialog } from './utils/ConfirmDialog';
46
- import { useDensity } from './utils/useDensity';
47
+ import {
48
+ useDensity,
49
+ useAvailableHeight,
50
+ rowsForHeight,
51
+ } from './utils/useDensity';
47
52
  import { DEFAULT_INTELLIGENT_SORTING_CONFIG } from '../utils/relationshipSortingUtils';
48
53
  import {
49
54
  extractColumnsMetadata,
@@ -144,6 +149,7 @@ import {
144
149
  renderPhoneColumn,
145
150
  renderAbnColumn,
146
151
  } from './columns/ColumnRenderers.jsx';
152
+ import AutoGrowCell from './columns/AutoGrowCell.jsx';
147
153
  import _ from 'lodash';
148
154
 
149
155
  // Enhanced multi-line content support for data cells
@@ -872,6 +878,22 @@ const DataGrid = forwardRef(
872
878
  debugLog('[DataGrid] Initial limit set to:', initialLimit);
873
879
  return initialLimit;
874
880
  });
881
+
882
+ // Set the moment the user picks a size from the pager's "Results per
883
+ // page" control. From then on nothing auto-sizes the page behind their
884
+ // back — not the profile default, not a window resize. Deliberately a
885
+ // ref rather than state: it must not itself trigger a re-render, and
886
+ // the sizing effect reads it without wanting to re-run when it flips.
887
+ const userChoseLimitRef = useRef(false);
888
+ // The current limit, readable from the sizing effect without listing
889
+ // `limit` as a dependency (which would re-run it on every page change).
890
+ const limitRef = useRef(limit);
891
+ limitRef.current = limit;
892
+
893
+ const handleLimitChange = useCallback((next) => {
894
+ userChoseLimitRef.current = true;
895
+ setLimit(next);
896
+ }, []);
875
897
  const [search, setSearch] = useState('');
876
898
 
877
899
  // This component instance survives client-side route changes — React Router
@@ -906,8 +928,12 @@ const DataGrid = forwardRef(
906
928
  userProfile_settings_rows_per_page: userProfile?.settings?.rows_per_page,
907
929
  currentLimit: limit,
908
930
  });
909
- // Only apply the user's preference once, when userProfile first becomes available
910
- if (!initialLimitApplied.current) {
931
+ // Only apply the user's preference once, when userProfile first
932
+ // becomes available — and never over a size the user has picked
933
+ // from the pager since. This seeds the limit for the first fetch;
934
+ // the sizing effect below replaces it as soon as the grid has
935
+ // measured itself against the window.
936
+ if (!initialLimitApplied.current && !userChoseLimitRef.current) {
911
937
  const userLimit = userProfile?.rows_per_page || userProfile?.settings?.rows_per_page;
912
938
  debugLog('[DataGrid] Checking userLimit:', userLimit);
913
939
  if (userLimit && userLimit > 0) {
@@ -959,6 +985,197 @@ const DataGrid = forwardRef(
959
985
  // prevents action buttons from being clipped.
960
986
  const { grid } = useDensity();
961
987
 
988
+ // ---------------------------------------------------------------
989
+ // Per-column autoGrow
990
+ // ---------------------------------------------------------------
991
+ //
992
+ // A column with `"autoGrow": true` lets the ROWS whose content does
993
+ // not fit the fixed height above grow to fit — and only those rows.
994
+ // Nothing here runs unless such a column exists AND the density gives
995
+ // a numeric `grid.rowHeight`; on desktop (`rowHeight === null`) the
996
+ // grid already measures every row, so autoGrow has nothing to add.
997
+ //
998
+ // Why this drives the grid's RowHeightManager instead of passing
999
+ // `rowHeight` as a function: the datagrid's types advertise
1000
+ // `rowHeight: number | ((rowIndex) => number) | null`, but in this
1001
+ // fork the function form is not implemented. `RowHeightManager`
1002
+ // stores it as `rowHeightFn` and never reads it again, and
1003
+ // `factory.tsx` derives `naturalRowHeight: typeof props.rowHeight !==
1004
+ // 'number'` — so a function rowHeight silently turns on exactly the
1005
+ // lazy per-row rAF measurement the fixed height exists to avoid.
1006
+ //
1007
+ // What IS implemented is the manager's per-index override map: every
1008
+ // row's height comes from `rowHeightManager.getRowHeight(index)`
1009
+ // (packages/react-virtual-list-pro/src/Row.tsx), which reads that map
1010
+ // first and falls back to the fixed height; `setHeights(map)`
1011
+ // re-indexes the offsets and emits `index`, which `VirtualList`
1012
+ // listens for and answers with a full re-layout. The cells follow the
1013
+ // row because Content/Row.tsx passes the row's own height down as the
1014
+ // cell's `initialRowHeight` on unexpanded rows, so a grown row's other
1015
+ // cells and its action buttons stay vertically centred in the taller
1016
+ // box exactly as they are today.
1017
+ //
1018
+ // Heights are keyed by ROW ID (stable across sorting, filtering and
1019
+ // virtualised scrolling) and only converted to the manager's
1020
+ // index-keyed map at flush time, from the grid's own computed data —
1021
+ // which includes group rows, so indexes stay right under groupBy/tree.
1022
+ const autoGrowHeightsRef = useRef(new Map());
1023
+ const autoGrowRowIndexRef = useRef(new Map());
1024
+ const autoGrowAppliedRef = useRef(null);
1025
+ const autoGrowRafRef = useRef(0);
1026
+
1027
+ const flushAutoGrowHeights = useCallback(() => {
1028
+ autoGrowRafRef.current = 0;
1029
+
1030
+ const gridApi = gridRef.current;
1031
+ const manager = gridApi?.rowHeightManager;
1032
+ const baseRowHeight = grid.rowHeight;
1033
+
1034
+ if (!manager || typeof baseRowHeight !== 'number') {
1035
+ return;
1036
+ }
1037
+
1038
+ const heights = autoGrowHeightsRef.current;
1039
+ const next = {};
1040
+
1041
+ // The grid's computed data is the authority on index → row: it is
1042
+ // the array the virtual list indexes into, group rows included.
1043
+ // The `onRenderRow` record is only a fallback for the frame
1044
+ // before `handle` has published the computed props.
1045
+ const data = Array.isArray(gridApi?.data) ? gridApi.data : null;
1046
+
1047
+ if (data) {
1048
+ const present = new Set();
1049
+
1050
+ for (let index = 0; index < data.length; index++) {
1051
+ const row = data[index];
1052
+
1053
+ // Group rows keep the default height — they carry a title,
1054
+ // not a column's content.
1055
+ if (!row || row.__group) {
1056
+ continue;
1057
+ }
1058
+
1059
+ const rowId = row.id;
1060
+
1061
+ if (rowId === undefined || rowId === null) {
1062
+ continue;
1063
+ }
1064
+
1065
+ const key = String(rowId);
1066
+
1067
+ present.add(key);
1068
+
1069
+ const measured = heights.get(key);
1070
+
1071
+ if (measured > baseRowHeight) {
1072
+ next[index] = measured;
1073
+ }
1074
+ }
1075
+
1076
+ // Rows that left the page cannot be measured again, so drop
1077
+ // their heights rather than letting the map grow forever.
1078
+ heights.forEach((_value, key) => {
1079
+ if (!present.has(key)) {
1080
+ heights.delete(key);
1081
+ }
1082
+ });
1083
+ } else {
1084
+ autoGrowRowIndexRef.current.forEach((key, index) => {
1085
+ const measured = heights.get(key);
1086
+
1087
+ if (measured > baseRowHeight) {
1088
+ next[index] = measured;
1089
+ }
1090
+ });
1091
+ }
1092
+
1093
+ // setHeights re-indexes and emits, and the re-layout re-renders
1094
+ // the rows — which measures them again. Applying an unchanged map
1095
+ // would therefore loop, so compare before writing.
1096
+ const serialised = JSON.stringify(next);
1097
+
1098
+ if (serialised === autoGrowAppliedRef.current) {
1099
+ return;
1100
+ }
1101
+
1102
+ autoGrowAppliedRef.current = serialised;
1103
+
1104
+ if (typeof manager.setHeights === 'function') {
1105
+ manager.setHeights(next);
1106
+ }
1107
+ }, [grid]);
1108
+
1109
+ // One flush per frame: a page of 100 rows measuring on mount is 100
1110
+ // reports and one re-layout.
1111
+ const scheduleAutoGrowFlush = useCallback(() => {
1112
+ if (autoGrowRafRef.current) {
1113
+ return;
1114
+ }
1115
+
1116
+ if (typeof requestAnimationFrame === 'undefined') {
1117
+ flushAutoGrowHeights();
1118
+ return;
1119
+ }
1120
+
1121
+ autoGrowRafRef.current =
1122
+ requestAnimationFrame(flushAutoGrowHeights);
1123
+ }, [flushAutoGrowHeights]);
1124
+
1125
+ /** `AutoGrowCell`'s callback: one row's natural height, in px. */
1126
+ const reportAutoGrowHeight = useCallback(
1127
+ (rowId, height, force) => {
1128
+ if (
1129
+ rowId === undefined ||
1130
+ rowId === null ||
1131
+ !Number.isFinite(height)
1132
+ ) {
1133
+ return;
1134
+ }
1135
+
1136
+ const key = String(rowId);
1137
+ const previous = autoGrowHeightsRef.current.get(key);
1138
+ const changed =
1139
+ previous === undefined || Math.abs(previous - height) > 1;
1140
+
1141
+ if (changed) {
1142
+ autoGrowHeightsRef.current.set(key, height);
1143
+ }
1144
+
1145
+ // `force` covers the mount/row-changed case: the height may be
1146
+ // identical while the INDEX it has to be applied at is not.
1147
+ if (changed || force) {
1148
+ scheduleAutoGrowFlush();
1149
+ }
1150
+ },
1151
+ [scheduleAutoGrowFlush]
1152
+ );
1153
+
1154
+ useEffect(
1155
+ () => () => {
1156
+ if (
1157
+ autoGrowRafRef.current &&
1158
+ typeof cancelAnimationFrame !== 'undefined'
1159
+ ) {
1160
+ cancelAnimationFrame(autoGrowRafRef.current);
1161
+ autoGrowRafRef.current = 0;
1162
+ }
1163
+ },
1164
+ []
1165
+ );
1166
+
1167
+ // Leaving the fixed-height densities hands row heights back to the
1168
+ // grid; anything measured under the old density is meaningless.
1169
+ useEffect(() => {
1170
+ if (typeof grid.rowHeight === 'number') {
1171
+ return;
1172
+ }
1173
+
1174
+ autoGrowHeightsRef.current.clear();
1175
+ autoGrowRowIndexRef.current.clear();
1176
+ autoGrowAppliedRef.current = null;
1177
+ }, [grid]);
1178
+
962
1179
  // Effect to handle autoRefresh setting
963
1180
  useEffect(() => {
964
1181
  if (ajaxSetting && ajaxSetting.autoRefresh !== undefined) {
@@ -1921,7 +2138,11 @@ const DataGrid = forwardRef(
1921
2138
  }
1922
2139
 
1923
2140
  // Show confirmation dialog for other actions to prevent accidental clicks
1924
- const confirmMessage = `Are you sure you want to perform "${iconConfig.label}" action on group "${groupValue}" (${groupRows.length} rows)?`;
2141
+ const confirmMessage = `Are you sure you want to perform "${
2142
+ iconConfig.label
2143
+ }" action on group "${getGroupDisplayLabel(groupValue)}" (${
2144
+ groupRows.length
2145
+ } rows)?`;
1925
2146
 
1926
2147
  confirmDialog({
1927
2148
  title: 'Confirm Group Action',
@@ -1959,6 +2180,28 @@ const DataGrid = forwardRef(
1959
2180
  );
1960
2181
  };
1961
2182
 
2183
+ /**
2184
+ * `groupBySetting.labelKey` is display-only: when set, the group row
2185
+ * shows the named field taken from the group's first row instead of
2186
+ * the raw group value (falling back to it when the field is empty).
2187
+ * The group KEY is still `groupValue` — it decides membership,
2188
+ * selection, collapse state, `handleGroupAction`/`shouldShowGroupAction`
2189
+ * and what the backend receives (`groupLabel`).
2190
+ */
2191
+ const getGroupDisplayLabel = (groupValue) => {
2192
+ const labelKey = ajaxSetting?.groupBySetting?.labelKey;
2193
+
2194
+ if (!labelKey) {
2195
+ return groupValue;
2196
+ }
2197
+
2198
+ const label = getGroupRows(groupValue)[0]?.[labelKey];
2199
+
2200
+ return label === null || label === undefined || label === ''
2201
+ ? groupValue
2202
+ : label;
2203
+ };
2204
+
1962
2205
  /**
1963
2206
  * The show-condition grammar shared by group action icons and row
1964
2207
  * action icons:
@@ -2096,7 +2339,9 @@ const DataGrid = forwardRef(
2096
2339
  iconConfig.fetch.messageFromResponse &&
2097
2340
  result?.data?.message
2098
2341
  ? result.data.message
2099
- : `${iconConfig.label} - Group: ${groupValue} completed successfully`
2342
+ : `${iconConfig.label} - Group: ${getGroupDisplayLabel(
2343
+ groupValue
2344
+ )} completed successfully`
2100
2345
  );
2101
2346
 
2102
2347
  // Reload grid data if needed
@@ -2113,7 +2358,9 @@ const DataGrid = forwardRef(
2113
2358
  } catch (error) {
2114
2359
  console.error('Group action fetch error:', error);
2115
2360
  toast.error(
2116
- `${iconConfig.label} - Group: ${groupValue} failed: ${error.message}`
2361
+ `${iconConfig.label} - Group: ${getGroupDisplayLabel(
2362
+ groupValue
2363
+ )} failed: ${error.message}`
2117
2364
  );
2118
2365
  }
2119
2366
  } else if (iconConfig.formModal) {
@@ -2137,7 +2384,11 @@ const DataGrid = forwardRef(
2137
2384
  // Fallback to existing switch logic if no fetch config
2138
2385
  // Skip toast notification for edit actions
2139
2386
  if (iconConfig.id !== 'edit') {
2140
- toast.info(`${iconConfig.label} - Group: ${groupValue}`);
2387
+ toast.info(
2388
+ `${iconConfig.label} - Group: ${getGroupDisplayLabel(
2389
+ groupValue
2390
+ )}`
2391
+ );
2141
2392
  }
2142
2393
 
2143
2394
  switch (iconConfig.id) {
@@ -2684,6 +2935,26 @@ const DataGrid = forwardRef(
2684
2935
 
2685
2936
  const onRenderRow = useCallback(
2686
2937
  (rowProps) => {
2938
+ // Index → row id, the fallback map autoGrow flushes from
2939
+ // before the grid has published its computed data (see the
2940
+ // autoGrow block above). Group rows are skipped: they keep the
2941
+ // default height.
2942
+ const autoGrowIndex =
2943
+ rowProps?.rowIndex ?? rowProps?.realIndex;
2944
+
2945
+ if (
2946
+ typeof autoGrowIndex === 'number' &&
2947
+ rowProps?.data &&
2948
+ !rowProps.data.__group &&
2949
+ rowProps.data.id !== undefined &&
2950
+ rowProps.data.id !== null
2951
+ ) {
2952
+ autoGrowRowIndexRef.current.set(
2953
+ autoGrowIndex,
2954
+ String(rowProps.data.id)
2955
+ );
2956
+ }
2957
+
2687
2958
  // save the original handlers to be called later
2688
2959
  const { onClick } = rowProps;
2689
2960
 
@@ -2853,10 +3124,10 @@ const DataGrid = forwardRef(
2853
3124
 
2854
3125
  const handleCoding = (c, data) => {
2855
3126
  const icons = [];
2856
- const assetPath =
2857
- import.meta.env.REACT_APP_ASSET_PATH !== undefined
2858
- ? import.meta.env.REACT_APP_ASSET_PATH
2859
- : '';
3127
+ // Read through buildEnv, not import.meta.env directly: the
3128
+ // latter is Vite's interface and throws a TypeError under
3129
+ // webpack, which took the whole grid down in the Next.js portal.
3130
+ const assetPath = buildEnv('REACT_APP_ASSET_PATH', '');
2860
3131
 
2861
3132
  const addIcon = (src, icon, alt, tooltipContent, key) => {
2862
3133
  if (src && src !== '') {
@@ -4594,16 +4865,91 @@ const DataGrid = forwardRef(
4594
4865
  };
4595
4866
  };
4596
4867
 
4868
+ /**
4869
+ * `"autoGrow": true` on a column definition.
4870
+ *
4871
+ * Opt-in, and only meaningful where the grid runs at a
4872
+ * fixed row height — on desktop (`grid.rowHeight === null`)
4873
+ * rows already measure naturally, so this returns the
4874
+ * column untouched and the code path below is the one that
4875
+ * has always run.
4876
+ *
4877
+ * Outermost decorator on purpose: it has to measure the
4878
+ * FINAL content of the cell, including the tree indent and
4879
+ * any row-action icons the decorators below add.
4880
+ */
4881
+ const decorateAutoGrow = (gridColumn, columnDef) => {
4882
+ if (
4883
+ columnDef?.autoGrow !== true ||
4884
+ typeof grid.rowHeight !== 'number' ||
4885
+ !gridColumn
4886
+ ) {
4887
+ return gridColumn;
4888
+ }
4889
+
4890
+ const originalRender = gridColumn.render;
4891
+ const existingClassName = gridColumn.className;
4892
+
4893
+ // The class relaxes the nowrap/ellipsis clamp that
4894
+ // global-datagrid.css puts on every non-wrapping cell;
4895
+ // without it the content can never take a second line
4896
+ // and so can never be taller than the row.
4897
+ const withAutoGrowClass = (base) =>
4898
+ [base, 'cell-auto-grow'].filter(Boolean).join(' ');
4899
+
4900
+ return {
4901
+ ...gridColumn,
4902
+ className:
4903
+ typeof existingClassName === 'function'
4904
+ ? (...args) =>
4905
+ withAutoGrowClass(
4906
+ existingClassName(...args)
4907
+ )
4908
+ : withAutoGrowClass(existingClassName),
4909
+ render: (arg) => {
4910
+ const content = originalRender
4911
+ ? originalRender(arg)
4912
+ : arg?.value ?? null;
4913
+
4914
+ const row = arg?.data;
4915
+
4916
+ // Group rows keep the default height, and an
4917
+ // empty cell has nothing to grow for.
4918
+ if (
4919
+ !row ||
4920
+ row.__group ||
4921
+ content === null ||
4922
+ content === undefined ||
4923
+ content === false
4924
+ ) {
4925
+ return content;
4926
+ }
4927
+
4928
+ return (
4929
+ <AutoGrowCell
4930
+ rowId={row.id}
4931
+ onMeasure={reportAutoGrowHeight}
4932
+ >
4933
+ {content}
4934
+ </AutoGrowCell>
4935
+ );
4936
+ },
4937
+ };
4938
+ };
4939
+
4597
4940
  // decorateTreeChildIndent sits INSIDE
4598
4941
  // decorateTreeParentActions: the latter draws parent rows
4599
4942
  // itself and delegates everything else downward, so the
4600
4943
  // member indent has to be the render it delegates to.
4601
4944
  const newColumns = columns.map((column) =>
4602
- decorateColumnRowActions(
4603
- decorateTreeParentActions(
4604
- decorateTreeChildIndent(
4605
- guardTreeParentRender(
4606
- renderColumn(column),
4945
+ decorateAutoGrow(
4946
+ decorateColumnRowActions(
4947
+ decorateTreeParentActions(
4948
+ decorateTreeChildIndent(
4949
+ guardTreeParentRender(
4950
+ renderColumn(column),
4951
+ column
4952
+ ),
4607
4953
  column
4608
4954
  ),
4609
4955
  column
@@ -4872,11 +5218,42 @@ const DataGrid = forwardRef(
4872
5218
  // Data Grid Styling
4873
5219
  const [windowHeight, setWindowHeight] = useState(window.innerHeight);
4874
5220
  const [dataCount, setDataCount] = useState(0);
5221
+
5222
+ // Pages that mount the grid directly (rather than through GenericIndex)
5223
+ // pass no gridHeight, and fitToRows deliberately never grows past the
5224
+ // rows - so on a tall screen the grid sat at a few rows' height and
5225
+ // scrolled inside itself. The grid now measures what is left between
5226
+ // its own top edge and the bottom of the viewport; fitToRows remains
5227
+ // the first-paint fallback.
5228
+ //
5229
+ // Measured from `.dataGridContainer` rather than from the whole
5230
+ // component, so the search/create bar and the legend above it are part
5231
+ // of `top` instead of being asked for a second time below.
5232
+ // `measureChrome` subtracts what the *page* needs under the grid — the
5233
+ // card's padding and border, the page gutter, any section rendered
5234
+ // after it — which used to be a flat 28px guess that was wrong for any
5235
+ // page nesting its grid differently.
5236
+ const containerRef = useRef(null);
5237
+ const measuredHeight = useAvailableHeight(containerRef, {
5238
+ bottomGutter: 0,
5239
+ measureChrome: true,
5240
+ recomputeKey: windowHeight,
5241
+ });
5242
+ const resolvedHeight =
5243
+ gridHeight && gridHeight > 0
5244
+ ? gridHeight
5245
+ : measuredHeight != null && measuredHeight > 0
5246
+ ? measuredHeight
5247
+ : null;
5248
+
5249
+ // Both minHeight AND height: with minHeight alone the grid settles at
5250
+ // its content height on a tall screen. An explicit height makes it
5251
+ // take the room measured for it.
5252
+ const initialHeight =
5253
+ resolvedHeight ?? fitToRows(dataCount, windowHeight);
4875
5254
  const [gridStyle, setGridStyle] = useState({
4876
- minHeight:
4877
- gridHeight && gridHeight > 0
4878
- ? gridHeight
4879
- : fitToRows(dataCount, windowHeight),
5255
+ minHeight: initialHeight,
5256
+ height: initialHeight,
4880
5257
  boxShadow: 'none',
4881
5258
  ...(hoverColor ? { '--hover-color': hoverColor } : {}),
4882
5259
  });
@@ -4884,39 +5261,116 @@ const DataGrid = forwardRef(
4884
5261
  style: style ? style : {},
4885
5262
  };
4886
5263
 
5264
+ /**
5265
+ * The part of the grid's height that is not data rows: the column
5266
+ * header, the filter row (only some pages have one), the footer rows
5267
+ * and the pagination toolbar.
5268
+ *
5269
+ * Read off the rendered grid rather than assumed, because it changes
5270
+ * with density, with `enableFiltering`, and with whether the page shows
5271
+ * a footer — and because the pager *is* inside the grid element, so
5272
+ * getting this wrong is exactly what leaves the last row hidden behind
5273
+ * it or the pager stranded under a band of empty body.
5274
+ *
5275
+ * Returns null on the first pass, before the grid is in the DOM; the
5276
+ * caller falls back to GRID_CHROME.
5277
+ */
5278
+ const measureGridChrome = () => {
5279
+ const root = containerRef.current;
5280
+
5281
+ if (!root || typeof root.querySelector !== 'function') {
5282
+ return null;
5283
+ }
5284
+
5285
+ const gridEl = root.querySelector('.InovuaReactDataGrid');
5286
+ const body = root.querySelector('.InovuaReactDataGrid__body');
5287
+
5288
+ if (!gridEl || !body) {
5289
+ return null;
5290
+ }
5291
+
5292
+ const chrome =
5293
+ gridEl.getBoundingClientRect().height -
5294
+ body.getBoundingClientRect().height;
5295
+
5296
+ return chrome > 0 ? chrome : null;
5297
+ };
5298
+
4887
5299
  useEffect(() => {
4888
5300
  const getMinimumHeight = () => {
4889
- if (gridHeight > 0) return gridHeight;
5301
+ if (resolvedHeight > 0) return resolvedHeight;
4890
5302
  return fitToRows(dataCount, window.innerHeight);
4891
5303
  };
4892
5304
 
4893
5305
  const minHeight = getMinimumHeight();
4894
- setGridStyle((prevState) => ({ ...prevState, minHeight }));
4895
-
4896
- // Priority for limit:
4897
- // 1. User's rows_per_page preference (already applied via initialLimitApplied ref)
4898
- // 2. ajaxSetting.take if >= 35
4899
- // 3. Calculated rows based on height (fallback)
4900
-
4901
- // Only auto-calculate limit if user hasn't set a preference
4902
- const userPreferredLimit = userProfile?.rows_per_page || userProfile?.settings?.rows_per_page;
5306
+ setGridStyle((prevState) => ({
5307
+ ...prevState,
5308
+ minHeight,
5309
+ height: minHeight,
5310
+ }));
4903
5311
 
4904
- if (userPreferredLimit && userPreferredLimit > 0) {
4905
- // User has a preference, don't override it
4906
- debugLog('[DataGrid] Respecting user preference, not auto-calculating limit:', userPreferredLimit);
4907
- } else if (ajaxSetting?.take >= 35) {
5312
+ // How many rows to ask for.
5313
+ //
5314
+ // The rule used to be "if the profile has a rows_per_page, never
5315
+ // auto-calculate". That is a stored default, not a decision anyone
5316
+ // made — it is 25 for most staff — so every index page on a 1080p
5317
+ // screen drew 25 rows and then several hundred px of empty grid
5318
+ // body under them, with the pager stranded at the bottom of it.
5319
+ //
5320
+ // The profile value now *seeds* the limit (see the initial state
5321
+ // and the effect that applies it once the profile arrives) and
5322
+ // nothing more. Once the grid has measured itself against the
5323
+ // window, the measurement wins — unless the user has picked a size
5324
+ // from the pager's "Results per page" control in this session, in
5325
+ // which case their choice is final for as long as the grid is
5326
+ // mounted. Whatever the pager does about persisting that choice to
5327
+ // the profile is unchanged.
5328
+ //
5329
+ // A page that passes an explicit `gridHeight` is not sized to the
5330
+ // window at all (a grid in a modal, a secondary grid on a detail
5331
+ // page), so it keeps the old order: profile preference, then a
5332
+ // configured `take`, then the arithmetic.
5333
+ const autoSizedToWindow = !(gridHeight && gridHeight > 0);
5334
+ const userPreferredLimit =
5335
+ userProfile?.rows_per_page ||
5336
+ userProfile?.settings?.rows_per_page;
5337
+
5338
+ if (userChoseLimitRef.current) {
5339
+ debugLog(
5340
+ '[DataGrid] Honouring the size the user picked this session:',
5341
+ limitRef.current
5342
+ );
5343
+ } else if (
5344
+ !autoSizedToWindow &&
5345
+ userPreferredLimit &&
5346
+ userPreferredLimit > 0
5347
+ ) {
5348
+ debugLog(
5349
+ '[DataGrid] Fixed-height grid, respecting profile preference:',
5350
+ userPreferredLimit
5351
+ );
5352
+ } else if (!autoSizedToWindow && ajaxSetting?.take >= 35) {
4908
5353
  setLimit(ajaxSetting.take);
4909
5354
  } else {
4910
- // Divide by the row height actually in force. This was
4911
- // hardcoded to 32 even in tablet mode, so tablet pages asked
4912
- // for far more rows than could ever fit on screen.
4913
- const calculateRows = (height) =>
4914
- Math.floor((height - 100) / grid.rowUnit);
4915
-
4916
- const rowCount = calculateRows(minHeight);
4917
- setLimit(rowCount);
5355
+ // Prefer the chrome the grid is really wearing — the column
5356
+ // header, the filter row if this page has one, and the pager
5357
+ // toolbar all live inside `minHeight`, and they differ by
5358
+ // density and by config. GRID_CHROME is the fallback for the
5359
+ // first pass, before the grid exists to be measured.
5360
+ const chrome = measureGridChrome() ?? GRID_CHROME;
5361
+
5362
+ setLimit(rowsForHeight(minHeight, chrome, grid.rowUnit));
4918
5363
  }
4919
- }, [dataCount, gridHeight, window.innerHeight, grid, userProfile?.rows_per_page, userProfile?.settings?.rows_per_page]);
5364
+ }, [
5365
+ dataCount,
5366
+ gridHeight,
5367
+ resolvedHeight,
5368
+ window.innerHeight,
5369
+ grid,
5370
+ ajaxSetting?.take,
5371
+ userProfile?.rows_per_page,
5372
+ userProfile?.settings?.rows_per_page,
5373
+ ]);
4920
5374
 
4921
5375
  useEffect(() => {
4922
5376
  const debouncedHandleResize = debounce(() => {
@@ -5072,8 +5526,9 @@ const DataGrid = forwardRef(
5072
5526
  </div>
5073
5527
  )}
5074
5528
 
5075
- {/* DataGrid container with its own border */}
5076
- <div className={styles.dataGridContainer}>
5529
+ {/* DataGrid container with its own border; its top edge is
5530
+ what the available height is measured from. */}
5531
+ <div className={styles.dataGridContainer} ref={containerRef}>
5077
5532
 
5078
5533
  {limit && limit > 0 ? (
5079
5534
  <ReactDataGrid
@@ -5328,126 +5783,130 @@ const DataGrid = forwardRef(
5328
5783
  aria-label="Select all items in this group"
5329
5784
  />
5330
5785
  )}
5331
- <button
5332
- className={`group-expand-btn ${styles.groupExpandBtn}`}
5333
- onClick={(e) => {
5334
- e.stopPropagation();
5335
- if (
5336
- typeof toggleGroup ===
5337
- 'function'
5338
- ) {
5339
- toggleGroup();
5786
+ <div
5787
+ className={`group-controls ${styles.groupControls}`}
5788
+ >
5789
+ <button
5790
+ className={`group-expand-btn ${styles.groupExpandBtn}`}
5791
+ onClick={(e) => {
5792
+ e.stopPropagation();
5793
+ if (
5794
+ typeof toggleGroup ===
5795
+ 'function'
5796
+ ) {
5797
+ toggleGroup();
5798
+ }
5799
+ }}
5800
+ onTouchStart={(e) => {
5801
+ e.stopPropagation();
5802
+ handleTouchStart(
5803
+ e,
5804
+ 'group-expand',
5805
+ collapsed
5806
+ ? 'Expand group'
5807
+ : 'Collapse group'
5808
+ );
5809
+ }}
5810
+ onTouchEnd={
5811
+ handleTouchEnd
5340
5812
  }
5341
- }}
5342
- onTouchStart={(e) => {
5343
- e.stopPropagation();
5344
- handleTouchStart(
5345
- e,
5346
- 'group-expand',
5813
+ onTouchMove={
5814
+ handleTouchMove
5815
+ }
5816
+ data-tooltip-id="system-tooltip"
5817
+ data-tooltip-content={
5347
5818
  collapsed
5348
5819
  ? 'Expand group'
5349
5820
  : 'Collapse group'
5350
- );
5351
- }}
5352
- onTouchEnd={
5353
- handleTouchEnd
5354
- }
5355
- onTouchMove={
5356
- handleTouchMove
5357
- }
5358
- data-tooltip-id="system-tooltip"
5359
- data-tooltip-content={
5360
- collapsed
5361
- ? 'Expand group'
5362
- : 'Collapse group'
5363
- }
5364
- >
5365
- {collapsed
5366
- ? '+'
5367
- : '−'}
5368
- </button>
5369
- {/* Dynamic group action buttons */}
5370
- {ajaxSetting?.groupBySetting?.icons?.map(
5371
- (
5372
- iconConfig,
5373
- index
5374
- ) => {
5375
- // Check if this icon should be shown based on show conditions
5376
- if (
5377
- !shouldShowGroupAction(
5378
- iconConfig,
5379
- groupValue
5380
- )
5381
- ) {
5382
- return null;
5383
5821
  }
5822
+ >
5823
+ {collapsed
5824
+ ? '+'
5825
+ : '−'}
5826
+ </button>
5827
+ {/* Dynamic group action buttons */}
5828
+ {ajaxSetting?.groupBySetting?.icons?.map(
5829
+ (
5830
+ iconConfig,
5831
+ index
5832
+ ) => {
5833
+ // Check if this icon should be shown based on show conditions
5834
+ if (
5835
+ !shouldShowGroupAction(
5836
+ iconConfig,
5837
+ groupValue
5838
+ )
5839
+ ) {
5840
+ return null;
5841
+ }
5384
5842
 
5385
- const IconComponent =
5386
- getGroupIconComponent(
5387
- iconConfig.id
5388
- );
5843
+ const IconComponent =
5844
+ getGroupIconComponent(
5845
+ iconConfig.id
5846
+ );
5389
5847
 
5390
- // Generate different color classes for multiple buttons
5391
- const colorClasses =
5392
- [
5393
- 'success',
5394
- 'warning',
5395
- 'info',
5396
- 'purple',
5397
- 'pink',
5398
- ];
5399
- const colorClass =
5400
- colorClasses[
5401
- index %
5402
- colorClasses.length
5403
- ];
5404
-
5405
- return (
5406
- <button
5407
- key={`group-action-${index}`}
5408
- className={`group-action-btn group-${iconConfig.id}-btn ${styles.groupActionBtn} ${styles[colorClass]}`}
5409
- onClick={(
5410
- e
5411
- ) => {
5412
- e.stopPropagation();
5413
- handleGroupAction(
5414
- groupValue,
5415
- iconConfig
5416
- );
5417
- }}
5418
- onTouchStart={(
5419
- e
5420
- ) => {
5421
- e.stopPropagation();
5422
- handleTouchStart(
5423
- e,
5424
- `group-${iconConfig.id}-${index}`,
5425
- iconConfig.label
5426
- );
5427
- }}
5428
- onTouchEnd={
5429
- handleTouchEnd
5430
- }
5431
- onTouchMove={
5432
- handleTouchMove
5433
- }
5434
- data-tooltip-id="system-tooltip"
5435
- data-tooltip-content={
5436
- iconConfig.label
5437
- }
5438
- >
5439
- <IconComponent
5440
- strokeWidth={
5441
- 2
5848
+ // Generate different color classes for multiple buttons
5849
+ const colorClasses =
5850
+ [
5851
+ 'success',
5852
+ 'warning',
5853
+ 'info',
5854
+ 'purple',
5855
+ 'pink',
5856
+ ];
5857
+ const colorClass =
5858
+ colorClasses[
5859
+ index %
5860
+ colorClasses.length
5861
+ ];
5862
+
5863
+ return (
5864
+ <button
5865
+ key={`group-action-${index}`}
5866
+ className={`group-action-btn group-${iconConfig.id}-btn ${styles.groupActionBtn} ${styles[colorClass]}`}
5867
+ onClick={(
5868
+ e
5869
+ ) => {
5870
+ e.stopPropagation();
5871
+ handleGroupAction(
5872
+ groupValue,
5873
+ iconConfig
5874
+ );
5875
+ }}
5876
+ onTouchStart={(
5877
+ e
5878
+ ) => {
5879
+ e.stopPropagation();
5880
+ handleTouchStart(
5881
+ e,
5882
+ `group-${iconConfig.id}-${index}`,
5883
+ iconConfig.label
5884
+ );
5885
+ }}
5886
+ onTouchEnd={
5887
+ handleTouchEnd
5442
5888
  }
5443
- size={
5444
- 14
5889
+ onTouchMove={
5890
+ handleTouchMove
5445
5891
  }
5446
- />
5447
- </button>
5448
- );
5449
- }
5450
- ) || null}
5892
+ data-tooltip-id="system-tooltip"
5893
+ data-tooltip-content={
5894
+ iconConfig.label
5895
+ }
5896
+ >
5897
+ <IconComponent
5898
+ strokeWidth={
5899
+ 2
5900
+ }
5901
+ size={
5902
+ 14
5903
+ }
5904
+ />
5905
+ </button>
5906
+ );
5907
+ }
5908
+ ) || null}
5909
+ </div>
5451
5910
  <div
5452
5911
  className={
5453
5912
  styles.groupInfo
@@ -5458,7 +5917,9 @@ const DataGrid = forwardRef(
5458
5917
  styles.groupValue
5459
5918
  }
5460
5919
  >
5461
- {groupValue}
5920
+ {getGroupDisplayLabel(
5921
+ groupValue
5922
+ )}
5462
5923
  {/* Important marker */}
5463
5924
  {hasImportantMarker && (
5464
5925
  <AlertCircle
@@ -5606,7 +6067,7 @@ const DataGrid = forwardRef(
5606
6067
  onFilterValueChange={(fv) => {
5607
6068
  handleFilterChange(fv, filterValue);
5608
6069
  }}
5609
- onLimitChange={setLimit}
6070
+ onLimitChange={handleLimitChange}
5610
6071
  onSelectionChange={handleSelectionChange}
5611
6072
  onSortInfoChange={handleSort}
5612
6073
  onRenderRow={onRenderRow}