@opencorestack/opengridx 2.0.4 → 3.0.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 (221) hide show
  1. package/CHANGELOG.md +408 -3
  2. package/README.md +93 -72
  3. package/dist/index.d.ts +611 -121
  4. package/dist/opengridx.css +1 -1
  5. package/dist/opengridx.es.js +7992 -5650
  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 -161
  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 +45 -22
  29. package/docs/components/datagrid.md +14 -12
  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 +100 -36
  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 +147 -41
  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 +56 -19
  54. package/docs/features/pinning.md +23 -5
  55. package/docs/features/selection.md +50 -10
  56. package/docs/features/sorting-pagination.md +66 -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 +44 -9
  60. package/docs/features/virtualization.md +64 -15
  61. package/docs/migration/v1-to-v2.md +115 -0
  62. package/docs/migration/v2-to-v3.md +1222 -0
  63. package/docs/performance.md +124 -0
  64. package/docs/roadmap.md +16 -10
  65. package/lib/components/Cell/Cell.css +13 -1
  66. package/lib/components/Cell/Cell.tsx +130 -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 +11 -0
  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 +84 -12
  76. package/lib/components/DataGrid/DataGrid.tsx +606 -872
  77. package/lib/components/DataGrid/GridAggregationFooter.tsx +53 -17
  78. package/lib/components/DataGrid/GridEmptyState.tsx +7 -1
  79. package/lib/components/DataGrid/GridErrorOverlay.tsx +15 -12
  80. package/lib/components/DataGrid/GridListView.tsx +166 -23
  81. package/lib/components/DataGrid/GridLiveRegion.tsx +31 -0
  82. package/lib/components/DataGrid/GridLoadingOverlay.tsx +27 -0
  83. package/lib/components/DataGrid/GridPaginationArea.tsx +44 -0
  84. package/lib/components/DataGrid/GridPinnedRows.tsx +65 -11
  85. package/lib/components/DataGrid/GridStandaloneColumnPanel.tsx +40 -38
  86. package/lib/components/DataGrid/GridToolbarSlot.tsx +51 -0
  87. package/lib/components/DataGrid/GridVirtualRows.tsx +48 -25
  88. package/lib/components/DetailPanel/DetailPanel.css +0 -6
  89. package/lib/components/DetailPanel/DetailPanel.tsx +34 -5
  90. package/lib/components/DetailPanel/DetailPanelContent.tsx +17 -0
  91. package/lib/components/FilterPanel/FilterPanel.css +13 -2
  92. package/lib/components/FilterPanel/FilterPanel.tsx +272 -99
  93. package/lib/components/Header/ColumnMenu.tsx +91 -39
  94. package/lib/components/Header/Header.css +3 -3
  95. package/lib/components/Header/Header.tsx +179 -160
  96. package/lib/components/ListView/ListView.css +25 -0
  97. package/lib/components/ListView/ListViewRow.tsx +79 -10
  98. package/lib/components/Pagination/Pagination.tsx +15 -7
  99. package/lib/components/Row/Row.tsx +185 -75
  100. package/lib/components/Toolbar/GlobalSearch.css +6 -3
  101. package/lib/components/Toolbar/GlobalSearch.tsx +26 -27
  102. package/lib/components/Toolbar/GridToolbar.css +12 -11
  103. package/lib/components/Toolbar/GridToolbar.tsx +133 -89
  104. package/lib/components/Toolbar/PivotPanel.tsx +89 -49
  105. package/lib/components/Tooltip/Tooltip.css +20 -0
  106. package/lib/components/Tooltip/Tooltip.tsx +68 -35
  107. package/lib/components/ui/Button.tsx +3 -0
  108. package/lib/components/ui/Checkbox.tsx +2 -1
  109. package/lib/components/ui/ExpandIcon.css +0 -9
  110. package/lib/components/ui/ExpandIcon.tsx +4 -1
  111. package/lib/hooks/core/gridApiPlaceholder.ts +34 -0
  112. package/lib/hooks/core/gridToolbarHostContext.ts +14 -0
  113. package/lib/hooks/core/useDataGrid.ts +115 -173
  114. package/lib/hooks/core/useGridAggregationApi.ts +72 -0
  115. package/lib/hooks/core/useGridApiMethods.ts +115 -0
  116. package/lib/hooks/core/useGridApiRef.ts +15 -3
  117. package/lib/hooks/core/useGridApiRefBinding.ts +18 -0
  118. package/lib/hooks/core/useGridAriaRows.ts +38 -0
  119. package/lib/hooks/core/useGridClipboardApi.ts +38 -0
  120. package/lib/hooks/core/useGridColumnLookup.ts +30 -0
  121. package/lib/hooks/core/useGridColumns.tsx +284 -76
  122. package/lib/hooks/core/useGridColumnsPanel.ts +77 -0
  123. package/lib/hooks/core/useGridControlledState.ts +99 -6
  124. package/lib/hooks/core/useGridDetailPanel.ts +51 -0
  125. package/lib/hooks/core/useGridDevWarnings.ts +123 -0
  126. package/lib/hooks/core/useGridFocusHandlers.ts +50 -0
  127. package/lib/hooks/core/useGridGroupingColumn.ts +62 -0
  128. package/lib/hooks/core/useGridHeaderHandlers.ts +92 -0
  129. package/lib/hooks/core/useGridHierarchy.ts +81 -0
  130. package/lib/hooks/core/useGridKeyboardNavigation.ts +452 -250
  131. package/lib/hooks/core/useGridLiveRowSelection.ts +67 -0
  132. package/lib/hooks/core/useGridPageCorrection.ts +55 -0
  133. package/lib/hooks/core/useGridRowIdOf.ts +32 -0
  134. package/lib/hooks/core/useGridRowInteractions.ts +81 -0
  135. package/lib/hooks/core/useGridRowPipeline.ts +69 -39
  136. package/lib/hooks/core/useGridRowSelection.ts +91 -0
  137. package/lib/hooks/core/useGridScrollSync.ts +80 -32
  138. package/lib/hooks/core/useGridScrollToIndexesApi.ts +45 -0
  139. package/lib/hooks/core/useGridStateSnapshot.ts +40 -5
  140. package/lib/hooks/core/useGridThemeDimensions.ts +49 -0
  141. package/lib/hooks/core/useGridToolbarProps.ts +97 -0
  142. package/lib/hooks/core/useGridViewport.ts +38 -0
  143. package/lib/hooks/core/useGridViewportSize.ts +26 -0
  144. package/lib/hooks/core/useGridVisibleRows.ts +20 -7
  145. package/lib/hooks/core/useLayout.ts +215 -195
  146. package/lib/hooks/core/useRowsScrollEndLatch.ts +41 -0
  147. package/lib/hooks/core/useStableColumns.ts +34 -0
  148. package/lib/hooks/features/useAggregation.ts +86 -90
  149. package/lib/hooks/features/useColumnGroupReorderGuard.ts +34 -0
  150. package/lib/hooks/features/useDetailPanelHeights.ts +29 -0
  151. package/lib/hooks/features/useGridClipboard.ts +133 -73
  152. package/lib/hooks/features/useGridDataSource.ts +354 -128
  153. package/lib/hooks/features/useGridEditing.ts +211 -51
  154. package/lib/hooks/features/useGridPivot.ts +92 -0
  155. package/lib/hooks/features/useGridSpanRenderWindow.ts +104 -0
  156. package/lib/hooks/features/useGridSpanning.ts +410 -306
  157. package/lib/hooks/features/usePivot.ts +0 -0
  158. package/lib/hooks/features/useServerTreeChildren.ts +50 -0
  159. package/lib/hooks/useColumnReorder.ts +38 -44
  160. package/lib/hooks/useRowGrouping.ts +335 -214
  161. package/lib/hooks/useRowReorder.ts +62 -31
  162. package/lib/hooks/useTreeData.ts +330 -262
  163. package/lib/index.ts +30 -2
  164. package/lib/state/index.ts +1 -0
  165. package/lib/state/types.ts +7 -1
  166. package/lib/state/useGridStateStorage.ts +88 -29
  167. package/lib/theme/DataGridThemeProvider.tsx +143 -38
  168. package/lib/theme/gridThemeContext.ts +5 -0
  169. package/lib/theme/index.ts +5 -0
  170. package/lib/theme/presets.ts +5 -4
  171. package/lib/theme/types.ts +40 -2
  172. package/lib/types/index.ts +311 -108
  173. package/lib/utils/aggregation/index.ts +157 -0
  174. package/lib/utils/aria/index.ts +40 -0
  175. package/lib/utils/columnGroups.ts +110 -0
  176. package/lib/utils/columnLookup.ts +33 -0
  177. package/lib/utils/columnWidth.ts +22 -0
  178. package/lib/utils/dataSource/index.ts +46 -0
  179. package/lib/utils/detailPanel.ts +66 -0
  180. package/lib/utils/dragData.ts +44 -0
  181. package/lib/utils/editing/index.ts +30 -0
  182. package/lib/utils/export/exportShared.ts +197 -0
  183. package/lib/utils/export/exportToExcelAdvanced.ts +344 -121
  184. package/lib/utils/export/exportToPdf.ts +143 -92
  185. package/lib/utils/export/groupLabel.ts +14 -0
  186. package/lib/utils/export/index.ts +278 -272
  187. package/lib/utils/export/pdfText.ts +50 -0
  188. package/lib/utils/filtering/index.ts +277 -148
  189. package/lib/utils/focus/index.ts +115 -0
  190. package/lib/utils/gridApiRows.ts +35 -0
  191. package/lib/utils/gridModes.ts +77 -0
  192. package/lib/utils/grouping/groupedExportRows.ts +91 -0
  193. package/lib/utils/pagination/index.ts +56 -0
  194. package/lib/utils/pinning/index.ts +77 -33
  195. package/lib/utils/pivot/index.ts +368 -0
  196. package/lib/utils/scroll/index.ts +78 -0
  197. package/lib/utils/scroll/scrollEnd.ts +18 -0
  198. package/lib/utils/sorting/index.ts +154 -30
  199. package/lib/utils/syntheticRows.ts +12 -0
  200. package/lib/utils/values.ts +206 -0
  201. package/lib/utils/viewport.ts +14 -0
  202. package/llms.txt +272 -166
  203. package/package.json +18 -6
  204. package/lib/components/ColumnGroupHeader/ColumnGroupHeader.tsx +0 -220
  205. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.test.tsx +0 -85
  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/Header/Header.test.tsx +0 -135
  210. package/lib/components/Pagination/Pagination.test.tsx +0 -64
  211. package/lib/hooks/core/useDataGrid.regression.test.ts +0 -66
  212. package/lib/hooks/core/useGridControlledState.test.ts +0 -86
  213. package/lib/hooks/core/useGridKeyboardNavigation.test.ts +0 -176
  214. package/lib/hooks/core/useGridRowPipeline.test.ts +0 -117
  215. package/lib/hooks/features/useAggregation.test.ts +0 -175
  216. package/lib/hooks/features/usePivot.test.ts +0 -202
  217. package/lib/hooks/useRowGrouping.test.ts +0 -137
  218. package/lib/utils/export/exportToPdf.test.ts +0 -120
  219. package/lib/utils/export/groupedExport.test.ts +0 -144
  220. package/lib/utils/filtering/filtering.test.ts +0 -178
  221. package/lib/utils/sorting/sorting.test.ts +0 -88
