Master-detail and tree data
Two ways to show something inside a row. Master-detail opens a panel you
draw yourself; tree data turns a flat list into a hierarchy using a parent
reference. Both are Enterprise, and both are silently stripped in Community —
turn on debugMode and the console says which.
Master-detail
Two things together: the config, and the renderer.
import { EnterpriseDataGrid } from '@kanunilabs/datagrid-react-enterprise';
<EnterpriseDataGrid
licenseKey={key}
dataSource={orders}
rowKey="id"
columns={columns}
masterDetail={{ height: 220 }}
renderDetail={(order) => <OrderLines orderId={order.id} />}
/>
masterDetail adds the expander column and reserves the row; renderDetail
fills it. Give both — the config on its own produces an expander that opens
an empty panel.
| Option | Type | Default |
|---|---|---|
masterDetail.height | number | the row height |
masterDetail.expandedByDefault | boolean | false |
masterDetail.export | { get, rows? } | none — see Details in Excel and PDF |
Driving it from code:
controller.toggleDetail(rowKey);
controller.isDetailExpanded(rowKey);
controller.collapseAllDetails();
Details in Excel and PDF
A panel is whatever you render — a chart, a form, another grid — so the grid cannot read it back into a file. Give the export the same content as a table and it goes under each master row:
<EnterpriseDataGrid
masterDetail={{
height: 220,
export: {
get: async (order) => ({
title: 'Order lines',
columns: ['Product', { header: 'Qty' }, { header: 'Price', numFmt: '#,##0.00' }],
rows: (await fetchLines(order.id)).map((l) => [l.product, l.qty, l.price]),
}),
rows: 'open', // the default: rows whose panel is open; 'all' for every row
},
}}
renderDetail={(order) => <OrderLines orderId={order.id} />}
/>
- Excel: the table starts one column in, under its master row, numbers and dates stay typed, and with the styled export (on in Enterprise) it is one outline level below the row, so Excel's − folds it away like the panel.
- PDF: shaded rows of the same table, from the second column on. A detail with more columns than the grid joins the extra values into its last cell — a PDF table has one set of columns.
- CSV is one table and leaves details out.
get may be async; the grid asks eight rows at a time. If it rejects, the
export fails rather than writing a file with some details missing. The toolbar
and menu exports use masterDetail.export; a call's own details option wins
(controller.exportToExcel({ details: { get, rows: 'all' } })).
Tree data
The rows stay flat; the grid builds the hierarchy from a parent reference.
<EnterpriseDataGrid
licenseKey={key}
dataSource={employees} // flat array
rowKey="id"
columns={columns}
tree={{ parentField: 'managerId', defaultExpanded: true }}
/>
A row whose parent is null (or missing) is a root. When the parent key is not
a plain field, resolve it yourself:
tree={{ getParentKey: (row) => row.managerRef?.id ?? null }}
The indent and the twisty land on the first visible column; a leaf keeps the slot so the text stays aligned.
From a path
When each row knows where it sits as a list of names — a file system, a
category trail, an org chart written as Sales / EMEA / Berlin — hand the
grid the path instead of a parent key:
const files = [
{ id: 1, path: ['Docs', '2026', 'q3-report.pdf'], size: 120 },
{ id: 2, path: ['Docs', 'notes.txt'], size: 4 },
{ id: 3, path: ['Pics', 'logo.png'], size: 38 },
];
<EnterpriseDataGrid
dataSource={files}
rowKey="id"
columns={columns}
tree={{ getDataPath: (row) => row.path }}
/>
A step that has a row of its own uses that row. A step that does not —
Docs, 2026 and Pics above — becomes a folder row: the grid makes it
up, shows its name in the tree column and leaves the other cells empty. A
folder row is not a record. It cannot be selected or edited, select-all and
the row numbers skip it, and it is never passed to your formatters, cell
renderers or class callbacks. It does sort with its siblings (by where its
first row sorts) and stays visible when a filter matches something inside it.
Its key is treePathKey(path) from @kanunilabs/datagrid-core, so you can
open one from code (the controller from onReady):
import { treePathKey } from '@kanunilabs/datagrid-core';
controller.toggleTreeRow(treePathKey(['Docs', '2026']));
From nested children
When the data already arrives as a tree — each row holding its children — name the field and pass it as it is:
const org = [
{ id: 1, name: 'CEO', reports: [
{ id: 2, name: 'CTO', reports: [{ id: 4, name: 'Developer' }] },
{ id: 3, name: 'CFO' },
] },
];
<EnterpriseDataGrid dataSource={org} rowKey="id" columns={columns} tree={{ childrenField: 'reports' }} />
The grid reads the nesting once on the way in and keeps your objects, so an
edit still writes to the row you passed. setData and an added row in
applyTransaction bring their children with them. getDataPath wins over
childrenField, which wins over parentField / getParentKey, if more than
one is set.
From the server
A tree too big to send whole can load one level at a time. Give a server-side
DataSource and say which rows have children:
<EnterpriseDataGrid
dataSource={{
// parentKey: null for the top level, otherwise the row being opened
load: ({ parentKey, skip, take, sort, filter }) =>
api.children(parentKey, { skip, take, sort, filter }),
}}
rowKey="id"
columns={columns}
tree={{ hasChildren: (row) => row.childCount > 0 }}
/>
The grid asks for the top level first. When a row is opened, it asks for that row's children, and only for the rows the screen needs. Each level is paged in blocks, the same way grouping on the server pages through groups. A closed row costs no request. A row opened, closed and opened again reuses what it already loaded.
Sort, filter and paging (skip / take) apply within each level, and
totalCount is the number of children that level has. When a filter matches a
row deep in the tree, the server decides whether that row's ancestors are sent
too. Sending them is what keeps the match reachable.
Which rows are open is part of the saved view, as for a tree in memory. "Expand all" also loads lazily: it opens every row, but only the levels the viewport reaches are fetched. Export refuses, as it does for grouping on the server, because the whole tree is never in the browser.
What these do not do
- A server tree needs
hasChildren. Pass a server-sideDataSourcewith onlyparentField/getParentKeyand the tree is ignored: the grid shows a flat list and logs a console warning when it is created, because finding parents needs every row in memory. WithhasChildrenthe tree loads level by level from the server; the path and nested forms are for data in memory. - Tree and grouping are exclusive. With both configured the tree wins and
groupByis ignored;debugModesays so once. - One panel height for the whole grid.
masterDetail.heightis not per-row, andgetRowHeightcannot override a detail row. - Saved views do not remember open detail panels. A restored view resets
them deliberately — they would reopen against rows that may be gone. Tree
expansion IS stored (
getViewState().tree), as are collapsed groups.expandAllDetails()/collapseAllDetails()open and close every visible panel, andtreeRowToggled/detailToggledreport each change. - Detail rows are skipped by cell navigation. Tree nodes open from the keyboard: → opens a closed node and ← closes an open one or goes to the parent, in the first column (where the toggle is); the toggle button itself still takes Enter and Space.
- Tree export is flat. Tree rows export as ordinary rows at level 0 — no
indentation. Detail panels are written only through
masterDetail.export. A folder row of a path tree is written as its name in the first column, the way a group banner is, and is left out of a selected-only export or one withincludeGroups: false. - A nested tree is read once. Changing a row's
childrenarray after it is loaded does not move anything; pass the data again withsetData, or add and remove rows withapplyTransaction. - Tree selection does not cascade. Ticking a parent does not tick its children; there is no descendant concept in the selection engine.
If any of those is load-bearing for you, say so — several are small, and knowing which one blocks a real project is how they get prioritised.