@opencorestack/opengridx 2.0.4 → 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 +45 -3
- package/dist/index.d.ts +43 -2
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +3927 -3839
- 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 +13 -4
- package/docs/components/column-visibility.md +40 -16
- 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 +36 -8
- 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 +42 -10
- 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/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 +15 -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
|
@@ -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;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { renderHook, act } from '@testing-library/react';
|
|
3
|
+
import { useTreeData } from './useTreeData';
|
|
4
|
+
import type { GridRowModel, GridRowId } from '../types';
|
|
5
|
+
|
|
6
|
+
type Row = GridRowModel & { id: number; path: string[]; name: string };
|
|
7
|
+
|
|
8
|
+
const ROWS: Row[] = [
|
|
9
|
+
{ id: 1, path: ['CEO'], name: 'Ada' },
|
|
10
|
+
{ id: 2, path: ['CEO', 'CTO'], name: 'Bo' },
|
|
11
|
+
{ id: 3, path: ['CEO', 'CTO', 'Dev'], name: 'Cy' },
|
|
12
|
+
{ id: 4, path: ['CEO', 'CFO'], name: 'Di' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const getRowId = (r: Row) => r.id;
|
|
16
|
+
const getTreeDataPath = (r: Row) => r.path;
|
|
17
|
+
|
|
18
|
+
const visibleIds = (rows: GridRowModel[] | null) => (rows ?? []).map(r => r.id as GridRowId);
|
|
19
|
+
|
|
20
|
+
describe('useTreeData — expansion state', () => {
|
|
21
|
+
it('expands to defaultGroupingExpansionDepth on the first render (no effect flash)', () => {
|
|
22
|
+
const { result } = renderHook(() =>
|
|
23
|
+
useTreeData({ rows: ROWS, getRowId, getTreeDataPath, treeData: true, defaultGroupingExpansionDepth: 1 })
|
|
24
|
+
);
|
|
25
|
+
expect(result.current.isGroupExpanded(1)).toBe(true);
|
|
26
|
+
expect(result.current.isGroupExpanded(2)).toBe(false);
|
|
27
|
+
expect(visibleIds(result.current.getVisibleRows())).toEqual([1, 2, 4]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('keeps a user collapse when rows are replaced with edited copies', () => {
|
|
31
|
+
const { result, rerender } = renderHook(
|
|
32
|
+
({ rows }) => useTreeData({ rows, getRowId, getTreeDataPath, treeData: true, defaultGroupingExpansionDepth: -1 }),
|
|
33
|
+
{ initialProps: { rows: ROWS } }
|
|
34
|
+
);
|
|
35
|
+
act(() => { result.current.toggleExpansion(2); });
|
|
36
|
+
expect(result.current.isGroupExpanded(2)).toBe(false);
|
|
37
|
+
|
|
38
|
+
rerender({ rows: ROWS.map(r => (r.id === 4 ? { ...r, name: 'Dee' } : r)) });
|
|
39
|
+
expect(result.current.isGroupExpanded(2)).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('keeps a lazily expanded node open after its children arrive', () => {
|
|
43
|
+
const lazyRows: Row[] = [
|
|
44
|
+
{ id: 1, path: ['CEO'], name: 'Ada' },
|
|
45
|
+
{ id: 2, path: ['CEO', 'CTO'], name: 'Bo', serverChildrenCount: 1 },
|
|
46
|
+
];
|
|
47
|
+
const { result, rerender } = renderHook(
|
|
48
|
+
({ rows }) => useTreeData({ rows, getRowId, getTreeDataPath, treeData: true, defaultGroupingExpansionDepth: 1 }),
|
|
49
|
+
{ initialProps: { rows: lazyRows } }
|
|
50
|
+
);
|
|
51
|
+
act(() => { result.current.toggleExpansion(2); });
|
|
52
|
+
expect(result.current.isGroupExpanded(2)).toBe(true);
|
|
53
|
+
|
|
54
|
+
rerender({ rows: [...lazyRows, { id: 3, path: ['CEO', 'CTO', 'Dev'], name: 'Cy' }] });
|
|
55
|
+
expect(result.current.isGroupExpanded(2)).toBe(true);
|
|
56
|
+
expect(visibleIds(result.current.getVisibleRows())).toContain(3);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('calls onRowExpansionChange exactly once per expand and never on collapse', () => {
|
|
60
|
+
const onRowExpansionChange = vi.fn();
|
|
61
|
+
const { result } = renderHook(() =>
|
|
62
|
+
useTreeData({ rows: ROWS, getRowId, getTreeDataPath, treeData: true, onRowExpansionChange })
|
|
63
|
+
);
|
|
64
|
+
act(() => { result.current.toggleExpansion(1); });
|
|
65
|
+
expect(onRowExpansionChange).toHaveBeenCalledTimes(1);
|
|
66
|
+
expect(onRowExpansionChange).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }));
|
|
67
|
+
|
|
68
|
+
act(() => { result.current.toggleExpansion(1); });
|
|
69
|
+
expect(onRowExpansionChange).toHaveBeenCalledTimes(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('discards user overrides when defaultGroupingExpansionDepth changes', () => {
|
|
73
|
+
const { result, rerender } = renderHook(
|
|
74
|
+
({ depth }) => useTreeData({ rows: ROWS, getRowId, getTreeDataPath, treeData: true, defaultGroupingExpansionDepth: depth }),
|
|
75
|
+
{ initialProps: { depth: -1 } }
|
|
76
|
+
);
|
|
77
|
+
act(() => { result.current.toggleExpansion(1); });
|
|
78
|
+
expect(result.current.isGroupExpanded(1)).toBe(false);
|
|
79
|
+
|
|
80
|
+
rerender({ depth: 1 });
|
|
81
|
+
expect(result.current.isGroupExpanded(1)).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
});
|
package/lib/hooks/useTreeData.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import { useMemo, useState, useCallback
|
|
1
|
+
import { useMemo, useState, useCallback } from 'react';
|
|
2
2
|
import { GridRowModel, GridRowId, GridTreeNode, GridFilterModel, GridSortItem } from '../types';
|
|
3
3
|
import type { GridRowMeta } from '../types';
|
|
4
4
|
import { isRowMatchingFilter } from '../utils/filtering';
|
|
5
5
|
import { compareValues } from '../utils/sorting';
|
|
6
6
|
|
|
7
|
+
const EMPTY_OVERRIDES: Map<GridRowId, boolean> = new Map();
|
|
8
|
+
|
|
9
|
+
interface ExpansionOverrides {
|
|
10
|
+
configKey: string;
|
|
11
|
+
overrides: Map<GridRowId, boolean>;
|
|
12
|
+
}
|
|
13
|
+
|
|
7
14
|
interface UseTreeDataProps<R extends GridRowModel> {
|
|
8
15
|
rows: R[];
|
|
9
16
|
getRowId: (row: R) => GridRowId;
|
|
@@ -157,49 +164,45 @@ export function useTreeData<R extends GridRowModel>(props: UseTreeDataProps<R>)
|
|
|
157
164
|
|
|
158
165
|
}, [rows, getTreeDataPath, treeData, getRowId]);
|
|
159
166
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
167
|
+
// Same model as useRowGrouping: depth-based default plus user overrides keyed by config.
|
|
168
|
+
// Rebuilding the tree (row edits, lazily loaded children) keeps the user's choices.
|
|
169
|
+
const configKey = `${treeData}|${defaultGroupingExpansionDepth}`;
|
|
170
|
+
const [expansionState, setExpansionState] = useState<ExpansionOverrides>(() => ({
|
|
171
|
+
configKey,
|
|
172
|
+
overrides: new Map(),
|
|
173
|
+
}));
|
|
174
|
+
const overrides = expansionState.configKey === configKey ? expansionState.overrides : EMPTY_OVERRIDES;
|
|
175
|
+
|
|
176
|
+
const isDefaultExpanded = useCallback((node: GridTreeNode | undefined) => (
|
|
177
|
+
Boolean(node?.children && node.children.length > 0) &&
|
|
178
|
+
(defaultGroupingExpansionDepth === -1 || (node?.depth ?? 0) < defaultGroupingExpansionDepth)
|
|
179
|
+
), [defaultGroupingExpansionDepth]);
|
|
180
|
+
|
|
181
|
+
const expandedGroupIds = useMemo<Set<GridRowId>>(() => {
|
|
182
|
+
const ids = new Set<GridRowId>();
|
|
183
|
+
if (!treeData) return ids;
|
|
184
|
+
treeNodes.forEach((node, id) => {
|
|
185
|
+
// A lazily loaded node can be expanded before it has any children, so an
|
|
186
|
+
// explicit override counts even when children is still empty.
|
|
187
|
+
if (overrides.get(id) ?? isDefaultExpanded(node)) ids.add(id);
|
|
170
188
|
});
|
|
171
|
-
return
|
|
172
|
-
}, [treeNodes,
|
|
173
|
-
|
|
174
|
-
const [expandedGroupIds, setExpandedGroupIds] = useState<Set<GridRowId>>(new Set());
|
|
175
|
-
|
|
176
|
-
useEffect(() => {
|
|
177
|
-
if (initialExpandedIds.size > 0) {
|
|
178
|
-
setExpandedGroupIds(initialExpandedIds);
|
|
179
|
-
}
|
|
180
|
-
}, [initialExpandedIds]);
|
|
189
|
+
return ids;
|
|
190
|
+
}, [treeData, treeNodes, overrides, isDefaultExpanded]);
|
|
181
191
|
|
|
182
192
|
const toggleExpansion = useCallback((id: GridRowId) => {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
} else {
|
|
190
|
-
next.add(id);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (isExpanding && onRowExpansionChange) {
|
|
194
|
-
const node = treeNodes.get(id);
|
|
195
|
-
if (node) {
|
|
196
|
-
onRowExpansionChange(node);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
return next;
|
|
193
|
+
const isExpanding = !expandedGroupIds.has(id);
|
|
194
|
+
setExpansionState(prev => {
|
|
195
|
+
const base = prev.configKey === configKey ? prev.overrides : EMPTY_OVERRIDES;
|
|
196
|
+
const next = new Map(base);
|
|
197
|
+
next.set(id, isExpanding);
|
|
198
|
+
return { configKey, overrides: next };
|
|
201
199
|
});
|
|
202
|
-
|
|
200
|
+
// Called outside the state updater: updaters may run twice, and this can trigger a server fetch.
|
|
201
|
+
if (isExpanding && onRowExpansionChange) {
|
|
202
|
+
const node = treeNodes.get(id);
|
|
203
|
+
if (node) onRowExpansionChange(node);
|
|
204
|
+
}
|
|
205
|
+
}, [expandedGroupIds, configKey, treeNodes, onRowExpansionChange]);
|
|
203
206
|
|
|
204
207
|
const getVisibleRows = useCallback(() => {
|
|
205
208
|
|
|
@@ -209,9 +212,7 @@ export function useTreeData<R extends GridRowModel>(props: UseTreeDataProps<R>)
|
|
|
209
212
|
rows.forEach(r => rowLookup.set(getRowId(r), r));
|
|
210
213
|
groupingRows.forEach(r => rowLookup.set(r.id, r));
|
|
211
214
|
|
|
212
|
-
const matchesFilter = (row: GridRowModel) =>
|
|
213
|
-
return isRowMatchingFilter(row, filterModel!);
|
|
214
|
-
};
|
|
215
|
+
const matchesFilter = (row: GridRowModel) => (filterModel ? isRowMatchingFilter(row, filterModel) : true);
|
|
215
216
|
|
|
216
217
|
const filterCache = new Map<GridRowId, boolean>();
|
|
217
218
|
|
package/lib/index.ts
CHANGED
|
@@ -11,6 +11,8 @@ export { FilterPanel } from './components/FilterPanel';
|
|
|
11
11
|
export { GridToolbar } from './components/Toolbar/GridToolbar';
|
|
12
12
|
export type { GridToolbarProps, ToolbarButtonRenderProps, ToolbarQuickFilterRenderProps } from './components/Toolbar/GridToolbar';
|
|
13
13
|
export { GridTooltip } from './components/Tooltip/Tooltip';
|
|
14
|
+
export { ColumnVisibilityPanel } from './components/ColumnVisibilityPanel/ColumnVisibilityPanel';
|
|
15
|
+
export type { ColumnVisibilityPanelProps } from './components/ColumnVisibilityPanel/ColumnVisibilityPanel';
|
|
14
16
|
|
|
15
17
|
export { useGridApiRef } from './hooks/core/useGridApiRef';
|
|
16
18
|
export { Button, Input, Checkbox } from './components/ui';
|
package/lib/types/index.ts
CHANGED
|
@@ -21,8 +21,9 @@ export interface GridRowModel {
|
|
|
21
21
|
*
|
|
22
22
|
* @since v1.1 — replaces the `_hasChildren` / `_treeDepth` / `_groupingField` etc.
|
|
23
23
|
* properties that were previously (and incorrectly) typed on `GridRowModel`.
|
|
24
|
-
* Those underscore fields remain on the row object at runtime
|
|
25
|
-
*
|
|
24
|
+
* Those underscore fields remain on the row object at runtime for backward
|
|
25
|
+
* compatibility; removal was deferred past v2.0 (still present as of v2.1.0)
|
|
26
|
+
* to a future major version. Do not rely on them being absent yet.
|
|
26
27
|
*/
|
|
27
28
|
export interface GridRowMeta {
|
|
28
29
|
hasChildren?: boolean;
|
|
@@ -235,6 +236,11 @@ export interface GridValueFormatterParams<R extends GridRowModel = GridRowModel>
|
|
|
235
236
|
export interface GridRenderCellParams<R extends GridRowModel = GridRowModel> {
|
|
236
237
|
/** The current cell value. */
|
|
237
238
|
value: unknown;
|
|
239
|
+
/**
|
|
240
|
+
* The value after `colDef.valueFormatter` (or `String(value)` when no formatter is set; `''` for null/undefined).
|
|
241
|
+
* @since v2.1
|
|
242
|
+
*/
|
|
243
|
+
formattedValue?: string;
|
|
238
244
|
/** The row object. */
|
|
239
245
|
row: R;
|
|
240
246
|
/** The field name and unique ID. */
|
|
@@ -704,6 +710,13 @@ export interface DataGridProps<R extends GridRowModel = GridRowModel> {
|
|
|
704
710
|
|
|
705
711
|
/** Callback fired when a row is clicked. */
|
|
706
712
|
onRowClick?: (params: GridRowParams<R>) => void;
|
|
713
|
+
/**
|
|
714
|
+
* Callback fired when a row is double-clicked. Also fires for group rows; check
|
|
715
|
+
* `apiRef` / `rowMeta` if you only want leaf rows. Not fired when the double-click
|
|
716
|
+
* lands on the checkbox, expand icon, drag handle, or an open cell editor.
|
|
717
|
+
* @since v2.1
|
|
718
|
+
*/
|
|
719
|
+
onRowDoubleClick?: (params: GridRowParams<R>) => void;
|
|
707
720
|
/** Callback fired when a cell is clicked. */
|
|
708
721
|
onCellClick?: (params: GridCellParams<R>) => void;
|
|
709
722
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { AGGREGATION_FUNCTIONS } from './index';
|
|
3
|
+
|
|
4
|
+
describe('AGGREGATION_FUNCTIONS', () => {
|
|
5
|
+
it('computes min / max over more values than a spread argument list allows', () => {
|
|
6
|
+
const values = Array.from({ length: 300_000 }, (_, i) => i);
|
|
7
|
+
expect(AGGREGATION_FUNCTIONS.min(values)).toBe(0);
|
|
8
|
+
expect(AGGREGATION_FUNCTIONS.max(values)).toBe(299_999);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('returns null for min / max / avg when there are no numeric values', () => {
|
|
12
|
+
expect(AGGREGATION_FUNCTIONS.min([null, undefined])).toBeNull();
|
|
13
|
+
expect(AGGREGATION_FUNCTIONS.max([])).toBeNull();
|
|
14
|
+
expect(AGGREGATION_FUNCTIONS.avg(['x'])).toBeNull();
|
|
15
|
+
});
|
|
16
|
+
});
|