@opencorestack/opengridx 2.1.0 → 3.0.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 (225) hide show
  1. package/CHANGELOG.md +399 -0
  2. package/README.md +95 -72
  3. package/dist/index.d.ts +590 -131
  4. package/dist/opengridx.css +1 -1
  5. package/dist/opengridx.es.js +7891 -5631
  6. package/dist/opengridx.es.js.map +1 -1
  7. package/dist/opengridx.umd.js +17 -23
  8. package/dist/opengridx.umd.js.map +1 -1
  9. package/docs/API_REFERENCE.md +348 -166
  10. package/docs/README.md +6 -1
  11. package/docs/architecture/datagrid-orchestration.md +81 -0
  12. package/docs/architecture/grid-row-meta.md +102 -0
  13. package/docs/architecture/use-grid-columns.md +128 -0
  14. package/docs/architecture/use-grid-controlled-state.md +157 -0
  15. package/docs/architecture/use-grid-row-pipeline.md +120 -0
  16. package/docs/architecture/use-grid-scroll-sync.md +111 -0
  17. package/docs/architecture/use-grid-state-snapshot.md +90 -0
  18. package/docs/architecture/use-grid-virtualization.md +115 -0
  19. package/docs/architecture/use-grid-visible-rows.md +85 -0
  20. package/docs/assets/banner-alt.png +0 -0
  21. package/docs/assets/banner.png +0 -0
  22. package/docs/assets/hero.png +0 -0
  23. package/docs/assets/logo.png +0 -0
  24. package/docs/components/aggregation-footer.md +15 -10
  25. package/docs/components/cell.md +16 -6
  26. package/docs/components/column-group-header.md +55 -28
  27. package/docs/components/column-resize.md +17 -13
  28. package/docs/components/column-visibility.md +6 -7
  29. package/docs/components/datagrid.md +15 -13
  30. package/docs/components/empty-state.md +2 -1
  31. package/docs/components/error-overlay.md +12 -7
  32. package/docs/components/filter-panel.md +28 -8
  33. package/docs/components/header.md +28 -9
  34. package/docs/components/pagination.md +14 -8
  35. package/docs/components/row.md +21 -5
  36. package/docs/components/toolbar.md +27 -9
  37. package/docs/components/tooltip.md +16 -7
  38. package/docs/customization/slots-api.md +90 -38
  39. package/docs/customization/theming.md +66 -18
  40. package/docs/features/aggregation-pivot.md +75 -9
  41. package/docs/features/cell-spanning.md +80 -125
  42. package/docs/features/clipboard.md +41 -16
  43. package/docs/features/custom-pagination.md +38 -18
  44. package/docs/features/data-source.md +36 -13
  45. package/docs/features/editing-reordering.md +125 -16
  46. package/docs/features/export-guide.md +126 -39
  47. package/docs/features/filtering.md +44 -6
  48. package/docs/features/infinite-scroll.md +54 -46
  49. package/docs/features/keyboard-navigation.md +83 -0
  50. package/docs/features/list-view.md +58 -5
  51. package/docs/features/loading-states.md +18 -20
  52. package/docs/features/master-detail.md +18 -5
  53. package/docs/features/pdf-export.md +72 -19
  54. package/docs/features/pinning.md +24 -5
  55. package/docs/features/selection.md +50 -10
  56. package/docs/features/sorting-pagination.md +64 -14
  57. package/docs/features/state-persistence.md +78 -10
  58. package/docs/features/toolbar-customization.md +26 -17
  59. package/docs/features/tree-data-grouping.md +36 -8
  60. package/docs/features/virtualization.md +37 -14
  61. package/docs/migration/v1-to-v2.md +115 -0
  62. package/docs/migration/v2-to-v3.md +1233 -0
  63. package/docs/performance.md +124 -0
  64. package/docs/roadmap.md +5 -5
  65. package/lib/components/Cell/Cell.css +17 -1
  66. package/lib/components/Cell/Cell.tsx +129 -57
  67. package/lib/components/Cell/CellErrorBoundary.tsx +12 -6
  68. package/lib/components/Cell/GridEditInputCell.tsx +114 -41
  69. package/lib/components/ColumnGroupHeader/ColumnGroupHeader.css +7 -1
  70. package/lib/components/ColumnResizeHandle/ColumnResizeHandle.css +15 -2
  71. package/lib/components/ColumnResizeHandle/ColumnResizeHandle.tsx +104 -38
  72. package/lib/components/ColumnResizeHandle/clampResizeWidth.ts +13 -0
  73. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +6 -0
  74. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +21 -13
  75. package/lib/components/DataGrid/DataGrid.css +85 -12
  76. package/lib/components/DataGrid/DataGrid.tsx +582 -876
  77. package/lib/components/DataGrid/GridAggregationFooter.tsx +53 -17
  78. package/lib/components/DataGrid/GridErrorOverlay.tsx +15 -12
  79. package/lib/components/DataGrid/GridListView.tsx +165 -24
  80. package/lib/components/DataGrid/GridLiveRegion.tsx +31 -0
  81. package/lib/components/DataGrid/GridLoadingOverlay.tsx +27 -0
  82. package/lib/components/DataGrid/GridPaginationArea.tsx +44 -0
  83. package/lib/components/DataGrid/GridPinnedRows.tsx +62 -11
  84. package/lib/components/DataGrid/GridStandaloneColumnPanel.tsx +40 -38
  85. package/lib/components/DataGrid/GridToolbarSlot.tsx +51 -0
  86. package/lib/components/DataGrid/GridVirtualRows.tsx +40 -24
  87. package/lib/components/DetailPanel/DetailPanel.css +0 -6
  88. package/lib/components/DetailPanel/DetailPanel.tsx +34 -5
  89. package/lib/components/DetailPanel/DetailPanelContent.tsx +17 -0
  90. package/lib/components/FilterPanel/FilterPanel.css +13 -2
  91. package/lib/components/FilterPanel/FilterPanel.tsx +272 -99
  92. package/lib/components/Header/ColumnMenu.tsx +91 -39
  93. package/lib/components/Header/Header.css +6 -3
  94. package/lib/components/Header/Header.tsx +181 -160
  95. package/lib/components/ListView/ListView.css +25 -0
  96. package/lib/components/ListView/ListViewRow.tsx +76 -10
  97. package/lib/components/Pagination/Pagination.tsx +15 -7
  98. package/lib/components/Row/Row.tsx +177 -77
  99. package/lib/components/Toolbar/GlobalSearch.css +6 -3
  100. package/lib/components/Toolbar/GlobalSearch.tsx +26 -27
  101. package/lib/components/Toolbar/GridToolbar.css +12 -11
  102. package/lib/components/Toolbar/GridToolbar.tsx +133 -89
  103. package/lib/components/Toolbar/PivotPanel.tsx +89 -49
  104. package/lib/components/Tooltip/Tooltip.css +20 -0
  105. package/lib/components/Tooltip/Tooltip.tsx +68 -35
  106. package/lib/components/ui/Button.tsx +3 -0
  107. package/lib/components/ui/Checkbox.tsx +2 -1
  108. package/lib/components/ui/ExpandIcon.css +0 -9
  109. package/lib/components/ui/ExpandIcon.tsx +4 -1
  110. package/lib/hooks/core/gridApiPlaceholder.ts +34 -0
  111. package/lib/hooks/core/gridToolbarHostContext.ts +14 -0
  112. package/lib/hooks/core/useDataGrid.ts +115 -173
  113. package/lib/hooks/core/useGridAggregationApi.ts +72 -0
  114. package/lib/hooks/core/useGridApiMethods.ts +115 -0
  115. package/lib/hooks/core/useGridApiRef.ts +19 -4
  116. package/lib/hooks/core/useGridApiRefBinding.ts +18 -0
  117. package/lib/hooks/core/useGridAriaRows.ts +38 -0
  118. package/lib/hooks/core/useGridClipboardApi.ts +38 -0
  119. package/lib/hooks/core/useGridColumnLookup.ts +30 -0
  120. package/lib/hooks/core/useGridColumns.tsx +279 -76
  121. package/lib/hooks/core/useGridColumnsPanel.ts +77 -0
  122. package/lib/hooks/core/useGridControlledState.ts +99 -6
  123. package/lib/hooks/core/useGridDetailPanel.ts +51 -0
  124. package/lib/hooks/core/useGridDevWarnings.ts +104 -5
  125. package/lib/hooks/core/useGridFocusHandlers.ts +50 -0
  126. package/lib/hooks/core/useGridGroupingColumn.ts +62 -0
  127. package/lib/hooks/core/useGridHeaderHandlers.ts +92 -0
  128. package/lib/hooks/core/useGridHierarchy.ts +81 -0
  129. package/lib/hooks/core/useGridKeyboardNavigation.ts +447 -243
  130. package/lib/hooks/core/useGridLiveRowSelection.ts +67 -0
  131. package/lib/hooks/core/useGridPageCorrection.ts +55 -0
  132. package/lib/hooks/core/useGridRowIdOf.ts +32 -0
  133. package/lib/hooks/core/useGridRowInteractions.ts +81 -0
  134. package/lib/hooks/core/useGridRowPipeline.ts +69 -39
  135. package/lib/hooks/core/useGridRowSelection.ts +91 -0
  136. package/lib/hooks/core/useGridScrollSync.ts +80 -32
  137. package/lib/hooks/core/useGridScrollToIndexesApi.ts +45 -0
  138. package/lib/hooks/core/useGridStateSnapshot.ts +40 -5
  139. package/lib/hooks/core/useGridThemeDimensions.ts +49 -0
  140. package/lib/hooks/core/useGridToolbarProps.ts +97 -0
  141. package/lib/hooks/core/useGridViewport.ts +38 -0
  142. package/lib/hooks/core/useGridViewportSize.ts +26 -0
  143. package/lib/hooks/core/useGridVisibleRows.ts +17 -5
  144. package/lib/hooks/core/useLayout.ts +215 -195
  145. package/lib/hooks/core/useRowsScrollEndLatch.ts +41 -0
  146. package/lib/hooks/core/useStableColumns.ts +34 -0
  147. package/lib/hooks/features/useAggregation.ts +82 -51
  148. package/lib/hooks/features/useColumnGroupReorderGuard.ts +34 -0
  149. package/lib/hooks/features/useDetailPanelHeights.ts +29 -0
  150. package/lib/hooks/features/useGridClipboard.ts +133 -73
  151. package/lib/hooks/features/useGridDataSource.ts +354 -128
  152. package/lib/hooks/features/useGridEditing.ts +211 -51
  153. package/lib/hooks/features/useGridPivot.ts +92 -0
  154. package/lib/hooks/features/useGridSpanRenderWindow.ts +104 -0
  155. package/lib/hooks/features/useGridSpanning.ts +410 -306
  156. package/lib/hooks/features/usePivot.ts +0 -0
  157. package/lib/hooks/features/useServerTreeChildren.ts +50 -0
  158. package/lib/hooks/useColumnReorder.ts +38 -44
  159. package/lib/hooks/useRowGrouping.ts +298 -160
  160. package/lib/hooks/useRowReorder.ts +62 -31
  161. package/lib/hooks/useTreeData.ts +287 -220
  162. package/lib/index.ts +28 -2
  163. package/lib/state/index.ts +1 -0
  164. package/lib/state/types.ts +7 -1
  165. package/lib/state/useGridStateStorage.ts +88 -29
  166. package/lib/theme/DataGridThemeProvider.tsx +143 -38
  167. package/lib/theme/gridThemeContext.ts +5 -0
  168. package/lib/theme/index.ts +5 -0
  169. package/lib/theme/presets.ts +5 -4
  170. package/lib/theme/types.ts +40 -2
  171. package/lib/types/index.ts +313 -116
  172. package/lib/utils/aggregation/index.ts +119 -24
  173. package/lib/utils/aria/index.ts +40 -0
  174. package/lib/utils/columnGroups.ts +110 -0
  175. package/lib/utils/columnLookup.ts +33 -0
  176. package/lib/utils/columnWidth.ts +22 -0
  177. package/lib/utils/dataSource/index.ts +46 -0
  178. package/lib/utils/detailPanel.ts +66 -0
  179. package/lib/utils/dragData.ts +44 -0
  180. package/lib/utils/editing/index.ts +30 -0
  181. package/lib/utils/export/exportShared.ts +197 -0
  182. package/lib/utils/export/exportToExcelAdvanced.ts +278 -139
  183. package/lib/utils/export/exportToPdf.ts +143 -92
  184. package/lib/utils/export/groupLabel.ts +14 -0
  185. package/lib/utils/export/index.ts +278 -272
  186. package/lib/utils/export/pdfText.ts +50 -0
  187. package/lib/utils/filtering/index.ts +277 -148
  188. package/lib/utils/focus/index.ts +115 -0
  189. package/lib/utils/gridApiRows.ts +35 -0
  190. package/lib/utils/gridModes.ts +77 -0
  191. package/lib/utils/grouping/groupedExportRows.ts +91 -0
  192. package/lib/utils/pagination/index.ts +56 -0
  193. package/lib/utils/pinning/index.ts +77 -33
  194. package/lib/utils/pivot/index.ts +368 -0
  195. package/lib/utils/scroll/index.ts +55 -5
  196. package/lib/utils/scroll/scrollEnd.ts +18 -0
  197. package/lib/utils/sorting/index.ts +154 -30
  198. package/lib/utils/syntheticRows.ts +12 -0
  199. package/lib/utils/values.ts +206 -0
  200. package/lib/utils/viewport.ts +14 -0
  201. package/llms.txt +261 -164
  202. package/package.json +13 -5
  203. package/lib/components/ColumnGroupHeader/ColumnGroupHeader.tsx +0 -220
  204. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.test.tsx +0 -85
  205. package/lib/components/DataGrid/DataGrid.defects.test.tsx +0 -103
  206. package/lib/components/DataGrid/DataGrid.regression.test.tsx +0 -132
  207. package/lib/components/DataGrid/DataGrid.smoke.test.tsx +0 -58
  208. package/lib/components/DataGrid/DataGrid.v2.test.tsx +0 -127
  209. package/lib/components/DataGrid/DataGrid.virtualization.browser.test.tsx +0 -183
  210. package/lib/components/Header/Header.test.tsx +0 -135
  211. package/lib/components/Pagination/Pagination.test.tsx +0 -64
  212. package/lib/hooks/core/useDataGrid.regression.test.ts +0 -66
  213. package/lib/hooks/core/useGridControlledState.test.ts +0 -86
  214. package/lib/hooks/core/useGridKeyboardNavigation.test.ts +0 -176
  215. package/lib/hooks/core/useGridRowPipeline.test.ts +0 -117
  216. package/lib/hooks/features/useAggregation.test.ts +0 -175
  217. package/lib/hooks/features/usePivot.test.ts +0 -202
  218. package/lib/hooks/useRowGrouping.test.ts +0 -298
  219. package/lib/hooks/useTreeData.test.ts +0 -83
  220. package/lib/utils/aggregation/aggregation.test.ts +0 -16
  221. package/lib/utils/export/exportToExcelAdvanced.test.ts +0 -109
  222. package/lib/utils/export/exportToPdf.test.ts +0 -120
  223. package/lib/utils/export/groupedExport.test.ts +0 -144
  224. package/lib/utils/filtering/filtering.test.ts +0 -178
  225. package/lib/utils/sorting/sorting.test.ts +0 -88
