Skip to content

Lists, pages & forms

This page shows how to draw each part of an App — lists, record pages, dashboards and forms — with one small example per task; every key is listed in the DSL reference.

import { listView, queue, page, summary, related, dashboard, metric, chart } from 'tablewalk/app';
export const triage = listView('Triage', 'issue', {
query: 'status = backlog or status = ready',
presentation: queue('name', { lines: ['status', 'priority'] }),
});
export const issuePage = page('Issue', 'issue', [
summary(['name', 'status', 'assignee_id']),
related('Comments', 'comment', 'issue_id', { show: ['body', 'created_at'] }),
]);
export const overview = dashboard('Overview', [
metric('Open issues', 'issue', { query: 'status != done' }),
chart('Opened by month', 'issue', 'month created_at'),
]);

Every block reads and writes through the same permission-checked routes as a plain grid, and every look dresses it. Browse them all in the component tour.

A list view is a grid unless its presentation says otherwise. The same rows, tabs and filters work under every presentation.

Presentation Helper Use it for
Grid (none) Tables of records; editable in place, totals, row menus
checklist checklist(primary, checked) A to-do list that ticks a boolean
queue queue(primary, { lines, facts, badge }) Keyboard-first triage with bulk actions
board { kind: 'board', by, … } Lanes by status or by a reference
calendar calendar(date, primary, { end }) A month of dated records, or ranges
cards cards(primary, { value, facts, image }) Pictures and prices, a storefront
split { kind: 'split', detail, limit } An inbox: the list beside the picked record
Custom customList(component, { show }) Rows drawn by your own component

order: '<numeric column>' keeps a grid, checklist, queue or cards in the order an editor drags them into (Alt+↑/↓ on the keyboard), each move an ordinary undoable update. totals: ['amount'] adds a footer of sums over everything the list matches, not just the page.

presentation: { kind: 'queue', primary: 'name', lines: ['status', 'priority'] }
presentation: { kind: 'board', by: 'status', lanes: ['backlog', 'ready', 'in_progress'], order: 'rank', primary: 'name' }
presentation: calendar('start_date', 'employee_id.name', { end: 'end_date', badge: 'status', lines: ['policy_id.name'] })
presentation: { kind: 'split', limit: 25, detail: ['name', 'description'] }
  • Queue. Arrow keys move, Space selects, Shift extends, Ctrl/Cmd+A takes the page. A resource action marked bulk: true is offered for the selection; the view’s actions narrows them by label.
  • Board. A card moves by drag, by Alt+←/→, or from its ⋯ menu. Over a plain column it moves only through a resource action whose set is exactly the lane column. by may be a reference (lanes are that table’s rows), swimlanes adds rows across the lanes, and limits: { in_progress: 4 } shows “3 / 4” on the lane head without enforcing it.
  • Calendar. A card per record on its day, or with end one bar across the days it spans. Dragging a card to another day is an ordinary update. A phone reads it as an agenda. Preview.
  • Split. The view’s own list beside the picked record’s detail fields; the pick is part of the address (/thread/inbox/2).
resources: {
issue: {
access: 'write',
actions: [
action('Start work', { set: { status: 'in_progress' }, when: 'status = ready', bulk: true }),
action('Cancel', { set: { status: 'canceled' }, confirm: true, bulk: true }),
],
},
},

A bulk run writes each selected record on its own through the ordinary update route, capped at one page (200). One refusal leaves the others applied, and the result names every record that was not. A grid over the same resource offers the same selection with a checkbox column. For one atomic request over many records, use a command with targets: 'many'; see several records at once.

open: 'modal' opens a row’s record page over the list, with Previous and Next (j and k) stepping through the list in its own order; open: 'peek' opens a read-only glance.

filters draws a bar above the rows: a search box on the name column, a “+ Status” picker per other column, and the applied filters as chips said in words (“Status is not Done”, “Rent from $1,000”). filterBar: 'rail' draws them as counted facets beside the rows instead (preview):

