@opencorestack/opengridx 1.2.3 → 2.0.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.
@@ -0,0 +1,127 @@
1
+ import React from 'react';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import { render, screen, fireEvent } from '@testing-library/react';
4
+ import { DataGrid } from './DataGrid';
5
+ import type { GridColDef } from '../../types';
6
+
7
+ const COLUMNS: GridColDef[] = [
8
+ { field: 'id', headerName: 'ID', width: 80 },
9
+ { field: 'name', headerName: 'Name', width: 150 },
10
+ { field: 'dept', headerName: 'Dept', width: 120 },
11
+ ];
12
+
13
+ const ROWS = [
14
+ { id: 1, name: 'Alice', dept: 'Eng' },
15
+ { id: 2, name: 'Bob', dept: 'HR' },
16
+ ];
17
+
18
+ // ── density ──────────────────────────────────────────────────────────────────
19
+
20
+ describe('DataGrid — density prop', () => {
21
+ it('renders without throwing for compact density', () => {
22
+ expect(() =>
23
+ render(<DataGrid rows={ROWS} columns={COLUMNS} density="compact" />)
24
+ ).not.toThrow();
25
+ });
26
+
27
+ it('renders without throwing for comfortable density', () => {
28
+ expect(() =>
29
+ render(<DataGrid rows={ROWS} columns={COLUMNS} density="comfortable" />)
30
+ ).not.toThrow();
31
+ });
32
+
33
+ it('applies compact row height via CSS variable', () => {
34
+ const { container } = render(
35
+ <DataGrid rows={ROWS} columns={COLUMNS} density="compact" />
36
+ );
37
+ const root = container.firstElementChild as HTMLElement;
38
+ expect(root.style.getPropertyValue('--ogx-row-height')).toBe('32px');
39
+ });
40
+
41
+ it('applies comfortable row height via CSS variable', () => {
42
+ const { container } = render(
43
+ <DataGrid rows={ROWS} columns={COLUMNS} density="comfortable" />
44
+ );
45
+ const root = container.firstElementChild as HTMLElement;
46
+ expect(root.style.getPropertyValue('--ogx-row-height')).toBe('72px');
47
+ });
48
+
49
+ it('uses rowHeight as default when density is standard', () => {
50
+ const { container } = render(
51
+ <DataGrid rows={ROWS} columns={COLUMNS} density="standard" rowHeight={52} />
52
+ );
53
+ const root = container.firstElementChild as HTMLElement;
54
+ expect(root.style.getPropertyValue('--ogx-row-height')).toBe('52px');
55
+ });
56
+ });
57
+
58
+ // ── disableRowSelectionOnClick ────────────────────────────────────────────────
59
+
60
+ describe('DataGrid — disableRowSelectionOnClick', () => {
61
+ it('clicking a row changes selection when prop is false (default)', () => {
62
+ const onChange = vi.fn();
63
+ render(
64
+ <DataGrid
65
+ rows={ROWS}
66
+ columns={COLUMNS}
67
+ onRowSelectionModelChange={onChange}
68
+ />
69
+ );
70
+ const rows = screen.getAllByRole('row');
71
+ // rows[0] is header, rows[1] is Alice
72
+ fireEvent.click(rows[1]);
73
+ expect(onChange).toHaveBeenCalled();
74
+ });
75
+
76
+ it('clicking a row does NOT change selection when disableRowSelectionOnClick=true', () => {
77
+ const onChange = vi.fn();
78
+ render(
79
+ <DataGrid
80
+ rows={ROWS}
81
+ columns={COLUMNS}
82
+ disableRowSelectionOnClick
83
+ onRowSelectionModelChange={onChange}
84
+ />
85
+ );
86
+ const rows = screen.getAllByRole('row');
87
+ fireEvent.click(rows[1]);
88
+ expect(onChange).not.toHaveBeenCalled();
89
+ });
90
+ });
91
+
92
+ // ── disableMultipleRowSelection ───────────────────────────────────────────────
93
+
94
+ describe('DataGrid — disableMultipleRowSelection', () => {
95
+ it('selecting a second row replaces the first when disableMultipleRowSelection=true', () => {
96
+ const selections: unknown[][] = [];
97
+ render(
98
+ <DataGrid
99
+ rows={ROWS}
100
+ columns={COLUMNS}
101
+ disableMultipleRowSelection
102
+ onRowSelectionModelChange={(model) => selections.push(model)}
103
+ />
104
+ );
105
+ const rows = screen.getAllByRole('row');
106
+ fireEvent.click(rows[1]); // select Alice (id=1)
107
+ fireEvent.click(rows[2]); // select Bob (id=2) — should replace
108
+ expect(selections.at(-1)).toEqual([2]);
109
+ expect(selections.at(-1)?.length).toBe(1);
110
+ });
111
+
112
+ it('clicking already-selected row deselects it when disableMultipleRowSelection=true', () => {
113
+ const selections: unknown[][] = [];
114
+ render(
115
+ <DataGrid
116
+ rows={ROWS}
117
+ columns={COLUMNS}
118
+ disableMultipleRowSelection
119
+ onRowSelectionModelChange={(model) => selections.push(model)}
120
+ />
121
+ );
122
+ const rows = screen.getAllByRole('row');
123
+ fireEvent.click(rows[1]); // select
124
+ fireEvent.click(rows[1]); // deselect
125
+ expect(selections.at(-1)).toEqual([]);
126
+ });
127
+ });
@@ -0,0 +1,135 @@
1
+ import React from 'react';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import { render, screen, fireEvent } from '@testing-library/react';
4
+ import { Header } from './Header';
5
+ import type { GridColDef } from '../../types';
6
+
7
+ const COLUMNS: GridColDef[] = [
8
+ { field: 'name', headerName: 'Name', width: 150 },
9
+ { field: 'age', headerName: 'Age', width: 100 },
10
+ { field: 'dept', headerName: 'Dept', width: 120 },
11
+ ];
12
+
13
+ describe('Header — multi-sort shift-click', () => {
14
+ it('plain click calls onSort with single field (replaces model)', () => {
15
+ const onSort = vi.fn();
16
+ const onSortAdd = vi.fn();
17
+ render(
18
+ <Header
19
+ columns={COLUMNS}
20
+ sortModel={[]}
21
+ onSort={onSort}
22
+ onSortAdd={onSortAdd}
23
+ />
24
+ );
25
+ fireEvent.click(screen.getByText('Name'));
26
+ expect(onSort).toHaveBeenCalledWith('name', 'asc');
27
+ expect(onSortAdd).not.toHaveBeenCalled();
28
+ });
29
+
30
+ it('shift-click calls onSortAdd (accumulates)', () => {
31
+ const onSort = vi.fn();
32
+ const onSortAdd = vi.fn();
33
+ render(
34
+ <Header
35
+ columns={COLUMNS}
36
+ sortModel={[{ field: 'name', sort: 'asc' }]}
37
+ onSort={onSort}
38
+ onSortAdd={onSortAdd}
39
+ />
40
+ );
41
+ fireEvent.click(screen.getByText('Age'), { shiftKey: true });
42
+ expect(onSortAdd).toHaveBeenCalledWith('age', 'asc');
43
+ expect(onSort).not.toHaveBeenCalled();
44
+ });
45
+
46
+ it('shift-click on already-sorted field cycles direction without replacing others', () => {
47
+ const onSort = vi.fn();
48
+ const onSortAdd = vi.fn();
49
+ render(
50
+ <Header
51
+ columns={COLUMNS}
52
+ sortModel={[
53
+ { field: 'name', sort: 'asc' },
54
+ { field: 'age', sort: 'asc' },
55
+ ]}
56
+ onSort={onSort}
57
+ onSortAdd={onSortAdd}
58
+ />
59
+ );
60
+ fireEvent.click(screen.getByText('Age'), { shiftKey: true });
61
+ expect(onSortAdd).toHaveBeenCalledWith('age', 'desc');
62
+ expect(onSort).not.toHaveBeenCalled();
63
+ });
64
+
65
+ it('shift-click on desc-sorted field removes it from model', () => {
66
+ const onSort = vi.fn();
67
+ const onSortAdd = vi.fn();
68
+ render(
69
+ <Header
70
+ columns={COLUMNS}
71
+ sortModel={[
72
+ { field: 'name', sort: 'asc' },
73
+ { field: 'age', sort: 'desc' },
74
+ ]}
75
+ onSort={onSort}
76
+ onSortAdd={onSortAdd}
77
+ />
78
+ );
79
+ fireEvent.click(screen.getByText('Age'), { shiftKey: true });
80
+ expect(onSortAdd).toHaveBeenCalledWith('age', null);
81
+ expect(onSort).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it('plain click on one column while another is sorted resets to single-key', () => {
85
+ const onSort = vi.fn();
86
+ const onSortAdd = vi.fn();
87
+ render(
88
+ <Header
89
+ columns={COLUMNS}
90
+ sortModel={[{ field: 'name', sort: 'asc' }]}
91
+ onSort={onSort}
92
+ onSortAdd={onSortAdd}
93
+ />
94
+ );
95
+ fireEvent.click(screen.getByText('Age'));
96
+ expect(onSort).toHaveBeenCalledWith('age', 'asc');
97
+ expect(onSortAdd).not.toHaveBeenCalled();
98
+ });
99
+
100
+ it('does not call onSort/onSortAdd for sortable:false columns', () => {
101
+ const onSort = vi.fn();
102
+ const onSortAdd = vi.fn();
103
+ const cols: GridColDef[] = [
104
+ { field: 'id', headerName: 'ID', sortable: false },
105
+ ];
106
+ render(<Header columns={cols} sortModel={[]} onSort={onSort} onSortAdd={onSortAdd} />);
107
+ fireEvent.click(screen.getByText('ID'));
108
+ expect(onSort).not.toHaveBeenCalled();
109
+ expect(onSortAdd).not.toHaveBeenCalled();
110
+ });
111
+ });
112
+
113
+ describe('Header — description tooltip', () => {
114
+ it('renders title attribute from column description', () => {
115
+ const cols: GridColDef[] = [
116
+ { field: 'salary', headerName: 'Salary', description: 'Annual salary in USD' },
117
+ ];
118
+ const { container } = render(
119
+ <Header columns={cols} sortModel={[]} />
120
+ );
121
+ const headerCell = container.querySelector('[data-field="salary"]');
122
+ expect(headerCell?.getAttribute('title')).toBe('Annual salary in USD');
123
+ });
124
+
125
+ it('has no title attribute when description is absent', () => {
126
+ const cols: GridColDef[] = [
127
+ { field: 'name', headerName: 'Name' },
128
+ ];
129
+ const { container } = render(
130
+ <Header columns={cols} sortModel={[]} />
131
+ );
132
+ const headerCell = container.querySelector('[data-field="name"]');
133
+ expect(headerCell?.getAttribute('title')).toBeNull();
134
+ });
135
+ });
@@ -134,6 +134,8 @@ export interface HeaderProps<R extends GridRowModel = GridRowModel> {
134
134
  onDrop?: (targetField: string) => (event: React.DragEvent) => void;
135
135
  focusedCell?: { id: string | number; field: string } | null;
136
136
  onHeaderClick?: (field: string) => void;
137
+ onSortAdd?: (field: string, direction: GridSortDirection) => void;
138
+ multiSort?: boolean;
137
139
  onHideColumn?: (field: string) => void;
138
140
  onPinColumn?: (field: string, side: 'left' | 'right' | null) => void;
139
141
  onManageColumns?: () => void;
@@ -166,6 +168,8 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
166
168
  onDrop,
167
169
  focusedCell,
168
170
  onHeaderClick,
171
+ onSortAdd,
172
+ multiSort = false,
169
173
  onHideColumn,
170
174
  onPinColumn,
171
175
  onManageColumns,
@@ -193,8 +197,8 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
193
197
  }
194
198
  }, [focusedCell]);
