Undo/Redo Plugin
The Undo/Redo plugin tracks all cell edits and lets users revert or replay changes with familiar keyboard shortcuts (Ctrl+Z / Ctrl+Y). It maintains an in-memory history stack with configurable depth—perfect for data entry workflows where mistakes happen.
⚠️ Required Dependency: This plugin requires EditingPlugin to be loaded first. UndoRedo tracks the edit history that EditingPlugin creates.
Installation
Section titled “Installation”import '@toolbox-web/grid/features/editing';import '@toolbox-web/grid/features/undo-redo';Basic Usage
Section titled “Basic Usage”Enable both features and history tracking is automatic—every cell edit is recorded, and users can navigate back and forth through the edit history.
import { queryGrid } from '@toolbox-web/grid';
const grid = queryGrid('tbw-grid');grid.gridConfig = { columns: [ { field: 'name', header: 'Name', editable: true }, { field: 'price', header: 'Price', type: 'number', editable: true }, { field: 'quantity', header: 'Quantity', type: 'number', editable: true }, ], features: { editing: 'dblclick', undoRedo: { maxHistorySize: 50 }, },};import '@toolbox-web/grid-react/features/editing';import '@toolbox-web/grid-react/features/undo-redo';import { DataGrid } from '@toolbox-web/grid-react';import type { GridConfig } from '@toolbox-web/grid-react';
const gridConfig: GridConfig = { columns: [ { field: 'name', header: 'Name', editable: true }, { field: 'price', header: 'Price', type: 'number', editable: true }, { field: 'quantity', header: 'Quantity', type: 'number', editable: true }, ], features: { editing: { editOn: 'dblclick' }, undoRedo: { maxHistorySize: 50 }, },};
function MyGrid({ data }) { return <DataGrid rows={data} gridConfig={gridConfig} style={{ height: '400px' }} />;}<script setup>import '@toolbox-web/grid-vue/features/editing';import '@toolbox-web/grid-vue/features/undo-redo';import { TbwGrid } from '@toolbox-web/grid-vue';import type { GridConfig } from '@toolbox-web/grid-vue';
const data = [ { name: 'Widget', price: 9.99, quantity: 10 }, { name: 'Gadget', price: 19.99, quantity: 5 },];
const gridConfig: GridConfig = { columns: [ { field: 'name', header: 'Name', editable: true }, { field: 'price', header: 'Price', type: 'number', editable: true }, { field: 'quantity', header: 'Quantity', type: 'number', editable: true }, ], features: { editing: 'dblclick', undoRedo: { maxHistorySize: 50 }, },};</script>
<template> <TbwGrid :rows="data" :grid-config="gridConfig" style="height: 400px" /></template>Define everything inside gridConfig.features, exactly like the other frameworks. The side-effect imports register the features so the adapter can bridge them.
import '@toolbox-web/grid-angular/features/editing';import '@toolbox-web/grid-angular/features/undo-redo';import { Component } from '@angular/core';import { Grid } from '@toolbox-web/grid-angular';import type { GridConfig } from '@toolbox-web/grid-angular';
@Component({ selector: 'app-my-grid', imports: [Grid], template: ` <tbw-grid [rows]="rows" [gridConfig]="gridConfig" style="height: 400px; display: block;"> </tbw-grid> `,})export class MyGridComponent { rows = [...];
gridConfig: GridConfig = { columns: [ { field: 'name', header: 'Name', editable: true }, { field: 'price', header: 'Price', type: 'number', editable: true }, { field: 'quantity', header: 'Quantity', type: 'number', editable: true }, ], features: { editing: true, undoRedo: { maxHistorySize: 50 }, }, };}Edit cells by double-clicking, then use Ctrl+Z / Ctrl+Y to undo/redo. Adjust the max history size with the control.
<tbw-grid style="height: 350px;"></tbw-grid>import '@toolbox-web/grid';import { queryGrid } from '@toolbox-web/grid';import '@toolbox-web/grid/features/editing';import '@toolbox-web/grid/features/undo-redo';
const columns = [ { field: 'id', header: 'ID', type: 'number' }, { field: 'name', header: 'Name', editable: true }, { field: 'quantity', header: 'Quantity', type: 'number', editable: true }, { field: 'price', header: 'Price', type: 'number', editable: true },];const sampleData = [ { id: 1, name: 'Widget A', quantity: 10, price: 25.99 }, { id: 2, name: 'Widget B', quantity: 5, price: 49.99 }, { id: 3, name: 'Widget C', quantity: 20, price: 15.0 },];
const grid = queryGrid('tbw-grid');
function rebuild(maxHistorySize = 100) { grid.gridConfig = { columns, features: { editing: true, undoRedo: { maxHistorySize } }, }; grid.rows = [...sampleData];}
rebuild();Configuration Options
Section titled “Configuration Options”See UndoRedoConfig for the full list of options and defaults.
Keyboard Shortcuts
Section titled “Keyboard Shortcuts”| Shortcut | Action |
|---|---|
Ctrl+Z / Cmd+Z | Undo |
Ctrl+Y / Cmd+Y / Cmd+Shift+Z | Redo |
Programmatic API
Section titled “Programmatic API”const plugin = grid.getPluginByName('undoRedo');
plugin.undo();plugin.redo();const canUndo = plugin.canUndo();const canRedo = plugin.canRedo();plugin.clearHistory();Row identity
Section titled “Row identity”Each recorded edit stores the row’s ID (from getRowId or row.id) alongside its
display index. Undo and redo find the row by that ID, so they still target the right row after the
grid is sorted or filtered, even when the row is filtered out of view. If a row has no ID, the
plugin falls back to the display index captured when the edit was made. That index goes stale once
the view changes, so configure getRowId when you combine undo/redo with sorting or filtering.
recordEdit(rowIndex, field, oldValue, newValue, rowId?) reads the ID from the row currently shown at
rowIndex, so call it while that index still points at the edited row. If the row may not be in
view, pass rowId yourself. For example, a cell-commit raised by updateRow() on a filtered-out
row reports rowIndex: -1, but its detail.rowId is still set.
After an undo or redo, focus moves to the restored cell once the grid has re-rendered, so it lands on the row’s new position after any re-sort or re-filter. If the row is filtered out, focus stays where it was. Undoing an edit on a row that has since been deleted does nothing.
Compound Actions (Transactions)
Section titled “Compound Actions (Transactions)”When a user edit cascades programmatic changes to other fields, group them into a single undo step so Ctrl+Z reverts everything at once:
grid.on('cell-commit', ({ row, rowId, rowIndex }) => { const undoRedo = grid.getPluginByName('undoRedo'); undoRedo.beginTransaction();
// Record cascaded updates manually (pass rowId so off-screen rows are targeted correctly) const oldTotal = row.total; undoRedo.recordEdit(rowIndex, 'total', oldTotal, newTotal, rowId); grid.updateRow(rowId, { total: newTotal });
// End after the auto-recorded original edit is captured queueMicrotask(() => undoRedo.endTransaction());});| Method | Description |
|---|---|
beginTransaction() | Start buffering edits into a compound group |
endTransaction() | Finalize and push the compound as a single undo step |
recordEdit() | Manually record a cell edit (buffered during txn) |
Events
Section titled “Events”<tbw-grid style="height: 240px;"></tbw-grid>import '@toolbox-web/grid';import { queryGrid } from '@toolbox-web/grid';import '@toolbox-web/grid/features/editing';import '@toolbox-web/grid/features/undo-redo';
const grid = queryGrid('tbw-grid');
grid.gridConfig = { columns: [ { field: 'name', header: 'Name', editable: true }, { field: 'department', header: 'Department', editable: true }, { field: 'salary', header: 'Salary', type: 'number', editable: true }, ], features: { editing: 'dblclick', undoRedo: true, },};
grid.rows = [ { name: 'Alice Johnson', department: 'Engineering', salary: 95000 }, { name: 'Bob Smith', department: 'Marketing', salary: 75000 }, { name: 'Carol White', department: 'Sales', salary: 68000 },];
const log = document.querySelector('#undo-redo-events-log');const clearBtn = document.querySelector('#clear-undo-redo-events-log');const undoBtn = document.querySelector('#undo-events-btn');const redoBtn = document.querySelector('#redo-events-btn');
function addLog(type: string, detail: string) { if (!log) return; const msg = document.createElement('div'); msg.innerHTML = `<span class="event-type">[${type}]</span> ${detail}`; log.insertBefore(msg, log.firstChild); while (log.children.length > 15) log.lastChild?.remove();}
clearBtn?.addEventListener('click', () => { if (log) log.innerHTML = ''; });
undoBtn?.addEventListener('click', () => { grid.getPluginByName('undoRedo')?.undo();});
redoBtn?.addEventListener('click', () => { grid.getPluginByName('undoRedo')?.redo();});
grid.on('undo', (d) => { addLog('undo', `field: ${d.field}, restored: "${d.oldValue}"`);});
grid.on('redo', (d) => { addLog('redo', `field: ${d.field}, reapplied: "${d.value}"`);});undo / redo
Section titled “undo / redo”Fired after an undo or redo action is applied, whether it came from the keyboard or from
plugin.undo() / plugin.redo(). If your app keeps rows in its own state, replay the action
from these events so a toolbar button and Ctrl+Z update that state the same way:
grid.on('undo', ({ action, type }) => { // `action` is a single 'cell-edit' or a 'compound' group (see Compound Actions above) if (action.type === 'cell-edit') { console.log(`${type}: reverted ${action.field} on row ${action.rowId ?? action.rowIndex}`); }});
grid.on('redo', ({ action, type }) => { if (action.type === 'cell-edit') { console.log(`${type}: reapplied ${action.field} on row ${action.rowId ?? action.rowIndex}`); }});See UndoRedoDetail for the full event payload type.
history-change
Section titled “history-change”Fired whenever the history changes: an edit is recorded outside a transaction, a non-empty
transaction ends, an undo or redo runs, or clearHistory() empties a non-empty history. Bind your
toolbar buttons to it:
grid.on('history-change', ({ canUndo, canRedo }) => { undoButton.disabled = !canUndo; redoButton.disabled = !canRedo;});See HistoryChangeDetail for the payload type. In the
framework adapters, onHistoryChange (React), @history-change (Vue) and (historyChange)
(Angular) expose the same event; useGridUndoRedo() / injectGridUndoRedo() already refresh
canUndo() / canRedo() from it.