vehicles: listView('Vehicles', 'vehicle', {
filterBar: 'rail',
filters: ['make', 'body_type', 'price', 'mileage', 'year'],
sorts: [{ label: 'Price, low to high', sort: 'price' }, { label: 'Newest', sort: 'listed_on desc' }],
})

A vocabulary or a reference is a checklist with live counts; a flag is a toggle; a date offers Overdue, Today, This week and a From/To range; a number with many values is a Min/Max range. On a phone the rail is a Filters sheet. Either way the selection is the list’s address, in words: /forecourt/vehicles?make=Toyota,Honda&price=..15000&sort=-price.

Save view… keeps the current tab, filters and sort as a named tab. An App without sign-in shares one set of saved views; an App with sign-in keeps each account’s own.

editable on a grid edits cells in place: true for every shown column the reader may write, or the columns named. A reference shown by name is edited with a picker.

tasks: listView('Tasks', 'task', {
show: ['subject', 'status', 'priority', 'due_on', 'owner_id.name'],
editable: ['priority', 'due_on', 'owner_id'],
})

Enter or F2 opens a cell, Enter saves, Esc cancels, Tab saves and moves on. Each cell is the record form’s update, judged by the server the same way, with Undo. With editable, a selection also offers Set field…: one value written to every selected record, reported applied or refused like a bulk action. The key, documents, file columns and columns given to actions by transitions are refused at load.

import on a resource or a list view offers Import beside New (preview):

resources: {
product: { access: 'write', import: true },
},
views: {
// Each row runs Record count on the line its bin and SKU find.
'count-sheet': listView('Count sheet', 'cycle_count_line', {
import: { command: recordCount, match: ['bin_id', 'product_id'] },
}),
},

The reader maps the file’s columns (up to 5,000 rows, 5 MB), then Check tries every row in a transaction that is rolled back and names each problem by field. Import writes the rows that passed in one transaction with one Undo; a command import runs the command once per row. Cells are read in the App’s ui.locale, references by their record’s name. Rows with problems download as a spreadsheet-safe CSV. On a tenant App every row lands in the member’s own tenant, with no Undo as one.

A page is a list of sections over one record:

import { page, summary, fields, workflow, related } from 'tablewalk/app';
export const opportunityPage = page('Opportunity', 'opportunity', [
summary(['name', 'stage', 'amount'], {
workflow: workflow('stage', ['prospecting', 'qualification', 'proposal', 'negotiation', 'closed_won'], { ordered: true }),
}),
fields(['probability', 'close_date'], { title: 'Forecast', tab: 'Details' }),
related('Tasks', 'task', 'opportunity_id', { show: ['subject', 'status', 'due_on'], tab: 'Work' }),
fields(['stage', 'owner_id', 'close_date'], { title: 'Properties', presentation: 'properties', region: 'aside' }),
]);

Key it in pages: pages: { opportunity: opportunityPage }.

  • region: 'aside' puts a section in the side panel.
  • Sections that share a tab share one panel of a tab strip.
  • A workflow strip shows where the record stands; with ordered: true earlier steps read as passed. It does not enforce transitions — use transitions or a command.
  • A fields section’s presentation is 'properties' (a property rail, edited in place), 'table' (a label-and-value sheet), 'facts' (values in one line), 'accordion', 'headline' (a price and its calls to action) or 'card' (the record a reference names, with a link to it).
Section Helper Shows
Fields fields(show, opts), summary(show) The record’s own values
Record header header({ status, facts, eyebrow, destructive }) The head’s status chip, facts line and verb order
Related list related(title, from, whose, opts) Rows pointing at the record
Current card current(title, from, whose, { query, sort, show, empty }) The one related row that matters now
Similar similar(title, from, { same, near }) Rows of the same table like this one
Figures metric, breakdown, chart with whose Counts and sums of related rows
Embedded view embed(view, { whose }) One of the App’s list views, narrowed to the record
Note note(text, { title, action }) The author’s guidance
History history(title, opts) The record’s own changes
Custom custom(title, component, opts) Your own component
related('Tasks', 'task', 'opportunity_id', {
show: ['subject', 'status', 'due_on', 'owner_id.name'],
query: 'status = open',
create: { label: 'Add task', show: ['subject', 'due_on'], defaults: { due_on: 'today' }, fill: { owner_id: 'owner_id' } },
empty: { title: 'No tasks yet', action: { label: 'View all tasks', view: 'tasks' } },
})