195
199
 
196
- const handleColumnClick = (colDef: GridColDef<R>) => () => {
197
- if (colDef.sortable === false || !onSort) return;
200
+ const handleColumnClick = (colDef: GridColDef<R>) => (e: React.MouseEvent) => {
201
+ if (colDef.sortable === false) return;
198
202
 
199
203
  const currentSort = sortModel.find(item => item.field === colDef.field);
200
204
  let newDirection: GridSortDirection = 'asc';
@@ -207,15 +211,25 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
207
211
  }
208
212
  }
209
213
 
210
- onSort(colDef.field, newDirection);
214
+ if ((e.shiftKey || multiSort) && onSortAdd) {
215
+ onSortAdd(colDef.field, newDirection);
216
+ } else if (onSort) {
217
+ onSort(colDef.field, newDirection);
218
+ }
211
219
  };
212
220
 
213
221
  const getSortIcon = (field: string) => {
214
222
  const sortItem = sortModel.find(item => item.field === field);
215
223
  if (!sortItem) return null;
216
224
 
225
+ const sortIndex = sortModel.length > 1 ? sortModel.indexOf(sortItem) + 1 : null;
226
+
217
227
  return (
218
- <span className={`ogx__sort-icon ogx__sort-icon--${sortItem.sort}`} aria-hidden="true" />
228
+ <span className={`ogx__sort-icon ogx__sort-icon--${sortItem.sort}`} aria-hidden="true">
229
+ {sortIndex !== null && (
230
+ <span className="ogx__sort-badge">{sortIndex}</span>
231
+ )}
232
+ </span>
219
233
  );
220
234
  };
