@opencorestack/opengridx 2.0.3 → 2.1.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 (47) hide show
  1. package/CHANGELOG.md +54 -3
  2. package/dist/index.d.ts +50 -2
  3. package/dist/opengridx.css +1 -1
  4. package/dist/opengridx.es.js +3805 -3692
  5. package/dist/opengridx.es.js.map +1 -1
  6. package/dist/opengridx.umd.js +16 -16
  7. package/dist/opengridx.umd.js.map +1 -1
  8. package/docs/API_REFERENCE.md +14 -4
  9. package/docs/components/column-visibility.md +40 -16
  10. package/docs/components/datagrid.md +1 -0
  11. package/docs/customization/slots-api.md +18 -6
  12. package/docs/features/export-guide.md +22 -3
  13. package/docs/features/sorting-pagination.md +2 -0
  14. package/docs/features/tree-data-grouping.md +8 -1
  15. package/docs/features/virtualization.md +66 -17
  16. package/docs/roadmap.md +13 -7
  17. package/lib/components/Cell/Cell.tsx +1 -0
  18. package/lib/components/DataGrid/DataGrid.css +7 -0
  19. package/lib/components/DataGrid/DataGrid.defects.test.tsx +103 -0
  20. package/lib/components/DataGrid/DataGrid.tsx +45 -11
  21. package/lib/components/DataGrid/DataGrid.virtualization.browser.test.tsx +183 -0
  22. package/lib/components/DataGrid/GridEmptyState.tsx +7 -1
  23. package/lib/components/DataGrid/GridListView.tsx +3 -0
  24. package/lib/components/DataGrid/GridPinnedRows.tsx +3 -0
  25. package/lib/components/DataGrid/GridVirtualRows.tsx +8 -1
  26. package/lib/components/ListView/ListViewRow.tsx +3 -0
  27. package/lib/components/Row/Row.tsx +10 -0
  28. package/lib/hooks/core/useGridColumns.tsx +8 -3
  29. package/lib/hooks/core/useGridDevWarnings.ts +42 -0
  30. package/lib/hooks/core/useGridKeyboardNavigation.ts +12 -14
  31. package/lib/hooks/core/useGridScrollSync.ts +62 -15
  32. package/lib/hooks/core/useGridVirtualization.ts +4 -1
  33. package/lib/hooks/core/useGridVisibleRows.ts +3 -2
  34. package/lib/hooks/features/useAggregation.ts +3 -38
  35. package/lib/hooks/useRowGrouping.test.ts +162 -1
  36. package/lib/hooks/useRowGrouping.ts +42 -59
  37. package/lib/hooks/useTreeData.test.ts +83 -0
  38. package/lib/hooks/useTreeData.ts +44 -43
  39. package/lib/index.ts +2 -0
  40. package/lib/types/index.ts +22 -2
  41. package/lib/utils/aggregation/aggregation.test.ts +16 -0
  42. package/lib/utils/aggregation/index.ts +62 -0
  43. package/lib/utils/export/exportToExcelAdvanced.test.ts +109 -0
  44. package/lib/utils/export/exportToExcelAdvanced.ts +89 -5
  45. package/lib/utils/scroll/index.ts +28 -0
  46. package/llms.txt +22 -13
  47. package/package.json +7 -3
@@ -1,4 +1,5 @@
1
1
  import { useState, useCallback } from 'react';
2
+ import { scrollRowIntoView } from '../../utils/scroll';
2
3
  import type { GridRowModel, GridRowId, GridColDef, GridCellParams, GridSortItem, GridSortDirection } from '../../types';
3
4
  import type { GridEditingState } from '../features/useGridEditing';
4
5
 