@@ -10,10 +10,15 @@ import type {
10
10
  GridRowMeta,
11
11
  GridColDef
12
12
  } from '../types';
13
- import { isRowMatchingFilter } from '../utils/filtering';
14
- import { compareValues } from '../utils/sorting';
13
+ import { createRowFilter } from '../utils/filtering';
14
+ import { sortItemsBySortModel, type GridSortValue } from '../utils/sorting';
15
+ import { callSafely, getCellValue } from '../utils/values';
16
+ import type { GridColumnLookup } from '../utils/columnLookup';
15
17
  import { computeAggregations } from '../utils/aggregation';
16
18
 
19
+ /** Where a group's aggregates are shown: on the group row, on a subtotal row after its children, or not at all. */
20
+ export type GridGroupAggregationPosition = 'inline' | 'footer' | null;
21
+
17
22
  export interface UseRowGroupingParams<R extends GridRowModel> {
18
23
  rows: R[];
19
24
  getRowId: (row: R) => GridRowId;
@@ -23,13 +28,44 @@ export interface UseRowGroupingParams<R extends GridRowModel> {
23
28
  defaultGroupingExpansionDepth?: number;
24
29
  filterModel?: GridFilterModel;
25
30
  sortModel?: GridSortItem[];
26
- getAggregationPosition?: (groupNode: GridTreeNode | null) => 'inline' | 'footer' | null;
31
+ getAggregationPosition?: (groupNode: GridTreeNode | null) => GridGroupAggregationPosition;
32
+ /** Column definitions, so filter and sort read leaf cells through valueGetter and use the column type. */
33
+ columnLookup?: GridColumnLookup;
34
+ /** 'server': the rows arrive filtered, so the groups are built from them as they are. */
35
+ filterMode?: 'client' | 'server';
36
+ /** 'server': the rows arrive sorted, so groups and leaves keep the order the server sent them in. */
37
+ sortingMode?: 'client' | 'server';
27
38
  }
28
39
 
29
- const defaultGetAggregationPosition = (groupNode: GridTreeNode | null): 'inline' | 'footer' | null => {
40
+ // Group rows show their aggregates inline; the grand total (null) is the grid's footer row.
41
+ const defaultGetAggregationPosition = (groupNode: GridTreeNode | null): GridGroupAggregationPosition =>
42
+ groupNode === null ? 'footer' : 'inline';
43
+
44
+ // A getAggregationPosition that throws falls back to the default position for that node.
45
+ function callAggregationPosition(
46
+ getAggregationPosition: (groupNode: GridTreeNode | null) => GridGroupAggregationPosition,
47
+ groupNode: GridTreeNode | null,
48
+ ): GridGroupAggregationPosition {
49
+ return callSafely(
50
+ () => getAggregationPosition(groupNode),
51
+ defaultGetAggregationPosition(groupNode),
52
+ 'getAggregationPosition',
53
+ 'getAggregationPosition threw; the default position is used',
54
+ );
55
+ }
30
56
 
31
- return groupNode?.depth === -1 ? 'footer' : 'inline';
32
- };
57
+ // The group-row label: the column's groupingValueFormatter, or "field: value" (also when it throws).
58
+ function formatGroupLabel(colDef: GridColDef | undefined, field: string, value: unknown): string {
59
+ const fallback = `${field}: ${String(value)}`;
60
+ const formatter = colDef?.groupingValueFormatter;
61
+ if (!formatter) return fallback;
62
+ return callSafely(
63
+ () => formatter({ field, value }),
64
+ fallback,
65
+ `groupingValueFormatter:${field}`,
66
+ `groupingValueFormatter for column "${field}" threw; the group is labelled "${field}: value"`,
67
+ );
68
+ }
33
69
 
34
70
  // Stable empty defaults — module-level constants prevent new object identity
35
71
  // on every render, which would otherwise invalidate useMemo deps and cause
@@ -38,11 +74,73 @@ const EMPTY_ROW_GROUPING_MODEL: GridRowGroupingModel = [];
38
74
  const EMPTY_AGGREGATION_MODEL: GridAggregationModel = {};
39
75
  const EMPTY_OVERRIDES: Map<GridRowId, boolean> = new Map();
40
76
 
77
+ const FIELD_SEPARATOR = '\u001f';
78
+
79
+ /** Options of the hierarchy hooks' getVisibleRows. */
80
+ export interface GridHierarchyVisibleRowsOptions {
81
+ /** Walk every group regardless of expansion (exports, getAllFilteredRows). */
82
+ expandAll?: boolean;
83
+ /** The column that shows the hierarchy: sorting by it orders group rows by their grouping value / label. */
84
+ labelField?: string;
85
+ }
86
+
41
87
  interface ExpansionOverrides {
42
88
  configKey: string;
43
89
  overrides: Map<GridRowId, boolean>;
44
90
  }
45
91
 
92
+ /**
93
+ * The bucket key of a grouping value. Distinct values never share a key, so `null` and `'null'`,
94
+ * `1` and `'1'`, `true` and `'true'` are separate groups; equal dates share one. Strings keep their
95
+ * own text, so the group ids of string values stay readable.
96
+ */
97
+ function groupKeyOf(value: unknown): string {
98
+ if (typeof value === 'string') return value;
99
+ if (value === null) return `${FIELD_SEPARATOR}null`;
100
+ if (value === undefined) return `${FIELD_SEPARATOR}undefined`;
101
+ if (value instanceof Date) return `${FIELD_SEPARATOR}date:${value.getTime()}`;
102
+ if (typeof value === 'object') {
103
+ try {
104
+ return `${FIELD_SEPARATOR}object:${JSON.stringify(value)}`;
105
+ } catch {
106
+ return `${FIELD_SEPARATOR}object:${String(value)}`;
107
+ }
108
+ }
109
+ return `${FIELD_SEPARATOR}${typeof value}:${String(value)}`;
110
+ }
111
+
112
+ const groupIdOf = (field: string, key: string, parentId: GridRowId | null): string =>
113
+ `auto-group-${field}-${key}-${parentId ?? 'root'}`;
114
+
115
+ /** The id of the subtotal row shown after a group's children when its aggregates are in 'footer' position. */
116
+ const footerIdOf = (groupId: GridRowId): string => `${groupId}${FIELD_SEPARATOR}footer`;
117
+
118
+ interface GroupRowVariants<R> {
119
+ /** The group row carrying its aggregates. */
120
+ inline: R;
121
+ /** The group row without aggregates ('footer' and null positions). */
122
+ bare: R;
123
+ /** The subtotal row, when there is an aggregation model. */
124
+ footer: R | null;
125
+ }
126
+
127
+ interface GroupingBuild<R extends GridRowModel> {
128
+ treeNodes: Map<GridRowId, GridTreeNode>;
129
+ rootIds: GridRowId[];
130
+ groupIds: GridRowId[];
131
+ groupRowVariants: Map<GridRowId, GroupRowVariants<R>>;
132
+ /** Synthetic rows by id: every group row (with its aggregates) and every subtotal row. */
133
+ groupingRows: Map<GridRowId, R>;
134
+ /** The leaf rows in the tree, by id. */
135
+ rowLookup: Map<GridRowId, R>;
136
+ }
137
+
138
+ const encodePosition = (position: GridGroupAggregationPosition): string =>
139
+ position === 'footer' ? 'f' : position === null ? 'n' : 'i';
140
+
141
+ const decodePosition = (code: string | undefined): GridGroupAggregationPosition =>
142
+ code === 'f' ? 'footer' : code === 'n' ? null : 'inline';
143
+
46
144
  export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingParams<R>) {
47
145
  const {
48
146
  rows,
@@ -53,48 +151,80 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
53
151
  defaultGroupingExpansionDepth = 0,
54
152
  filterModel,
55
153
  sortModel,
56
- getAggregationPosition = defaultGetAggregationPosition
154
+ getAggregationPosition = defaultGetAggregationPosition,
155
+ columnLookup,
156
+ filterMode = 'client',
157
+ sortingMode = 'client',
57
158
  } = params;
58
159
 
160
+ // Keyed by value, so an inline `rowGroupingModel={['region']}` or aggregation model does not
161
+ // rebuild (and re-aggregate) every group on each parent render.
162
+ const requestedModelKey = rowGroupingModel.join(FIELD_SEPARATOR);
163
+ const requestedModel = useMemo<GridRowGroupingModel>(
164
+ () => (requestedModelKey === '' ? [] : requestedModelKey.split(FIELD_SEPARATOR)),
165
+ [requestedModelKey]
166
+ );
167
+ const aggregationModelKey = JSON.stringify(Object.entries(aggregationModel));
168
+ const stableAggregationModel = useMemo<GridAggregationModel>(
169
+ () => Object.fromEntries(JSON.parse(aggregationModelKey) as Array<[string, string]>),
170
+ [aggregationModelKey]
171
+ );
172
+ const hasAggregation = Object.keys(stableAggregationModel).length > 0;
173
+
59
174
  const columnsLookup = useMemo(() => {
60
175
  const map = new Map<string, GridColDef<R>>();
61
176
  (columns ?? []).forEach(col => map.set(col.field, col));
62
177
  return map;
63
178
  }, [columns]);
64
179
 
180
+ const isActive = requestedModel.length > 0;
181
+
182
+ // Fields with `groupable: false` are dropped before building, so the remaining levels keep their
183
+ // depths (indent, defaultGroupingExpansionDepth and export depth) as if the field was never listed.
184
+ const groupingFields = useMemo(
185
+ () => requestedModel.filter(field => columnsLookup.get(field)?.groupable !== false),
186
+ [requestedModel, columnsLookup]
187
+ );
188
+ const groupingFieldsKey = groupingFields.join(FIELD_SEPARATOR);
189
+
65
190
  // User expand/collapse choices are stored as overrides on top of the depth-based
66
191
  // default, keyed by the grouping config. Rebuilding the tree (row edits, new rows
67
192
  // arrays) keeps them because group ids are deterministic; changing the grouping
68
193
  // config itself discards them.
69
- const configKey = `${rowGroupingModel.join('\u001f')}|${defaultGroupingExpansionDepth}`;
194
+ const configKey = `${groupingFieldsKey}|${defaultGroupingExpansionDepth}`;
70
195
  const [expansionState, setExpansionState] = useState<ExpansionOverrides>(() => ({
71
196
  configKey,
72
197
  overrides: new Map(),
73
198
  }));
74
199
  const overrides = expansionState.configKey === configKey ? expansionState.overrides : EMPTY_OVERRIDES;
75
200
 
76
- const { treeNodes, rootIds, groupingRows } = useMemo(() => {
201
+ // Groups hold only the rows that pass the filter, so group subtotals, the "(n)" counts and the set
202
+ // of groups all describe what is shown. With filterMode 'server' the rows already are the result.
203
+ // null = every row passes.
204
+ const matchingRows = useMemo<Set<R> | null>(() => {
205
+ if (!isActive || filterMode === 'server') return null;
206
+ const rowFilter = createRowFilter(filterModel, columnLookup);
207
+ return rowFilter ? new Set(rows.filter(row => rowFilter(row))) : null;
208
+ }, [isActive, rows, filterMode, filterModel, columnLookup]);
209
+
210
+ const build = useMemo<GroupingBuild<R>>(() => {
77
211
  const treeNodes = new Map<GridRowId, GridTreeNode>();
78
- const groupingRows = new Map<GridRowId, R>();
79
212
  const rootIds: GridRowId[] = [];
213
+ const groupIds: GridRowId[] = [];
214
+ const groupRowVariants = new Map<GridRowId, GroupRowVariants<R>>();
215
+ const groupingRows = new Map<GridRowId, R>();
216
+ const rowLookup = new Map<GridRowId, R>();
80
217
 
81
- if (rowGroupingModel.length === 0) {
82
- return { treeNodes, rootIds, groupingRows };
218
+ if (!isActive) {
219
+ return { treeNodes, rootIds, groupIds, groupRowVariants, groupingRows, rowLookup };
83
220
  }
84
221
 
85
- const getGroupId = (field: string, value: unknown, parentId: GridRowId | null) => {
86
- return `auto-group-${field}-${value}-${parentId || 'root'}`;
87
- };
88
-
89
- const groupRows = (
90
- currentRows: R[],
91
- depth: number,
92
- parentId: GridRowId | null
93
- ) => {
94
- if (depth >= rowGroupingModel.length) {
95
-
96
- return currentRows.map(row => {
222
+ const groupRows = (currentRows: R[], depth: number, parentId: GridRowId | null): GridRowId[] => {
223
+ if (depth >= groupingFields.length) {
224
+ const leaves = matchingRows ? currentRows.filter(row => matchingRows.has(row)) : currentRows;
225
+ return leaves.map(row => {
97
226
  const id = getRowId(row);
227
+ rowLookup.set(id, row);
98
228
  treeNodes.set(id, {
99
229
  id,
100
230
  parentId,
@@ -106,84 +236,70 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
106
236
  });
107
237
  }
108
238
 
109
- const field = rowGroupingModel[depth];
110
-
111
- // Skip fields where the column has explicitly opted out of grouping.
239
+ const field = groupingFields[depth];
112
240
  const colDef = columnsLookup.get(field);
113
- if (colDef && colDef.groupable === false) {
114
- return groupRows(currentRows, depth + 1, parentId);
115
- }
116
-
117
- const groups = new Map<string, R[]>();
118
241
 
119
- // Group current rows by value of the current field
120
- currentRows.forEach(row => {
121
- const value = row[field];
122
- const key = String(value); // reliable map key
123
- if (!groups.has(key)) {
124
- groups.set(key, []);
242
+ // Bucket by the value the cell shows (through valueGetter), keyed so distinct values stay apart.
243
+ const buckets = new Map<string, { value: unknown; rows: R[] }>();
244
+ for (const row of currentRows) {
245
+ const value = getCellValue(row, field, colDef as GridColDef | undefined);
246
+ const key = groupKeyOf(value);
247
+ let bucket = buckets.get(key);
248
+ if (!bucket) {
249
+ bucket = { value, rows: [] };
250
+ buckets.set(key, bucket);
125
251
  }
126
- groups.get(key)!.push(row);
127
- });
128
-
129
- const groupIds: GridRowId[] = [];
130
-
131
- // Create group rows
132
- groups.forEach((groupRowsList, key) => {
133
- // Use the raw value from the first row of the group for the groupingValue
134
- const firstRow = groupRowsList[0];
135
- const rawValue = firstRow[field];
252
+ bucket.rows.push(row);
253
+ }
136
254
 
137
- const groupId = getGroupId(field, key, parentId);
255
+ const ids: GridRowId[] = [];
256
+ buckets.forEach((bucket, key) => {
257
+ // Group order is the order of first appearance among all rows, so filtering never
258
+ // reorders the groups; a group with no matching row is left out.
259
+ const matching = matchingRows ? bucket.rows.filter(row => matchingRows.has(row)) : bucket.rows;
260
+ if (matching.length === 0) return;
261
+ const groupId = groupIdOf(field, key, parentId);
262
+ ids.push(groupId);
138
263
  groupIds.push(groupId);
139
264
 
140
- // Calculate Aggregations for this group
141
- const aggregatedValues: Record<string, unknown> =
142
- computeAggregations(groupRowsList, aggregationModel, columnsLookup, 'useRowGrouping');
265
+ // Aggregated over this group's (filtered) leaf rows.
266
+ const aggregatedValues: Record<string, unknown> = hasAggregation
267
+ ? computeAggregations(matching, stableAggregationModel, columnsLookup, 'useRowGrouping')
268
+ : {};
143
269
 
144
- const groupRow = {
145
- [field]: rawValue,
146
- ...aggregatedValues,
147
- id: groupId
148
- } as unknown as R;
149
-
150
- groupingRows.set(groupId, groupRow);
270
+ const inline = { [field]: bucket.value, ...aggregatedValues, id: groupId } as unknown as R;
271
+ const bare = { [field]: bucket.value, id: groupId } as unknown as R;
272
+ const footer = hasAggregation
273
+ ? ({ ...aggregatedValues, id: footerIdOf(groupId) } as unknown as R)
274
+ : null;
275
+ groupRowVariants.set(groupId, { inline, bare, footer });
276
+ groupingRows.set(groupId, inline);
277
+ if (footer) groupingRows.set(footer.id, footer);
151
278
 
152
279
  const treeNode: GridTreeNode = {
153
280
  id: groupId,
154
281
  parentId,
155
282
  depth,
156
- groupingKey: key,
283
+ groupingKey: String(bucket.value),
157
284
  groupingField: field,
158
- groupingValue: rawValue,
285
+ groupingValue: bucket.value,
159
286
  aggregatedValues,
160
287
  isExpanded: false,
161
- children: [],
162
- label: colDef?.groupingValueFormatter
163
- ? colDef.groupingValueFormatter({ field, value: rawValue })
164
- : `${field}: ${String(rawValue)}`
288
+ children: [],
289
+ label: formatGroupLabel(colDef, field, bucket.value),
290
+ descendantCount: matching.length,
165
291
  };
166
-
167
- treeNode.aggregationPosition = getAggregationPosition(treeNode);
168
-
169
292
  treeNodes.set(groupId, treeNode);
170
-
171
- const childrenIds = groupRows(groupRowsList, depth + 1, groupId);
172
- treeNodes.get(groupId)!.children = childrenIds;
173
-
174
- const descendantCount = groupRowsList.length;
175
- treeNodes.get(groupId)!.descendantCount = descendantCount;
293
+ treeNode.children = groupRows(bucket.rows, depth + 1, groupId);
176
294
  });
177
-
178
- return groupIds;
295
+ return ids;
179
296
  };
180
297
 
181
- const topLevelIds = groupRows(rows, 0, null);
182
- rootIds.push(...topLevelIds);
183
-
184
- return { treeNodes, rootIds, groupingRows };
298
+ rootIds.push(...groupRows(rows, 0, null));
299
+ return { treeNodes, rootIds, groupIds, groupRowVariants, groupingRows, rowLookup };
300
+ }, [isActive, rows, matchingRows, groupingFields, hasAggregation, stableAggregationModel, getRowId, columnsLookup]);
185
301
 
186
- }, [rows, rowGroupingModel, aggregationModel, getRowId, getAggregationPosition, columnsLookup]);
302
+ const { treeNodes, rootIds, groupIds, groupRowVariants, groupingRows, rowLookup } = build;
187
303
 
188
304
  const isDefaultExpanded = useCallback((node: GridTreeNode | undefined) => (
189
305
  Boolean(node?.children && node.children.length > 0) &&
@@ -213,109 +329,129 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
213
329
  return expandedGroupIds.has(id);
214
330
  }, [expandedGroupIds]);
215
331
 
216
- const getVisibleRows = useCallback(() => {
217
- if (rowGroupingModel.length === 0) return null;
218
-
219
- const result: R[] = [];
220
- const seenIds = new Set<GridRowId>();
221
-
222
- const rowLookup = new Map<GridRowId, R>();
223
- rows.forEach(r => rowLookup.set(getRowId(r), r));
224
-
225
- const doesNodeMatchFilter = (nodeId: GridRowId): boolean => {
226
- const node = treeNodes.get(nodeId);
227
- if (!node) return false;
228
-
229
- if (!node.children || node.children.length === 0) {
230
-
231
- const row = rowLookup.get(nodeId);
232
- if (!row) return false;
233
-
234
- if (filterModel) {
235
- if (!isRowMatchingFilter(row, filterModel)) return false;
332
+ // getAggregationPosition runs once per group per render (it receives the group's current
333
+ // expansion state), but only its answers are memoized on, so an inline callback does not
334
+ // rebuild the rows or the row metadata.
335
+ const positionsKey = hasAggregation
336
+ ? groupIds.map(id => {
337
+ const node = treeNodes.get(id);
338
+ return node ? encodePosition(callAggregationPosition(getAggregationPosition, { ...node, isExpanded: expandedGroupIds.has(id) })) : 'i';
339
+ }).join('')
340
+ : '';
341
+ const aggregationPositions = useMemo<Map<GridRowId, GridGroupAggregationPosition>>(() => {
342
+ const map = new Map<GridRowId, GridGroupAggregationPosition>();
343
+ groupIds.forEach((id, index) => map.set(id, decodePosition(positionsKey[index])));
344
+ return map;
345
+ }, [groupIds, positionsKey]);
346
+
347
+ /** Where the grand total goes: `null` hides the grid's aggregation footer. */
348
+ const rootAggregationPosition: GridGroupAggregationPosition = hasAggregation ? callAggregationPosition(getAggregationPosition, null) : 'footer';
349
+
350
+ const isClientSort = sortingMode !== 'server' && Boolean(sortModel && sortModel.length > 0);
351
+
352
+ /**
353
+ * The rows to render, in order. `labelField` is the column that shows the group labels: sorting
354
+ * by it orders the groups by their grouping value (a group row has no value of its own there).
355
+ */
356
+ const getVisibleRows = useCallback((options?: GridHierarchyVisibleRowsOptions): R[] | null => {
357
+ if (!isActive) return null;
358
+ const labelField = options?.labelField;
359
+ const expandAll = options?.expandAll ?? false;
360
+
361
+ const activeSortModel = isClientSort && sortModel ? sortModel : [];
362
+
363
+ const readSortValue = (id: GridRowId, sortItem: GridSortItem): GridSortValue => {
364
+ const node = treeNodes.get(id);
365
+ if (node && groupRowVariants.has(id)) {
366
+ const groupingField = node.groupingField ?? '';
367
+ if (sortItem.field === groupingField || sortItem.field === labelField) {
368
+ return { value: node.groupingValue, type: columnLookup?.byField.get(groupingField)?.type };
369
+ }
370
+ if (node.aggregatedValues && Object.prototype.hasOwnProperty.call(node.aggregatedValues, sortItem.field)) {
371
+ return { value: node.aggregatedValues[sortItem.field], type: columnLookup?.byField.get(sortItem.field)?.type };
236
372
  }
237
- return true;
373
+ return { value: undefined };
238
374
  }
239
-
240
- return node.children.some(childId => doesNodeMatchFilter(childId));
375
+ const row = rowLookup.get(id);
376
+ const colDef = columnLookup?.byField.get(sortItem.field);
377
+ return { value: row ? getCellValue(row, sortItem.field, colDef) : undefined, type: colDef?.type };
241
378
  };
242
379
 
243
- const traverse = (ids: GridRowId[]) => {
244
-
245
- // Copy before sorting: ids may be node.children / rootIds from the memoized tree.
246
- const visibleIds = filterModel ? ids.filter(doesNodeMatchFilter) : [...ids];
247
-
248
- if (sortModel && sortModel.length > 0) {
249
- visibleIds.sort((aId, bId) => {
250
- const rowA = groupingRows.get(aId) || rowLookup.get(aId);
251
- const rowB = groupingRows.get(bId) || rowLookup.get(bId);
252
-
253
- if (!rowA || !rowB) return 0;
254
-
255
- for (const sortItem of sortModel) {
256
- const valA = rowA[sortItem.field];
257
- const valB = rowB[sortItem.field];
258
- const compareResult = compareValues(valA, valB, sortItem.sort);
259
- if (compareResult !== 0) return compareResult;
260
- }
261
- return 0;
262
- });
263
- }
380
+ const result: R[] = [];
381
+ const seenIds = new Set<GridRowId>();
264
382
 
265
- visibleIds.forEach(id => {
266
- if (seenIds.has(id)) return;
383
+ const traverse = (ids: readonly GridRowId[]) => {
384
+ const ordered = activeSortModel.length > 0 ? sortItemsBySortModel(ids, activeSortModel, readSortValue) : ids;
385
+ for (const id of ordered) {
386
+ if (seenIds.has(id)) continue;
387
+ seenIds.add(id);
388
+
389
+ const variants = groupRowVariants.get(id);
390
+ if (!variants) {
391
+ // Hierarchy info travels in rowMetaMap; the consumer's row object is passed through unchanged.
392
+ const row = rowLookup.get(id);
393
+ if (row) result.push(row);
394
+ continue;
395
+ }
267
396
 
268
397
  const node = treeNodes.get(id);
269
- const isGroup = node?.children && node.children.length > 0;
270
-
271
- const row = isGroup ? groupingRows.get(id) : rowLookup.get(id);
272
-
273
- if (row && node) {
274
- seenIds.add(id);
275
-
276
- const enhancedRow = {
277
- ...row,
278
- _treeDepth: node.depth,
279
- _isExpanded: expandedGroupIds.has(id),
280
- _hasChildren: isGroup,
281
- _groupingField: node.groupingField,
282
- _groupingValue: node.groupingValue,
283
- _descendantCount: node.descendantCount
284
- };
285
- result.push(enhancedRow);
286
-
287
- if (expandedGroupIds.has(id) && isGroup) {
288
- traverse(node.children!);
289
- }
398
+ const isExpanded = expandedGroupIds.has(id);
399
+ // null is a position ("hidden"), so no `??` default here.
400
+ const position = aggregationPositions.has(id) ? aggregationPositions.get(id) ?? null : 'inline';
401
+ // 'footer' moves the aggregates below the children; while collapsed there is no
402
+ // subtotal row, so they stay on the group row instead of disappearing.
403
+ const showInline = position === 'inline' || (position === 'footer' && !isExpanded);
404
+ result.push(showInline ? variants.inline : variants.bare);
405
+
406
+ if ((isExpanded || expandAll) && node?.children) {
407
+ traverse(node.children);
408
+ // expandAll (exports) walks the data only; exporters write their own subtotals.
409
+ if (isExpanded && !expandAll && position === 'footer' && variants.footer) result.push(variants.footer);
290
410
  }
291
- });
411
+ }
292
412
  };
293
413
 
294
414
  traverse(rootIds);
295
-
296
415
  return result;
297
-
298
- }, [rows, getRowId, treeNodes, rootIds, groupingRows, filterModel, sortModel, expandedGroupIds, rowGroupingModel]);
416
+ }, [isActive, isClientSort, sortModel, treeNodes, groupRowVariants, rowLookup, columnLookup, expandedGroupIds, aggregationPositions, rootIds]);
299
417
 
300
418
  const getNode = useCallback((id: GridRowId) => treeNodes.get(id), [treeNodes]);
301
419
 
302
420
  const rowMetaMap = useMemo<Map<GridRowId, GridRowMeta>>(() => {
303
421
  const map = new Map<GridRowId, GridRowMeta>();
304
422
  treeNodes.forEach((node, id) => {
305
- const isGroup = Boolean(node.children && node.children.length > 0);
306
- map.set(id, {
307
- hasChildren: isGroup,
423
+ const variants = groupRowVariants.get(id);
424
+ if (!variants) {
425
+ map.set(id, {
426
+ hasChildren: false,
427
+ treeDepth: node.depth,
428
+ isGroupRow: false,
429
+ });
430
+ return;
431
+ }
432
+ const groupMeta: GridRowMeta = {
433
+ hasChildren: true,
308
434
  treeDepth: node.depth,
309
435
  groupingField: node.groupingField,
310
436
  groupingValue: node.groupingValue,
311
437
  groupLabel: node.label,
312
438
  descendantCount: node.descendantCount,
313
- isGroupRow: isGroup,
314
- isExpanded: isGroup ? expandedGroupIds.has(id) : undefined,
315
- });
439
+ isGroupRow: true,
440
+ isExpanded: expandedGroupIds.has(id),
441
+ };
442
+ map.set(id, groupMeta);
443
+ if (variants.footer && aggregationPositions.get(id) === 'footer') {
444
+ map.set(variants.footer.id, {
445
+ ...groupMeta,
446
+ hasChildren: false,
447
+ treeDepth: node.depth + 1,
448
+ isExpanded: undefined,
449
+ isGroupFooter: true,
450
+ });
451
+ }
316
452
  });
317
453
  return map;
318
- }, [treeNodes, expandedGroupIds]);
454
+ }, [treeNodes, groupRowVariants, expandedGroupIds, aggregationPositions]);
319
455
 
320
456
  return useMemo(() => ({
321
457
  treeNodes,
@@ -325,5 +461,7 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
325
461
  getVisibleRows,
326
462
  getNode,
327
463
  rowMetaMap,
328
- }), [treeNodes, groupingRows, toggleExpansion, isGroupExpanded, getVisibleRows, getNode, rowMetaMap]);
464
+ aggregationPositions,
465
+ rootAggregationPosition,
466
+ }), [treeNodes, groupingRows, toggleExpansion, isGroupExpanded, getVisibleRows, getNode, rowMetaMap, aggregationPositions, rootAggregationPosition]);
329
467
  }