Elyra
Elyra The coding agent eTerm The terminal that knows where each command ends Huskarl AI colleagues on your own Mac Starf An activity monitor for Apple silicon that never invents a number etrans An SSH and SFTP client for macOS Elyra VM Virtual machines for macOS, Linux and Windows on your Mac Litr A small, native web browser for macOS Notr A notebook for macOS e The native code editor Elyra Grove Native local development environment Askr The real server for Laravel & PHP Elyra Framework Rust + Svelte 5 framework for desktop apps Elyra Conductor Local project conductor Refr Local-first PDF workspace for macOS Elyra Workspace A desktop workspace for coding agents Elyra SQL Server MySQL-compatible SQL server in Rust Elyra Félagi Agents as teammates on one board Elyra SQL Client Native desktop SQL workbench Elyra SQL Anywhere Replication-ready SQL engine Elyra Sjá SEO & GEO workspace for macOS Elyra DataGrid Server-driven data grid for Laravel
Elyra
Editing (inline / modal / slideover / in-cell)

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 update mutation 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 (one update mutation 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.