@opencorestack/opengridx 2.0.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +54 -3
  2. package/dist/index.d.ts +50 -2
  3. package/dist/opengridx.css +1 -1
  4. package/dist/opengridx.es.js +3805 -3692
  5. package/dist/opengridx.es.js.map +1 -1
  6. package/dist/opengridx.umd.js +16 -16
  7. package/dist/opengridx.umd.js.map +1 -1
  8. package/docs/API_REFERENCE.md +14 -4
  9. package/docs/components/column-visibility.md +40 -16
  10. package/docs/components/datagrid.md +1 -0
  11. package/docs/customization/slots-api.md +18 -6
  12. package/docs/features/export-guide.md +22 -3
  13. package/docs/features/sorting-pagination.md +2 -0
  14. package/docs/features/tree-data-grouping.md +8 -1
  15. package/docs/features/virtualization.md +66 -17
  16. package/docs/roadmap.md +13 -7
  17. package/lib/components/Cell/Cell.tsx +1 -0
  18. package/lib/components/DataGrid/DataGrid.css +7 -0
  19. package/lib/components/DataGrid/DataGrid.defects.test.tsx +103 -0
  20. package/lib/components/DataGrid/DataGrid.tsx +45 -11
  21. package/lib/components/DataGrid/DataGrid.virtualization.browser.test.tsx +183 -0
  22. package/lib/components/DataGrid/GridEmptyState.tsx +7 -1
  23. package/lib/components/DataGrid/GridListView.tsx +3 -0
  24. package/lib/components/DataGrid/GridPinnedRows.tsx +3 -0
  25. package/lib/components/DataGrid/GridVirtualRows.tsx +8 -1
  26. package/lib/components/ListView/ListViewRow.tsx +3 -0
  27. package/lib/components/Row/Row.tsx +10 -0
  28. package/lib/hooks/core/useGridColumns.tsx +8 -3
  29. package/lib/hooks/core/useGridDevWarnings.ts +42 -0
  30. package/lib/hooks/core/useGridKeyboardNavigation.ts +12 -14
  31. package/lib/hooks/core/useGridScrollSync.ts +62 -15
  32. package/lib/hooks/core/useGridVirtualization.ts +4 -1
  33. package/lib/hooks/core/useGridVisibleRows.ts +3 -2
  34. package/lib/hooks/features/useAggregation.ts +3 -38
  35. package/lib/hooks/useRowGrouping.test.ts +162 -1
  36. package/lib/hooks/useRowGrouping.ts +42 -59
  37. package/lib/hooks/useTreeData.test.ts +83 -0
  38. package/lib/hooks/useTreeData.ts +44 -43
  39. package/lib/index.ts +2 -0
  40. package/lib/types/index.ts +22 -2
  41. package/lib/utils/aggregation/aggregation.test.ts +16 -0
  42. package/lib/utils/aggregation/index.ts +62 -0
  43. package/lib/utils/export/exportToExcelAdvanced.test.ts +109 -0
  44. package/lib/utils/export/exportToExcelAdvanced.ts +89 -5
  45. package/lib/utils/scroll/index.ts +28 -0
  46. package/llms.txt +22 -13
  47. package/package.json +7 -3
@@ -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
+ });
@@ -1,9 +1,16 @@
1
- import { useMemo, useState, useCallback, useEffect } from 'react';
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
- const initialExpandedIds = useMemo(() => {
161
- if (!treeData) return new Set<GridRowId>();
162
-
163
- const initialExpanded = new Set<GridRowId>();
164
- treeNodes.forEach(node => {
165
- if (node.children && node.children.length > 0) {
166
- if (defaultGroupingExpansionDepth === -1 || node.depth < defaultGroupingExpansionDepth) {
167
- initialExpanded.add(node.id);
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 initialExpanded;
172
- }, [treeNodes, treeData, defaultGroupingExpansionDepth]);
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
- setExpandedGroupIds(prev => {
184
- const next = new Set(prev);
185
- const isExpanding = !next.has(id);
186
-
187
- if (next.has(id)) {
188
- next.delete(id);
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
- }, [treeNodes, onRowExpansionChange]);
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';
@@ -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 in v1.1 for
25
- * backward compatibility; they will be removed from the runtime in v2.0.
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 rowsToExport = rowScope === 'selected' ? sRows : allRows;
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
- rowsToExport.forEach((row, rowIdx) => {
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
+ }