221
235
 
@@ -420,7 +434,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
420
434
  }
421
435
  onHeaderClick?.(colDef.field);
422
436
  if (isSortable) {
423
- handleColumnClick(colDef)();
437
+ handleColumnClick(colDef)(e);
424
438
  }
425
439
  };
426
440
 
@@ -433,6 +447,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
433
447
  key={colDef.field}
434
448
  className={classNames}
435
449
  style={style}
450
+ title={colDef.description}
436
451
  onClick={handleHeaderClick}
437
452
  role="columnheader"
438
453
  draggable={!!onDragStart}
@@ -300,6 +300,7 @@ export function useDataGrid<R extends GridRowModel = GridRowModel>(params: UseDa
300
300
  getAllRows: () => Array.from(stateRef.current.rows.idRowsLookup.values()),
301
301
  getVisibleRows: () => Array.from(stateRef.current.rows.idRowsLookup.values()),
302
302
  getAllFilteredRows: () => Array.from(stateRef.current.rows.idRowsLookup.values()),
303
+ getGroupedExportRows: () => null,
303
304
  getAggregationResult: () => null,
304
305
  getAggregationModel: () => null,
305
306
  getColumn: (field: string) => stateRef.current.columns.lookup.get(field) || null,
@@ -95,10 +95,10 @@ export function useGridColumns<R extends GridRowModel>(
95
95
  }
96
96
 
97
97
  if (isRowGrouping && hasChildren && groupingField) {
98
+ const groupLabel = meta?.groupLabel ?? `${groupingField}: ${String(groupingValue)}`;
98
99
  content = (
99
100
  <div className="ogx__group-cell-content">
100
- {col.field === groupingField ? '' : `${groupingField}: `}
101
- {String(groupingValue)}
101
+ {groupLabel}
102
102
  {descendantCount !== undefined ? ` (${descendantCount})` : ''}
103
103
  </div>
104
104
  );
@@ -81,7 +81,7 @@ export function useGridControlledState(params: UseGridControlledStateParams): Us
81
81
  onPinnedColumnsChange,
82
82
  pivotModel: propPivotModel,
83
83
  onPivotModelChange,
84
- paginationModel: propPaginationModel = { page: 0, pageSize: 100 },
84
+ paginationModel: propPaginationModel,
85
85
  onPaginationModelChange,
86
86
  rowSelectionModel: propRowSelectionModel,
87
87
  onRowSelectionModelChange,
@@ -144,9 +144,9 @@ export function useGridControlledState(params: UseGridControlledStateParams): Us
144
144
  }, [onPivotModelChange]);