create opens the child resource’s own New form in a drawer, with the parent key filled for you. defaults sit over that form’s defaults, fill copies the record’s columns (the task takes the opportunity’s owner), and on a list with a query its equalities (status = open) fill the new row, so what is added is listed there. empty explains an empty list; empty: false hides the section until it has a row. totals sums columns over every related row, and same narrows by a second key the row shares with the record. Sections stand as written, each at its own width; a page that says listTabs: true draws adjacent related lists as tabs.

A page’s figures are the dashboard’s helpers given whose:

metric('Open balance', 'invoice', { whose: 'customer_id', measure: 'sum total', query: 'status = open' }),
breakdown('Issue status', 'issue', 'status', { whose: 'project_id' }),
chart('Billed by month', 'invoice', 'month issued_on', { whose: 'customer_id', measure: 'sum total' }),
note('Call before extending credit past the limit.', { title: 'Credit' }),
embed('invoices', { whose: 'customer_id', limit: 5 }),

Titles, empty copy and labels accept translated copy through messages() and copy(), with English as the fallback and Spanish offered in Preferences.

A related list over a file resource of images can be a gallery, with a lightbox (arrow keys, swipe, Escape). Other file lists draw as attachments, with Attach, preview, Download, Rename and Remove where the App allows.

related('Photos', 'vehicle_photo', 'vehicle_id', { presentation: 'gallery', show: ['caption'], sort: 'position', limit: 12 }),
{ kind: 'fields', title: 'Key facts', presentation: 'facts', show: ['year', 'mileage', 'fuel', 'transmission'] },
{ kind: 'fields', title: 'Features', show: ['features'], presentation: 'accordion', collapsed: true },
{ kind: 'fields', presentation: 'headline', region: 'aside', show: ['price'], subfigure: 'est_monthly',
actions: [{ label: 'Inquire about this car', view: 'inquire', open: 'modal', prefill: { stock_ref: 'stock_ref' } }] },
similar('Similar vehicles', 'vehicle', { same: ['body_type'], near: { price: 0.25 } }, {
query: 'status != sold', limit: 4,
show: ['title', 'price', 'year', 'mileage'], presentation: { kind: 'cards', primary: 'title', value: 'price', facts: ['year', 'mileage'] },
}),

A headline’s action opens one of the App’s views; open: 'modal' draws a public form over the record, and prefill starts it with the record’s values. On a public form, load refuses a prefill that reads a field the public grant does not open or fills an input the form does not name prefillable: without open: 'modal' the values ride in the form’s address, which history and referrers keep. A component’s openForm(view, prefill) is not checked at load. It always draws the form in a dialog, so nothing reaches an address, and fills only the prefillable inputs, shown and editable. The server cannot tell a prefilled value from a typed one, so its command checks both alike. similar lists rows sharing the same columns with near numbers within a fraction (0.25 is ±25%), closest first. Forecourt’s vehicle page uses all of these; they are in preview.

history() shows a record’s own changes, newest first: who, when, the action or command, and each field from what to what.

import { page, summary, history } from 'tablewalk/app';
export const taskPage = page('Task', 'task', [
summary(['subject', 'status', 'owner_id', 'due_on']),
history('History', { region: 'aside', limit: 20 }),
]);

Declaring it makes the resource keep a history, written in each write’s own transaction — a Save, a cell, an action, a command, an Undo, an agent’s call. Provision the store once:

import { provisionSqliteRecordHistory } from 'tablewalk/maintenance';
provisionSqliteRecordHistory('./tasks.db');
// provisionPostgresRecordHistory(url), provisionMysqlRecordHistory(url)