@@ -14,6 +15,7 @@ interface ColumnMetrics {
14
15
  interface VirtualizationSnapshot {
15
16
  cumulativeHeights: number[];
16
17
  pinnedTopHeight: number;
18
+ pinnedBottomHeight: number;
17
19
  columnMetrics: ColumnMetrics | null;
18
20
  }
19
21
 
@@ -37,6 +39,8 @@ export interface UseGridKeyboardNavigationParams<R extends GridRowModel> {
37
39
  pageSize: number;
38
40
  virtualization: VirtualizationSnapshot;
39
41
  viewportRef: React.RefObject<HTMLDivElement | null>;
42
+ /** Number of top-pinned rows at the start of allRenderableRows. */
43
+ pinnedTopRowCount?: number;
40
44
  }
41
45
 
42
46
  export interface FocusedCell {
@@ -71,6 +75,7 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
71
75
  pageSize,
72
76
  virtualization,
73
77
  viewportRef,
78
+ pinnedTopRowCount = 0,
74
79
  } = params;
75
80
 
76
81
  const [focusedCell, setFocusedCell] = useState<FocusedCell | null>(null);
@@ -311,21 +316,13 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
311
316
 
312
317
  const el = viewportRef.current;
313
318
  if (el) {
314
- const { cumulativeHeights, pinnedTopHeight, columnMetrics } = virtualization;
315
- const { clientHeight, clientWidth, scrollTop, scrollLeft } = el;
316
-
317
- const rowTop = nextRowIndex <= 0 ? 0 : cumulativeHeights[nextRowIndex - 1];
318
- const rowBottom = cumulativeHeights[nextRowIndex];
319
- const visibleTop = scrollTop + pinnedTopHeight;
320
- const visibleBottom = scrollTop + clientHeight;
321
-
322
- let newScrollTop = scrollTop;
323
- if (rowTop < visibleTop) {
324
- newScrollTop = Math.max(0, rowTop - pinnedTopHeight);
325
- } else if (rowBottom > visibleBottom) {
326
- newScrollTop = rowBottom - clientHeight;
319
+ const { cumulativeHeights, pinnedBottomHeight, columnMetrics } = virtualization;
320
+ const { clientWidth, scrollLeft } = el;
321
+
322
+ // allRenderableRows = pinned top + center + pinned bottom; pinned rows are sticky and always visible.
323
+ if (nextRowIndex >= 0) {
324
+ scrollRowIntoView(el, nextRowIndex - pinnedTopRowCount, cumulativeHeights, pinnedBottomHeight);
327
325
  }
328
- if (newScrollTop !== scrollTop) el.scrollTop = newScrollTop;
329
326
 
330
327
  if (columnMetrics) {
331
328
  const { leftPinnedWidth, rightPinnedWidth, unpinnedAccWidths, unpinnedCols, totalSpecialsWidth, pinnedSpecialsWidth } = columnMetrics;
@@ -365,6 +362,7 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
365
362
  pagination,
366
363
  pageSize,
367
364
  viewportRef,
365
+ pinnedTopRowCount,
368
366
  ]);
369
367
 
370
368
  return { focusedCell, setFocusedCell, handleFocus, handleBlur, handleKeyDown };
@@ -3,42 +3,84 @@ import type { GridRowScrollEndParams } from '../../types';
3
3
 
4
4
  export interface UseGridScrollSyncParams {
5
5
  onRowsScrollEnd?: (params: GridRowScrollEndParams) => void;
6
+ /** Minimum overscan rows — the adaptive algorithm never goes below this. Defaults to 3. */
7
+ overscanRowCount?: number;
6
8
  }
7
9
 
8
10
  export interface UseGridScrollSyncResult {
9
11
  scrollTop: number;
10
12
  scrollLeft: number;
13
+ /** Dynamically computed overscan row count based on current scroll velocity. */
14
+ overscanRows: number;
11
15
  handleScroll: (event: React.UIEvent<HTMLDivElement>) => void;
12
16
  }
13
17
 
18
+ /** Maps scroll velocity (px/ms) to an overscan row count. */
19
+ function velocityToOverscan(velocity: number, minOverscan: number): number {
20
+ let base: number;
21
+ if (velocity < 0.5) base = 3;
22
+ else if (velocity < 3) base = 5;
23
+ else if (velocity < 15) base = 12;
24
+ else if (velocity < 40) base = 20;
25
+ else base = 30;
26
+ return Math.max(base, minOverscan);
27
+ }
28
+
14
29
  export function useGridScrollSync(params: UseGridScrollSyncParams): UseGridScrollSyncResult {
15
- const { onRowsScrollEnd } = params;
30
+ const { onRowsScrollEnd, overscanRowCount = 3 } = params;
16
31
 
17
- const pendingScrollRef = useRef({ scrollTop: 0, scrollLeft: 0 });
18
- const scrollRafRef = useRef<number | null>(null);
19
- const [scrollPos, setScrollPos] = useState({ scrollTop: 0, scrollLeft: 0 });
32
+ const pendingRef = useRef({ scrollTop: 0, scrollLeft: 0, velocity: 0 });
33
+ const prevRef = useRef({ scrollTop: 0, scrollLeft: 0, time: 0 });
34
+ const rafRef = useRef<number | null>(null);
35
+ const decayRef = useRef<ReturnType<typeof setTimeout> | null>(null);
36
+
37
+ const [scrollState, setScrollState] = useState({
38
+ scrollTop: 0,
39
+ scrollLeft: 0,
40
+ overscanRows: overscanRowCount,
41
+ });
20
42
 
21
43
  useEffect(() => {
22
44
  return () => {
23
- if (scrollRafRef.current !== null) cancelAnimationFrame(scrollRafRef.current);
45
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
46
+ if (decayRef.current !== null) clearTimeout(decayRef.current);
24
47
  };
25
48
  }, []);
26
49
 
27
50
  const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
28
51
  const target = event.currentTarget;
29
- pendingScrollRef.current = { scrollTop: target.scrollTop, scrollLeft: target.scrollLeft };
52
+ const now = performance.now();
53
+
54
+ // Velocity: total positional delta / elapsed time (px/ms)
55
+ const deltaPos = Math.abs(target.scrollTop - prevRef.current.scrollTop)
56
+ + Math.abs(target.scrollLeft - prevRef.current.scrollLeft);
57
+ const deltaTime = now - prevRef.current.time;
58
+ const velocity = deltaTime > 0 ? deltaPos / deltaTime : 0;
30
59
 
31
- if (scrollRafRef.current !== null) cancelAnimationFrame(scrollRafRef.current);
32
- scrollRafRef.current = requestAnimationFrame(() => {
33
- scrollRafRef.current = null;
34
- setScrollPos(pendingScrollRef.current);
60
+ prevRef.current = { scrollTop: target.scrollTop, scrollLeft: target.scrollLeft, time: now };
61
+ pendingRef.current = { scrollTop: target.scrollTop, scrollLeft: target.scrollLeft, velocity };
62
+
63
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
64
+ rafRef.current = requestAnimationFrame(() => {
65
+ rafRef.current = null;
66
+ const { scrollTop, scrollLeft, velocity: v } = pendingRef.current;
67
+ setScrollState({
68
+ scrollTop,
69
+ scrollLeft,
70
+ overscanRows: velocityToOverscan(v, overscanRowCount),
71
+ });
35
72
  });
36
73
 
74
+ // Decay back to the minimum overscan 200ms after scrolling stops
75
+ if (decayRef.current !== null) clearTimeout(decayRef.current);
76
+ decayRef.current = setTimeout(() => {
77
+ decayRef.current = null;
78
+ setScrollState(prev => ({ ...prev, overscanRows: overscanRowCount }));
79
+ }, 200);
80
+
37
81
  if (onRowsScrollEnd) {
38
82
  const { scrollTop, scrollHeight, clientHeight } = target;
39
- const scrollThreshold = 100;
40
-
41
- if (scrollHeight - scrollTop - clientHeight < scrollThreshold) {
83
+ if (scrollHeight - scrollTop - clientHeight < 100) {
42
84
  onRowsScrollEnd({
43
85
  visibleTop: scrollTop,
44
86
  visibleBottom: scrollTop + clientHeight,
@@ -46,7 +88,12 @@ export function useGridScrollSync(params: UseGridScrollSyncParams): UseGridScrol
46
88
  });
47
89
  }
48
90
  }
49
- }, [onRowsScrollEnd]);
91
+ }, [onRowsScrollEnd, overscanRowCount]);
50
92
 
51
- return { scrollTop: scrollPos.scrollTop, scrollLeft: scrollPos.scrollLeft, handleScroll };
93
+ return {
94
+ scrollTop: scrollState.scrollTop,
95
+ scrollLeft: scrollState.scrollLeft,
96
+ overscanRows: scrollState.overscanRows,
97
+ handleScroll,
98
+ };
52
99
  }
@@ -14,6 +14,8 @@ export interface UseGridVirtualizationParams<R extends GridRowModel> {
14
14
  checkboxSelection: boolean;
15
15
  pinCheckboxColumn: boolean;
16
16
  pinExpandColumn: boolean;
17
+ /** Adaptive overscan row count from useGridScrollSync. Defaults to 5. */
18
+ overscanRows?: number;
17
19
  }
18
20
 
19
21
  export interface GridVirtualizationResult<R extends GridRowModel = GridRowModel> {
@@ -57,6 +59,7 @@ export function useGridVirtualization<R extends GridRowModel>(
57
59
  checkboxSelection,
58
60
  pinCheckboxColumn,
59
61
  pinExpandColumn,
62
+ overscanRows = 5,
60
63
  } = params;
61
64
 
62
65
  return useMemo<GridVirtualizationResult<R>>(() => {
@@ -120,7 +123,6 @@ export function useGridVirtualization<R extends GridRowModel>(
120
123
  const scrollbarBuffer = hasHorizontalScroll ? 16 : 0;
121
124
  const totalHeight = pinnedTopHeight + unpinnedRowsHeight + pinnedBottomHeight + scrollbarBuffer + 2;
122
125
 
123
- const overscanRows = 5;
124
126
  let firstRowIndex = 0;
125
127
  let lastRowIndex = unpinnedRowsLength - 1;
126
128
  let offsetTop = 0;
@@ -182,5 +184,6 @@ export function useGridVirtualization<R extends GridRowModel>(
182
184
  checkboxSelection,
183
185
  pinCheckboxColumn,
184
186
  pinExpandColumn,
187
+ overscanRows,
185
188
  ]);
186
189
  }
@@ -43,8 +43,9 @@ export function useGridVisibleRows<R extends GridRowModel>(
43
43
  rowIndex: topPinnedCount + centerRows.length + index,
44
44
  }));
45
45
 
46
- const centerStartIndex = Math.max(0, firstRowIndex - topPinnedCount);
47
- const centerEndIndex = Math.min(centerRows.length, lastRowIndex - topPinnedCount + 1);
46
+ // renderContext indices come from the unpinned-row layout, so they index centerRows directly.
47
+ const centerStartIndex = Math.max(0, firstRowIndex);
48
+ const centerEndIndex = Math.min(centerRows.length, lastRowIndex + 1);
48
49
 
49
50
  const centerVisible = centerRows
50
51
  .slice(centerStartIndex, centerEndIndex)
@@ -9,30 +9,9 @@ import type {
9
9
  GridSortItem,
10
10
  GridDataSource,
11
11
  } from '../../types';
12
+ import { computeAggregations } from '../../utils/aggregation';
12
13
 
13
- export type BuiltInAggFn = 'sum' | 'avg' | 'count' | 'min' | 'max' | 'unique';
14
-
15
- const AGGREGATION_FUNCTIONS: Record<BuiltInAggFn, (values: unknown[]) => unknown> = {
16
- sum: (values) => {
17
- const nums = values.filter((v) => v != null && !isNaN(Number(v)));
18
- return nums.reduce((acc: number, v) => acc + Number(v), 0);
19
- },
20
- avg: (values) => {
21
- const nums = values.filter((v) => v != null && !isNaN(Number(v)));
22
- if (nums.length === 0) return null;
23
- return nums.reduce((acc: number, v) => acc + Number(v), 0) / nums.length;
24
- },
25
- count: (values) => values.filter((v) => v != null).length,
26
- min: (values) => {
27
- const nums = values.filter((v) => v != null && !isNaN(Number(v))).map(Number);
28
- return nums.length ? Math.min(...nums) : null;
29
- },
30
- max: (values) => {
31
- const nums = values.filter((v) => v != null && !isNaN(Number(v))).map(Number);
32
- return nums.length ? Math.max(...nums) : null;
33
- },
34
- unique: (values) => new Set(values.filter((v) => v != null)).size,
35
- };
14
+ export type { BuiltInAggFn } from '../../utils/aggregation';
36
15
 
37
16
  export function formatAggregationValue(value: unknown, fnName: string): string {
38
17
  if (value == null) return '—';
@@ -85,21 +64,7 @@ export function useAggregation<R extends GridRowModel>(
85
64
  const clientResult = useMemo<GridAggregationResult>(() => {
86
65
  if (isServerSide || Object.keys(aggregationModel).length === 0) return {};
87
66
 
88
- const result: GridAggregationResult = {};
89
- for (const [field, fnName] of Object.entries(aggregationModel)) {
90
- const colDef = columnsLookup.get(field);
91
- if (colDef?.availableAggregationFunctions && !colDef.availableAggregationFunctions.includes(fnName)) {
92
- continue;
93
- }
94
- const fn = AGGREGATION_FUNCTIONS[fnName as BuiltInAggFn];
95
- if (!fn) {
96
- console.warn(`[useAggregation] Unknown aggregation function: "${fnName}"`);
97
- continue;
98
- }
99
- const values = rows.map((row) => (row as GridRowModel)[field]);
100
- result[field] = fn(values);
101
- }
102
- return result;
67
+ return computeAggregations(rows, aggregationModel, columnsLookup, 'useAggregation');
103
68
  }, [rows, columnsLookup, aggregationModel, isServerSide]);
104
69
 
105
70
  const [serverResult, setServerResult] = useState<GridAggregationResult>({});
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { renderHook } from '@testing-library/react';
2
+ import { renderHook, act } from '@testing-library/react';
3
3
  import { useRowGrouping } from './useRowGrouping';
4
4
 
5
5
  const ROWS = [
@@ -111,6 +111,98 @@ describe('useRowGrouping — rowMetaMap', () => {
111
111
  });
112
112
  });
113
113
 
114
+ describe('useRowGrouping — multi-field rowGroupingModel', () => {
115
+ const MULTI_ROWS = [
116
+ { id: 1, dept: 'Engineering', country: 'US', name: 'Alice' },
117
+ { id: 2, dept: 'Engineering', country: 'US', name: 'Bob' },
118
+ { id: 3, dept: 'Engineering', country: 'UK', name: 'Carol' },
119
+ { id: 4, dept: 'HR', country: 'US', name: 'Dave' },
120
+ ];
121
+ const MULTI_PARAMS = {
122
+ rows: MULTI_ROWS,
123
+ getRowId: GET_ROW_ID,
124
+ rowGroupingModel: ['dept', 'country'],
125
+ aggregationModel: AGGREGATION_MODEL,
126
+ };
127
+
128
+ it('nests one group level per field, in rowGroupingModel order', () => {
129
+ const { result } = renderHook(() => useRowGrouping(MULTI_PARAMS));
130
+ const map = result.current.rowMetaMap;
131
+
132
+ const depts = new Set<unknown>();
133
+ const countries = new Set<unknown>();
134
+ map.forEach((meta) => {
135
+ if (!meta.isGroupRow) return;
136
+ if (meta.treeDepth === 0) {
137
+ expect(meta.groupingField).toBe('dept');
138
+ depts.add(meta.groupingValue);
139
+ } else if (meta.treeDepth === 1) {
140
+ expect(meta.groupingField).toBe('country');
141
+ countries.add(meta.groupingValue);
142
+ }
143
+ });
144
+ // dept groups: Engineering, HR
145
+ expect(depts).toEqual(new Set(['Engineering', 'HR']));
146
+ // country groups: US (under Engineering), UK (under Engineering), US (under HR)
147
+ expect(countries).toEqual(new Set(['US', 'UK']));
148
+ });
149
+
150
+ it('places leaf rows at depth === rowGroupingModel.length', () => {
151
+ const { result } = renderHook(() => useRowGrouping(MULTI_PARAMS));
152
+ const map = result.current.rowMetaMap;
153
+
154
+ [1, 2, 3, 4].forEach((id) => {
155
+ const meta = map.get(id);
156
+ expect(meta?.isGroupRow).toBe(false);
157
+ expect(meta?.treeDepth).toBe(2);
158
+ });
159
+ });
160
+
161
+ it('produces one depth-0 group per dept, one depth-1 group per (dept, country) pair', () => {
162
+ const { result } = renderHook(() => useRowGrouping(MULTI_PARAMS));
163
+ const map = result.current.rowMetaMap;
164
+
165
+ let depth0Count = 0;
166
+ let depth1Count = 0;
167
+ map.forEach((meta) => {
168
+ if (!meta.isGroupRow) return;
169
+ if (meta.treeDepth === 0) depth0Count++;
170
+ if (meta.treeDepth === 1) depth1Count++;
171
+ });
172
+ // depth 0: Engineering, HR
173
+ expect(depth0Count).toBe(2);
174
+ // depth 1: Engineering/US, Engineering/UK, HR/US
175
+ expect(depth1Count).toBe(3);
176
+ });
177
+
178
+ it('reveals the next level down only after both ancestor groups are expanded', () => {
179
+ const { result } = renderHook(() => useRowGrouping(MULTI_PARAMS));
180
+
181
+ // Fully collapsed: only depth-0 groups are visible.
182
+ let visible = result.current.getVisibleRows();
183
+ expect(visible).not.toBeNull();
184
+ expect(visible!.length).toBe(2);
185
+
186
+ const deptGroupId = [...result.current.treeNodes.entries()]
187
+ .find(([, node]) => node.depth === 0 && node.groupingValue === 'Engineering')![0];
188
+
189
+ act(() => { result.current.toggleExpansion(deptGroupId); });
190
+ visible = result.current.getVisibleRows();
191
+ // Engineering expanded reveals its 2 country subgroups; HR still collapsed (1 group).
192
+ expect(visible!.length).toBe(2 + 2);
193
+
194
+ // 'US' also exists as a country subgroup under HR — select by parentId to
195
+ // disambiguate and target Engineering's own US subgroup.
196
+ const countryGroupId = [...result.current.treeNodes.entries()]
197
+ .find(([, node]) => node.depth === 1 && node.groupingValue === 'US' && node.parentId === deptGroupId)![0];
198
+
199
+ act(() => { result.current.toggleExpansion(countryGroupId); });
200
+ visible = result.current.getVisibleRows();
201
+ // Expanding Engineering/US additionally reveals its 2 leaf rows (Alice, Bob).
202
+ expect(visible!.length).toBe(2 + 2 + 2);
203
+ });
204
+ });
205
+
114
206
  describe('useRowGrouping — groupable: false', () => {
115
207
  it('skips a column with groupable:false — no group rows created for that field', () => {
116
208
  const columns = [{ field: 'dept', groupable: false as const }];
@@ -135,3 +227,72 @@ describe('useRowGrouping — groupable: false', () => {
135
227
  expect(groupCount).toBe(2);
136
228
  });
137
229
  });
230
+
231
+ describe('useRowGrouping — group aggregation matches useAggregation semantics', () => {
232
+ const NULL_ROWS = [
233
+ { id: 1, g: 'A', v: 10 as number | null },
234
+ { id: 2, g: 'A', v: null as number | null },
235
+ { id: 3, g: 'A', v: 20 as number | null },
236
+ ];
237
+ const MODEL = ['g'];
238
+ const aggFor = (fn: string, columns?: { field: string; availableAggregationFunctions?: string[] }[]) => {
239
+ const aggregationModel = { v: fn };
240
+ const { result } = renderHook(() => useRowGrouping({ rows: NULL_ROWS, getRowId: GET_ROW_ID, rowGroupingModel: MODEL, aggregationModel, columns }));
241
+ return [...result.current.treeNodes.values()].find(n => n.depth === 0)!.aggregatedValues!;
242
+ };
243
+
244
+ it('ignores nulls for min / avg / count instead of treating them as 0', () => {
245
+ expect(aggFor('min').v).toBe(10);
246
+ expect(aggFor('avg').v).toBe(15);
247
+ expect(aggFor('count').v).toBe(2);
248
+ expect(aggFor('sum').v).toBe(30);
249
+ expect(aggFor('max').v).toBe(20);
250
+ });
251
+
252
+ it('supports the unique aggregation', () => {
253
+ expect(aggFor('unique').v).toBe(2);
254
+ });
255
+
256
+ it('honors availableAggregationFunctions', () => {
257
+ expect(aggFor('avg', [{ field: 'v', availableAggregationFunctions: ['sum'] }])).not.toHaveProperty('v');
258
+ });
259
+ });
260
+
261
+ describe('useRowGrouping — expansion state', () => {
262
+ it('preserves user expansion when rows are replaced with edited copies', () => {
263
+ const { result, rerender } = renderHook(({ rows }) => useRowGrouping({ ...BASE_PARAMS, rows }), { initialProps: { rows: ROWS } });
264
+ const eng = [...result.current.treeNodes.values()].find(n => n.groupingValue === 'Engineering')!.id;
265
+ act(() => { result.current.toggleExpansion(eng); });
266
+ rerender({ rows: ROWS.map(r => (r.id === 1 ? { ...r, name: 'Alicia' } : r)) });
267
+ expect(result.current.isGroupExpanded(eng)).toBe(true);
268
+ });
269
+
270
+ it('preserves user expansion when an equal rowGroupingModel is passed as a new array', () => {
271
+ const { result, rerender } = renderHook(({ model }) => useRowGrouping({ ...BASE_PARAMS, rowGroupingModel: model }), { initialProps: { model: ['dept'] } });
272
+ const eng = [...result.current.treeNodes.values()].find(n => n.groupingValue === 'Engineering')!.id;
273
+ act(() => { result.current.toggleExpansion(eng); });
274
+ rerender({ model: ['dept'] });
275
+ expect(result.current.isGroupExpanded(eng)).toBe(true);
276
+ });
277
+
278
+ it('discards user overrides when defaultGroupingExpansionDepth changes', () => {
279
+ const { result, rerender } = renderHook(({ depth }) => useRowGrouping({ ...BASE_PARAMS, defaultGroupingExpansionDepth: depth }), { initialProps: { depth: -1 } });
280
+ const hr = [...result.current.treeNodes.values()].find(n => n.groupingValue === 'HR')!.id;
281
+ expect(result.current.isGroupExpanded(hr)).toBe(true);
282
+ act(() => { result.current.toggleExpansion(hr); });
283
+ expect(result.current.isGroupExpanded(hr)).toBe(false);
284
+ // depth 1 also expands depth-0 groups by default; the collapse override must not survive.
285
+ rerender({ depth: 1 });
286
+ expect(result.current.isGroupExpanded(hr)).toBe(true);
287
+ });
288
+
289
+ it('does not reorder the memoized tree when getVisibleRows sorts', () => {
290
+ const { result } = renderHook(() => useRowGrouping({ ...BASE_PARAMS, sortModel: SORT_DESC, defaultGroupingExpansionDepth: -1 }));
291
+ const eng = [...result.current.treeNodes.values()].find(n => n.groupingValue === 'Engineering')!;
292
+ const before = [...eng.children!];
293
+ result.current.getVisibleRows();
294
+ expect(eng.children).toEqual(before);
295
+ });
296
+ });
297
+
298
+ const SORT_DESC = [{ field: 'name', sort: 'desc' as const }];
@@ -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,
@@ -12,6 +12,7 @@ import type {
12
12
  } from '../types';
13
13
  import { isRowMatchingFilter } from '../utils/filtering';
14
14
  import { compareValues } from '../utils/sorting';
15
+ import { computeAggregations } from '../utils/aggregation';
15
16
 
16
17
  export interface UseRowGroupingParams<R extends GridRowModel> {
17
18
  rows: R[];
@@ -35,6 +36,12 @@ const defaultGetAggregationPosition = (groupNode: GridTreeNode | null): 'inline'
35
36
  // infinite re-render loops when callers omit optional params.
36
37
  const EMPTY_ROW_GROUPING_MODEL: GridRowGroupingModel = [];
37
38
  const EMPTY_AGGREGATION_MODEL: GridAggregationModel = {};
39
+ const EMPTY_OVERRIDES: Map<GridRowId, boolean> = new Map();
40
+
41
+ interface ExpansionOverrides {
42
+ configKey: string;
43
+ overrides: Map<GridRowId, boolean>;
44
+ }
38
45
 
39
46
  export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingParams<R>) {
40
47
  const {
@@ -55,7 +62,16 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
55
62
  return map;
56
63
  }, [columns]);
57
64
 
58
- const [expandedGroupIds, setExpandedGroupIds] = useState<Set<GridRowId>>(new Set());
65
+ // User expand/collapse choices are stored as overrides on top of the depth-based
66
+ // default, keyed by the grouping config. Rebuilding the tree (row edits, new rows
67
+ // arrays) keeps them because group ids are deterministic; changing the grouping
68
+ // config itself discards them.
69
+ const configKey = `${rowGroupingModel.join('\u001f')}|${defaultGroupingExpansionDepth}`;
70
+ const [expansionState, setExpansionState] = useState<ExpansionOverrides>(() => ({
71
+ configKey,
72
+ overrides: new Map(),
73
+ }));
74
+ const overrides = expansionState.configKey === configKey ? expansionState.overrides : EMPTY_OVERRIDES;
59
75
 
60
76
  const { treeNodes, rootIds, groupingRows } = useMemo(() => {
61
77
  const treeNodes = new Map<GridRowId, GridTreeNode>();
@@ -122,24 +138,8 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
122
138
  groupIds.push(groupId);
123
139
 
124
140
  // 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
- }
141
+ const aggregatedValues: Record<string, unknown> =
142
+ computeAggregations(groupRowsList, aggregationModel, columnsLookup, 'useRowGrouping');
143
143
 
144
144
  const groupRow = {
145
145
  [field]: rawValue,
@@ -185,42 +185,29 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
185
185
 
186
186
  }, [rows, rowGroupingModel, aggregationModel, getRowId, getAggregationPosition, columnsLookup]);
187
187
 
188
- useEffect(() => {
189
- if (defaultGroupingExpansionDepth === -1) {
190
-
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
- }
188
+ const isDefaultExpanded = useCallback((node: GridTreeNode | undefined) => (
189
+ Boolean(node?.children && node.children.length > 0) &&
190
+ (defaultGroupingExpansionDepth === -1 || (node?.depth ?? 0) < defaultGroupingExpansionDepth)
191
+ ), [defaultGroupingExpansionDepth]);
210
192
 
211
- }, [rowGroupingModel, defaultGroupingExpansionDepth, treeNodes]);
193
+ const expandedGroupIds = useMemo<Set<GridRowId>>(() => {
194
+ const ids = new Set<GridRowId>();
195
+ treeNodes.forEach((node, id) => {
196
+ if (!node.children || node.children.length === 0) return;
197
+ if (overrides.get(id) ?? isDefaultExpanded(node)) ids.add(id);
198
+ });
199
+ return ids;
200
+ }, [treeNodes, overrides, isDefaultExpanded]);
212
201
 
213
202
  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;
203
+ setExpansionState(prev => {
204
+ const base = prev.configKey === configKey ? prev.overrides : EMPTY_OVERRIDES;
205
+ const current = base.get(id) ?? isDefaultExpanded(treeNodes.get(id));
206
+ const next = new Map(base);
207
+ next.set(id, !current);
208
+ return { configKey, overrides: next };
222
209
  });
223
- }, []);
210
+ }, [configKey, treeNodes, isDefaultExpanded]);
224
211
 
225
212
  const isGroupExpanded = useCallback((id: GridRowId) => {
226
213
  return expandedGroupIds.has(id);
@@ -255,10 +242,8 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
255
242
 
256
243
  const traverse = (ids: GridRowId[]) => {
257
244
 
258
- let visibleIds = ids;
259
- if (filterModel) {
260
- visibleIds = ids.filter(doesNodeMatchFilter);
261
- }
245
+ // Copy before sorting: ids may be node.children / rootIds from the memoized tree.
246
+ const visibleIds = filterModel ? ids.filter(doesNodeMatchFilter) : [...ids];
262
247
 
263
248
  if (sortModel && sortModel.length > 0) {
264
249
  visibleIds.sort((aId, bId) => {
@@ -299,15 +284,13 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
299
284
  };
300
285
  result.push(enhancedRow);
301
286
 
302
- if (isExpandedGroupIds.has(id) && isGroup) {
287
+ if (expandedGroupIds.has(id) && isGroup) {
303
288
  traverse(node.children!);
304
289
  }
305
290
  }
306
291
  });
307
292
  };
308
293
 
309
- const isExpandedGroupIds = expandedGroupIds;
310
-
311
294
  traverse(rootIds);
312
295
 
313
296
  return result;