145
145
 
146
146
  // ── Pagination ───────────────────────────────────────────────────────────
147
- const isPaginationControlled = propPaginationModel !== undefined && params.paginationModel !== undefined;
147
+ const isPaginationControlled = propPaginationModel !== undefined;
148
148
  const [internalPaginationModel, setInternalPaginationModel] = useState<GridPaginationModel>(
149
- () => initialState?.pagination?.paginationModel ?? propPaginationModel
149
+ () => initialState?.pagination?.paginationModel ?? propPaginationModel ?? { page: 0, pageSize: 100 }
150
150
  );
151
151
  const effectivePaginationModel = isPaginationControlled ? propPaginationModel : internalPaginationModel;
152
152
 
@@ -132,4 +132,44 @@ describe('useAggregation — client-side', () => {
132
132
  );
133
133
  expect(result.current.aggregationResult).toEqual(serverResults);
134
134
  });
135
+
136
+ it('skips aggregation when function not in availableAggregationFunctions', () => {
137
+ const columns = [{ field: 'salary', availableAggregationFunctions: ['avg', 'count'] }];
138
+ const { result } = renderHook(() =>
139
+ useAggregation({
140
+ rows: ROWS,
141
+ columns,
142
+ aggregationModel: { salary: 'sum' },
143
+ isServerSide: false,
144
+ })
145
+ );
146
+ // 'sum' is not in the allowed list, so the result should be absent
147
+ expect(result.current.aggregationResult.salary).toBeUndefined();
148
+ });
149
+
150
+ it('computes aggregation when function is in availableAggregationFunctions', () => {
151
+ const columns = [{ field: 'salary', availableAggregationFunctions: ['sum', 'avg'] }];
152
+ const { result } = renderHook(() =>
153
+ useAggregation({
154
+ rows: ROWS,
155
+ columns,
156
+ aggregationModel: { salary: 'sum' },
157
+ isServerSide: false,
158
+ })
159
+ );
160
+ expect(result.current.aggregationResult.salary).toBe(260000);
161
+ });
162
+
163
+ it('does not restrict aggregation when availableAggregationFunctions is absent', () => {
164
+ const columns = [{ field: 'salary' }];
165
+ const { result } = renderHook(() =>
166
+ useAggregation({
167
+ rows: ROWS,
168
+ columns,
169
+ aggregationModel: { salary: 'sum' },
170
+ isServerSide: false,
171
+ })
172
+ );
173
+ expect(result.current.aggregationResult.salary).toBe(260000);
174
+ });
135
175
  });
