Documentation

Multi-level grouping and group summaries

Grouping itself is Community, and so is the group panel, and so are footer column totals. Enterprise adds exactly two things:

  1. a second and further grouping level,
  2. those same totals repeated on every group header.

Worth knowing before you reach for a licence key: if one level is enough, you already have everything.

One level — Community

import { DataGrid } from '@kanunilabs/datagrid-react';

<DataGrid
  dataSource={orders}
  rowKey="id"
  columns={columns}
  grouping={{ groupBy: 'region', panel: true }}
  summaries={[
    { columnId: 'units', type: 'sum' },
    { columnId: 'revenue', type: 'sum' },
  ]}
/>

panel: true puts the drop strip above the grid; dragging a header into it groups by that column. summaries draws the totals in the footer.

More levels — Enterprise

import { EnterpriseDataGrid } from '@kanunilabs/datagrid-react-enterprise';

<EnterpriseDataGrid
  licenseKey={key}
  dataSource={orders}
  rowKey="id"
  columns={columns}
  grouping={{
    groupBy: ['region', 'category', 'rep'],   // outermost first
    panel: true,
    groupSummaries: true,
    collapsedByDefault: true,
  }}
  summaries={[
    { columnId: 'units', type: 'sum' },
    { columnId: 'revenue', type: 'sum' },
  ]}
/>
OptionTypeDefault
grouping.groupBystring | string[] | nullnull
grouping.panelbooleanfalse
grouping.groupSummariesbooleanfalse
grouping.collapsedByDefaultbooleanfalse
grouping.showGroupedColumnsbooleanfalse
grouping.display'fullWidth' | 'aligned' | 'column''fullWidth'
grouping.groupColumn{ headerName?, width?, minWidth? }—
grouping.stickyHeadersbooleanfalse

groupSummaries: true on its own shows nothing. The group header reuses the summaries array, so an empty one produces empty headers. There is no separate per-level or per-group definition list.

Totals land inside the header strip — ▸ Category: Toys (12) · Revenue: 1,240 — not in a footer row beneath the members, even though the header menu item is worded "Show group footers".

Summary types are sum, avg, min, max, count and custom. avg, min and max skip blanks; count counts non-empty cells.

summaries={[
  {
    columnId: 'margin',
    type: 'custom',
    calculate: (cells) => cells.reduce((a, c) => a + Number(c.value ?? 0), 0) / cells.length,
  },
]}

Inside a group header, calculate receives only that group's cells.

Group rows aligned to the columns

By default a group row is one banner across the grid, with its summaries written out after the label ("Sum Revenue: 11,733"). For a report you read down the columns, put each summary under its own column instead:

grouping={{ groupBy: 'region', groupSummaries: true, display: 'aligned' }}

The group row then becomes a row of cells. The group's caption, value, count and toggle sit in the first visible column, indented by level. Each column that has a group summary shows it in its own cell, formatted like the footer. If a column has two summaries (sum and avg), the cell shows the first one declared. A click still opens and closes the group, the row is still not selectable, and its box in the checkbox column ticks every row under it. Both the React and the JavaScript grid support it. 'fullWidth' is the default.

Editing a group total

Enterprise. In a plan or a budget the number people change is often the subtotal: "North gets 80 units, not 40". Give the column groupEdit and the subtotal under it becomes editable:

<EnterpriseDataGrid
  editing={{ mode: 'cell' }}
  grouping={{ groupBy: 'region', groupSummaries: true, display: 'aligned' }}
  summaries={[{ columnId: 'units', type: 'sum' }]}
  columns={[
    { field: 'region' },
    { field: 'units', dataType: 'number', groupEdit: 'proportional' },
  ]}
/>

Double-click the subtotal in a group row, type the new total, press Enter; Escape keeps the old one. The new total is shared out over every row in the group — nested groups included, open or closed:

groupEditWhat the rows get
'proportional'Each row keeps its share: 10 and 30 of 40 become 20 and 60 of 80. A group adding up to zero is split evenly.
'even'Every row the same: 80 over 4 rows is 20 each.
a functionWhatever it returns. It gets groupKey, columnId, aggregate, target and the group's rows (with their value and whether they may change) and returns { rowKey, value }[].

It works where the column's subtotal is a sum or an avg (for an average, the rows move so their average is what you typed), and only in group rows that show subtotals under their columns — display: 'aligned' or 'column' with groupSummaries on. Whole numbers stay whole: when every row and the new total are whole, the remainder goes to the rows with the largest fractions, so the rows add up to exactly the total typed.

The change goes through editing like any other: one step on the undo stack (Ctrl+Z takes back the whole share-out), rows the user may not change (editable: false, allowUpdating) keep their value and the others take the difference, and in batch mode the rows wait for Save. A typed total is read the way a paste is, so "1.234,5" and "1,234.5" both work.

Group column

With display: 'column' the group rows get a column of their own at the start of the grid:

grouping={{ groupBy: ['region', 'city'], display: 'column', groupSummaries: true }}

