List views

The list renderer in Commerce Studio — columns and the 26 column types, the filter types, sorting, base filters, KPIs, queues, rails, view modes, bulk actions, saved views and import/export.

A list view is the workhorse of an operator surface. Required keys: route, type, entity, columns.

cockpit.json
{
  "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
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
TypeRenders
badgeA pill, coloured by value. Give it a vocabulary so the tone and label come from the app that owns the values.
moneyFormatted 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.
relationResolved through the column's relation object — required when the type is relation.
media / media-collectionA storage-asset thumbnail: the single asset, or the first of a gallery.
marketsThe market assignment of a scopeable row.
toggleAn inline switch. On a non-boolean column, toggle: { "on": "active", "off": "inactive" } says what the two positions write.
default-flagThe exclusive "is default" marker, with the mutual demotion built in.
meter / progressA bar, driven by thresholds or a total.
countA count over a child collection.
templateA closed template: {prefix}{counter:pad(4)}{suffix}. Anything richer belongs in a generated column.
quantityA number with its unit.
identityA person or company cell, with avatar_from.
relative-time"3 days ago". stale_after_days marks it as stale.
country-list / flagCountry codes as flags.
string-list / record-listA declared jsonb shape read as chips or rows.
localized-textThe operator's locale out of a locale map.

Common extras on any column:

KeyDoes
widthA CSS grid track: "130px", "20%", "minmax(0,1.2fr)".
sortableOffer sorting on this column.
hiddenRender off-screen on small breakpoints.
formatA presentation variant: dates take short-datetime, date-window, relative, relative-urgent; numbers take percent or range.
until_columnWith 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, externalTurn the cell into a link.
vocabulary{app}.{name} — where the enum comes from.
zero_as_emptyA zero here means "nobody recorded one" and reads as an em dash — a tax rate on an untaxed line.
editableEdit in the cell. The write is optimistic and reverts on rejection — the operator keeps what they typed, not what the row said.
editable_whenWhich 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_deniedWhat 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
text    select    multi-select    boolean    date-range
Four are in production use. Every shipped commerce app uses 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.
cockpit.json
"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.

KeyDoes
vocabularyWhere 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_fromLiteral options, or options read from another entity.
displaydefault, pills or tabs.
show_counts, hide_emptyShow how many rows each option has; hide options with none.
modeserver or client.
viaFilter 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

cockpit.json
"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

cockpit.json
"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

cockpit.json
"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

cockpit.json
"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:

KindDispatch
apiCalled once per selected row, with :id substituted per row.
navigateEntered once, receiving the whole selection as ?ids=<comma-separated> on the target route.

KPIs

cockpit.json
"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

cockpit.json
"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

cockpit.json
"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

cockpit.json
"view_modes": ["list", "gallery"],
"gallery": { "image": "hero_image", "title": "name", "subtitle": "code" }

Alternate renderings of the same query. list is always available.

ModeUse when
galleryRows carry an image worth scanning. image is optional — a catalogue read by card is how media QA finds the rows with none.
cardsThe shape of one row matters more than comparing rows. Configure with card.
pivotRows collapse onto a key and cross an axis — stock levels by SKU across locations. Configure with pivot.
The pivot reads the whole set, not a page. A grid built from page one of forty is an aggregate that quietly omits stock. Expect it to be slower than a page, and do not offer it on an unbounded list.

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

cockpit.json
"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

cockpit.json
"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

cockpit.json
"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

Was this page helpful?