@@ -2,6 +2,7 @@
2
2
  import { useMemo, useEffect, useRef, useCallback, useState } from 'react';
3
3
  import type {
4
4
  GridRowModel,
5
+ GridColDef,
5
6
  GridAggregationModel,
6
7
  GridAggregationResult,
7
8
  GridFilterModel,
@@ -46,6 +47,7 @@ export function formatAggregationValue(value: unknown, fnName: string): string {
46
47
 
47
48
  export interface UseAggregationParams<R extends GridRowModel> {
48
49
  rows: R[];
50
+ columns?: GridColDef[];
49
51
  aggregationModel: GridAggregationModel;
50
52
  isServerSide: boolean;
51
53
  dataSource?: GridDataSource<R>;
@@ -65,6 +67,7 @@ export function useAggregation<R extends GridRowModel>(
65
67
  ): UseAggregationReturn {
66
68
  const {
67
69
  rows,
70
+ columns,
68
71
  aggregationModel,
69
72
  isServerSide,
70
73
  dataSource,
@@ -73,11 +76,21 @@ export function useAggregation<R extends GridRowModel>(
73
76
  serverAggregationResults,
74
77
  } = params;
75
78
 
79
+ const columnsLookup = useMemo(() => {
80
+ const map = new Map<string, GridColDef>();
81
+ (columns ?? []).forEach(col => map.set(col.field, col));
82
+ return map;
83
+ }, [columns]);
84
+
76
85
  const clientResult = useMemo<GridAggregationResult>(() => {
77
86
  if (isServerSide || Object.keys(aggregationModel).length === 0) return {};
78
87
 
79
88
  const result: GridAggregationResult = {};
80
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
+ }
81
94
  const fn = AGGREGATION_FUNCTIONS[fnName as BuiltInAggFn];
82
95
  if (!fn) {
83
96
  console.warn(`[useAggregation] Unknown aggregation function: "${fnName}"`);
@@ -87,7 +100,7 @@ export function useAggregation<R extends GridRowModel>(
87
100
  result[field] = fn(values);
88
101
  }
89
102
  return result;
90
- }, [rows, aggregationModel, isServerSide]);
103
+ }, [rows, columnsLookup, aggregationModel, isServerSide]);
91
104
 
92
105
  const [serverResult, setServerResult] = useState<GridAggregationResult>({});
93
106
  const [isLoading, setIsLoading] = useState(false);
@@ -81,4 +81,57 @@ describe('useRowGrouping — rowMetaMap', () => {
81
81
  }
82
82
  });
83
83
  });
