@opencorestack/opengridx 1.3.0 → 2.0.1

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}
@@ -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,
@@ -29,6 +29,7 @@ export interface GridRowMeta {
29
29
  treeDepth?: number;
30
30
  groupingField?: string;
31
31
  groupingValue?: unknown;
32
+ groupLabel?: string;
32
33
  descendantCount?: number;
33
34
  isExpanded?: boolean;
34
35
  isGroupRow?: boolean;
@@ -81,16 +82,22 @@ export interface GridColDef<R extends GridRowModel = GridRowModel> {
81
82
  field: string;
82
83
  /** Text displayed in the column header. */
83
84
  headerName?: string;
84
- /** Tooltip or accessible description for the header. */
85
+ /** Tooltip shown on header hover — used as the column's accessibility label. */
85
86
  description?: string;
86
-
87
87
  /** If true, this column can be used as a grouping dimension. */
88
88
  groupable?: boolean;
89
89
 
90
+ /**
91
+ * Custom formatter for the group-header label when this column is used as a grouping field.
92
+ * Receives the field name and grouped value; return the string to display.
93
+ * Falls back to `"field: value"` when omitted.
94
+ */
95
+ groupingValueFormatter?: (params: { field: string; value: unknown }) => string;
96
+
90
97
  /** If true, this column can be summarized using aggregation functions. */
91
98
  aggregable?: boolean;
92
- /** List of allowed aggregation functions (e.g., ['sum', 'avg']). */
93
- availableAggregationFunctions?: string[];
99
+ /** Restricts which aggregation functions are offered for this column (e.g., ['sum', 'avg']). */
100
+ availableAggregationFunctions?: string[];
94
101
 
95
102
  /** Width in pixels or a percentage string. Defaults to automatic calculation. */
96
103
  width?: number | string;
@@ -608,6 +615,13 @@ export interface DataGridProps<R extends GridRowModel = GridRowModel> {
608
615
  sortModel?: GridSortItem[];
609
616
  /** Callback fired when the sorting model changes. */
610
617
  onSortModelChange?: (model: GridSortItem[]) => void;
618
+ /**
619
+ * When true, clicking any sortable column header appends/cycles that column in the
620
+ * sort model instead of replacing it — no Shift key required. Plain click still
621
+ * cycles asc → desc → removed for that column while all other active sort keys stay.
622
+ * Shift+click continues to work regardless of this prop.
623
+ */
624
+ multiSort?: boolean;
611
625
 
612
626
  /** Filtering mode: 'client' (default) or 'server'. */
613
627
  filterMode?: 'client' | 'server';
@@ -641,8 +655,6 @@ export interface DataGridProps<R extends GridRowModel = GridRowModel> {
641
655
 
642
656
  /** Defines which rows are pinned to the top or bottom. */
643
657
  pinnedRows?: GridRowPinning;
644
- /** Callback fired when row pinning changes. */
645
- onPinnedRowsChange?: (model: GridRowPinning) => void;
646
658
 
647
659
  /** If true, a column of checkboxes is added for row selection. */
648
660
  checkboxSelection?: boolean;
@@ -720,8 +732,6 @@ export interface DataGridProps<R extends GridRowModel = GridRowModel> {
720
732
 
721
733
  /** Controlled state for row grouping. */
722
734
  rowGroupingModel?: GridRowGroupingModel;
723
- /** Callback fired when row grouping changes. */
724
- onRowGroupingModelChange?: (model: GridRowGroupingModel) => void;
725
735
  /** Controlled state for data aggregation (e.g., { salary: 'sum' }). */
726
736
  aggregationModel?: GridAggregationModel;
727
737
  /** Callback fired when aggregation model changes. */