Grouping
Multi-level grouping with drag-to-group, collapsible groups, per-level aggregates, and drill-into detail rows — all computed in the database.
Enabling grouping
const options = {
/* ... */
grouping: { aggregates: [{ field: 'revenue', fn: 'sum', as: 'sum_revenue' }] },
};
Livewire: set public bool $groupable = true; and (optionally) return
aggregates() from the component.
Drag-to-group
When grouping is enabled a group panel appears above the grid. Drag a column header onto it to group by that column; drag several to nest levels. Each grouped column shows as a chip with a ✕ to remove it.
Programmatic (client core):
grid.groupBy('region');
grid.groupBy('category'); // second level
grid.ungroup('category');
grid.setGroups(['region']);
How it renders
Grouping produces a hierarchical, indented list of group header rows with a count and per-group aggregates, each collapsible via a chevron:
▾ Region: Nord — 1,666,667 rows · sum_revenue 3,350,158,788
▾ Category: Elektronikk — 833,333 rows · sum_revenue 1,675,214,197
▾ Category: Bok — 833,334 rows · sum_revenue 1,674,751,357
▾ Region: Sør — 1,666,667 rows · ...
grid.toggleGroup(path); // collapse / expand a group node
grid.isGroupCollapsed(path);
How it works on the server
For a cross-tab presentation of the same machinery — dimensions on two axes, with margins — see Pivot.
Send groupView: { skip, take } to page the outermost grouping level; every
descendant of a selected outer group comes with it, so the tree stays whole, and
the response carries groupTotal for building a pager.
Group rows are capped per level by GridLimits::maxGroupRows (10 000). Grouping a
high-cardinality column is expensive rather than abusive, so the cap clamps and
the response sets groupsTruncated: true — check it and tell the user the
grouping is partial, instead of showing a truncated result as complete.
Grouping a column requires it to be filterable — grouping emits the column's
distinct values, which is exactly what filterable: false withholds. Levels are
capped by GridLimits::maxGroupLevels (5), because the server runs one query per
level; raise it under datagrid.limits if you need deeper nesting.
The server runs one GROUP BY query per level (GROUP BY g1, then
GROUP BY g1, g2, ...), so each level's aggregates are computed directly over
that level's rows. This is correct for non-additive functions (avg,
median, percentile, countDistinct) — not just additive sum/count — and
The deepest query also fixes the hierarchical ordering of the emitted headers.
GROUP BY ROLLUP(…) would compute the same numbers in one query — it aggregates
base rows per grouping set, so its subtotals are correct too — but it is not used
here, for three reasons: support differs across the drivers (SQLite has none), its
subtotal rows are distinguished by NULL in the grouping column, which is
ambiguous when the data itself contains NULL, and its output ordering is not
guaranteed to be hierarchical.
// The client sends group[] with per-level aggregates; the server returns
// GridResponse.groups: [{ field, value, count, level, path, aggregates }]
Aggregate functions
sum, avg, min, max, count, countDistinct, median, percentile
(with p). median/percentile require the ElyraSQL driver.
grouping: {
aggregates: [
{ field: 'revenue', fn: 'sum', as: 'sum_revenue' },
{ field: 'revenue', fn: 'percentile', p: 0.95, as: 'p95' },
],
}
Per-level aggregates are correct for every function, including non-additive ones
like group-level avg and percentile.
Detail rows (drill into a group)
Enable detailRows to let users expand an innermost group and reveal its
actual data rows (lazily fetched, filtered by the group path):
// Vue / Svelte
grouping: { detailRows: true, detailLimit: 200, aggregates: [/* ... */] }
// Livewire
public bool $groupDetailRows = true;
public int $groupDetailLimit = 200;
The leaf group's chevron toggles its detail rows; expanding fetches up to
detailLimit rows (WHERE groupField = value AND <current filter>, no grouping)
and renders them indented under the header. Non-leaf group chevrons still
collapse their subgroups.
Footer aggregates (whole result)
Independent of grouping, footer aggregates run over the entire filtered result and are correct for any function. They are cached per filter.
const options = {
aggregates: [
{ field: 'revenue', fn: 'sum', as: 'sum_revenue' },
{ field: 'margin', fn: 'avg', as: 'avg_margin' },
],
};