@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.
- package/CHANGELOG.md +54 -3
- package/dist/index.d.ts +50 -2
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +3805 -3692
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +16 -16
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +14 -4
- package/docs/components/column-visibility.md +40 -16
- package/docs/components/datagrid.md +1 -0
- package/docs/customization/slots-api.md +18 -6
- package/docs/features/export-guide.md +22 -3
- package/docs/features/sorting-pagination.md +2 -0
- package/docs/features/tree-data-grouping.md +8 -1
- package/docs/features/virtualization.md +66 -17
- package/docs/roadmap.md +13 -7
- package/lib/components/Cell/Cell.tsx +1 -0
- package/lib/components/DataGrid/DataGrid.css +7 -0
- package/lib/components/DataGrid/DataGrid.defects.test.tsx +103 -0
- package/lib/components/DataGrid/DataGrid.tsx +45 -11
- package/lib/components/DataGrid/DataGrid.virtualization.browser.test.tsx +183 -0
- package/lib/components/DataGrid/GridEmptyState.tsx +7 -1
- package/lib/components/DataGrid/GridListView.tsx +3 -0
- package/lib/components/DataGrid/GridPinnedRows.tsx +3 -0
- package/lib/components/DataGrid/GridVirtualRows.tsx +8 -1
- package/lib/components/ListView/ListViewRow.tsx +3 -0
- package/lib/components/Row/Row.tsx +10 -0
- package/lib/hooks/core/useGridColumns.tsx +8 -3
- package/lib/hooks/core/useGridDevWarnings.ts +42 -0
- package/lib/hooks/core/useGridKeyboardNavigation.ts +12 -14
- package/lib/hooks/core/useGridScrollSync.ts +62 -15
- package/lib/hooks/core/useGridVirtualization.ts +4 -1
- package/lib/hooks/core/useGridVisibleRows.ts +3 -2
- package/lib/hooks/features/useAggregation.ts +3 -38
- package/lib/hooks/useRowGrouping.test.ts +162 -1
- package/lib/hooks/useRowGrouping.ts +42 -59
- package/lib/hooks/useTreeData.test.ts +83 -0
- package/lib/hooks/useTreeData.ts +44 -43
- package/lib/index.ts +2 -0
- package/lib/types/index.ts +22 -2
- package/lib/utils/aggregation/aggregation.test.ts +16 -0
- package/lib/utils/aggregation/index.ts +62 -0
- package/lib/utils/export/exportToExcelAdvanced.test.ts +109 -0
- package/lib/utils/export/exportToExcelAdvanced.ts +89 -5
- package/lib/utils/scroll/index.ts +28 -0
- package/llms.txt +22 -13
- 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,
|
|
315
|
-
const {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
18
|
-
const
|
|
19
|
-
const
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
47
|
-
const
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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 (
|
|
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;
|