It is read as the reader may read: fields they cannot read, or that audit.redact names, show only that they changed. Writers are named, never by email. Not shown to signed-out visitors, and not yet under auth.rows: 'tenant'.

A resource’s notify rules tell people about writes. A bell in the App bar counts unread notices, and /{app}/notifications lists them all.

resources: {
post: {
notify: [{
on: 'insert', when: { status: 'published' },
to: 'thread_id.author_id', // a person, through auth.me
title: '{author_id.display_name} replied in {thread_id.title}',
open: 'thread_id',
}],
},
purchase_order: {
notify: [{ on: { changes: 'status' }, to: 'buyer_email', title: 'PO-{id} is {status}' }],
},
},

on is 'insert', 'update', { changes: column } or { action: label }. to is an email column, a reference to the auth.me table, or a path of up to two references. Notices are written in the write’s own transaction; the writer is never told of their own change, and a reader sees a notice only while they can read its record. Provision the store once with provisionSqliteInbox, provisionPostgresInbox or provisionMysqlInbox from tablewalk/maintenance. Needs sign-in. On a tenant App a notice is kept in the inbox of the tenant whose record was written, and read only there; email and mentions are not served there yet.

email: true on a rule sends each notice by email too, after the write commits, through the config’s email service: only to an account of the App that may read the record, and never to one that turned it off under Email me these too in Notifications. mentions: { in: 'body', by: 'handle' } tells the people a text column @-mentions, found by a handle column of the auth.me table; to may then be left out:

notify: [{ on: 'insert', mentions: { in: 'body', by: 'handle' }, email: true,
title: '{author_id.display_name} mentioned you in {thread_id.title}', open: 'thread_id' }],

A record’s first action is filled, the next two are quiet, the rest are under More, with Delete last when the resource says delete: true. Under a header(), destructive verbs (named in destructive, confirming, or setting a value toned bad) move last and are drawn quiet, so the filled one is the forward step. favorite: true on a resource or a view adds a star; starred items are listed under Favorites in the menu, kept in the reader’s browser.

import { dashboard, metric, chart, breakdown, rows, embed, activity, firstRun } from 'tablewalk/app';
export const operations = dashboard('Operations', [
firstRun('Get started', [
{ label: 'Add your first customer', view: 'customers' },
{ label: 'Add your first order', view: 'orders' },
]),
metric('Open pipeline', 'opportunity', { measure: 'sum amount', query: 'stage != closed_won', view: 'opportunities' }),
chart('Lines moved by day', 'stock_movement', 'day moved_at', {
over: 'moved_at', windows: ['last 7 days', 'last 30 days'], shape: 'bar',
}),
breakdown('Pipeline by stage', 'opportunity', 'stage', { measure: 'sum amount' }),
embed('orders', { title: 'Open orders', tab: 'Open', limit: 8, interactive: true }),
activity('Latest updates', { purchase_order: { icon: 'receipt' } }, { limit: 6 }),
], {
heading: 'Good morning, {me.name}',
windows: ['last 30 days', 'last 90 days'],
aside: [rows('Closing soon', 'opportunity', { show: ['name', 'amount', 'close_date'], limit: 4 })],
});
Section What it draws
metric A figure, its change against the prior period, an optional target meter; view makes the card a link
chart shape: 'line' (default), 'bar', 'donut' or 'stacked' (two keys)
breakdown Ranked horizontal bars by a column
matrix Counts by two keys
rows The newest or matching rows of a table
board A board of a table’s rows
embed A list view; interactive: true adds its search, filters, selection and bulk actions
activity Recent changes across resources, from their history, as the reader may read them
note The author’s guidance, with an optional link
firstRun Next steps in place of an empty dashboard; gone once the tables have rows
  • Periods. The dashboard’s windows apply to sections that declare over. A section may carry its own windows, addressed by its title: ?period.lines-moved-by-day=last-30-days.
  • Layout. width: 'half' | 'third' | 'two-thirds'; metrics share one strip. aside is a supporting column that follows the main flow on a phone.
  • Greeting. heading and description may say {me.name}.