The column holds each group row's toggle, value and count, indented by level. The rest of the group row is laid out as in 'aligned': each group summary sits under its own column. A record's cell in the group column is empty, since the group header above it already gives the value.

  • Header. The grouped columns' names, "Region › City". Set groupColumn: { headerName: 'Group' } to name it yourself.
  • Width. 200 px by default, at least 80 (groupColumn.width, minWidth). It resizes and auto-fits like any column, and the width is kept in getViewState().
  • Sorting. A click on the header turns every grouping level around at once, A→Z to Z→A. In single-sort mode a click on another header replaces that sort, as any header click does, and the groups go back to A→Z.
  • Pinning. It is pinned left, before any column you pinned there, so it stays in view while the grid scrolls sideways.
  • What it does not have. No filter, no header menu, no total in the footer row. You cannot hide or move it. It exists only while rows are grouped.
  • Where it is left out. Copy, editing, the chart and the AI state leave it out like the grid's other own columns.
  • Export. It becomes the first column of an export: the group label on group rows, blank on records.
  • Where it works. In Community, in the React and the JavaScript grid. Grouping by two or more levels is still Enterprise.

Sticky group headers

Scroll into a long group and its header leaves the screen with the first rows. Keep it in view:

grouping={{ groupBy: ['region', 'city'], stickyHeaders: true }}

While you scroll, the headers of the groups the top row belongs to stay under the column header, outermost first: a region, then the city inside it. When the next group arrives, its header pushes the old one up and out. A sticky header is the same row as the real one, with the same summaries, the same checkbox and the same aligned cells. Clicking it opens or closes the group. Closing a group from its sticky header also scrolls the group's real header into view, so you do not land on whatever rows moved up into its place.

Keyboard navigation accounts for it: moving the focus up to a row stops short of the headers that will cover it. The sticky copies are hidden from screen readers and out of the tab order; the real headers stay in the row list. Both the React and the JavaScript grid support it, in Community. It is off by default. It works with client data only, because a remote grid does not hold the rows between a header and the top of the screen.

Dates by day, week, month, quarter or year

A date column groups by its exact value unless it says otherwise — a year of orders makes hundreds of groups. groupInterval groups it by calendar unit:

{ field: 'placed', dataType: 'date', groupInterval: 'month' } // 'day' | 'week' | 'month' | 'quarter' | 'year'

The banners read in the grid's language ("October 2026", "Q4 2026"; a week shows its ISO number and the Monday it starts on), in date order, blanks last. The group's key is the language-free bucket — placed::2026-10 — so which groups are collapsed survives a language switch and a saved view. Dates are local calendar dates; a bare "2026-10-03" string is that calendar day, not UTC midnight. It applies to client-side data: a server-side source groups the way its server does.

Reacting to it

controller.events.on('groupChanged', ({ groupBy, levels }) => {
  // groupBy = outermost level, levels = every level
});

columnStateChanged fires too, because the grouped column leaves the data area and comes back when grouping is removed.

From code: controller.groupBy(id), controller.setGroupSummaries(true), controller.expandAllGroups(), controller.collapseAllGroups().

See it working →

What this does not do

  • Community trims silently. Pass three levels to DataGrid and you get the first one; groupSummaries is forced to false. The warning is printed only under debugMode: true. Adding a second level in the Community UI replaces the first rather than nesting — a deliberate swap, not a no-op.
  • Group keys are String(value). Apart from date buckets (groupInterval, above) there is no groupValueGetter. Two values with the same text (1 and '1') merge into one group; null and undefined collect under a localised "(Blanks)".
  • valueFormatter is not applied to the header caption. A formatter takes a row, and a group strip has no single row. lookup columns are the exception — those resolve to the label rather than the code.
  • A banner subtotal is not editable. With the default display: 'fullWidth' the subtotals are text in one strip, so there is no cell to type in — use 'aligned' or 'column' for editing a group total.
  • The runtime "group footers" toggle is React-only. The vanilla header menu has no such item; call controller.setGroupSummaries(...) yourself.
  • Grouping outranks the user's sort. Group columns are pushed to the front of the sort, so a user's own sort becomes the secondary key. They can still flip a group column's direction, and that is kept.
  • One group column, not one per level. display: 'column' puts every level in a single column, indented. A column per level is not available, and a record does not repeat its group values in the group column. With display: 'aligned' the name sits in the first visible column and scrolls sideways with it; the group column is pinned and does not.
  • A group header is not a selectable row. Space on a focused header opens or closes it. With selection: { mode: 'multiple', checkboxColumn: true } the banner carries a checkbox that ticks every row under the group — open or collapsed, nested or not — and shows all / some / none; from code, controller.toggleGroupSelection(key), setGroupSelected(key, on) and getGroupSelectionState(key).
  • groupable: false only closes the UI. The header menu item is disabled and the column drops out of the panel list, but controller.groupBy('thatOne') still works.
  • Export lays a group row out like the aligned display. The first column holds "<label> (<count>)"; with groupSummaries, each subtotal sits under its own column (the first summary declared for it), formatted as on screen and, in Excel, written as a number you can still sum. A summary on the FIRST column has no cell of its own there and is not exported. Styled Excel export turns nesting into real outline levels; PDF only indents.
  • Group totals lag unsaved edits. Footer totals read pending edits; the cached group tree does not, so a group header can show the old number until the edit is committed.
  • Tree wins over grouping. Configure both and groupBy is ignored, warned once under debugMode.
  • Server-side grouping is the server's job. With a DataSource object the client computes nothing: return groups: RemoteGroupRow[] keyed on groupKeys length, with aggregates under ${columnId}:${type}. type: 'custom' is never sent (a JS reducer cannot run there). expandAllGroups() opens every level without fetching the table: groups open as they arrive, and only the groups the viewport reaches ask for their children. Closing one afterwards keeps it closed; collapseAllGroups() closes them all again.