Lattice Grid Buy a licence

developer guide

Data Grid Row Edit Form

Some records are easier to edit as a form than as a line of cells. A row opens into one, in a side panel, a dialog or inline, each field getting the editor its type deserves, and saving runs the same validation and the same write path as editing in place.

Developer guideEditing › Data Grid Row Edit Form

Editing a row on a form

Double-clicking a row opens it in a panel (a right-hand drawer or a centred dialog) with one control per field, a Save and a Cancel. It is the shape almost every application built on a grid ends up wanting, and until now the shape they had to build themselves.

The grid's own columns, in a drawer

createGrid(element, {
  columns, rows, rowKey: 'id',
  editable: true,
  rowForm: true,
});

That is the whole of it for the common case: the form is the row, edited with the same editors, types, formats and lookups the cells use. Where the record has more to it than the grid shows, give a load function, and then say which fields and in what order, because nothing in the grid knows the shape of something it has never seen.

A fuller record, in a dialog

rowForm: {
  mode: 'dialog',                       // 'drawer' is the default
  title: ({ data }) => 'Edit ' + data.name,
  load: ({ key }) => fetch(`/api/orders/${key}`).then((r) => r.json()),
  fields: [
    { field: 'name',  label: 'Name' },
    { field: 'ref',   label: 'Reference' },   // not a column
    { field: 'notes', label: 'Notes', editor: 'textarea' },
    { field: 'score', label: 'Score', editor: 'rating', props: { max: 5 } },
  ],
}

Which editor a field gets

Every editor is available on a form, including your own from the module registry, the form builds its controls through the same call a cell does, so a field gets the same editor, type, formatting and lookup its column would have given it. A field named after a column borrows that column outright and needs nothing further.

A field the grid has never seen (or one you want entered differently from its cell) says so on the field itself. editor names it, and type, props and lookup configure it exactly as they would on a column. Overriding the control does not change where the value goes: a field still writes back only if it maps to a column.

A picker opens when it is asked to. A popup editor, a date, a dropdown, a tree, a colour, a code panel: is its panel: in a cell it opens the moment the cell does, which is right, because the user has just asked to edit that one cell. A form builds every field at once, so on a form the field shows the current value on a control and the panel opens over it when clicked. Choosing puts the panel away again and updates the control.

The panel opens before the record arrives. A click that does nothing for half a second reads as a click that was missed, and the user clicks again. So the panel appears immediately with a loading state and fills in when the record lands.

A failure keeps the panel open and offers a retry inside it. Closing would discard the intent and leave the user nothing to act on but the row they already double-clicked.

And a load that never answers is a failure too. A promise that neither resolves nor rejects is what a dropped request looks like from the page; left alone it spins until the user gives up, which reads as an application that has hung rather than a request that failed. After timeout milliseconds, two seconds unless you say otherwise, the form stops waiting and shows the same message and retry as any other failure. Set timeout: false to wait indefinitely, which is right only where your own loader already has a limit and would rather report that one. A record that turns up after the form gave up on it is discarded rather than dropped into a panel the user may have moved on from.

The fields scroll and the heading and buttons do not, so Save stays reachable on a record with forty fields. If a validator refuses one of them, the form stays open, the field is marked, and it is scrolled into view and focused: on a long form the offending field is otherwise nowhere near the button that was just pressed.

Save collects the changed fields, writes the ones that map to columns, and announces the lot. Where the record actually lives is not something the grid can know, so persisting is yours: a field that came from load and is not a column is reported in unmapped and not written, since inventing a column for it would put data in the grid that the grid was never asked to show. Save is disabled while there is nothing to save.

MemberDoes
form.open(key)Open a row by key. Returns false if there is no such row.
form.close()Close without saving.
form.save()Commit the fields and close. Returns false if a validator refused, or if there is nothing to save.
form.isOpen()Whether the panel is showing.
form:openedFired with { key, row }.
form:savedFired with { key, values, changed, unmapped }.
form:closedFired with { key }.
form:errorFired with { key, error, timedOut } when a load fails or runs out of time.

The form takes the double click. On an editable grid that gesture also opens a cell editor, and the two cannot both own it, a form that quietly did nothing where a cell happened to be editable would be worse than no form. So where rowForm is configured, double-clicking a row opens the form and the cell editor stays reachable by Enter or by typing into the cell. Set trigger: false to leave opening entirely to form.open() and keep double-click for cells.

Putting the form in your own element

A drawer and a dialog both sit over the grid. Give container an element of your own and the form is built there instead, a sidebar beside the grid, a panel below it, a column in a layout you already have. It fills what it is given, so the size and position are yours.

A sidebar the application owns

createGrid(element, {
  columns, rows, rowKey: 'id', editable: true,
  rowForm: { container: '#record-panel' },   // or the element itself, or a function
});

A selector is resolved when the form opens, not when the grid is configured, because a grid is routinely built before the layout around it exists. A container that cannot be found falls back to opening over the grid: better a form in the wrong place than a double-click that appears to do nothing.

A form in your own container is not modal. It sits beside the grid rather than over it, so it takes nothing away: it is announced as a region rather than a dialog, and Tab moves out of it into the rest of your page instead of being trapped. Claiming otherwise would tell a screen reader user the page had gone away when it plainly has not. Escape still closes it, and it still takes focus when it opens.

Over the grid, the panel is a modal dialog: it takes focus when it opens, traps Tab while it is showing, closes on Escape, and returns focus to whatever had it before. Its width can be set with width.