Lattice Grid Buy a licence

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.

SettingWhat 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.
configThe 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.
targetA 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.
pathThe property of the master's record the detail rows live on, when it cannot be worked out by identity.
cacheLimitHow 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.