Row grouping and aggregation
Group by one or more value columns, choose how group labels are displayed, and calculate aggregates over each group's leaf descendants.
- React
- Angular
- Core TypeScript
<Grid
rowData={rows}
columnDefs={columns}
groupDefaultExpanded={1}
groupRowsSticky
toolbar={{ grouping: true }}
onGridReady={(api) => {
api.dispatch({ type: "rowGroupSet", colIds: ["region", "country"] });
}}
/>
<awb-grid
[rowData]="rows"
[columnDefs]="columns"
[groupDefaultExpanded]="1"
[groupRowsSticky]="true"
[toolbar]="{ grouping: true }"
(gridReady)="group($event)"
/>
group(api: IGridAPI) {
api.dispatch({ type: "rowGroupSet", colIds: ["region", "country"] });
}
const core = new GridCore(measurer, {
columnDefs,
groupDefaultExpanded: 1,
groupRowsSticky: true,
toolbar: { grouping: true },
});
api.dispatch({ type: "rowGroupSet", colIds: ["region", "country"] });
api.setAllGroupsExpanded(true);
Multi-level row grouping
const options = {
groupDisplayType: "singleColumn",
groupDefaultExpanded: 1,
groupSortMode: "local",
columnDefs: [
{ colId: "region", key: "region", label: "Region", groupable: true },
{ colId: "country", key: "country", label: "Country", groupable: true },
{ key: "revenue", label: "Revenue", type: ColumnType.CURRENCY },
],
} satisfies GridOptions;
api.setRowGroupColumns(["region", "country"]);
api.setAllGroupsExpanded(true);
Use groupRowsSelectable: true when generated group rows should participate in
selection, navigation, and copying.
Selecting a group. With row selection checkboxes on, a group row's checkbox is inert by
default. Set groupSelectsChildren: true and it selects the whole group: every selectable leaf
under it, including collapsed and nested ones, and clears them all again once all are selected.
The group's checkbox shows the group's state — ticked when all its rows are selected, a dash when
only some are — and a fully selected group row paints as selected. Group rows themselves are
never stored in the selection, so getSelectedRows() and select-all still return data rows.
Filtered-out rows and rows isRowSelectable refuses are skipped. This is separate from
groupRowsSelectable, which makes group rows targets of the cell cursor, ranges and the
clipboard; the two combine. See Selection for ranges, paging and the
server-side model.
Display and expansion
groupDisplayType accepts singleColumn, multipleColumns, or groupRows.
groupDefaultExpanded sets the initial depth; later user expansion remains
stable across refreshes (when group values are unchanged). groupRowsSticky
docks the active ancestor chain while the body scrolls — see
pinned & sticky rows.
The toolbar provides a grouping picker, ordered chips, drag insertion, keyboard
reordering, and clear-all behavior. The API can replace grouped columns and
expand or collapse all groups in one update, or a named set of groups in one
rebuild (ids come from api.getGroupNodes() or api.getRowNode(id)):
api.setAllGroupsExpanded(false);
api.setGroupExpanded(["emea-group-id", "apac-group-id"], true);
Group-row cells
A group row has no data row of its own, so each of its cells holds the first of these that applies:
- the column's aggregate for this group, when one is assigned;
- the group label, on the group's label column (the auto-group column, the
tree column, or the grouped column for this level in
multipleColumns); - the column's
groupValueGetterresult, when it defines one; - nothing.
The same rule feeds the cell text (through the column's valueFormatter),
copy, the click and double-click payloads, ActionFrame params, and the group
header rows of an Excel export.
groupValueGetter gives a column without an aggregate something to show on a
group row. It receives the group node, the group's aggregates by colId,
and, on the server-side row model, the raw group row as data; it never runs on
a leaf, and a column's valueGetter never runs on a group row. A margin column
derived from two summed columns:
const margin = {
colId: "margin",
label: "Margin",
valueGetter: (row) => (row.revenue - row.cost) / row.revenue,
groupValueGetter: ({ aggregates }) => {
const revenue = aggregates.revenue as number | undefined;
const cost = aggregates.cost as number | undefined;
return revenue && cost != null ? (revenue - cost) / revenue : undefined;
},
valueFormatter: ({ value }) => value == null ? "" : `${(value * 100).toFixed(1)}%`,
} satisfies ColDef;
api.setAggregates([
{ colId: "revenue", type: AggregateType.SUM },
{ colId: "cost", type: AggregateType.SUM },
]);
groupValueGetter inherits through defaultColDef like valueGetter. A
column's cellRenderer runs on group rows too, with this value as value and
isGroup: true — see rendering group rows.
api.getCellValue({ rowId, colId }) reads the raw value of any cell by the same
rule: a leaf's column value (its valueGetter, else the data field), or a group
row's group cell value. It returns undefined for a row or column the grid does
not hold.
Aggregates
Available functions are count, distinct count, sum, average, min, max, median, first, and last — plus any custom function the grid registers. The footer scope can be none, current page, or all rows. Grouped client-side rows also receive per-group aggregate values.
api.setAggregates([
{ colId: "revenue", type: AggregateType.SUM },
{ colId: "orders", type: AggregateType.COUNT },
]);
Blank cells (null, undefined, or "") take no part in an aggregate. count
is the number of non-blank values — what the Excel export's COUNTA formula
counts — and distinct count the number of distinct ones; a group's row count is
the separate count after its label (see below). When a group has nothing to
aggregate (no rows, or only blank or non-numeric cells), sum, average, median,
min, and max are null and the cell is empty rather than 0.
The aggregate model can hold several types for one column (api.setAggregates
accepts them, and pivot mode turns each into its own column). Outside pivot a
group row cell and the footer cell show one: the last type registered for the
column, in both places.
first and last are the first and last non-blank value in display order —
after sorting, so sorting the grid changes them. They keep the source value as
is (a date stays a date) and, having no Excel equivalent, are exported as static
values; the other built-ins export as live SUM/AVERAGE/… formulas.
A column's own function
aggFunc on a column definition names its aggregate function. The column is
aggregated from the start — it joins the initial aggregate model exactly as if
api.setAggregates had listed it, so group rows show its per-group values and the
footer turns on at page scope — and it is the function the footer and the
aggregate menu use for the column when nothing else is chosen, in place of the
built-in rule (sum for numbers, count otherwise):
const columnDefs: ColDef[] = [
{ colId: "region", key: "region" },
{ colId: "revenue", key: "revenue", type: ColumnType.NUMBER, aggFunc: AggregateType.SUM },
{ colId: "lastOrder", key: "lastOrder", type: ColumnType.DATE, aggFunc: AggregateType.LAST },
];
The seeding happens once, with the first column definitions: removing the
aggregate afterwards (from the menu, the column panel, or api.setAggregates([]))
sticks. An unknown name warns once and the column keeps the built-in rule, and
aggFunc on an aggregatable: false column is ignored with a warning.
Custom aggregate functions
aggFuncs registers functions by name. A registered name is accepted wherever a
built-in type is: ColDef.aggFunc, api.setAggregates, the aggregate menus
(listed after the built-ins, labelled with the name as given), saved views, and
pivot measures. A name that is neither built in nor registered warns once and the
column's default function is used instead.
A function receives { values, rows, colId } for the rows it covers — a group's
leaf descendants, or the footer's page or whole dataset — in display order.
values are the raw cell values with blanks included (unlike the built-ins,
your function decides what a blank means), and rows are the matching row nodes.
Return the aggregate; the column's valueFormatter renders it, as it does a
built-in's, and null/undefined leaves the cell empty:
const options = {
aggFuncs: {
// Units-weighted average price: what a plain average of prices gets wrong.
weightedPrice: ({ rows }) => {
let revenue = 0;
let units = 0;
for (const { data } of rows) {
revenue += data.price * data.units;
units += data.units;
}
return units === 0 ? null : revenue / units;
},
// The distinct non-blank values, joined.
distinctList: ({ values }) =>
[...new Set(values.filter(v => v != null && v !== ""))].join(", "),
},
columnDefs: [
{ colId: "region", key: "region" },
{ colId: "rep", key: "rep", aggFunc: "distinctList" },
{ colId: "price", key: "price", type: ColumnType.CURRENCY, aggFunc: "weightedPrice" },
{ colId: "units", key: "units", type: ColumnType.NUMBER },
],
} satisfies GridOptions;
// Names type-check wherever a type does:
api.setAggregates([
{ colId: "price", type: "weightedPrice" },
{ colId: "rep", type: AggregateType.FIRST },
]);
Exports write a custom function's values (footer and group subtotals) as static
cells — there is no formula to write. aggFuncs is read at creation. On the
server-side row model the server computes every aggregate: any name travels to it
unchanged in the request's aggregates (and needs no entry in aggFuncs, though
registering one is what lists it in the menus).
Group counts
A group row's label ends in its row count: EMEA (12). groupChildCount decides
what that parenthesised value is — on screen, on copy, and in both exports, which
always agree. true (the default) is the row count; false shows no count on any
group:
const options = {
groupChildCount: false,
} satisfies GridOptions;
A function decides per group. It receives the group node, the default rowCount,
the group's aggregates by colId (the columns with an aggregate assigned), and, on
the server-side row model, the raw group row as data. Return a number or a string
to write inside the parentheses, or null to show no count for that group — a
headcount total instead of a row count, say:
const options = {
groupChildCount: ({ aggregates, rowCount }) =>
aggregates.headcount != null ? `${aggregates.headcount} FTE` : rowCount,
} satisfies GridOptions;
api.setAggregates([{ colId: "headcount", type: AggregateType.SUM }]);
Tree rows never show a count. groupChildCount is a live prop/input in both
wrappers, and api.updateGridOptions({ groupChildCount }) changes it in place.
Group footers
A group header already carries its group's aggregates. groupTotalRow repeats
them in a footer (subtotal) row of its own: "bottom" after the group's last row,
"top" directly under its header. grandTotalRow adds one row of totals over
every filtered row — not just the current page — at the start or end of the list:
const options = {
groupTotalRow: "bottom",
grandTotalRow: "bottom",
columnDefs: [
{ colId: "region", key: "region" },
{ colId: "rep", key: "rep" },
{ colId: "units", key: "units", type: ColumnType.NUMBER },
],
} satisfies GridOptions;
api.setRowGroupColumns(["region"]);
api.setAggregates([{ colId: "units", type: AggregateType.SUM }]);
▾ EMEA (3) 42
Ann 10
Bob 20
Cid 12
Total EMEA 42
▾ APAC (2) 15
…
Total APAC 15
Grand Total 57
- Where the label goes. The group column (
"singleColumn"), the level's own column ("multipleColumns"), or — under"groupRows", where the header is one spanning cell — the first column after the row-number and checkbox columns. In"groupRows"the footer is an ordinary row of cells, so it is where the per-column totals become visible. The label isTotal <group>(without the count) andGrand Total;groupTotalRowLabelreplaces it, given the footernode, itsgroupNode,grandTotal, thegroupLabel, thedefaultLabeland theaggregatesby colId. - Cells. Aggregate columns show the same formatted value as the header row —
custom functions,
groupValueGetterand the group-row cell renderers included (a renderer can tell a footer byparams.node.footer). Other cells are blank. - Rows of the view. A collapsed group shows no footer. Footers move with
their groups when the groups sort or filter, count toward pagination, row
numbers and
aria-rowindex, andgetDisplayedRowAtIndexreturns them. The cell cursor stops on them where it stops on group rows (groupRowsSelectable), and a copied footer cell is its shown text. - Never data. Footers are not selectable (their checkbox is hidden, and
groupSelectsChildrensubtrees, select-all andgetSelectedRows()skip them), never editable, never docked bygroupRowsSticky— though a docked header is pushed out by its footer, the last row of its block — and never exported: the Excel export writes its ownSUBTOTALrows, CSV writes leaf rows only. - Styling. Footer rows carry
pte-row-footer(and the grand total alsopte-row-grand-total) on every section row, besidespte-group-row; the theme draws a rule along their top.IRowNode.footer,grandTotalandgroupNodeidentify them ingetRowClassand other callbacks. - Scope. Row grouping on the client-side row model only: tree data and pivot
mode ignore both options, and the server-side row model warns once and ignores
them. All three options are live (
api.updateGridOptions, and props/inputs in both wrappers); changing a position rebuilds the groups, keeping expansion and the row selection.
Server-side grouping and aggregation
Server-side grouping requests one parent listing at a time and sends aggregate definitions with the request:
const dataSource: IServerSideDataSource = {
async getRows({ request }) {
// request.groupBy: ["region", "country"]
// request.groupKeys: [{ key: "region", value: "EMEA" }, ...]
return queryOneGroupingLevel(request);
},
getAggregates: async ({ request }) => ({
values: await loadAggregateValues(request),
}),
};
const options = {
rowModelType: "serverSide",
serverSideDataSource: dataSource,
getGroupChildCount: (row) => row.childCount,
} satisfies GridOptions;
Each expanded group loads its own child listing lazily — see
server-side data. The same getAggregates serves the
footer's Entire dataset scope — see
whole-dataset totals.
Row grouping is also one of pivot mode's three roles: pivoting reuses the group tree and crosses it with pivot columns and aggregates (client-side row model).