developer guide
Tree data and master-detail
A hierarchy from a parent reference or a path, orphans and cycles absorbed rather than dropped, lazy children, and rows that expand into a nested grid.
Developer guide › Tree data and master-detail
Tree data
Rows that sit under one another rather than in a flat list. Two shapes, and they answer different questions about where the hierarchy lives.
The row names its parent
tree: {
parentKey: 'parentId', // a field, or a function of the row
label: 'name', // what the tree column shows
orphans: 'Unassigned', // or 'root', the default
}
The row carries its own ancestry
tree: { path: (row) => row.hierarchy } // ['EMEA', 'UK', 'Colchester']
Parent-reference is what a join or a document store produces. Every node is a real row. A row whose parent is not in the data (filtered away, not loaded, or simply wrong) is an orphan: it goes to the root by default, or into a named bucket. It is never dropped, because hiding a record over a bad reference loses data the user can see in a flat view.
Path-based rows describe their own place, so intermediate levels may have no
row at all: EMEA/UK/Colchester with no EMEA/UK row still needs a
UK node to sit under. Those are synthesised, and render as group rows: a heading
over the rows beneath it with no record of its own. A real row arriving later for a level
already synthesised fills that node rather than appearing beside it.
A parent cycle is not a hierarchy and cannot be walked. It is reported once and cut, with the rows shown at the root: wrong place beats vanished.
The grid generates a tree column to carry the expander and the indent, on the
same terms as the auto-group, selection and detail columns: pinned to the start, and absent
from columns.visible(), saved views, exports and the tool panel. Its text comes
from tree.label; without one it falls back to your first visible column, which
then appears twice until you hide it, the grid does not remove a column you did not ask it
to remove.
Loading a branch on demand
Children fetched when the node is opened
tree: {
parentKey: 'parentId',
hasChildren: (data) => data.childCount > 0,
loadChildren: (row, signal) => api.children(row.data.id, { signal }),
}
hasChildren lets a row declare children it does not hold, so the expander is
there before anything is fetched: without it there is nothing to click and the branch can
never load. Such a node reads as closed even though tree nodes are otherwise expanded
by default, because an open branch with nothing under it leaves no gesture to load it.
The rows that arrive are added to the data set, so the hierarchy rebuilds through the same
pipeline as everything else and the new rows sort, filter and export like any other. A branch
is fetched once however often it is toggled; closing it before the rows arrive aborts the
request through the signal. A rejection is reported and leaves the branch
unloaded, so reopening tries again rather than showing an empty node for good.
tree:loading, tree:loaded and tree:loadFailed are on the
event bus.
Expansion is the same state group expansion uses, so rows.expand,
rows.collapse, expandAll, collapseAll and saved views
all work on it. A collapsed branch is skipped rather than hidden, so it costs nothing.
Grouping and tree together is not a combination: the grouping wins and says so
once, because two expanders in one row would be two hierarchies claiming the same rows.
Master-detail
A master row expands to reveal a detail region: by default a nested grid over whatever
detail.rows(row) returns, which may be a promise. The grid adds an expander
column while the feature is on, on the same terms as the auto-group and selection columns:
pinned to the start, and absent from columns.visible(), saved views, exports and
the tool panel.
Inline, a detail row beneath its master
detail: {
rows: (row) => api.lines(row.data.id), // array or promise
config: { columns: [{ field: 'port' }, { field: 'vlan' }] },
height: 240,
isMaster: (data) => data.lineCount > 0, // default: every data row
cacheLimit: 10,
}
grid.detail.toggle(key);
grid.detail.keys(); // every open master
grid.detail.closeAll();
The detail is a real display row: virtualised, height-managed, and pushing the rows below it
down. Any number of masters can be open at once. height takes a number or a
function of the row.
A detail pane instead of a detail row
Targeted, the list-and-pane layout
detail: {
target: '#detail-pane', // a selector or an element
rows: (row) => api.lines(row.data.id),
config: { columns: [{ field: 'port' }, { field: 'vlan' }] },
}
grid.detail.active(); // the open master, or null
grid.detail.placement(); // 'inline' | 'target' | null
With target the detail renders into an element you own rather than into a row.
No detail row is created, so the grid's row count does not change when a master opens, and
nothing about the list's geometry moves.
Exactly one master is open at a time in this placement. One element cannot
show two details, and stacking them turns a fixed-height pane into a scrolling list of grids
with no rule for how tall each should be. Expanding a second master closes the first;
height is ignored, because the pane's height is yours.
A target selector that matches no element is reported once and leaves the
details unshown: silence there is indistinguishable from a detail that fails to open, and
the cause is not visible from the grid.
The control changes with the placement, because the gesture does. Inline it
is a chevron that turns down when the row expands, carrying aria-expanded: the
ordinary disclosure pattern. Targeted, nothing expands: the row is being chosen and its
detail appears elsewhere, so the control becomes the “opens elsewhere” glyph and
a toggle (aria-pressed) rather than a disclosure. A chevron there would promise
an expansion that never comes. The chosen row is marked with
lat-row--detail-active and aria-current, since with the detail off
to the side nothing else in the grid says which record the pane belongs to.
An editable detail
The detail is a whole grid, so it edits like one: put edit in
detail.config and its cells are editable. The rows it shows are usually a
sub-array of the master's own record, so an edit there changes the master's data directly;
there is nothing to copy back.
One listener on the master covers every detail
detail: {
rows: (row) => row.data.ports, // a sub-array of the record
config: {
columns: [{ field: 'port' }, { field: 'vlan', type: 'number', edit: true }],
edit: { enabled: true },
},
}
grid.on('detail:cell:changed', (e) => {
e.masterKey; // 'C1', the row the detail belongs to
e.path; // 'ports.1.vlan': where it lands on the master's record
e.value; // 999
e.oldValue; // 101
});
A nested grid is created by the grid, not by you, so its own events would otherwise be out of
reach. The edit lifecycle (detail:edit:started,
detail:edit:stopped, detail:cell:changed) is re-emitted on the
master, tagged with the master it came from. You never have to hold the nested grid to hear
about an edit inside it.
path is the dot notation from the master's record to the value that changed, so
a host can persist a detail edit against the master and never think about the nested grid at
all. It is worked out by identity: rows(row) usually returns an array that is
already a property of the record, and that property is the prefix. A detail fetched from a
server is not part of the master's record, so its path is null,
set detail.path to name it yourself when you want one anyway.
For anything the forwarded events do not cover, detail.onCreate(grid, masterRow)
hands you the nested grid itself as it is built.
| Setting | What it does |
|---|---|
rows(row) | The nested grid's rows. May return a promise; the region is built empty and loaded when it settles, so an empty detail and a pending one do not look the same. |
config | The nested grid's configuration. It inherits the licence and module registry from the master by construction. |
render(container, row) | Draw the region yourself instead of a nested grid. Return anything with a destroy() method. |
isMaster(data, row) | Which rows can expand. Group rows and detail rows never can. |
target | A selector or element to render the detail into. Omit for inline. One master open at a time. |
onCreate(grid, row) | The nested grid, as it is created. |
path | The property of the master's record the detail rows live on, when it cannot be worked out by identity. |
cacheLimit | How many regions are retained after closing, so collapse and re-expand does not refetch. Open regions are never evicted, whatever the limit. 0 destroys on collapse. Default 10. |