Display rules live on the resource and dress every place a value appears:

import { money, tones } from 'tablewalk/app';
export const resources = {
opportunity: {
display: {
amount: money('USD'),
stage: tones({ closed_won: 'good', closed_lost: 'bad', proposal: 'warn' }),
},
},
};

The full list — percent, number, relative, items, duration, email, phone, url, masked, unit, bar, avatar, icons, values, humanize, subtitle — is under resources.

Forms are drawn from the schema. A resource’s form curates its New form:

app.ts
import { defineApp, type TypedAppConfig } from 'tablewalk/app';
type Tables = { task: 'id' | 'name' | 'completed' | 'due_on' };
export default defineApp({
id: 'tick', name: 'Tick', connection: 'tick',
resources: {
task: {
access: 'write',
form: {
show: ['name', 'due_on'],
defaults: { completed: false },
visibleIf: { due_on: 'completed = false' },
},
},
},
views: { tasks: { kind: 'list', table: 'task', label: 'Tasks' } },
home: 'tasks',
} satisfies TypedAppConfig<Tables>);
  • show orders the inputs, defaults fills them, groups lays them out, and visibleIf shows an input only while the draft matches a condition over its own columns. None of these changes what the server accepts.
  • A default may be a word: 'today' on a date, 'now' on a date and time, 'me' for the signed-in person (their row on a reference to the auth.me table, their email in a text column).
  • description is a sentence under the heading, and a group’s description one line under its label. open is 'drawer' (over the list) or 'page' (a centred page, as a command form is). Without it, up to 8 fields are a drawer, and more fields or steps are a page.
  • steps (preview) draws the New form as a stepper. A step’s id is its label’s slug unless you give one.

Each field is drawn from its column: a vocabulary of up to four words is radios, a longer one a listbox, both in the column’s values or humanized words. A flag is a switch, a date or timestamp a picker, and money shows its currency. Text longer than 255 characters, or named like prose (description, notes, body), gets several lines. fields changes one field’s dress:

form: {
description: 'One piece of work someone can finish.',
show: ['title', 'project_id', 'milestone_id', 'notes'],
defaults: { owner_id: 'me', due_on: 'today' },
groups: [{ label: 'The work', description: 'What it is and where it belongs.', fields: ['title', 'project_id', 'milestone_id'] }],
fields: {
title: { placeholder: 'Start with a verb', help: 'What done looks like.' },
milestone_id: { where: { project_id: 'project_id' } },
notes: { multiline: true },
},
},

label, help and placeholder are this form’s words. multiline forces one line or several. where narrows a reference’s choices by an earlier field: the milestone picker waits for a project, then lists only that project’s milestones. The server applies where on every write, so a milestone from another project is refused, and so is moving a task to a project that its milestone does not belong to.

  • Existing records use explicit Save and Discard by default; ui.editing.save: 'blur' saves a field when it loses focus.
  • A resource’s validation rules are checked on every write, and a refusal marks the field it names.
  • Deletion is opt-in with delete: true.
  • A Save, a New and a Delete each confirm with Undo. Only the person who made the change can undo it, and only while nobody has changed the record since and their access still writes it; otherwise Undo says why it cannot. The record’s history shows the Undo. A deletion that cascaded to other records offers none. Not on tenant Apps.

A business operation is a command, not a Save followed by something else. Command forms, signed in or public, are on the commands page, and so is a long public application saved on every step.

Where the menu sits and how wide the page is are ui.shell settings; see UI. A focused App can hide the chrome:

ui: {
shell: { menu: false, search: false, breadcrumbs: false, width: 'narrow', gutter: 'comfortable' },
},

On a phone the menu folds into the bar, tables become two-line record summaries, and dialogs fill the screen. ui.shell.counts: true shows live row counts in the menu.

Source: Compass opportunity page, Thread views, Forecourt vehicle page, Depot dashboards.