Documentation

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.

OptionTypeDefault
masterDetail.heightnumberthe row height
masterDetail.expandedByDefaultbooleanfalse
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' } })).

See it working →

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.

See it working →

What these do not do

  • A server tree needs hasChildren. Pass a server-side DataSource with only parentField / getParentKey and 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. With hasChildren the 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 groupBy is ignored; debugMode says so once.
  • One panel height for the whole grid. masterDetail.height is not per-row, and getRowHeight cannot 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, and treeRowToggled / detailToggled report 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 with includeGroups: false.
  • A nested tree is read once. Changing a row's children array after it is loaded does not move anything; pass the data again with setData, or add and remove rows with applyTransaction.
  • 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.