@@ -1,4 +1,4 @@
1
- import { useState, useMemo, useEffect, useCallback } from 'react';
1
+ import { useState, useMemo, useCallback } from 'react';
2
2
  import type {
3
3
  GridRowModel,
4
4
  GridRowId,
@@ -10,8 +10,14 @@ 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';
17
+ import { computeAggregations } from '../utils/aggregation';
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;
15
21
 
16
22
  export interface UseRowGroupingParams<R extends GridRowModel> {
17
23
  rows: R[];
@@ -22,19 +28,118 @@ export interface UseRowGroupingParams<R extends GridRowModel> {
22
28
  defaultGroupingExpansionDepth?: number;
23
29
  filterModel?: GridFilterModel;
24
30
  sortModel?: GridSortItem[];
25
- 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';
26
38
  }
27
39
 
28
- 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
+ }
29
56
 
30
- return groupNode?.depth === -1 ? 'footer' : 'inline';
31
- };
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
+ }
32
69
 
33
70
  // Stable empty defaults — module-level constants prevent new object identity
34
71
  // on every render, which would otherwise invalidate useMemo deps and cause
35
72
  // infinite re-render loops when callers omit optional params.
36
73
  const EMPTY_ROW_GROUPING_MODEL: GridRowGroupingModel = [];
