Row Grouping & Aggregation
Sections, aggregates, grand totals, sticky headings
Grouping collapses rows that share a value into expandable sections — all invoices for a customer, all employees in a department — and puts a summary on each section heading.
Two features that ship together, because grouping without totals is half a feature:
- Grouping builds the sections.
- Aggregation computes the number on each heading — a sum, an average, your own function.
Requires
GroupModule. Grouping adds a pipeline stage and a store namespace, so it is a real module in the original sense. Without it,groupingis ignored entirely and no section headings render. See Modules.
The smallest grouped grid
import { createGrid, GroupModule, Grid } from '@elitegrid/vue'
const grid = createGrid<Invoice>({
modules: [GroupModule],
columns,
data: invoices,
grouping: {
model: ['customer'],
},
})
model is the list of columns to group by, outermost first. Two entries means two levels:
grouping: {
model: ['region', 'customer'], // every customer nested inside its region
}
You can pass the ColumnDef object instead of the field name. Both are type-checked and both follow a rename:
const regionColumn = { field: 'region', header: 'Region' }
grouping: { model: [regionColumn, 'customer'] }
An entry that matches no column warns by name in development rather than silently grouping by nothing.
The column you group by disappears
This is the first surprise, and it is deliberate. Grouping by Customer hides the Customer column, because the value is already in the section heading — showing it again puts the same string on every row of the section.
If you want it back:
grouping: {
model: ['customer'],
keepGroupedColumns: true,
}
Why this is the default: a grouped column left visible shows every row its own group's value twice — the group label above it, the same value again in the cell. Hiding it is the behaviour nearly everyone wants, so it is the default, with one flag to opt out.
Aggregation — the number on the heading
A column shows nothing on a group row unless you tell it what to compute. Set value.aggregate on the column:
const columns: ColumnDef<Invoice>[] = [
{ field: 'customer', header: 'Customer' },
{
field: 'amount',
header: 'Amount',
value: { aggregate: 'sum' },
},
{
field: 'daysLate',
header: 'Days Late',
value: { aggregate: 'avg' },
},
]
Seven built-in names: 'sum', 'avg', 'min', 'max', 'count', 'first', 'last'.
A column with no
aggregaterenders blank on a group row — never the first child's value. That is a safety decision. Showing the first row's amount on a heading is how a reader comes to believe a group of 12 invoices is worth $2,400.
Your own aggregate
Pass a function instead. It receives the extracted values and the typed rows, because most real aggregates need a sibling field:
{
field: 'amount',
header: 'Amount',
value: {
// a weighted average needs the weight column
aggregate: (values, rows) => {
const totalWeight = rows.reduce((s, r) => s + r.weight, 0)
if (totalWeight === 0) return 0
return rows.reduce((s, r) => s + r.amount * r.weight, 0) / totalWeight
},
},
}
// "how many in this group are overdue"
value: { aggregate: (values, rows) => rows.filter((r) => r.overdue).length }
A typo in a built-in name is a compile error, not a blank cell — the names are a union resolved by a switch, not a string looked up in a registry at runtime.
There is no aggregate formatter, on purpose.
display.formatterreceives aTDatarow, and a group row is not one. If you want control over how an aggregate is displayed, return a string from a custom aggregate — that is the supported path.Aggregates are computed from the leaves at every level, never folded up from the level below. Folding is wrong for any non-associative function: an
avgover unequal group sizes, or any custommedian, comes out wrong.
A grand total over everything
grouping: {
model: ['region'],
grandTotal: 'bottom', // or 'top'
}
The grand total is a pinned row, so it stays visible while you scroll, and it re-totals whenever a filter changes. It works with an empty model too — a totals row on an ungrouped grid:
grouping: { model: [], grandTotal: 'top' }
Controlling the group key
By default a group's key is the column's display value — the same text search and tooltips read. So a column shown as $1,234 groups as $1,234, and a formatter that maps a status code to a label groups by the label, with nothing written.
Two cases the default gets wrong, and both warn in development:
A cellRenderer column — a rendered node has no text to group on.
A Date column — every distinct instant becomes its own group, which is never what you want. Bucket it:
{
field: 'issuedAt',
header: 'Issued',
group: {
valueGetter: (row) => String(row.issuedAt.getFullYear()),
},
}
Ordering the sections
{
field: 'priority',
header: 'Priority',
group: {
comparator: (a, b) => RANK[a] - RANK[b], // 'high' before 'low'
},
}
group.comparator orders the sections. sort.comparator orders the rows inside a section. They are independent, and the distinction matters:
- Sorting the grid by a grouped column flips the section order.
- Sorting by any other column reorders rows inside each section and leaves the section order alone.
The default section order is a numeric-aware locale compare, so Item 2 sorts before Item 10.
Blocking the menu affordance
{ field: 'notes', header: 'Notes', group: { enabled: false } }
That removes "Group by this column" from the column menu. Grouping it programmatically still works — this gates the affordance, not the capability.
How the sections look
One column, or the full width
grouping: {
model: ['region'],
displayType: 'singleColumn', // default
groupColumn: { header: 'Region', width: 220, minWidth: 120 },
}
'singleColumn' prepends one synthetic column holding the chevron, the key and the count, indented by depth. 'fullWidth' spans the heading across every column and leaves your data columns alone.
What starts open
grouping: {
model: ['region', 'customer'],
expandedByDefault: 1, // top level only
}
false (the default) starts everything closed, true everything open, and a number opens to that depth.
Counts
grouping: { model: ['region'], showCounts: false } // drop the "(12)"
Sticky headings
As you scroll inside a section its heading pins under the column header, and the end of its own section pushes it out. On by default — a feature nobody enables is a feature nobody has, and it costs nothing on a grid with one group.
Nested headings stack: at depth 2 both levels stay pinned, outermost above.
grouping: { model: ['region'], stickyGroups: false } // turn it off
The chip bar
A bar above the column header naming what you are grouped by, outermost first. Drag a chip to reorder a level, × to remove one.
grouping: {
model: ['region'],
panel: 'whenGrouped', // default — appears only once something is grouped
}
true keeps it always visible, false never. The default is 'whenGrouped' because the bar takes no column dropped onto it — an empty one cannot teach you to group, so it should not occupy space pretending to. Creating a grouping is the column menu's "Group by this column".
Collapsed sections that still say something
A collapsed section is a summary, not a dead end. collapsedPreview puts something behind the heading's label:
grouping: {
model: ['region'],
collapsedPreview: 'share',
}
'share' paints a faint proportion bar — this section's rows against the filtered total. It costs zero horizontal space, and the eye ranks the sections before reading a single number.
Or render anything you like:
grouping: {
model: ['region'],
collapsedPreview: ({ leafRows, share }) =>
h(AvatarStack, { users: leafRows.slice(0, 5), dimmed: share < 0.1 }),
}
leafRows is this section's filtered rows, and share is leafRows.length over the filtered total as a 0–1 number — handed to you so a custom preview paints the same proportion without re-deriving the denominator and getting it wrong.
Off by default.
Replacing the heading cell
grouping: {
model: ['region'],
cellRenderer: ({ group, label, toggle }) =>
h('button', { class: 'yg-my-heading', onClick: toggle }, [
`${group.expanded ? '▾' : '▸'} ${label} — ${group.count} rows`,
]),
}
params |
What it is |
|---|---|
label |
e.g. "Acme Corp (12)" — already honours showCounts and the locale |
toggle() |
Expand or collapse this section |
group.path |
Ordered keys from the root: ['EMEA', 'Acme Corp'] |
group.key |
This level's key — the last element of path |
group.level |
0 for a top-level section; -1 for the grand total |
group.count |
Descendant row count, after filtering |
group.aggregates |
Aggregate values by column id |
group.expanded |
Current state |
group.leaves |
Every descendant row |
Selection
A group's checkbox selects everything under it, and the heading shows a tri-state box (checked / unchecked / partial) that follows its children:
const grid = createGrid<Invoice>({
modules: [GroupModule, SelectionModule],
columns,
data: invoices,
selection: { mode: 'multiple' },
grouping: { model: ['customer'], selectsChildren: true }, // default
})
With selectsChildren: false the checkbox is hidden, not disabled. A group row is not in the row model, so it cannot be selected as a thing in its own right — with its children off the table the box would be inert, and an inert checkbox is worse than none. The slot stays reserved so no cell shifts out from under its header.
Pagination
grouping: {
model: ['customer'],
paginateBy: 'rows', // default
}
'rows'pages what the user actually sees, so the scrollbar and the row count stay honest. A section may straddle a page boundary.'groups'pages whole top-level sections — "25 customers per page".
Filtering a grouped grid
Nothing to configure, but worth knowing: the grouping stage runs after filter and search. Sections are built only from rows that survived them, which means an empty section cannot exist, counts always mean surviving rows, and there is no stale parent left behind.
Export
By default a CSV contains your rows, not your section headings — a spreadsheet user re-groups with a pivot table, and a file with summary rows interleaved is not a dataset.
grouping: { model: ['region'], exportGroups: true } // include the headings
Two things that are handled for you, and are worth knowing because the obvious expectation is wrong:
- The columns you grouped by are always in the file, even though grouping hides them on screen. Hiding is a display decision, and a CSV has no headings to carry the value instead — a grid grouped by Department used to export a file with no Department column, carrying its grouping in row order alone.
- An export does not depend on which sections are expanded. Rows come out in display order but not display membership, so collapsing everything does not produce a file of headings and no rows.
Driving it from code
api.setGroupModel(['region', 'customer']) // group, outermost first
api.setGroupModel([]) // ungroup
api.getGroupModel() // [] when nothing is grouped
api.setGroupExpanded(['EMEA', 'Acme Corp'], true)
api.expandAllGroups()
api.collapseAllGroups()
A path is a plain string array, so it survives JSON and a page reload — which is what lets grid state restore which sections were open.
Events
const grid = createGrid<Invoice>({
modules: [GroupModule],
columns,
data: invoices,
grouping: { model: ['region'] },
events: {
onGroupModelChange: (model) => console.log('grouped by', model),
onGroupExpand: (path, expanded) => console.log(path, expanded),
},
})
Gotchas
- A group row is not one of your rows. It is a tagged object in the same flat list. If you write a custom renderer that walks rows, narrow with
isGroupRow(row)before assuming you have aTData. Your own rows are untouched and still passed by reference. - A column with no
value.aggregateis blank on a heading. That is intentional; see above. - Date and
cellRenderercolumns needgroup.valueGetter. Both warn in development. keepGroupedColumnsis false by default — the column you grouped by vanishes until you ask for it back.exportGroupsis false by default — headings are not in your CSV unless you ask.GroupModulemust come from@elitegrid/vue, not@elitegrid/core. Core's copy groups correctly and renders no headings at all. It warns if you get this wrong.
Next: Row Features covers pinned rows, row numbers, drag-to-reorder and expandable detail rows.