developer guide
Dashboard Designer Guide: Embed a Report Builder
Give your users a dashboard builder inside your own application. You hand the designer your data and your rules; it hands back the dashboard as plain JSON. This guide takes you from an empty element to a saved, guarded, AI-assisted report.
Embed it in five minutes
The designer is a separate module, createDesigner, that mounts on an element. It works in plain
JavaScript with no framework. Give it your sources, the viewers your page already loads, and where to start;
then listen for the state event, which fires once for every change an author makes. The state is
the whole dashboard as JSON, so saving it is one line.
import { createGrid, createHeadlessGrid } from '@toclocoinc/lattice-grid';
import { createLayout } from '@toclocoinc/lattice-grid/modules/layout';
import { createChart } from '@toclocoinc/lattice-grid/modules/charts';
import { createDesigner } from '@toclocoinc/lattice-grid/modules/designer';
const designer = createDesigner(el, {
mode: 'edit',
sources: { orders: { kind: 'rows', rows: orders, rowKey: 'id' } },
state: { // or leave it out: one empty page
pages: [{ id: 'p1', title: 'Overview', spec: { panels: [
{ id: 'table', kind: 'grid', title: 'Orders', source: 'orders' },
] } }],
selectedPageId: 'p1',
},
factories: { createGrid, createHeadlessGrid, createLayout, createChart },
});
designer.on('state', ({ state }) => console.log('save this', state));
mode: 'edit' opens the editing screen: a top bar with page tabs and undo and redo, a left rail with
the data panel, the live canvas, and a properties panel for the selected widget. mode: 'view' shows
only the dashboard. Switch at any time with designer.setMode('view'). The canvas is the same
dashboard a reader sees, so what an author builds is what readers get. For a complete page you can open and read,
see the demo repository, and for the
designer running over sample retail data, the Designer page.
Sources and relationships
A source is a named set of rows, or a pushdown source over a database. Say what each field is, with a label, a type and a format, or leave it out and the designer works it out from the rows. Declare how sources relate and the data panel lists them together, and a widget can bring in fields from a related source. Relationships are many-to-one, one-to-many or one-to-one.
const designer = createDesigner(el, {
mode: 'edit',
sources: {
orders: {
kind: 'rows', rows: orders, rowKey: 'id',
fields: [ // say what each field is...
{ id: 'amount', label: 'Order total', type: 'currency', format: '$0,0.00', description: 'What the order was worth.' },
], // ...or leave it out and it is worked out from the rows
},
customers: { kind: 'rows', rows: customers, rowKey: 'id' },
},
relationships: [{ from: 'orders.customerId', to: 'customers.id', type: 'many-to-one' }],
factories: { createGrid, createHeadlessGrid, createLayout },
});
designer.dataPanel.reachable('orders'); // ['customers']: what an orders widget can bring in
designer.dataPanel.compiledRelationships(); // the same links as data router join edges
Dragging a field from the data panel onto the canvas adds a sensible widget for it: a KPI tile that adds up a number, a grid for text. Each relationship also compiles to the data router join that realises it, so the same links can drive a router of your own.
Guardrails: what an author may use
Guardrails are an allow-list for sources, fields, widget kinds, chart types, formatting, pages, calculated fields, derived grids and AI, plus a cap on widgets per page. Anything you leave out is allowed. What you restrict is hidden in the palette, the properties and the data panel, and the API refuses a change that would add it, naming the guardrail. A saved dashboard that already holds a restricted widget is never dropped: view mode shows it, and edit mode shows it pinned and marked as locked, with no handles to move or resize it.
const designer = createDesigner(el, {
mode: 'edit',
sources: { orders: { kind: 'rows', rows: orders, rowKey: 'id' }, customers: { kind: 'rows', rows: customers, rowKey: 'id' } },
guardrails: {
sources: ['orders'], // customers is hidden from the data panel
widgets: ['grid', 'kpi'], // no charts, maps or notes
maxWidgetsPerPage: 6,
},
factories: { createGrid, createHeadlessGrid, createLayout },
});
designer.guardrails.allowsWidget('grid'); // true
designer.guardrails.refuse('widget', 'chart'); // names the guardrail that stops it
designer.setGuardrails({ widgets: ['grid', 'kpi', 'chart'] }); // change the rules while it runs
designer.setGuardrails(undefined); // and lift them
Saving and loading a dashboard
getState() returns the whole report as plain JSON, and setState() puts it back exactly.
Keys the designer does not know are carried through untouched, so a state saved by a newer release loads into an
older one without losing anything. Saved dashboards keep loading as the designer grows: an older state is brought
up to date as it loads, and a state from a newer version is refused by name rather than half-applied.
designer.migrate(state, { dryRun: true }) reports what a state would become without applying it.
const key = 'my-dashboard';
// Save on every change, to wherever you keep things.
designer.on('state', ({ state }) => localStorage.setItem(key, JSON.stringify(state)));
// Open with what was saved.
const saved = JSON.parse(localStorage.getItem(key) ?? 'null');
if (saved) designer.setState(saved);
// A saved dashboard from an older release loads as it always did. Check one first without applying it:
const report = designer.migrate(saved ?? designer.getState(), { dryRun: true });
console.log(report.ok);
The designer puts nothing outside its element and leaves no listener on the page, so destroy()
leaves nothing behind. Undo and redo cover every change and are cleared by setState() unless you
pass { keepHistory: true }.
Derived grids: the next question, answered
A derived grid is built from another grid or source with an ordered list of steps: filter, group and roll up, pivot, join a related source, sort and keep the top few. It is both a widget and a source other widgets can read, and it follows its parent live. Each step is one undo, and the data panel shows a one-line lineage.
// The page holds a grid widget for each source it reads: orders and customers.
const id = designer.commands.run('addDerivedGrid', {
from: 'orders',
title: 'Top customers',
fromLabel: 'Orders',
steps: [
{ kind: 'join', to: 'customers', fields: ['name'] }, // bring the customer across
{ kind: 'topN', by: 'name', limit: 8,
measures: [{ id: 'amount', of: 'amount', fn: 'sum' }],
sort: [{ col: 'amount', dir: 'desc' }] },
],
});
designer.derived.lineage(id); // 'Top customers ← Orders + customers'
designer.derived.provenance(id); // which steps the engine answered, and which engine ran them
designer.commands.run('addDerivedStep', id, { kind: 'filter', where: { col: 'amount', op: 'gt', value: 100 } });
designer.undo(); // every step is one undo
Over a pushdown source, steps the engine can answer run in the engine, and
designer.derived.provenance(id) says which ones, and which engine ran them. A step the engine
cannot do is refused by name in designer.derived.refusals(id) instead of being computed on a
partial page of rows. For the grid-level
version of the same idea, see derived and chained grids.
Statistics widgets
Six widgets add the statistics layer to a dashboard: column profile, distribution, correlation, group comparison, forecast and model error. Each is a chart widget, configured in the same properties panel as any other chart, and each is built on the grid's own statistics, so the figures match what the grid reports. Over a pushdown source the column profile computes in the data engine, and the others say by name that they need the rows. See statistics and shadow columns for the calls underneath.
designer.statisticsWidgets.kinds;
// ['columnProfile', 'distribution', 'correlation', 'groupComparison', 'forecast', 'modelError']
designer.statisticsWidgets.add('columnProfile', 'amount-profile', {
title: 'Order totals',
source: 'orders',
options: { y: 'amount' },
});
designer.undo(); // adding a widget is one undo step
AI assist with your own model
The assistant takes a sentence from an author, asks your model for a page, checks what comes back and shows what survives as a highlighted preview the author accepts or rejects. Accepting is one undo step. The model is reached through a callback you supply, so the designer carries no key and no endpoint, and no row leaves the browser unless you opt in to sending a bounded sample. Everything the model returns is checked twice: once against the sources, fields and widget kinds that exist, and again against your guardrails. Whatever is dropped is reported by name, and a reply that is not a proposal is reported rather than thrown.
const designer = createDesigner(el, {
mode: 'edit',
sources: { orders: { kind: 'rows', rows: orders, rowKey: 'id' } },
// Your own model call. The designer holds no key and no endpoint.
llm: async (request) => callMyModel(request),
factories: { createGrid, createHeadlessGrid, createLayout },
});
const result = await designer.assist.propose('A grid of orders');
if (result.ok) designer.assist.accept(); // shown as a preview first; accepting is one undo step
Keyboard, screen readers and themes
Every action works without a pointer. Alt+Shift+M switches between edit and view mode, Alt+Shift+N adds a note,
Alt+Shift+G adds a grid over the first source, F6 and Shift+F6 move between regions, and Alt+Shift+K lists
every shortcut. A widget's handle moves and resizes with the arrow keys, and each change is announced in a
polite live region. The designer follows your theme, including the four design-system presets, in light, dark
and high contrast; designer.audit() lists any control missing an accessible name.
For the dashboard format underneath, read Build a dashboard from a JSON spec. Comparing options? See Lattice Designer vs AG Studio.