List views
A list view is the workhorse of an operator surface. Required keys: route, type, entity, columns.
{
"route": "/suppliers",
"type": "list",
"entity": "suppliers",
"title": { "en": "Suppliers", "de": "Lieferanten" },
"columns": [
{ "name": "code", "label": "Code", "type": "text", "width": "140px", "sortable": true },
{ "name": "name", "label": { "en": "Name" }, "type": "text", "width": "minmax(0,1.4fr)" },
{ "name": "status", "label": "Status", "type": "badge", "width": "130px",
"vocabulary": "suppliers.statuses" },
{ "name": "created_at", "label": { "en": "Added" }, "type": "relative-time", "sortable": true }
],
"filters": [
{ "name": "q", "label": { "en": "Search" }, "type": "text", "columns": ["code", "name"] },
{ "name": "status", "label": "Status", "type": "select", "column": "status",
"vocabulary": "suppliers.statuses" }
],
"sort": { "column": "created_at", "direction": "desc" },
"page_size": 50,
"row_action": "/suppliers/:id",
"actions": [
{ "label": { "en": "New supplier" }, "icon": "add-circle", "kind": "navigate",
"to": "/suppliers/new", "variant": "primary" }
],
"permissions": ["suppliers.read"]
}
entity is the entity path — typically your app's table name. The Cockpit reads it with a column projection derived from columns[], so a column you do not declare is not fetched.
Columns
A column is { name, label } plus a type picking the cell renderer. 26 types:
text number money boolean date datetime
badge image relation media media-collection
markets toggle default-flag meter progress
count template quantity identity relative-time
country-list flag string-list record-list localized-text
| Type | Renders |
|---|---|
badge | A pill, coloured by value. Give it a vocabulary so the tone and label come from the app that owns the values. |
money | Formatted per currency. Use currency_field so a price is never labelled in the wrong one, or currency when it is a fact about the collection rather than the row. |
relation | Resolved through the column's relation object — required when the type is relation. |
media / media-collection | A storage-asset thumbnail: the single asset, or the first of a gallery. |
markets | The market assignment of a scopeable row. |
toggle | An inline switch. On a non-boolean column, toggle: { "on": "active", "off": "inactive" } says what the two positions write. |
default-flag | The exclusive "is default" marker, with the mutual demotion built in. |
meter / progress | A bar, driven by thresholds or a total. |
count | A count over a child collection. |
template | A closed template: {prefix}{counter:pad(4)}{suffix}. Anything richer belongs in a generated column. |
quantity | A number with its unit. |
identity | A person or company cell, with avatar_from. |
relative-time | "3 days ago". stale_after_days marks it as stale. |
country-list / flag | Country codes as flags. |
string-list / record-list | A declared jsonb shape read as chips or rows. |
localized-text | The operator's locale out of a locale map. |
Common extras on any column:
| Key | Does |
|---|---|
width | A CSS grid track: "130px", "20%", "minmax(0,1.2fr)". |
sortable | Offer sorting on this column. |
hidden | Render off-screen on small breakpoints. |
format | A presentation variant: dates take short-datetime, date-window, relative, relative-urgent; numbers take percent or range. |
until_column | With format: "date-window" or "range", the column holding the closing bound — so an operator can answer "is this live today?" per row. |
link_to, href_column, external | Turn the cell into a link. |
vocabulary | {app}.{name} — where the enum comes from. |
zero_as_empty | A zero here means "nobody recorded one" and reads as an em dash — a tax rate on an untaxed line. |
editable | Edit in the cell. The write is optimistic and reverts on rejection — the operator keeps what they typed, not what the row said. |
editable_when | Which rows take a value in this cell. Only a quantified association has a quantity; offering the input on every row lets an operator set one the app ignores. |
on_denied | What a denied cross-app embed falls back to: raw-id, hide or fail. Cross-app reads are refused per tenant by design, so the honest answer is usually the raw key, not an error. |
Filters
Filters are the narrowest list in the file — five types in the schema:
text select multi-select boolean date-range
text, select, boolean and date-range. multi-select is in the schema and the renderer handles it, but no shipped app declares one — so it is supported rather than proven. Prefer the four if you want a shape with production mileage behind it."filters": [
{ "name": "q", "label": { "en": "Search" }, "type": "text", "columns": ["code", "name"] },
{ "name": "status", "label": "Status", "type": "select", "column": "status",
"vocabulary": "suppliers.statuses", "display": "pills", "show_counts": true },
{ "name": "active", "label": { "en": "Active" }, "type": "boolean", "column": "active" },
{ "name": "window", "label": { "en": "Added between" }, "type": "date-range", "column": "created_at" }
]
A text filter searches the columns you list, OR'd together. The others target one column; when neither column nor columns is set, name doubles as the column name.
| Key | Does |
|---|---|
vocabulary | Where the options come from. Use it whenever the values are a merchant's to extend — without it, every filter re-types its options as literals, which is how a filter comes to offer a value the tenant retired and miss one they added. |
options / options_from | Literal options, or options read from another entity. |
display | default, pills or tabs. |
show_counts, hide_empty | Show how many rows each option has; hide options with none. |
mode | server or client. |
via | Filter through a junction table instead of a column on the row. |
There is no free-form filter expression. If an operator needs a slice these five shapes cannot express, that is a capability answering a question, not a filter.
base_filters — what the list is
"base_filters": { "status": "active", "deleted_at": "is.null" }
Filters every read applies and no control can clear — what the list is, as opposed to how an operator narrowed it. Values may carry a filter operator such as is.null.
Sort and paging
"sort": { "column": "created_at", "direction": "desc" },
"page_size": 50
The operator can override the sort per column from the UI. Declare it on anything called "recent" — a list without an explicit sort shows whatever came back first.
Row navigation and actions
"row_action": "/suppliers/:id",
"row_actions": [
{ "label": { "en": "Edit" }, "icon": "pen", "kind": "navigate", "to": "/suppliers/:id/edit" },
{ "label": { "en": "Delete" }, "kind": "delete", "variant": "danger" }
]
row_action is where a row-click goes; omit it to disable row-click navigation. row_actions is a trailing column of per-row buttons, and :id — or any other row attribute — is substituted into a to or endpoint path.
actions are the toolbar buttons in the page header. Both use the same shape — see Actions and writes.
Selection and bulk actions
"bulk_actions": [
{ "label": { "en": "Pause" }, "kind": "api", "method": "POST", "endpoint": "/suppliers/:id/pause" },
{ "label": { "en": "Compare" }, "kind": "navigate", "to": "/suppliers/compare" }
]
Declaring bulk_actions turns selectable on unless it is explicitly false. Two dispatch semantics, and the difference matters:
| Kind | Dispatch |
|---|---|
api | Called once per selected row, with :id substituted per row. |
navigate | Entered once, receiving the whole selection as ?ids=<comma-separated> on the target route. |
KPIs
"kpis": [
{ "label": { "en": "Active suppliers" }, "agg": "count", "filters": { "status": "active" } },
{ "label": { "en": "Spend, 30 days" }, "agg": "sum", "column": "amount",
"entity": "supplier_invoices", "window": "30d", "format": "money", "on_unavailable": "dash" }
]
Metric tiles above the table. agg is count, sum or avg; window is a rolling window on the entity's timestamp column (7d, 30d); format is number, money or percent.
Each tile resolves on its own. One that cannot be read shows a dash rather than failing the surface — on_unavailable picks dash or hide.
Queues
"queues": [
{ "key": "needs_review", "label": { "en": "Needs review" },
"filters": { "status": "pending", "reviewed_at": "is.null" }, "icon": "clipboard-list" }
]
A named preset setting several filters at once. A queue is the question an operator asks; a filter is a column. Give it a badge to show how many rows are in it.
The rail
"rail": {
"label": { "en": "Browse" },
"sections": [
{ "key": "category", "type": "tree", "entity": "categories",
"parent_key": "parent_id", "label_field": "name" },
{ "key": "country", "type": "facet", "column": "country", "show_counts": true }
]
}
A left rail holding the dimensions an operator navigates by, as opposed to the ones they occasionally narrow with. tree walks a self-referencing entity; facet is a pill list over one column. A facet may filter via a junction table, take its options from a vocabulary, and allow multi selection.
View modes
"view_modes": ["list", "gallery"],
"gallery": { "image": "hero_image", "title": "name", "subtitle": "code" }
Alternate renderings of the same query. list is always available.
| Mode | Use when |
|---|---|
gallery | Rows carry an image worth scanning. image is optional — a catalogue read by card is how media QA finds the rows with none. |
cards | The shape of one row matters more than comparing rows. Configure with card. |
pivot | Rows collapse onto a key and cross an axis — stock levels by SKU across locations. Configure with pivot. |
card names fields, not typography — what a card looks like stays the Cockpit's, which is what keeps two card panels looking like the same product.
Saved views
"saved_views": true
Named filter-and-sort snapshots. Off unless declared — a view worth saving is an app-level judgement, not a default.
The empty state
"empty": {
"title": { "en": "No suppliers yet" },
"description": { "en": "Add your first supplier to start recording purchase prices." },
"icon": "building",
"action": { "label": { "en": "New supplier" }, "kind": "navigate", "to": "/suppliers/new" }
}
Copy for a state the renderer would otherwise word generically. The app knows why its list is empty; the renderer does not.
Grouping and reordering
group_by collapses the table under a column's values. reorder enables drag ordering on a position column.
Import and export
"io": {
"export": { "enabled": true, "format": "csv" },
"import": { "enabled": true, "format": "csv", "keys": ["code"], "mode": "upsert" }
}
Wires Import and Export buttons on the view. List views support import and export; detail views support export only. Present plus enabled !== false shows the button.
profile runs a saved import/export profile instead of an ad-hoc run; format is csv, xml, json or xlsx; keys and mode are import-only.
This surface is inert in the local preview, which has no import/export service behind it.
Panels
panels holds worklists the app computes, above or below the table. That exists because some answers have no query: "which stock rows are at or below their reorder point" compares two columns against a threshold that is partly a tenant setting — no row stores it and no filter expresses it. The app answers, the panel renders.
Where to go next
- Detail views — where a row click lands.
- Forms — the create and edit views.
- Actions and writes — the action shape used by
actions,row_actionsandbulk_actions. - Value lists — what a
vocabularyreference resolves against.