@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
|
@@ -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. */
|
|
@@ -608,6 +614,13 @@ export interface DataGridProps<R extends GridRowModel = GridRowModel> {
|
|
|
608
614
|
headerHeight?: number;
|
|
609
615
|
/** If true, the grid height will adjust to match the total height of its rows. */
|
|
610
616
|
autoHeight?: boolean;
|
|
617
|
+
/**
|
|
618
|
+
* Minimum number of rows to render outside the visible viewport (overscan buffer).
|
|
619
|
+
* The grid adapts this value upward automatically based on scroll velocity to prevent
|
|
620
|
+
* blank flashes during fast scrolling. This prop sets the floor — the buffer never
|
|
621
|
+
* drops below this value regardless of scroll speed. Defaults to `3`.
|
|
622
|
+
*/
|
|
623
|
+
overscanRowCount?: number;
|
|
611
624
|
|
|
612
625
|
/** Sorting mode: 'client' (default) or 'server'. */
|
|
613
626
|
sortingMode?: 'client' | 'server';
|
|
@@ -697,6 +710,13 @@ export interface DataGridProps<R extends GridRowModel = GridRowModel> {
|
|
|
697
710
|
|
|
698
711
|
/** Callback fired when a row is clicked. */
|
|
699
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;
|
|
700
720
|
/** Callback fired when a cell is clicked. */
|
|
701
721
|
onCellClick?: (params: GridCellParams<R>) => void;
|
|
702
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
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { GridAggregationModel, GridAggregationResult, GridColDef, GridRowModel } from '../../types';
|
|
2
|
+
|
|
3
|
+
export type BuiltInAggFn = 'sum' | 'avg' | 'count' | 'min' | 'max' | 'unique';
|
|
4
|
+
|
|
5
|
+
const toNumbers = (values: unknown[]): number[] => {
|
|
6
|
+
const nums: number[] = [];
|
|
7
|
+
for (const v of values) {
|
|
8
|
+
if (v == null) continue;
|
|
9
|
+
const n = Number(v);
|
|
10
|
+
if (!isNaN(n)) nums.push(n);
|
|
11
|
+
}
|
|
12
|
+
return nums;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Loops instead of Math.min(...nums): spreading >~120k arguments throws RangeError.
|
|
16
|
+
const minOf = (nums: number[]): number | null => {
|
|
17
|
+
if (nums.length === 0) return null;
|
|
18
|
+
let m = nums[0];
|
|
19
|
+
for (let i = 1; i < nums.length; i++) if (nums[i] < m) m = nums[i];
|
|
20
|
+
return m;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const maxOf = (nums: number[]): number | null => {
|
|
24
|
+
if (nums.length === 0) return null;
|
|
25
|
+
let m = nums[0];
|
|
26
|
+
for (let i = 1; i < nums.length; i++) if (nums[i] > m) m = nums[i];
|
|
27
|
+
return m;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const AGGREGATION_FUNCTIONS: Record<BuiltInAggFn, (values: unknown[]) => unknown> = {
|
|
31
|
+
sum: (values) => toNumbers(values).reduce((acc, n) => acc + n, 0),
|
|
32
|
+
avg: (values) => {
|
|
33
|
+
const nums = toNumbers(values);
|
|
34
|
+
return nums.length ? nums.reduce((acc, n) => acc + n, 0) / nums.length : null;
|
|
35
|
+
},
|
|
36
|
+
count: (values) => values.filter((v) => v != null).length,
|
|
37
|
+
min: (values) => minOf(toNumbers(values)),
|
|
38
|
+
max: (values) => maxOf(toNumbers(values)),
|
|
39
|
+
unique: (values) => new Set(values.filter((v) => v != null)).size,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function computeAggregations<R extends GridRowModel>(
|
|
43
|
+
rows: R[],
|
|
44
|
+
aggregationModel: GridAggregationModel,
|
|
45
|
+
columnsLookup: Map<string, GridColDef<R>>,
|
|
46
|
+
warnPrefix: string,
|
|
47
|
+
): GridAggregationResult {
|
|
48
|
+
const result: GridAggregationResult = {};
|
|
49
|
+
for (const [field, fnName] of Object.entries(aggregationModel)) {
|
|
50
|
+
const colDef = columnsLookup.get(field);
|
|
51
|
+
if (colDef?.availableAggregationFunctions && !colDef.availableAggregationFunctions.includes(fnName)) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const fn = AGGREGATION_FUNCTIONS[fnName as BuiltInAggFn];
|
|
55
|
+
if (!fn) {
|
|
56
|
+
console.warn(`[${warnPrefix}] Unknown aggregation function: "${fnName}"`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
result[field] = fn(rows.map((row) => row[field]));
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import ExcelJS from 'exceljs';
|
|
3
|
+
import { exportToExcelAdvanced } from './exportToExcelAdvanced';
|
|
4
|
+
import type { GridColDef, GridGroupedExportRow, GridRowModel } from '../../types';
|
|
5
|
+
|
|
6
|
+
type Row = GridRowModel & { id: number; dept: string; salary: number };
|
|
7
|
+
|
|
8
|
+
const columns: GridColDef<Row>[] = [
|
|
9
|
+
{ field: 'dept', headerName: 'Department' },
|
|
10
|
+
{ field: 'salary', headerName: 'Salary', type: 'number' },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const flatRows: Row[] = [
|
|
14
|
+
{ id: 1, dept: 'Engineering', salary: 90000 },
|
|
15
|
+
{ id: 2, dept: 'Engineering', salary: 85000 },
|
|
16
|
+
{ id: 3, dept: 'Marketing', salary: 70000 },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const groupedRows: GridGroupedExportRow[] = [
|
|
20
|
+
{ type: 'group-header', depth: 0, groupField: 'dept', groupValue: 'Engineering' },
|
|
21
|
+
{ type: 'leaf', depth: 1, row: flatRows[0] },
|
|
22
|
+
{ type: 'leaf', depth: 1, row: flatRows[1] },
|
|
23
|
+
{ type: 'group-subtotal', depth: 0, groupField: 'dept', groupValue: 'Engineering', aggregatedValues: { salary: 175000 } },
|
|
24
|
+
{ type: 'group-header', depth: 0, groupField: 'dept', groupValue: 'Marketing' },
|
|
25
|
+
{ type: 'leaf', depth: 1, row: flatRows[2] },
|
|
26
|
+
{ type: 'group-subtotal', depth: 0, groupField: 'dept', groupValue: 'Marketing', aggregatedValues: { salary: 70000 } },
|
|
27
|
+
{ type: 'grand-total', depth: 0, aggregatedValues: { salary: 245000 } },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
let capturedBlob: Blob | null = null;
|
|
31
|
+
const origCreateObjectURL = URL.createObjectURL;
|
|
32
|
+
const origRevokeObjectURL = URL.revokeObjectURL;
|
|
33
|
+
const origClick = HTMLAnchorElement.prototype.click;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
capturedBlob = null;
|
|
37
|
+
URL.createObjectURL = (blob: Blob) => { capturedBlob = blob; return 'blob:mock'; };
|
|
38
|
+
URL.revokeObjectURL = () => {};
|
|
39
|
+
HTMLAnchorElement.prototype.click = () => {};
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
URL.createObjectURL = origCreateObjectURL;
|
|
44
|
+
URL.revokeObjectURL = origRevokeObjectURL;
|
|
45
|
+
HTMLAnchorElement.prototype.click = origClick;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
async function readSheet(): Promise<ExcelJS.Worksheet> {
|
|
49
|
+
const wb = new ExcelJS.Workbook();
|
|
50
|
+
await wb.xlsx.load(await capturedBlob!.arrayBuffer());
|
|
51
|
+
return wb.worksheets[0];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const rowValues = (ws: ExcelJS.Worksheet) =>
|
|
55
|
+
ws.getSheetValues().slice(1).map(r => (Array.isArray(r) ? r.slice(1) : []));
|
|
56
|
+
|
|
57
|
+
describe('exportToExcelAdvanced — flat', () => {
|
|
58
|
+
it('writes a header row and one row per record when groupedRows is absent', async () => {
|
|
59
|
+
await exportToExcelAdvanced(flatRows, columns);
|
|
60
|
+
const ws = await readSheet();
|
|
61
|
+
expect(rowValues(ws)).toEqual([
|
|
62
|
+
['Department', 'Salary'],
|
|
63
|
+
['Engineering', 90000],
|
|
64
|
+
['Engineering', 85000],
|
|
65
|
+
['Marketing', 70000],
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('exportToExcelAdvanced — grouped', () => {
|
|
71
|
+
it('writes group headers, leaves, subtotals and a single grand total in order', async () => {
|
|
72
|
+
await exportToExcelAdvanced(flatRows, columns, {
|
|
73
|
+
groupedRows,
|
|
74
|
+
aggregationModel: { salary: 'sum' },
|
|
75
|
+
aggregationResult: { salary: 245000 },
|
|
76
|
+
sheets: [{ name: 'Data', includeSummary: true }],
|
|
77
|
+
});
|
|
78
|
+
const ws = await readSheet();
|
|
79
|
+
expect(rowValues(ws)).toEqual([
|
|
80
|
+
['Department', 'Salary'],
|
|
81
|
+
['Department: Engineering'],
|
|
82
|
+
['Engineering', 90000],
|
|
83
|
+
['Engineering', 85000],
|
|
84
|
+
['Subtotal', 175000],
|
|
85
|
+
['Department: Marketing'],
|
|
86
|
+
['Marketing', 70000],
|
|
87
|
+
['Subtotal', 70000],
|
|
88
|
+
['Grand Total', 245000],
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('outlines leaf rows one level below their group header', async () => {
|
|
93
|
+
await exportToExcelAdvanced(flatRows, columns, { groupedRows });
|
|
94
|
+
const ws = await readSheet();
|
|
95
|
+
expect(ws.getRow(2).outlineLevel ?? 0).toBe(0);
|
|
96
|
+
expect(ws.getRow(3).outlineLevel).toBe(1);
|
|
97
|
+
expect(ws.getRow(4).outlineLevel).toBe(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('uses groupingValueFormatter for group-header labels', async () => {
|
|
101
|
+
const cols: GridColDef<Row>[] = [
|
|
102
|
+
{ ...columns[0], groupingValueFormatter: ({ value }) => `Dept ${String(value)}` },
|
|
103
|
+
columns[1],
|
|
104
|
+
];
|
|
105
|
+
await exportToExcelAdvanced(flatRows, cols, { groupedRows });
|
|
106
|
+
const ws = await readSheet();
|
|
107
|
+
expect(ws.getRow(2).getCell(1).value).toBe('Dept Engineering');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import type { GridColDef, GridRowModel, GridAggregationModel } from '../../types';
|
|
2
|
+
import type { GridColDef, GridRowModel, GridAggregationModel, GridGroupedExportRow } from '../../types';
|
|
3
3
|
import { formatAggregationValue } from '../../hooks/features/useAggregation';
|
|
4
4
|
|
|
5
5
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
@@ -68,6 +68,19 @@ export interface ExcelAdvancedExportOptions {
|
|
|
68
68
|
aggregationModel?: GridAggregationModel | null;
|
|
69
69
|
/** Currently selected row IDs */
|
|
70
70
|
selectedRows?: (string | number)[];
|
|
71
|
+
/**
|
|
72
|
+
* Grouped row structure, typically from `apiRef.current.getGroupedExportRows()`.
|
|
73
|
+
* When provided, sheets with `rows: 'all'` are written in grouped order: group-header,
|
|
74
|
+
* leaf, group-subtotal and grand-total rows, using Excel row outlining so groups
|
|
75
|
+
* collapse natively. A `grand-total` entry replaces the sheet's `includeSummary` row.
|
|
76
|
+
* Ignored for `rows: 'selected'` sheets.
|
|
77
|
+
* @since v2.1
|
|
78
|
+
*/
|
|
79
|
+
groupedRows?: GridGroupedExportRow[];
|
|
80
|
+
/** Fill color for group-header rows in grouped export (default: '#e8eaf6') */
|
|
81
|
+
groupHeaderFillColor?: string;
|
|
82
|
+
/** Fill color for group-subtotal rows in grouped export (default: '#f0f4ff') */
|
|
83
|
+
groupSubtotalFillColor?: string;
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
// ─── Cell type inference ──────────────────────────────────────────────────────
|
|
@@ -187,6 +200,9 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
|
|
|
187
200
|
aggregationResult = null,
|
|
188
201
|
aggregationModel = null,
|
|
189
202
|
selectedRows,
|
|
203
|
+
groupedRows,
|
|
204
|
+
groupHeaderFillColor = '#e8eaf6',
|
|
205
|
+
groupSubtotalFillColor = '#f0f4ff',
|
|
190
206
|
} = options;
|
|
191
207
|
|
|
192
208
|
// Lazy-load ExcelJS to keep initial bundle lean
|
|
@@ -270,7 +286,12 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
|
|
|
270
286
|
alternateRowColor = '#f8fafc',
|
|
271
287
|
} = def;
|
|
272
288
|
|
|
273
|
-
const
|
|
289
|
+
const useGrouped = rowScope === 'all' && Boolean(groupedRows && groupedRows.length > 0);
|
|
290
|
+
const rowsToExport = rowScope === 'selected'
|
|
291
|
+
? sRows
|
|
292
|
+
: useGrouped
|
|
293
|
+
? groupedRows!.flatMap(e => (e.type === 'leaf' && e.row ? [e.row] : []))
|
|
294
|
+
: allRows;
|
|
274
295
|
|
|
275
296
|
// ─ Pre-fetch images for this sheet ──────────────────────────────────────────
|
|
276
297
|
const imageColumns = exportColumns.filter(col => columnStyles[col.field]?.embedImage);
|
|
@@ -343,7 +364,7 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
|
|
|
343
364
|
// Header occupies index 0 when present; first data row is at index 1.
|
|
344
365
|
let excelRowIdx = includeHeaders ? 1 : 0;
|
|
345
366
|
|
|
346
|
-
|
|
367
|
+
const writeDataRow = (row: GridRowModel, rowIdx: number, outlineLevel: number) => {
|
|
347
368
|
const cellValues = exportColumns.map(col => {
|
|
348
369
|
// Image columns: write empty string; image placed below
|
|
349
370
|
if (columnStyles[col.field]?.embedImage) return '';
|
|
@@ -361,6 +382,7 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
|
|
|
361
382
|
|
|
362
383
|
const dataRow = ws.addRow(cellValues);
|
|
363
384
|
dataRow.font = { size: bodyFontSize };
|
|
385
|
+
if (outlineLevel > 0) dataRow.outlineLevel = outlineLevel;
|
|
364
386
|
|
|
365
387
|
// Alternate row background
|
|
366
388
|
if (alternateRowColor && rowIdx % 2 === 1) {
|
|
@@ -440,10 +462,72 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
|
|
|
440
462
|
}
|
|
441
463
|
|
|
442
464
|
excelRowIdx++;
|
|
443
|
-
}
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
const groupLabel = (entry: GridGroupedExportRow): string => {
|
|
468
|
+
const colDef = exportColumns.find(c => c.field === entry.groupField);
|
|
469
|
+
if (colDef?.groupingValueFormatter && entry.groupField) {
|
|
470
|
+
return colDef.groupingValueFormatter({ field: entry.groupField, value: entry.groupValue });
|
|
471
|
+
}
|
|
472
|
+
return `${colDef?.headerName ?? entry.groupField ?? ''}: ${String(entry.groupValue ?? '')}`;
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
// Numbers are written raw so the column numFmt applies; other values use the formatter.
|
|
476
|
+
const aggregateCell = (col: GridColDef<GridRowModel>, values: Record<string, unknown>): unknown => {
|
|
477
|
+
const val = values[col.field];
|
|
478
|
+
if (val == null) return '';
|
|
479
|
+
if (typeof val === 'number') return val;
|
|
480
|
+
if (col.valueFormatter) return col.valueFormatter({ value: val, row: {} as GridRowModel, field: col.field });
|
|
481
|
+
return formatAggregationValue(val, aggregationModel?.[col.field] ?? '');
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const writeSummaryRow = (label: string, values: Record<string, unknown>, fill: string, outlineLevel: number, bold: boolean) => {
|
|
485
|
+
const cells = exportColumns.map((col, i) => (i === 0 && values[col.field] == null ? label : aggregateCell(col, values)));
|
|
486
|
+
const summaryRow = ws.addRow(cells);
|
|
487
|
+
summaryRow.height = 18;
|
|
488
|
+
if (outlineLevel > 0) summaryRow.outlineLevel = outlineLevel;
|
|
489
|
+
summaryRow.font = { bold, italic: !bold, size: bodyFontSize, color: { argb: argb(headerTextColor) } };
|
|
490
|
+
summaryRow.eachCell((cell, colIndex) => {
|
|
491
|
+
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: argb(fill) } };
|
|
492
|
+
const colDef = exportColumns[colIndex - 1];
|
|
493
|
+
cell.alignment = { horizontal: colIndex === 1 ? 'left' : (colDef?.type === 'number' ? 'right' : 'left'), vertical: 'middle' };
|
|
494
|
+
cell.border = topBorder();
|
|
495
|
+
});
|
|
496
|
+
excelRowIdx++;
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
let wroteGrandTotal = false;
|
|
500
|
+
|
|
501
|
+
if (useGrouped) {
|
|
502
|
+
let stripe = 0;
|
|
503
|
+
groupedRows!.forEach(entry => {
|
|
504
|
+
if (entry.type === 'group-header') {
|
|
505
|
+
const groupHeaderRow = ws.addRow([groupLabel(entry)]);
|
|
506
|
+
groupHeaderRow.height = 18;
|
|
507
|
+
if (entry.depth > 0) groupHeaderRow.outlineLevel = entry.depth;
|
|
508
|
+
groupHeaderRow.font = { bold: true, size: bodyFontSize, color: { argb: argb(headerTextColor) } };
|
|
509
|
+
for (let c = 1; c <= exportColumns.length; c++) {
|
|
510
|
+
groupHeaderRow.getCell(c).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: argb(groupHeaderFillColor) } };
|
|
511
|
+
}
|
|
512
|
+
groupHeaderRow.getCell(1).alignment = { horizontal: 'left', vertical: 'middle', indent: entry.depth };
|
|
513
|
+
excelRowIdx++;
|
|
514
|
+
stripe = 0;
|
|
515
|
+
} else if (entry.type === 'leaf' && entry.row) {
|
|
516
|
+
writeDataRow(entry.row, stripe++, entry.depth);
|
|
517
|
+
} else if (entry.type === 'group-subtotal' && entry.aggregatedValues) {
|
|
518
|
+
writeSummaryRow('Subtotal', entry.aggregatedValues, groupSubtotalFillColor, entry.depth, false);
|
|
519
|
+
} else if (entry.type === 'grand-total' && entry.aggregatedValues) {
|
|
520
|
+
writeSummaryRow('Grand Total', entry.aggregatedValues, headerFillColor, 0, true);
|
|
521
|
+
wroteGrandTotal = true;
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
ws.properties.outlineProperties = { summaryBelow: true, summaryRight: false };
|
|
525
|
+
} else {
|
|
526
|
+
rowsToExport.forEach((row, rowIdx) => writeDataRow(row, rowIdx, 0));
|
|
527
|
+
}
|
|
444
528
|
|
|
445
529
|
// ── Aggregation totals row ────────────────────────────────────────────
|
|
446
|
-
if (includeSummary && aggregationResult && aggregationModel) {
|
|
530
|
+
if (includeSummary && !wroteGrandTotal && aggregationResult && aggregationModel) {
|
|
447
531
|
// Label row
|
|
448
532
|
const labelValues = exportColumns.map(col => {
|
|
449
533
|
const fn = aggregationModel![col.field];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vertically scrolls the grid viewport so the center (unpinned) row at `centerIndex`
|
|
3
|
+
* is fully visible below the sticky header / top-pinned rows and above the
|
|
4
|
+
* bottom-pinned rows. `cumulativeHeights` is the unpinned-row layout.
|
|
5
|
+
*/
|
|
6
|
+
export function scrollRowIntoView(
|
|
7
|
+
viewport: HTMLElement,
|
|
8
|
+
centerIndex: number,
|
|
9
|
+
cumulativeHeights: number[],
|
|
10
|
+
pinnedBottomHeight: number,
|
|
11
|
+
): void {
|
|
12
|
+
if (centerIndex < 0 || centerIndex >= cumulativeHeights.length) return;
|
|
13
|
+
const virtualContainer = viewport.querySelector<HTMLElement>('.ogx__virtual-container');
|
|
14
|
+
if (!virtualContainer) return;
|
|
15
|
+
|
|
16
|
+
const { scrollTop, clientHeight } = viewport;
|
|
17
|
+
// Everything above the center rows is sticky, so it covers exactly this many
|
|
18
|
+
// pixels at the top of the viewport at any scroll position.
|
|
19
|
+
const stickyTop = virtualContainer.getBoundingClientRect().top - viewport.getBoundingClientRect().top + scrollTop;
|
|
20
|
+
const rowTop = stickyTop + (centerIndex === 0 ? 0 : cumulativeHeights[centerIndex - 1]);
|
|
21
|
+
const rowBottom = stickyTop + cumulativeHeights[centerIndex];
|
|
22
|
+
|
|
23
|
+
if (rowTop < scrollTop + stickyTop) {
|
|
24
|
+
viewport.scrollTop = Math.max(0, rowTop - stickyTop);
|
|
25
|
+
} else if (rowBottom > scrollTop + clientHeight - pinnedBottomHeight) {
|
|
26
|
+
viewport.scrollTop = rowBottom - clientHeight + pinnedBottomHeight;
|
|
27
|
+
}
|
|
28
|
+
}
|