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.
Queues, boards and bulk actions
Section titled “Queues, boards and bulk actions”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: trueis offered for the selection; the view’sactionsnarrows 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
setis exactly the lane column.bymay be a reference (lanes are that table’s rows),swimlanesadds rows across the lanes, andlimits: { in_progress: 4 }shows “3 / 4” on the lane head without enforcing it. - Calendar. A card per record on its day, or with
endone 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
detailfields; 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.
Filter rails and your own views
Section titled “Filter rails and your own views”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.
Editing in a list
Section titled “Editing in a list”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.
Importing from a CSV file
Section titled “Importing from a CSV file”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.
Record pages
Section titled “Record pages”Compose a record page
Section titled “Compose a record page”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
tabshare one panel of a tab strip. - A workflow strip shows where the record stands; with
ordered: trueearlier steps read as passed. It does not enforce transitions — usetransitionsor a command. - A fields section’s
presentationis'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 lists and contextual creation
Section titled “Related lists and contextual creation”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.
Galleries, the lightbox and accordions
Section titled “Galleries, the lightbox and accordions”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.
A record’s history
Section titled “A record’s history”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'.
Notifications and the inbox
Section titled “Notifications and the inbox”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' }],The record header and Favorites
Section titled “The record header and Favorites”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.
Dashboards and display rules
Section titled “Dashboards and display rules”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
windowsapply to sections that declareover. A section may carry its ownwindows, addressed by its title:?period.lines-moved-by-day=last-30-days. - Layout.
width: 'half' | 'third' | 'two-thirds'; metrics share one strip.asideis a supporting column that follows the main flow on a phone. - Greeting.
headinganddescriptionmay 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 and Save
Section titled “Forms and Save”Forms are drawn from the schema. A resource’s form curates its New form:
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>);showorders the inputs,defaultsfills them,groupslays them out, andvisibleIfshows 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 theauth.metable, their email in a text column). descriptionis a sentence under the heading, and a group’sdescriptionone line under its label.openis'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 orstepsare a page.steps(preview) draws the New form as a stepper. A step’sidis 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
validationrules 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.