Editing (inline / modal / slideover / in-cell)
The grid supports full CRUD with four presentation modes you can switch between:
- inline — edit the whole row's cells in place, with Save/Cancel.
- modal — a centered dialog form.
- slideover — a right-side flyout form.
- incell — edit a single cell in place (spreadsheet-style); optionally batch multiple edits and save them together.
Editing is server-validated and only writes columns you mark editable.
Double-click to edit. Double-clicking anywhere on a row enters edit mode. In
incell mode it opens the cell you double-clicked when that cell is editable,
otherwise the first editable cell of the row. In inline, modal and
slideover modes it opens the row editor.
Server side
Mark columns editable with validation rules (the write allow-list):
Grid::table('sales')->key('id')
->column('status', editable: true, editor: 'select',
rules: 'required|in:pending,paid,shipped,delivered,returned')
->column('qty', type: 'number', editable: true, rules: 'required|integer|min:0')
->column('revenue', type: 'number', editable: true, rules: 'required|numeric');
Expose a mutation endpoint (Vue/Svelte) or let Livewire call it directly:
use Elyra\DataGrid\Protocol\GridMutation;
Route::post('/api/sales/mutate', fn (Request $r) =>
salesGrid()->mutate(GridMutation::fromArray($r->all())));
mutate() returns a GridMutationResult:
{ "ok": true, "id": 42, "row": { "id": 42, "status": "paid", ... } }
{ "ok": false, "errors": { "status": ["The selected status is invalid."] } }
Validation. On create, every editable column's rules are applied — so
required fires even for fields the client omitted. On update, only the
submitted fields are validated (partial updates). Only columns you declared
editable are ever written; anything else in the payload is dropped.
Authorization (secure-by-default). mutate(), mutateBatch() and export()
refuse to run until you configure authorization — either declare a policy with
authorize(), or explicitly opt out with withoutAuthorization() (use the latter
only when the surrounding route/controller/policy already guards the operation).
Without one of those, the call throws a GridException before touching the DB.
The authorize() callback receives the ability
(create | update | delete | export) and the request/mutation, and must
return true; returning false throws an unauthorized GridException. Reads
(respond()) are not gated here, so protect the route/policy for those.
Grid::table('sales')->key('id')
->authorize(fn (string $ability, $ctx) => match ($ability) {
'delete' => auth()->user()?->can('delete', Sale::class) ?? false,
'export' => auth()->user()?->can('export', Sale::class) ?? false,
default => auth()->check(),
});
Livewire
class SalesGrid extends DataGrid
{
public bool $editable = true;
public string $editMode = 'slideover'; // 'inline' | 'modal' | 'slideover'
// ... definition() + columns() (with editor/options) + optional aggregates()
}
The grid adds a command column (edit / delete), a New button, and renders
the chosen editor for each editable column. Validation errors appear inline in
the form. Persistence goes straight through GridDefinition::mutate().
Vue / Svelte
Provide an editing option with a mutate function and the mode:
const options = {
/* columns with editable/editor/options ... */
editing: {
mode: 'slideover', // 'inline' | 'modal' | 'slideover'
mutate: async (m) => (await fetch('/api/sales/mutate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(m),
})).json(),
},
};
Programmatic (client core):
grid.beginEdit(rowId); // edit an existing row
grid.beginCreate(); // new row
grid.setDraft('status', 'paid');
await grid.commitEdit(); // validates + persists via mutate()
grid.cancelEdit();
await grid.removeRow(rowId);
grid.isEditing(rowId);
On success the edited row updates in place; create/delete invalidate the cached total and refetch.
Inline editing
In inline mode, clicking Edit turns the row's editable cells into inputs
(using each column's editor), with Save / Cancel in the command
column. Press Enter to save.
In-cell & batch editing
incell mode is spreadsheet-style: double-click an editable cell to edit it
in place. Press Enter or blur to commit, Escape to cancel.
- Immediate (default): each committed cell persists straight away (one
updatemutation for that field). - Batch (
batch: true): committed cells accumulate as unsaved changes, highlighted in the grid. A toolbar Save (n) / Cancel bar appears; Save persists every dirty row (oneupdatemutation per row) and Cancel discards them.
// Vue / Svelte
editing: { mode: 'incell', batch: true, mutate }
Bulk mutations (one request)
By default a batch Save and a clipboard paste send one update mutation
per affected row. For a client-driven grid (Vue/Svelte) that means N HTTP calls.
Provide an optional mutateBatch to collapse them into a single request:
editing: {
mode: 'incell', batch: true,
mutate: (m) => post('/api/sales/mutate', m),
mutateBatch: async (mutations) =>
(await post('/api/sales/mutate-batch', { mutations })), // GridMutationResult[]
}
use Elyra\DataGrid\Protocol\GridBatchMutation;
Route::post('/api/sales/mutate-batch', fn (Request $r) =>
salesGrid()->mutateBatch(GridBatchMutation::fromArray($r->all())));
mutateBatch() runs the whole batch in one transaction (a thrown or unauthorized
mutation rolls it back; per-row validation failures come back as ok: false
without rolling back) and returns one GridMutationResult per input, in order.
When mutateBatch is omitted the grid falls back to sequential mutate calls,
so it's fully backward-compatible. (Livewire already batches server-side in a
single request.)
// Livewire
public string $editMode = 'incell';
public bool $batchEdit = true;
Unsaved cells are overlaid on the displayed data and flagged with a dg-cell-dirty
style, so users see pending edits before saving. Client-core exposes the
machinery for custom UIs: commitCell, isDirty, dirtyValueOf, cellValue,
dirtyRowCount, saveBatch, cancelBatch.
Editors
text, number, date, select (uses options), and checkbox. See
Columns.
Clipboard paste
With editing enabled, paste tab-separated data (e.g. from a spreadsheet) starting at the active cell — it maps onto editable columns and persists each affected row. See Selection & keyboard.
Change auditing
Every applied mutation can fire GridRowChanged, carrying the row before and
after — the basis for an audit log, and for an undo button:
use Elyra\DataGrid\Server\Events\GridRowChanged;
Event::listen(function (GridRowChanged $e) {
AuditEntry::create([
'table' => $e->table,
'op' => $e->op, // create | update | delete
'row_id' => $e->id,
'changes' => $e->changes(),
'user_id' => auth()->id(),
]);
});
changes() returns field => ['old' => …, 'new' => …] for the columns that
actually differ. Only the differences: a save that submitted five fields and
altered one audits as one change, or every "save" writes noise into the log.
Comparison is loose on purpose — a value round-tripped through the database comes
back as a string ("10.00" for an integer written into a decimal column), so a
strict comparison would report a change on every save.
Scope: declared columns only
The event describes the declared columns, keyed by protocol key. The table may hold columns the grid never exposes — password hashes, tokens, internal flags — and an audit event is not a licence to read them. It is the same boundary the mutation response already respects.
A column that is declared but not editable still appears: what the grid exposes
is the boundary, not what it can write.
What it costs, and when you pay
Capturing the previous row needs a SELECT before the write. Auditing is
therefore automatic but not unconditional:
datagrid.audit.enabled |
Behaviour |
|---|---|
null (default) |
On exactly when something is listening for GridRowChanged. |
true |
Always on. |
false |
Always off, even with a listener registered. |
The default matters at scale: a 500-row clipboard paste would otherwise pay 500
extra reads for an event with no consumer. The cost is one extra SELECT per
mutated row — an update re-reads the row for its response anyway, and a create
costs nothing extra at all.
A listener that throws is not caught. Inside mutateBatch() that rolls the
write back, which is the correct coupling for an audit trail: a record you can
silently lose is not a record. It also matches how Laravel's own model events
behave.
Undo
inverse() returns the mutation that would undo the one just applied:
Event::listen(function (GridRowChanged $e) {
if ($undo = $e->inverse()) {
session()->push('undo-stack', $undo);
}
});
// later
salesGrid()->mutate(session()->pull('undo-stack')[0]);
| Op | Inverse |
|---|---|
create |
delete with the new id. |
update |
update restoring the previous values. Safe by construction: only editable columns can change, so every changed column is one the write allow-list accepts back. |
delete |
null. |
A delete has no inverse, deliberately. Restoring the row would mean re-creating it
from the old values — but those include non-editable columns (the key, timestamps,
expression columns), and the write allow-list strips exactly those. The "restored"
row would come back with a new id and missing data, and a required non-editable
column would fail validation outright. An undo that quietly produces a different
row is worse than one that admits it cannot be built.
If you need restore-on-delete, use soft deletes: there the inverse of a delete is
an ordinary update, and inverse() handles it.