tutorial
Build a back-office admin panel: edit, validate, save
Last updated 5 September 2026
An admin panel is the screen a team lives in: a table of records they read, change and save all day. It is also where a grid usually falls down, because editing is not one feature but a pipeline. A value has to be typed, checked, written to a server, confirmed or rolled back, and undone if it was a mistake, and some cells must refuse the edit outright. This tutorial builds that whole pipeline from one dataset, with no backend to run.
You will build it against a stand-in save endpoint so it runs anywhere, then change one function to point it at your own API. The finished panel is one click away if you want to see the destination first.
Open the finished panel in the sandbox
The problem: editing is a pipeline
Showing rows is the easy half. The moment a team can change them, a run of hard questions arrives together. What does a good value look like, and what happens to a bad one? When does a change reach the server, and what does the screen do while it waits? Which cells may this person touch at all? What undoes a mistake? Answer these one at a time and each is small; answer them by hand across a raw table and they turn into the bulk of the code. The grid answers them for you, so the panel below is mostly configuration.
Set up the page
Load the grid from the CDN with one script tag. There is no build step and
no import: the grid is on the global LatticeGrid. On localhost
it is free to use; a deployed site is licensed per domain, which the sandbox
already carries for you.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.32.0/lattice-grid.min.css">
<script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.32.0/lattice-grid.min.js"></script>
Lay out a toolbar with an export button and an element for the panel.
<style>
body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; background: #f6f7f9; color: #1b2430; }
.bar { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.bar h1 { font-size: 15px; margin: 0; font-weight: 600; color: #3a4250; }
.bar .spacer { flex: 1; }
button.export { font: inherit; font-size: 13px; font-weight: 600; padding: 8px 14px; border: 1px solid #cfd6df; border-radius: 8px; background: #fff; cursor: pointer; }
button.export:hover { border-color: #2d6bff; color: #2d6bff; }
#panel { height: 520px; background: #fff; border: 1px solid #e3e6ea; border-radius: 10px; overflow: hidden; }
</style>
<div class="bar">
<h1>Products</h1>
<span class="spacer"></span>
<button class="export" id="export">Export to Excel</button>
</div>
<div id="panel"></div>
Load the data
A real panel reads its rows from an API; here a seeded generator stands in
for one, so the example runs with nothing behind it. Every product carries a
stable id, which is the row key the grid updates and saves
against.
// A seeded generator, so the panel shows the same catalogue to everyone.
function rng(seed) {
var a = seed >>> 0;
return function () {
a = (a + 0x6d2b79f5) >>> 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function generateProducts(count) {
var rand = rng(41);
var CATEGORIES = ['Cabling', 'Optics', 'Power', 'Racks', 'Tools'];
var SUPPLIERS = ['Northwind', 'Contoso', 'Fabrikam', 'Initech', 'Globex'];
var NOUNS = ['Patch lead', 'Transceiver', 'PDU', 'Shelf', 'Crimp kit', 'Adapter', 'Bracket'];
var pick = function (list) { return list[Math.floor(rand() * list.length)]; };
var rows = [];
for (var i = 0; i < count; i++) {
rows.push({
id: 'SKU-' + (1000 + i),
name: pick(NOUNS) + ' ' + (10 + Math.floor(rand() * 90)),
category: pick(CATEGORIES),
supplier: pick(SUPPLIERS),
price: 5 + Math.round(rand() * 4000) / 10,
stock: Math.floor(rand() * 240),
active: rand() > 0.15,
});
}
return rows;
}
Columns and editors
A column's type brings its editor, its sort, its export format and its clipboard round trip together, so naming the type is most of the work. A price is a number, a category is a choice from a list, active is a checkbox. The rules and the permission check named here are built in the sections below; the columns wire them in.
var money = { style: 'currency', currency: 'USD', decimals: 2 };
var CATEGORIES = ['Cabling', 'Optics', 'Power', 'Racks', 'Tools'];
var SUPPLIERS = ['Northwind', 'Contoso', 'Fabrikam', 'Initech', 'Globex'];
var columns = [
{ field: 'id', title: 'SKU', layout: { width: 120, pin: 'start' } },
{
field: 'name', title: 'Name', layout: { width: 200 },
edit: { enabled: true, editor: 'text', validate: rules.name },
},
{
field: 'category', title: 'Category', filter: { type: 'set' },
lookup: { options: CATEGORIES },
edit: { enabled: true, editor: 'select' },
},
{
field: 'supplier', title: 'Supplier', filter: { type: 'set' },
lookup: { options: SUPPLIERS },
edit: { enabled: true, editor: 'select' },
},
{
field: 'price', title: 'Price', type: 'number', format: money, total: 'sum',
edit: {
enabled: function (ctx) { return canEdit('price', ctx); },
editor: 'number', validate: rules.price,
},
},
{
field: 'stock', title: 'Stock', type: 'number', total: 'sum',
edit: {
enabled: function (ctx) { return canEdit('stock', ctx); },
editor: 'number', validate: rules.stock,
},
},
{
field: 'active', title: 'Active', type: 'boolean', layout: { width: 100 },
edit: { enabled: true, editor: 'checkbox' },
},
];
Inline, bulk and the fill handle
Turning editing on is one option. Adding a range selection with a fill handle is what turns single edits into bulk ones: select a block, drag the corner, and every filled cell is written through the same validation and the same save as a hand-typed one. The full grid setup follows further down; the part that makes bulk editing work is the selection and edit configuration.
Validation that refuses a bad value
A rule runs on commit, after the column parses the value and before it reaches the record. Return true to accept it, or return the sentence to show. A refused value never lands, and the editor stays open on it, so the person fixes it in place rather than discovering later that it was dropped. Because the rule is given the whole row, not just the cell, it can be about the record and not only about what was typed.
// Validation runs on commit, after the column parses the value and before it
// reaches the record. Return true to accept, or the sentence to show. A refused
// value never lands and the editor stays open on it, so nothing bad is written.
var rules = {
name: function (ctx) {
return String(ctx.value || '').trim().length >= 2 ? true : 'Give the product a name.';
},
price: function (ctx) {
var n = Number(ctx.value);
return Number.isFinite(n) && n >= 0 ? true : 'A price is a number, zero or more.';
},
stock: function (ctx) {
var n = Number(ctx.value);
return Number.isInteger(n) && n >= 0 ? true : 'Whole units, zero or more.';
},
};
Write back to your API
With optimistic writes on, the cell shows the new value the instant it passes validation, then waits for your save. If the save resolves, the pending mark clears. If it throws, the grid rolls the cell back to the last value a save vouched for and puts the thrown message where the person can read it. That covers the case validation cannot: a rule the server enforces and the browser does not.
// The write-back. In production this is one fetch to your own API; here it is a
// stand-in that answers after a moment. Resolve to accept the write; throw to
// have the grid roll the cell back to its last confirmed value and show why. The
// thrown message reaches the person who typed the value, not a log.
function saveToApi(change) {
// change carries { key, colId, value } and the rest. Send exactly what moved.
return new Promise(function (resolve, reject) {
setTimeout(function () {
// The server owns the last word. A price over the approval limit is
// refused on a rule the browser does not enforce.
if (change.colId === 'price' && Number(change.value) > 8000) {
reject(new Error('A price over 8,000 needs a second approver.'));
return;
}
// fetch('/api/products/' + change.key, { method: 'PATCH', body: ... })
resolve();
}, 500);
});
}
Two events report the outcome, if you want more than the flash on the cell.
// Two events report the outcome of each write, if you want more than the flash:
grid.on('cell:confirmed', function (e) {
console.log('saved', e.key, e.colId, '=', e.value);
});
grid.on('cell:reverted', function (e) {
console.log('refused', e.key, e.colId, ':', e.reason);
});
Per-cell permissions
Whether a cell may be edited is a question asked per cell, not per column, so a locked record refuses an edit while the row beneath it accepts one. Here a discontinued product is read-only apart from the switch that reactivates it. A column with no edit block at all is never editable, which is the default rather than something to switch off.
// Permissions, answered per cell. A discontinued product is locked apart from
// the switch that brings it back; a price can only move while the product is
// active. The predicate is asked for each cell, so two rows in one column can
// disagree.
function canEdit(field, ctx) {
var data = ctx.data || {};
if (field === 'active') return true;
if (!data.active) return false;
return true;
}
Undo and redo
One history stack covers every kind of write: a typed cell, a fill and a range clear each land as a single entry, so undo reverses the gesture rather than the cells it happened to touch. Fill a hundred cells and one undo puts all hundred back. The undo and redo buttons on the rail drive the same stack as the keyboard, so nothing extra is wired.
var grid = LatticeGrid.createGrid(document.getElementById('panel'), {
rowKey: 'id',
theme: 'light',
rows: generateProducts(5000),
columns: columns,
// A range selection with the fill handle is what turns single edits into bulk
// ones: select a block, drag the corner, and every filled cell writes through
// the same validation and the same save.
selection: { mode: 'multiple', ranges: true, fillHandle: true },
edit: {
enabled: true,
// Optimistic writes: the cell shows the new value at once, then waits for
// saveToApi. confirm 'auto' clears the pending mark when it resolves and
// rolls the cell back when it throws.
commit: saveToApi,
confirm: 'auto',
pendingTimeout: 4000,
// One history entry per gesture, so a filled block undoes in one step.
undoDepth: 100,
},
// A flash on each cell as its value changes, so a save landing is visible.
highlightOnChange: { duration: 400 },
// Conditional formatting: low stock in amber, none in red.
formatting: {
stock: [
{ id: 'stock-none', when: { op: 'eq', value: 0 }, style: { color: '#c92a2a', fontWeight: '600' } },
{ id: 'stock-low', when: { op: 'between', value: 1, value2: 10 }, style: { color: '#e8590c' } },
],
},
// The icon rail carries undo, redo, the Excel export and the column and filter
// panels, so the common actions have a home without any wiring.
toolPanel: {
side: 'left',
panels: ['columns', 'filters'],
actions: ['undo', 'redo', 'excel', 'clipboard', 'restore'],
exportName: 'products',
},
});
That is the whole panel: typed columns, validation, optimistic write-back, per-cell permissions and undo, all from one configuration. Run it in the sandbox and edit any part of it live.
Export to Excel
The rail already carries an export action, and a toolbar button gives you the same thing under your own control. It writes a real spreadsheet from the current rows and columns, filters and sort included, on the client and with no server round trip.
// The toolbar button writes a real .xlsx from the grid's current rows and
// columns, filters and sort included, with no ZIP dependency and no server.
document.getElementById('export').addEventListener('click', function () {
grid.export.excel({ fileName: 'products.xlsx' });
});
What you built
One dataset became a panel a team can work in: edit inline or in bulk, with a rule that refuses a bad value, a save that confirms or rolls back on what the server says, permissions decided per cell, undo over every gesture, and a spreadsheet on demand. Swapping the stand-in save for a call to your own API is the only change between this page and production.
Next, see the optimistic writes demo and the validation demo for these ideas as compact examples, or read the editing overview for the full set of editors and rules.