84
+
85
+ it('populates groupLabel from groupingValueFormatter when provided', () => {
86
+ const formatter = ({ field, value }: { field: string; value: unknown }) =>
87
+ `${String(field).toUpperCase()}: ${String(value)}`;
88
+ const columns = [{ field: 'dept', groupingValueFormatter: formatter }];
89
+ const { result } = renderHook(() =>
90
+ useRowGrouping({ ...BASE_PARAMS, columns })
91
+ );
92
+ const map = result.current.rowMetaMap;
93
+ let foundFormatted = false;
94
+ map.forEach((meta) => {
95
+ if (meta.isGroupRow) {
96
+ expect(meta.groupLabel).toMatch(/^DEPT:/);
97
+ foundFormatted = true;
98
+ }
99
+ });
100
+ expect(foundFormatted).toBe(true);
101
+ });
102
+
103
+ it('uses default field: value label when groupingValueFormatter is absent', () => {
104
+ const { result } = renderHook(() => useRowGrouping(BASE_PARAMS));
105
+ const map = result.current.rowMetaMap;
106
+ map.forEach((meta) => {
107
+ if (meta.isGroupRow) {
108
+ expect(meta.groupLabel).toMatch(/^dept:/);
109
+ }
110
+ });
111
+ });
112
+ });
113
+
114
+ describe('useRowGrouping — groupable: false', () => {
115
+ it('skips a column with groupable:false — no group rows created for that field', () => {
116
+ const columns = [{ field: 'dept', groupable: false as const }];
117
+ const { result } = renderHook(() =>
118
+ useRowGrouping({ ...BASE_PARAMS, columns })
119
+ );
120
+ const map = result.current.rowMetaMap;
121
+ // With groupable:false on 'dept', no group rows should exist
122
+ let groupCount = 0;
123
+ map.forEach((meta) => { if (meta.isGroupRow) groupCount++; });
124
+ expect(groupCount).toBe(0);
125
+ });
126
+
127
+ it('still groups when groupable is true (explicit)', () => {
128
+ const columns = [{ field: 'dept', groupable: true as const }];
129
+ const { result } = renderHook(() =>
130
+ useRowGrouping({ ...BASE_PARAMS, columns })
131
+ );
132
+ const map = result.current.rowMetaMap;
133
+ let groupCount = 0;
134
+ map.forEach((meta) => { if (meta.isGroupRow) groupCount++; });
135
+ expect(groupCount).toBe(2);
136
+ });
84
137
  });
