apsw-gridwright
Documentation

Grouping and aggregation

Collapsible group headers over rows that share a value, with a built-in or custom aggregate per column, computed for each group and, with summaryRow, once more for the whole result.

tsx
import { Gridwright, grouping } from 'apsw-gridwright/react';

const columns = [
    { id: 'name', header: 'Name' },
    { id: 'department', header: 'Department' },
    { id: 'salary', header: 'Salary', aggregate: 'sum', formatValue: (value) => money.format(value) },
];

<Gridwright
    columns={columns}
    data={employees}
    aria-label="Employees"
    addons={[grouping({ groupBy: ['department'], summaryRow: true })]}
/>;

That groups by department, shows how many rows and the total salary beside each department's title, and a grand total across every department in the table's footer.

Options#

OptionTypeDefaultWhat it does
groupBystring[]requiredColumn ids, in nesting order. The first groups the whole set; the next groups within it, one level deeper.
summaryRowbooleanfalseA grand-total row in the table footer, aggregating across every matching row.
defaultExpandedbooleantrueWhether a group starts expanded.
serverGroupedbooleanfalseThe data source already returns group headers and member rows itself; the local grouping stage is skipped. See Server-side grouping.

The aggregate column option#

ts
{ id: 'salary', header: 'Salary', aggregate: 'sum' }
{ id: 'salary', header: 'Salary', aggregate: 'avg' }
{ id: 'salary', header: 'Salary', aggregate: (values, rows) => Math.max(...values) - Math.min(...values) }

A column with no aggregate reports nothing. The built-ins are sum, avg, min, max and count; sum and avg skip a value that is not a finite number rather than producing NaN, and min/max compare the same way the engine sorts, so they work on dates and strings too. A custom function receives every group's values for that column and the rows they came from, and returns whatever the group's cell should show — a range, a distinct count, a formatted summary.

count ignores the column's values and returns how many rows are in the group; put it on whichever column reads best, { id: 'name', aggregate: 'count' } for "12 employees" rather than a number with no label.

Nesting#

tsx
grouping({ groupBy: ['department', 'region'] });

Groups by department, and within each department, by region. A group's aggregate covers every row under it, at whatever depth: a department's total is not the sum of its regions recomputed, it is the same reduction run over the department's whole membership.

Collapsing#

A group starts expanded unless defaultExpanded: false. The toggle is a real <button> inside the group's row, named after the group — Collapse Engineering group — because a column of identically named toggles tells a screen reader nothing about which group each one is. Collapsing calls api.invalidatePipeline() under the hood: the grid already has every row it needs, so nothing is refetched.

Accessibility#

The table is role="treegrid" while grouping() is listed: a group header carries aria-level and aria-expanded, which role="grid" has no place for. A member row carries aria-level one past its group's, so a screen reader can tell a row nested two groups deep from one nested one. Every string — the toggle's name, the item count, the summary row's labels — is in the add-on's gridwright:grouping messages, translated in all five packs.

Server-side grouping#

tsx
grouping({ groupBy: ['department'], serverGrouped: true });

With serverGrouped: true the local stage is skipped, and the data source is expected to answer the discriminated shape itself — a mix of group headers and member rows, in the same order a local grouping stage would have produced. This is for a server that already returns pre-grouped pages; there is no capabilities.group flag, because nothing above the pipeline is meant to know where the grouping happened.

Without serverGrouped, grouping needs every matching row in memory, the same as a plain array. A source whose capabilities.paginate is true is refused rather than grouped one page at a time — that would show a total for a department that is silently missing the rows still on the server. The refusal reaches plugin:error the same way any other plugin failure does; the rows stay ungrouped rather than the grid going empty.

Exporting a grouped grid#

api.getMatchingRows() on a grouped grid answers the same discriminated rows the table renders — group headers included — because grouping is one of the stages getMatchingRows() runs, the same as it is for exportMenu(). A column's formatValue and exportValue answer '' for a group header rather than throwing, so a default export of a grouped grid does not crash; it shows a mostly blank row where each group header was. For a flat export instead, unwrap first:

ts
import { ungroupedRows } from 'apsw-gridwright';

const rows = ungroupedRows(api.getMatchingRows().rows);

With the other add-ons#

Add-onTogether
treeData()Refused, by name. Both transform rows at the same pipeline stage and both change the row type; there is no combined design yet.
virtualRows()Both. Group headers render through renderRow in both bodies, so they work under a fixed row height like any other row.
columnFilters(), search()Both. Filtering, search and sorting run before grouping, on flat rows, so a group reflects what the reader is looking at.
columnLayout()Both. A group header spans the visible columns; pinning and reordering apply to the columns underneath it.
rowActions()Both. A member row's menu receives the grid row, and rowDataOf(row) gives your row, as under treeData(). A group header draws its own row, so it has no menu.
inlineEditing()Both. commit is called with your row's own id: a member row's id is the id you gave getRowId (or the row's id), not a wrapper's. A group header has nothing to edit.
rowDetail()Both. render and hasDetail receive your row; a group header has no toggle.
selection()Both. onSelectionChange reports your ids and your rows. A group header has no checkbox.
cellNavigation()Both. The cursor passes over a group header, which is one cell spanning the row; its toggle is a Tab stop. headerRow: true works as it does anywhere.

A row id that begins with / can be mistaken for a group's id, which is built as /<column>:<value>. Your own ids are used as they are, so keep them clear of that shape.

Writing your own#

The plugin, the controller and the aggregate math are exported from apsw-gridwright as well as the add-on from apsw-gridwright/react, for building a grouped view of your own: groupingPlugin, createGroupingController, createGroupingDataSource, groupColumns, computeAggregate and the GroupedRow<TRow> type. grouping() in src/react/grouping/addon.tsx is built from exactly these and nothing else, so a third-party grouping view has the same reach.

Edit this page on GitHub