37
74
  const EMPTY_AGGREGATION_MODEL: GridAggregationModel = {};
75
+ const EMPTY_OVERRIDES: Map<GridRowId, boolean> = new Map();
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
+
87
+ interface ExpansionOverrides {
88
+ configKey: string;
89
+ overrides: Map<GridRowId, boolean>;
90
+ }
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';
38
143
 
39
144
  export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingParams<R>) {
40
145
  const {
@@ -46,39 +151,80 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
46
151
  defaultGroupingExpansionDepth = 0,
47
152
  filterModel,
48
153
  sortModel,
49
- getAggregationPosition = defaultGetAggregationPosition
154
+ getAggregationPosition = defaultGetAggregationPosition,
155
+ columnLookup,
156
+ filterMode = 'client',
157
+ sortingMode = 'client',
50
158
  } = params;
51
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
+
52
174
  const columnsLookup = useMemo(() => {
53
175
  const map = new Map<string, GridColDef<R>>();
54
176
  (columns ?? []).forEach(col => map.set(col.field, col));
55
177
  return map;
56
178
  }, [columns]);
57
179
 
58
- const [expandedGroupIds, setExpandedGroupIds] = useState<Set<GridRowId>>(new Set());
59
-
60
- const { treeNodes, rootIds, groupingRows } = useMemo(() => {
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
+
190
+ // User expand/collapse choices are stored as overrides on top of the depth-based
191
+ // default, keyed by the grouping config. Rebuilding the tree (row edits, new rows
192
+ // arrays) keeps them because group ids are deterministic; changing the grouping
193
+ // config itself discards them.
194
+ const configKey = `${groupingFieldsKey}|${defaultGroupingExpansionDepth}`;
195
+ const [expansionState, setExpansionState] = useState<ExpansionOverrides>(() => ({
196
+ configKey,
197
+ overrides: new Map(),
198
+ }));
199
+ const overrides = expansionState.configKey === configKey ? expansionState.overrides : EMPTY_OVERRIDES;
200
+
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>>(() => {
61
211
  const treeNodes = new Map<GridRowId, GridTreeNode>();
62
- const groupingRows = new Map<GridRowId, R>();
63
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>();
64
217
 
65
- if (rowGroupingModel.length === 0) {
66
- return { treeNodes, rootIds, groupingRows };
218
+ if (!isActive) {
219
+ return { treeNodes, rootIds, groupIds, groupRowVariants, groupingRows, rowLookup };
67
220
  }
68
221
 
69
- const getGroupId = (field: string, value: unknown, parentId: GridRowId | null) => {
70
- return `auto-group-${field}-${value}-${parentId || 'root'}`;
71
- };
72
-
73
- const groupRows = (
74
- currentRows: R[],
75
- depth: number,
76
- parentId: GridRowId | null
77
- ) => {
78
- if (depth >= rowGroupingModel.length) {
79
-
80
- 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 => {
81
226
  const id = getRowId(row);
227
+ rowLookup.set(id, row);
82
228
  treeNodes.set(id, {
83
229
  id,
84
230
  parentId,
@@ -90,249 +236,222 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
90
236
  });
91
237
  }
92
238
 
93
- const field = rowGroupingModel[depth];
94
-
95
- // Skip fields where the column has explicitly opted out of grouping.
239
+ const field = groupingFields[depth];
96
240
  const colDef = columnsLookup.get(field);
97
- if (colDef && colDef.groupable === false) {
98
- return groupRows(currentRows, depth + 1, parentId);
99
- }
100
-
101
- const groups = new Map<string, R[]>();
102
241
 
103
- // Group current rows by value of the current field
104
- currentRows.forEach(row => {
105
- const value = row[field];
106
- const key = String(value); // reliable map key
107
- if (!groups.has(key)) {
108
- 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);
109
251
  }
110
- groups.get(key)!.push(row);
111
- });
112
-
113
- const groupIds: GridRowId[] = [];
114
-
115
- // Create group rows
116
- groups.forEach((groupRowsList, key) => {
117
- // Use the raw value from the first row of the group for the groupingValue
118
- const firstRow = groupRowsList[0];
119
- const rawValue = firstRow[field];
252
+ bucket.rows.push(row);
253
+ }
120
254
 
121
- 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);
122
263
  groupIds.push(groupId);
123
264
 
124
- // Calculate Aggregations for this group
125
- const aggregatedValues: Record<string, unknown> = {};
126
- if (aggregationModel) {
127
- Object.entries(aggregationModel).forEach(([aggField, aggType]) => {
128
- const values = groupRowsList.map(r => r[aggField]);
129
- if (aggType === 'sum') {
130
- aggregatedValues[aggField] = values.reduce((a: number, b) => a + (Number(b) || 0), 0);
131
- } else if (aggType === 'min') {
132
- aggregatedValues[aggField] = Math.min(...values.map(v => Number(v) || 0));
133
- } else if (aggType === 'max') {
134
- aggregatedValues[aggField] = Math.max(...values.map(v => Number(v) || 0));
135
- } else if (aggType === 'avg') {
136
- const sum = values.reduce((a: number, b) => a + (Number(b) || 0), 0);
137
- aggregatedValues[aggField] = values.length ? sum / values.length : 0;
138
- } else if (aggType === 'count') {
139
- aggregatedValues[aggField] = values.length;
140
- }
141
- });
142
- }
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
- useEffect(() => {
189
- if (defaultGroupingExpansionDepth === -1) {
304
+ const isDefaultExpanded = useCallback((node: GridTreeNode | undefined) => (
305
+ Boolean(node?.children && node.children.length > 0) &&
306
+ (defaultGroupingExpansionDepth === -1 || (node?.depth ?? 0) < defaultGroupingExpansionDepth)
307
+ ), [defaultGroupingExpansionDepth]);
190
308
 
191
- const allGroupIds = new Set<GridRowId>();
192
- treeNodes.forEach((node, id) => {
193
- if (node.children && node.children.length > 0) {
194
- allGroupIds.add(id);
195
- }
196
- });
197
- setExpandedGroupIds(allGroupIds);
198
- } else if (defaultGroupingExpansionDepth > 0) {
199
- const depthIds = new Set<GridRowId>();
200
- treeNodes.forEach((node, id) => {
201
- if (node.depth < defaultGroupingExpansionDepth && node.children && node.children.length > 0) {
202
- depthIds.add(id);
203
- }
204
- });
205
- setExpandedGroupIds(depthIds);
206
- } else {
207
-
208
- setExpandedGroupIds(new Set());
209
- }
210
-
211
- }, [rowGroupingModel, defaultGroupingExpansionDepth, treeNodes]);
309
+ const expandedGroupIds = useMemo<Set<GridRowId>>(() => {
310
+ const ids = new Set<GridRowId>();
311
+ treeNodes.forEach((node, id) => {
312
+ if (!node.children || node.children.length === 0) return;
313
+ if (overrides.get(id) ?? isDefaultExpanded(node)) ids.add(id);
314
+ });
315
+ return ids;
316
+ }, [treeNodes, overrides, isDefaultExpanded]);
212
317
 
213
318
  const toggleExpansion = useCallback((id: GridRowId) => {
214
- setExpandedGroupIds(prev => {
215
- const next = new Set(prev);
216
- if (next.has(id)) {
217
- next.delete(id);
218
- } else {
219
- next.add(id);
220
- }
221
- return next;
319
+ setExpansionState(prev => {
320
+ const base = prev.configKey === configKey ? prev.overrides : EMPTY_OVERRIDES;
321
+ const current = base.get(id) ?? isDefaultExpanded(treeNodes.get(id));
322
+ const next = new Map(base);
323
+ next.set(id, !current);
324
+ return { configKey, overrides: next };
222
325
  });
223
- }, []);
326
+ }, [configKey, treeNodes, isDefaultExpanded]);
224
327
 
225
328
  const isGroupExpanded = useCallback((id: GridRowId) => {
226
329
  return expandedGroupIds.has(id);
227
330
  }, [expandedGroupIds]);
228
331
 
229
- const getVisibleRows = useCallback(() => {
230
- if (rowGroupingModel.length === 0) return null;
231
-
232
- const result: R[] = [];
233
- const seenIds = new Set<GridRowId>();
234
-
235
- const rowLookup = new Map<GridRowId, R>();
236
- rows.forEach(r => rowLookup.set(getRowId(r), r));
237
-
238
- const doesNodeMatchFilter = (nodeId: GridRowId): boolean => {
239
- const node = treeNodes.get(nodeId);
240
- if (!node) return false;
241
-
242
- if (!node.children || node.children.length === 0) {
243
-
244
- const row = rowLookup.get(nodeId);
245
- if (!row) return false;
246
-
247
- if (filterModel) {
248
- 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 };
249
372
  }
250
- return true;
373
+ return { value: undefined };
251
374
  }
252
-
253
- 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 };
254
378
  };
255
379
 
256
- const traverse = (ids: GridRowId[]) => {
257
-
258
- let visibleIds = ids;
259
- if (filterModel) {
260
- visibleIds = ids.filter(doesNodeMatchFilter);
261
- }
262
-
263
- if (sortModel && sortModel.length > 0) {
264
- visibleIds.sort((aId, bId) => {
265
- const rowA = groupingRows.get(aId) || rowLookup.get(aId);
266
- const rowB = groupingRows.get(bId) || rowLookup.get(bId);
267
-
268
- if (!rowA || !rowB) return 0;
269
-
270
- for (const sortItem of sortModel) {
271
- const valA = rowA[sortItem.field];
272
- const valB = rowB[sortItem.field];
273
- const compareResult = compareValues(valA, valB, sortItem.sort);
274
- if (compareResult !== 0) return compareResult;
275
- }
276
- return 0;
277
- });
278
- }
380
+ const result: R[] = [];
381
+ const seenIds = new Set<GridRowId>();
279
382
 
280
- visibleIds.forEach(id => {
281
- 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
+ }
282
396
 
283
397
  const node = treeNodes.get(id);
284
- const isGroup = node?.children && node.children.length > 0;
285
-
286
- const row = isGroup ? groupingRows.get(id) : rowLookup.get(id);
287
-
288
- if (row && node) {
289
- seenIds.add(id);
290
-
291
- const enhancedRow = {
292
- ...row,
293
- _treeDepth: node.depth,
294
- _isExpanded: expandedGroupIds.has(id),
295
- _hasChildren: isGroup,
296
- _groupingField: node.groupingField,
297
- _groupingValue: node.groupingValue,
298
- _descendantCount: node.descendantCount
299
- };
300
- result.push(enhancedRow);
301
-
302
- if (isExpandedGroupIds.has(id) && isGroup) {
303
- traverse(node.children!);
304
- }
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);
305
410
  }
306
- });
411
+ }
307
412
  };
308
413
 
309
- const isExpandedGroupIds = expandedGroupIds;
310
-
311
414
  traverse(rootIds);
312
-
313
415
  return result;
314
-
315
- }, [rows, getRowId, treeNodes, rootIds, groupingRows, filterModel, sortModel, expandedGroupIds, rowGroupingModel]);
416
+ }, [isActive, isClientSort, sortModel, treeNodes, groupRowVariants, rowLookup, columnLookup, expandedGroupIds, aggregationPositions, rootIds]);
316
417
 
