diff --git a/README.md b/README.md
index a2d3ca14bd..c0f8a19c21 100644
--- a/README.md
+++ b/README.md
@@ -790,7 +790,7 @@ Optional attribute to help with Cypress (or similar) selectors.
**How it works:**
1. The `groupBy` prop specifies which columns should be used for grouping
-2. The `rowGrouper` function groups rows by the specified column keys
+2. Rows are grouped by their value for each of those columns, or by `getRowGroupKey` when provided
3. Group rows are rendered with expand/collapse toggles
4. Child rows are nested under their parent groups
5. Groups can be expanded/collapsed by clicking the toggle or using keyboard navigation (← , → )
@@ -840,33 +840,25 @@ const columns: readonly Column[] = [
{ key: 'name', name: 'Name' }
];
+const groupBy = ['country', 'city'];
+
function MyGrid() {
return (
);
}
```
-###### `rowGrouper: (rows: readonly R[], columnKey: string) => Record`
-
-**Required.** A function that groups rows by the specified column key. Returns an object where keys are the group values and values are arrays of rows belonging to that group.
-
-```tsx
-function rowGrouper(rows: readonly Row[], columnKey: string): Record {
- return Object.groupBy(rows, (row) => row[columnKey]);
-}
-```
-
-:warning: **Performance:** Define this function outside your component or memoize it with `useCallback` to prevent unnecessary re-renders.
+:warning: **Performance:** Define this array outside your component or memoize it with `useMemo`, otherwise all the rows are grouped again on every render.
###### `expandedGroupIds: ReadonlySet`
-**Required.** A set of group IDs that are currently expanded. Group IDs are generated by `groupIdGetter`.
+**Required.** A set of group IDs that are currently expanded. Group IDs are generated by `getGroupId`.
```tsx
import { useState } from 'react';
@@ -889,7 +881,24 @@ function MyGrid() {
**Required.** Callback triggered when groups are expanded or collapsed.
-###### `groupIdGetter?: Maybe<(groupKey: string, parentId?: string) => string>`
+###### `getRowGroupKey?: Maybe<(row: R, columnKey: string) => string>`
+
+A function that returns the key of the group a row belongs to, for the specified column key. Rows sharing the same group key are grouped together, and groups are ordered by the first row that belongs to them. If not provided, a default implementation is used that stringifies the row's value for that column, i.e., `String(row[columnKey])`.
+
+Provide your own to group on something other than the raw cell value:
+
+```tsx
+function getRowGroupKey(row: Row, columnKey: string): string {
+ if (columnKey === 'date') {
+ return row.date.getFullYear().toString();
+ }
+ return String(row[columnKey]);
+}
+```
+
+:warning: **Performance:** Define this function outside your component or memoize it with `useCallback`, otherwise all the rows are grouped again on every render.
+
+###### `getGroupId?: Maybe<(groupKey: string, parentId?: string) => string>`
Function to generate unique IDs for group rows. If not provided, a default implementation is used that concatenates parent and group keys with `__`.
diff --git a/src/TreeDataGrid.tsx b/src/TreeDataGrid.tsx
index 05f50d2945..78c0ee6f41 100644
--- a/src/TreeDataGrid.tsx
+++ b/src/TreeDataGrid.tsx
@@ -32,23 +32,42 @@ export interface TreeDataGridProps extends
columns: readonly Column, NoInfer>[];
rowHeight?: Maybe>) => number)>;
groupBy: readonly string[];
- rowGrouper: (
- rows: readonly NoInfer[],
- columnKey: string
- ) => Record[]>;
+ getRowGroupKey?: Maybe<(row: NoInfer, columnKey: string) => string>;
expandedGroupIds: ReadonlySet;
onExpandedGroupIdsChange: (expandedGroupIds: Set) => void;
- groupIdGetter?: Maybe<(groupKey: string, parentId?: string) => string>;
+ getGroupId?: Maybe<(groupKey: string, parentId?: string) => string>;
}
-interface GroupBy {
+interface PartialGroupRow {
+ readonly groups: GroupsMap;
+ // all the descendant rows of the group, not just the direct children
readonly childRows: readonly TRow[];
- readonly childGroups: readonly TRow[] | ReadonlyGroupByMap;
readonly startRowIndex: number;
}
-type GroupByMap = Map>;
-type ReadonlyGroupByMap = ReadonlyMap>;
+type GroupsMap = ReadonlyMap>;
+
+// only used while the rows are being grouped
+interface MutablePartialGroupRow {
+ readonly groups: MutableGroupsMap;
+ readonly childRows: TRow[];
+ startRowIndex: number;
+}
+
+type MutableGroupsMap = Map>;
+
+interface GroupedRows {
+ readonly groups: GroupsMap | undefined;
+ readonly rowsCount: number;
+}
+
+// Group rows are only ever created by `TreeDataGrid`, so every instance can share a single
+// set, which keeps `isGroupRow` stable and out of the dependency arrays below
+const allGroupRows = new WeakSet();
+
+function isGroupRow(row: TRow | GroupRow): row is GroupRow {
+ return typeof row === 'object' && row !== null && allGroupRows.has(row);
+}
export function TreeDataGrid({
columns: rawColumns,
@@ -63,10 +82,10 @@ export function TreeDataGrid({
onSelectedRowsChange: rawOnSelectedRowsChange,
renderers,
groupBy: rawGroupBy,
- rowGrouper,
+ getRowGroupKey: rawGetRowGroupKey,
expandedGroupIds,
onExpandedGroupIdsChange,
- groupIdGetter: rawGroupIdGetter,
+ getGroupId: rawGetGroupId,
...props
}: TreeDataGridProps) {
const contextRenderers = useRenderers();
@@ -74,7 +93,8 @@ export function TreeDataGrid({
const headerAndTopSummaryRowsCount = 1 + (props.topSummaryRows?.length ?? 0);
const { leftKey, rightKey } = getLeftRightKey(props.direction);
const toggleGroupLatest = useLatestFunc(toggleGroup);
- const groupIdGetter = rawGroupIdGetter ?? defaultGroupIdGetter;
+ const getRowGroupKey = rawGetRowGroupKey ?? defaultGetRowGroupKey;
+ const getGroupId = rawGetGroupId ?? defaultGetGroupId;
const { columns, groupBy } = useMemo(() => {
const columns = rawColumns.toSorted(({ key: aKey }, { key: bKey }) => {
@@ -112,59 +132,68 @@ export function TreeDataGrid({
return { columns, groupBy };
}, [rawColumns, rawGroupBy]);
- const [groupedRows, rowsCount] = useMemo(() => {
- if (groupBy.length === 0) return [undefined, rawRows.length];
+ // Grouping only depends on the rows, it is not affected by the expanded state
+ const { groups, rowsCount } = useMemo((): GroupedRows => {
+ if (groupBy.length === 0) return { groups: undefined, rowsCount: rawRows.length };
+
+ // Group the rows in a single pass, each row is added to every group it belongs to
+ const groups: MutableGroupsMap = new Map();
+
+ for (const row of rawRows) {
+ let parentGroups = groups;
+
+ for (const columnKey of groupBy) {
+ const groupKey = getRowGroupKey(row, columnKey);
+ const group = parentGroups.getOrInsertComputed(groupKey, () => ({
+ groups: new Map(),
+ childRows: [],
+ startRowIndex: 0
+ }));
+ group.childRows.push(row);
+ parentGroups = group.groups;
+ }
+ }
- function groupRows(
- rows: readonly R[],
- groupByIndex: number,
- startRowIndex: number
- ): [ReadonlyGroupByMap, number] {
+ // Number the groups as if they were all expanded, and return the total number of rows.
+ // Neither depends on the expanded state, so collapsed groups never have to be traversed
+ // when the rows are flattened.
+ function setStartRowIndexes(groups: MutableGroupsMap, startRowIndex: number): number {
let groupRowsCount = 0;
- const groupByKey = groupBy[groupByIndex];
- const isLastGroupBy = groupByIndex === groupBy.length - 1;
- const groups: GroupByMap = new Map();
-
- for (const [key, childRows] of Object.entries(rowGrouper(rows, groupByKey))) {
- // Recursively group each parent group
- const [childGroups, childRowsCount] = isLastGroupBy
- ? [childRows, childRows.length]
- : groupRows(childRows, groupByIndex + 1, startRowIndex + groupRowsCount + 1); // 1 for parent row
- groups.set(key, { childRows, childGroups, startRowIndex: startRowIndex + groupRowsCount });
- groupRowsCount += childRowsCount + 1; // 1 for parent row
+
+ for (const group of groups.values()) {
+ group.startRowIndex = startRowIndex + groupRowsCount;
+ groupRowsCount++; // 1 for the group row
+ groupRowsCount +=
+ group.groups.size === 0
+ ? // the group is on the last level, it only contains rows
+ group.childRows.length
+ : setStartRowIndexes(group.groups, startRowIndex + groupRowsCount);
}
- return [groups, groupRowsCount];
+ return groupRowsCount;
}
- return groupRows(rawRows, 0, 0);
- }, [groupBy, rowGrouper, rawRows]);
+ const rowsCount = setStartRowIndexes(groups, 0);
- const [rows, isGroupRow] = useMemo((): [
- readonly (R | GroupRow)[],
- (row: R | GroupRow) => row is GroupRow
- ] => {
- const allGroupRows = new Set();
- if (!groupedRows) return [rawRows, isGroupRow];
+ return { groups, rowsCount };
+ }, [getRowGroupKey, groupBy, rawRows]);
- const flattenedRows: (R | GroupRow)[] = [];
+ const rows = useMemo((): readonly (R | GroupRow)[] => {
+ if (groups === undefined) {
+ return rawRows;
+ }
- const expandGroup = (
- rows: ReadonlyGroupByMap | readonly R[],
- parentId: string | undefined,
- level: number
- ): void => {
- if (isReadonlyArray(rows)) {
- flattenedRows.push(...rows);
- return;
- }
+ const flattenedRows: (R | GroupRow)[] = [];
+ // Only the expanded groups are traversed
+ function expandGroups(groups: GroupsMap, parentId: string | undefined, level: number): void {
let posInSet = 0;
- for (const [groupKey, row] of rows) {
- const id = groupIdGetter(groupKey, parentId);
+ for (const [groupKey, group] of groups) {
+ // destructuring in the loop head would stop React Compiler from compiling the component
+ const { groups: childGroups, childRows, startRowIndex } = group;
+ const id = getGroupId(groupKey, parentId);
const isExpanded = expandedGroupIds.has(id);
- const { childRows, childGroups, startRowIndex } = row;
const groupRow: GroupRow = {
id,
@@ -174,25 +203,30 @@ export function TreeDataGrid({
childRows,
level,
posInSet: posInSet++,
- startRowIndex,
- setSize: rows.size
+ setSize: groups.size,
+ startRowIndex
};
flattenedRows.push(groupRow);
allGroupRows.add(groupRow);
if (isExpanded) {
- expandGroup(childGroups, id, level + 1);
+ if (childGroups.size === 0) {
+ // the group is on the last level, it only contains rows.
+ // Push them one by one: spreading them would throw in groups with too many rows
+ for (const row of childRows) {
+ flattenedRows.push(row);
+ }
+ } else {
+ expandGroups(childGroups, id, level + 1);
+ }
}
}
- };
+ }
- expandGroup(groupedRows, undefined, 0);
- return [flattenedRows, isGroupRow];
+ expandGroups(groups, undefined, 0);
- function isGroupRow(row: R | GroupRow): row is GroupRow {
- return allGroupRows.has(row);
- }
- }, [expandedGroupIds, groupedRows, rawRows, groupIdGetter]);
+ return flattenedRows;
+ }, [expandedGroupIds, getGroupId, groups, rawRows]);
const rowHeight = useMemo(() => {
if (typeof rawRowHeight === 'function') {
@@ -205,7 +239,7 @@ export function TreeDataGrid({
}
return rawRowHeight;
- }, [isGroupRow, rawRowHeight]);
+ }, [rawRowHeight]);
const getParentRowAndIndex = useCallback(
(row: R | GroupRow) => {
@@ -219,7 +253,7 @@ export function TreeDataGrid({
return undefined;
},
- [isGroupRow, rows]
+ [rows]
);
const rowKeyGetter = useCallback(
@@ -241,7 +275,7 @@ export function TreeDataGrid({
return rows.indexOf(row);
},
- [getParentRowAndIndex, isGroupRow, rawRowKeyGetter, rows]
+ [getParentRowAndIndex, rawRowKeyGetter, rows]
);
const selectedRows = useMemo((): Maybe> => {
@@ -263,7 +297,7 @@ export function TreeDataGrid({
}
return selectedRows;
- }, [isGroupRow, rawRowKeyGetter, rawSelectedRows, rows]);
+ }, [rawRowKeyGetter, rawSelectedRows, rows]);
function onSelectedRowsChange(newSelectedRows: Set) {
if (!rawOnSelectedRowsChange) return;
@@ -453,10 +487,10 @@ export function TreeDataGrid({
);
}
-function defaultGroupIdGetter(groupKey: string, parentId: string | undefined) {
- return parentId === undefined ? groupKey : `${parentId}__${groupKey}`;
+function defaultGetRowGroupKey(row: unknown, columnKey: string) {
+ return String((row as Record)[columnKey]);
}
-function isReadonlyArray(arr: unknown): arr is readonly unknown[] {
- return Array.isArray(arr);
+function defaultGetGroupId(groupKey: string, parentId: string | undefined) {
+ return parentId === undefined ? groupKey : `${parentId}__${groupKey}`;
}
diff --git a/test/browser/TreeDataGrid.test.tsx b/test/browser/TreeDataGrid.test.tsx
index cd1ae2375d..c736920823 100644
--- a/test/browser/TreeDataGrid.test.tsx
+++ b/test/browser/TreeDataGrid.test.tsx
@@ -90,10 +90,12 @@ function rowKeyGetter(row: Row) {
}
function TestGrid({
groupBy,
- groupIdGetter
+ getGroupId,
+ getRowGroupKey
}: {
groupBy: string[];
- groupIdGetter: ((groupKey: string, parentId?: string) => string) | undefined;
+ getGroupId: ((groupKey: string, parentId?: string) => string) | undefined;
+ getRowGroupKey: ((row: Row, columnKey: string) => string) | undefined;
}) {
const [rows, setRows] = useState(initialRows);
const [selectedRows, setSelectedRows] = useState((): ReadonlySet => new Set());
@@ -107,7 +109,7 @@ function TestGrid({
bottomSummaryRows={bottomSummaryRows}
rowKeyGetter={rowKeyGetter}
groupBy={groupBy}
- rowGrouper={rowGrouper}
+ getRowGroupKey={getRowGroupKey}
selectedRows={selectedRows}
onSelectedRowsChange={setSelectedRows}
expandedGroupIds={expandedGroupIds}
@@ -115,17 +117,20 @@ function TestGrid({
onRowsChange={setRows}
onCellCopy={onCellCopySpy}
onCellPaste={onCellPasteSpy}
- groupIdGetter={groupIdGetter}
+ getGroupId={getGroupId}
/>
);
}
-function rowGrouper(rows: readonly Row[], columnKey: string) {
- return Object.groupBy(rows, (r) => r[columnKey as keyof Row]) as Record;
+interface SetupOptions {
+ getGroupId?: (groupKey: string, parentId?: string) => string;
+ getRowGroupKey?: (row: Row, columnKey: string) => string;
}
-function setup(groupBy: string[], groupIdGetter?: (groupKey: string, parentId?: string) => string) {
- return page.render( );
+function setup(groupBy: string[], { getGroupId, getRowGroupKey }: SetupOptions = {}) {
+ return page.render(
+
+ );
}
async function testHeaderCellsContent(expected: readonly string[]) {
@@ -163,25 +168,47 @@ test('should group by multiple columns', async () => {
await testRowCount(4);
});
-test('should use groupIdGetter when provided', async () => {
- const groupIdGetter = vi.fn((groupKey: string, parentId?: string) =>
+test('should use getGroupId when provided', async () => {
+ const getGroupId = vi.fn((groupKey: string, parentId?: string) =>
parentId === undefined ? groupKey : `${groupKey}#${parentId}`
);
- await setup(['country', 'year'], groupIdGetter);
- expect(groupIdGetter).toHaveBeenCalled();
+ await setup(['country', 'year'], { getGroupId });
+ expect(getGroupId).toHaveBeenCalled();
await expect.element(treeGrid).toHaveAttribute('aria-rowcount', '13');
await testHeaderCellsContent(['', 'Country', 'Year', 'Sport', 'Id']);
await testRowCount(4);
- groupIdGetter.mockClear();
+ getGroupId.mockClear();
await userEvent.click(page.getCell({ name: 'USA' }));
await testRowCount(6);
- expect(groupIdGetter).toHaveBeenCalled();
+ expect(getGroupId).toHaveBeenCalled();
await userEvent.click(page.getCell({ name: 'Canada' }));
await testRowCount(8);
await userEvent.click(page.getCell({ name: '2020' }));
await testRowCount(9);
});
+test('should use getRowGroupKey when provided', async () => {
+ const getRowGroupKey = vi.fn((row: Row, columnKey: string) =>
+ columnKey === 'country' ? row.country.slice(0, 1) : String(row[columnKey as keyof Row])
+ );
+ await setup(['country'], { getRowGroupKey });
+ expect(getRowGroupKey).toHaveBeenCalled();
+ await expect.element(treeGrid).toHaveAttribute('aria-rowcount', '9');
+ await testRowCount(4);
+ await expect.element(page.getCell({ name: 'U' })).toBeInTheDocument();
+ await expect.element(page.getCell({ name: 'C' })).toBeInTheDocument();
+});
+
+test('should order groups by the first row that belongs to them', async () => {
+ // integer-like group keys in descending order, which must not be sorted numerically
+ await setup(['year'], { getRowGroupKey: (row) => String(3000 - row.year) });
+ for (const [index, groupKey] of ['980', '979', '978'].entries()) {
+ await expect
+ .element(getRowWithCell(page.getCell({ name: groupKey })))
+ .toHaveAttribute('aria-posinset', String(index + 1));
+ }
+});
+
test('should ignore duplicate groupBy columns', async () => {
await setup(['year', 'year', 'year']);
await expect.element(treeGrid).toHaveAttribute('aria-rowcount', '10');
@@ -195,6 +222,33 @@ test('should use groupBy order while grouping', async () => {
await testRowCount(5);
});
+test('should expand a group with more rows than a function call can take as arguments', async () => {
+ // browsers limit the number of arguments, e.g., in `push(...childRows)`, and a million is above all of their limits
+ const rows = Array.from({ length: 1_000_000 }, (_, id): Row => ({
+ id,
+ country: 'USA',
+ year: 2020
+ }));
+
+ await page.render(
+ {}}
+ />
+ );
+
+ // the header row, the group row, and the rows of the group
+ await expect.element(treeGrid).toHaveAttribute('aria-rowcount', '1000002');
+ // the row count doesn't depend on the expanded state, so also check that the rows were added
+ await expect.element(page.getCell({ name: '0', exact: true })).toBeVisible();
+});
+
test('should toggle group when group cell is clicked', async () => {
await setup(['year']);
await testRowCount(5);
@@ -483,7 +537,6 @@ test('adding a top summary row when no rows or cells are active should not focus
rows={rows}
topSummaryRows={topSummaryRows}
groupBy={[]}
- rowGrouper={() => ({})}
expandedGroupIds={new Set()}
onExpandedGroupIdsChange={() => {}}
/>
diff --git a/test/browser/columnOrder.test.tsx b/test/browser/columnOrder.test.tsx
index 3ed1fe4921..6360e88440 100644
--- a/test/browser/columnOrder.test.tsx
+++ b/test/browser/columnOrder.test.tsx
@@ -41,7 +41,6 @@ test('column order', async () => {
columns={columns}
rows={rows}
groupBy={groupBy}
- rowGrouper={() => ({})}
expandedGroupIds={new Set()}
onExpandedGroupIdsChange={() => {}}
/>
diff --git a/test/visual/treeGrid.test.tsx b/test/visual/treeGrid.test.tsx
index 16c20dfb03..2592b0cbea 100644
--- a/test/visual/treeGrid.test.tsx
+++ b/test/visual/treeGrid.test.tsx
@@ -70,7 +70,6 @@ test('tree grid', async () => {
topSummaryRows={topSummaryRows}
bottomSummaryRows={bottomSummaryRows}
groupBy={['country', 'year']}
- rowGrouper={rowGrouper}
expandedGroupIds={new Set(['USA', 'USA__2020'])}
onExpandedGroupIdsChange={() => {}}
style={{ height: 350 }}
@@ -83,7 +82,3 @@ test('tree grid', async () => {
function rowKeyGetter(row: Row) {
return row.id;
}
-
-function rowGrouper(rows: readonly Row[], columnKey: string) {
- return Object.groupBy(rows, (r) => r[columnKey as keyof Row]) as Record;
-}
diff --git a/website/routes/RowGrouping.tsx b/website/routes/RowGrouping.tsx
index 04cf0cc575..fe79da139a 100644
--- a/website/routes/RowGrouping.tsx
+++ b/website/routes/RowGrouping.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useMemo, useState } from 'react';
import { faker } from '@faker-js/faker';
import { createFileRoute } from '@tanstack/react-router';
import { css } from 'ecij';
@@ -138,27 +138,44 @@ function createRows(): readonly Row[] {
});
}
- rows.sort((r1, r2) => compare(r2.country, r1.country));
-
return rows;
}
const options = ['country', 'year', 'sport', 'athlete'] as const;
+type Option = (typeof options)[number];
+
+// Groups are ordered by the first row that belongs to them,
+// so the rows are sorted by the grouped columns, in order, to sort the groups at every level
+function sortRows(rows: readonly Row[], groupBy: readonly Option[]): readonly Row[] {
+ return rows.toSorted((r1, r2) => {
+ for (const option of groupBy) {
+ // countries are sorted in descending order, so the expanded United States group is near the top
+ const result =
+ option === 'country'
+ ? compare(r2.country, r1.country)
+ : compare(String(r1[option]), String(r2[option]));
+ if (result !== 0) return result;
+ }
+ return 0;
+ });
+}
+
function RowGrouping() {
const direction = useDirection();
- const [rows] = useState(createRows);
+ const [rawRows] = useState(createRows);
const [selectedRows, setSelectedRows] = useState((): ReadonlySet => new Set());
- const [selectedOptions, setSelectedOptions] = useState([
+ const [selectedOptions, setSelectedOptions] = useState([
options[0],
options[1]
]);
+ const rows = useMemo(() => sortRows(rawRows, selectedOptions), [rawRows, selectedOptions]);
const [expandedGroupIds, setExpandedGroupIds] = useState(
(): ReadonlySet =>
new Set(['United States of America', 'United States of America__2015'])
);
- function toggleOption(option: string, enabled: boolean) {
+ function toggleOption(option: Option, enabled: boolean) {
const index = selectedOptions.indexOf(option);
if (enabled) {
if (index === -1) {
@@ -196,7 +213,6 @@ function RowGrouping() {
selectedRows={selectedRows}
onSelectedRowsChange={setSelectedRows}
groupBy={selectedOptions}
- rowGrouper={rowGrouper}
expandedGroupIds={expandedGroupIds}
onExpandedGroupIdsChange={setExpandedGroupIds}
defaultColumnOptions={{ resizable: true }}
@@ -205,7 +221,3 @@ function RowGrouping() {
);
}
-
-function rowGrouper(rows: readonly Row[], columnKey: string) {
- return Object.groupBy(rows, (r) => r[columnKey as keyof Row]) as Record;
-}