@@ -7,7 +7,8 @@ import type {
7
7
  GridTreeNode,
8
8
  GridFilterModel,
9
9
  GridSortItem,
10
- GridRowMeta
10
+ GridRowMeta,
11
+ GridColDef
11
12
  } from '../types';
12
13
  import { isRowMatchingFilter } from '../utils/filtering';
13
14
  import { compareValues } from '../utils/sorting';
@@ -15,6 +16,7 @@ import { compareValues } from '../utils/sorting';
15
16
  export interface UseRowGroupingParams<R extends GridRowModel> {
16
17
  rows: R[];
17
18
  getRowId: (row: R) => GridRowId;
19
+ columns?: GridColDef<R>[];
18
20
  rowGroupingModel?: GridRowGroupingModel;
19
21
  aggregationModel?: GridAggregationModel;
20
22
  defaultGroupingExpansionDepth?: number;
@@ -38,6 +40,7 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
38
40
  const {
39
41
  rows,
40
42
  getRowId,
43
+ columns,
41
44
  rowGroupingModel = EMPTY_ROW_GROUPING_MODEL,
42
45
  aggregationModel = EMPTY_AGGREGATION_MODEL,
43
46
  defaultGroupingExpansionDepth = 0,
@@ -46,6 +49,12 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
46
49
  getAggregationPosition = defaultGetAggregationPosition
47
50
  } = params;
48
51
 
52
+ const columnsLookup = useMemo(() => {
53
+ const map = new Map<string, GridColDef<R>>();
54
+ (columns ?? []).forEach(col => map.set(col.field, col));
55
+ return map;
56
+ }, [columns]);
57
+
49
58
  const [expandedGroupIds, setExpandedGroupIds] = useState<Set<GridRowId>>(new Set());
50
59
 
51
60
  const { treeNodes, rootIds, groupingRows } = useMemo(() => {
@@ -82,6 +91,13 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
82
91
  }
83
92
 
84
93
  const field = rowGroupingModel[depth];
94
+
95
+ // Skip fields where the column has explicitly opted out of grouping.
96
+ const colDef = columnsLookup.get(field);
97
+ if (colDef && colDef.groupable === false) {
98
+ return groupRows(currentRows, depth + 1, parentId);
99
+ }
100
+
85
101
  const groups = new Map<string, R[]>();
86
102
 
87
103
  // Group current rows by value of the current field
@@ -143,7 +159,9 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
143
159
  aggregatedValues,
144
160
  isExpanded: false,
145
161
  children: [],
146
- label: `${field}: ${String(rawValue)}`
162
+ label: colDef?.groupingValueFormatter
163
+ ? colDef.groupingValueFormatter({ field, value: rawValue })
164
+ : `${field}: ${String(rawValue)}`
147
165
  };
148
166
 
149
167
  treeNode.aggregationPosition = getAggregationPosition(treeNode);
@@ -165,7 +183,7 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
165
183
 
166
184
  return { treeNodes, rootIds, groupingRows };
167
185
 
168
- }, [rows, rowGroupingModel, aggregationModel, getRowId, getAggregationPosition]);
186
+ }, [rows, rowGroupingModel, aggregationModel, getRowId, getAggregationPosition, columnsLookup]);
169
187
 
170
188
  useEffect(() => {
171
189
  if (defaultGroupingExpansionDepth === -1) {
@@ -307,6 +325,7 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
307
325
  treeDepth: node.depth,
308
326
  groupingField: node.groupingField,
309
327
  groupingValue: node.groupingValue,
328
+ groupLabel: node.label,
310
329
  descendantCount: node.descendantCount,
311
330
  isGroupRow: isGroup,
312
331
  isExpanded: isGroup ? expandedGroupIds.has(id) : undefined,