317
418
  const getNode = useCallback((id: GridRowId) => treeNodes.get(id), [treeNodes]);
318
419
 
319
420
  const rowMetaMap = useMemo<Map<GridRowId, GridRowMeta>>(() => {
320
421
  const map = new Map<GridRowId, GridRowMeta>();
321
422
  treeNodes.forEach((node, id) => {
322
- const isGroup = Boolean(node.children && node.children.length > 0);
323
- map.set(id, {
324
- 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,
325
434
  treeDepth: node.depth,
326
435
  groupingField: node.groupingField,
327
436
  groupingValue: node.groupingValue,
328
437
  groupLabel: node.label,
329
438
  descendantCount: node.descendantCount,
330
- isGroupRow: isGroup,
331
- isExpanded: isGroup ? expandedGroupIds.has(id) : undefined,
332
- });
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
+ }
333
452
  });
334
453
  return map;
335
- }, [treeNodes, expandedGroupIds]);
454
+ }, [treeNodes, groupRowVariants, expandedGroupIds, aggregationPositions]);
336
455
 
337
456
  return useMemo(() => ({
338
457
  treeNodes,
@@ -342,5 +461,7 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
342
461
  getVisibleRows,
343
462
  getNode,
344
463
  rowMetaMap,
345
- }), [treeNodes, groupingRows, toggleExpansion, isGroupExpanded, getVisibleRows, getNode, rowMetaMap]);
464
+ aggregationPositions,
465
+ rootAggregationPosition,
466
+ }), [treeNodes, groupingRows, toggleExpansion, isGroupExpanded, getVisibleRows, getNode, rowMetaMap, aggregationPositions, rootAggregationPosition]);
346
467
  }