Detail views
A detail view is one record. Required keys: route, type, entity. Content comes from sections.
{
"route": "/suppliers/:id",
"type": "detail",
"entity": "suppliers",
"title": { "en": "Supplier" },
"sections": [
{
"title": { "en": "Identity" },
"fields": [
{ "name": "code", "label": "Code", "type": "text", "readonly": true },
{ "name": "name", "label": { "en": "Name" }, "type": "text" },
{ "name": "status", "label": "Status", "type": "select", "vocabulary": "suppliers.statuses" }
]
},
{
"title": { "en": "Timestamps" },
"column": "aside",
"fields": [
{ "name": "created_at", "label": { "en": "Created" }, "type": "datetime", "readonly": true },
{ "name": "updated_at", "label": { "en": "Updated" }, "type": "datetime", "readonly": true }
]
}
],
"actions": [
{ "label": { "en": "Edit" }, "icon": "pen", "kind": "navigate", "to": "/suppliers/:id/edit" }
],
"permissions": ["suppliers.read"]
}
:id is the entity-id placeholder in the route.
Sections
A section is one card on the surface. Without a variant it is the label → value grid.
| Key | Does |
|---|---|
title, description | Heading and intro. |
fields | The record's fields — same shapes as a form. |
variant | How the section renders. See below. |
column | main or aside. |
columns | How many field columns inside the card. |
count_from | A count shown in the heading. |
actions | Buttons on this section. |
flush | Drop the card padding. |
footnote | A sentence under the section — what the data means, or what it deliberately does not cover. Microcopy is product. |
fields_from | Fields resolved at runtime. The only way to express a surface whose shape is tenant data rather than app metadata — an attribute-driven product form. |
Section variants
definition-list timeline status-flow activity
comments app-panel market-assignment
| Variant | Renders |
|---|---|
definition-list | The default label → value grid. |
timeline | An event feed, configured with timeline. |
status-flow | The record's position in a state machine, configured with status_flow. |
activity | A logged-activity feed, configured with activity. |
comments | A notes thread. |
app-panel | A worklist the app computes, hanging off this record — the detail-view twin of a list panels entry. |
market-assignment | Which markets this row applies to. |
market-assignment holds no field of the record. Market scope lives in the scope registry, so it is written through the scopes API and gets its own save. It renders nothing on an entity that is not scopeable — see Permissions and scope.Layout
"layout": "main-aside"
stacked, main-aside, rail or profile. A section's column decides which side it lands on.
rail adds a persistent side panel with its own title, sections and fields.
Tabs
"tabs": [
{ "key": "overview", "label": { "en": "Overview" }, "sections": [] },
{ "key": "prices", "label": { "en": "Prices" }, "children": [],
"requires": { "columns": ["currency"] },
"placeholder": { "title": { "en": "Pricing not available on this version" } } }
]
Each tab carries its own sections, children and panels, plus an optional badge.
requires is load-bearing, not decoration. It renders the tab only when the installed version carries the named columns. Version skew across many installed apps is normal, and a tab referencing a column an older install does not have would otherwise render broken. placeholder is what shows instead.The header
"header": {
"image_field": "logo",
"badges": [{ "name": "status", "vocabulary": "suppliers.statuses" }],
"meta": [{ "name": "code" }, { "name": "country" }],
"parent": { "entity": "supplier_groups", "column": "group_id", "route": "/supplier-groups/:id" }
}
parent and ancestors give the record its place in a hierarchy; badges, meta and subline give the operator the facts they need without scrolling.
For records that are people or organisations, identity is the better shape — it names fields by role rather than by layout:
"identity": {
"title_from": ["first_name", "last_name"],
"fallback": "email",
"avatar": "initials",
"badges": [{ "name": "role" }],
"reach": [{ "name": "email", "type": "email" }, { "name": "phone", "type": "tel" }],
"parent_link": { "entity": "organizations", "column": "organization_id", "route": "/organizations/:id" }
}
Editing
"edit": {
"mode": "dialog",
"label": { "en": "Edit supplier" },
"fields": [
{ "name": "name", "label": { "en": "Name" }, "type": "text", "required": true },
{ "name": "country", "label": { "en": "Country" }, "type": "text" }
],
"submit_label": { "en": "Save" }
}
mode is dialog or page. write sends the save to your own capability instead of the row — see Actions and writes.
For a record nobody may edit:
"readonly": true
Say so explicitly for a record written by a checkout or a webhook, rather than leaving the absence of an edit button ambiguous.
panels on a detail view is a different thing again: a named subset of columns with its own save — a form embedded as a tab, with its own submit_label and an optional discard.
Child lists
"children": [
{
"entity": "supplier_prices",
"label": { "en": "Agreed prices" },
"parent_key": "supplier_id",
"currency_from": "currency",
"columns": [
{ "name": "sku", "label": "SKU", "type": "text" },
{ "name": "unit_price", "label": { "en": "Price" }, "type": "money" }
],
"sort": { "column": "sku", "direction": "asc" },
"page_size": 25,
"row_action": "/supplier-prices/:id",
"actions": [
{ "label": { "en": "Add price" }, "kind": "navigate", "to": "/supplier-prices/new?supplier_id=:id" }
]
}
]
An embedded, parent-filtered list of a related entity: order → items, product → variants, supplier → agreed prices. The Cockpit fetches the child entity filtered to parent_key = the current record's id.
It is deliberately a slim subset of a list view: pagination is page-local, and filters and import/export stay on the child's own full list view.
Two substitution rules that catch people out:
| Where | :id is |
|---|---|
row_action | The child row's id. |
actions | The parent record's id — so a create-form route can pre-bind the child's parent_key. |
Notable keys:
| Key | Does |
|---|---|
parent_key | The child column holding the parent's id. An array covers a polymorphic owner — an address hanging off either an organization or a contact. |
currency_from | The parent column naming the currency the whole collection is priced in. Applied to every money column that names none of its own, so a column with currency_field keeps the more specific answer. |
mode | table, grid, cards, matrix or pivot. |
edit, create, delete, reorder | In-place affordances on the collection. |
search | A search box naming the columns it looks in. A paged table searches server-side; the modes that read the whole collection filter what is in hand. |
group_by | Rows under headings — a column on the row, or a value a join away. |
row_tone | Rows worth setting apart — a quantity tier under the SKU it belongs to. |
bulk_adjust | Move one numeric column across the rows currently on screen — a repricing pass, scoped to what the search left visible on purpose. |
row_lock | Rows the operator may see but not remove, with the reason. A derived row reappears on the next recompute; saying so beats a delete that silently undoes itself. |
write | Where this collection's writes go, per verb. A verb left out stays a row write, so a collection can route its creates through the app and keep editing a position column as the plain write it is. |
on_unavailable | A cross-app child read may legitimately be denied per tenant. A muted note is almost always right; an error card is not. |
footer | Totals under the collection. |
through, unique, assign, matrix, pivot, card, empty, badge | Junction tables, uniqueness hints, assignment pickers and the alternate renderings. |
Banners
"banners": [
{ "when": { "status": "paused" }, "tone": "warning",
"title": { "en": "This supplier is paused" },
"detail": { "en": "No purchase orders will be raised against them." },
"since": "paused_at" }
]
tone is info, warning, danger or success. since names a timestamp column so the banner can say how long the state has held, and action puts a button in it.
Metrics
"metrics": {
"entity": "supplier_metrics",
"parent_key": "supplier_id",
"computed_at": "computed_at",
"stale_after_hours": 24,
"tiles": [
{ "label": { "en": "Spend, 365 days" }, "name": "spend_365d", "format": "money" }
],
"on_unavailable": "dash"
}
A panel over a projection keyed to this record.
stale_after_hours. A stale number presented as current is worse than no number, and the panel says so when computed_at is older than the window.Referenced by
"referenced_by": [
{ "app": "orders", "entity": "orders", "column": "supplier_ref",
"match": { "from": "code" },
"label": { "en": "Orders" },
"route": "/orders?supplier_ref=:code",
"on_unavailable": "note" }
]
Rows elsewhere that point at this record. match.from covers a business key crossing an app boundary — which a foreign key deliberately cannot, because the platform does not allow cross-app foreign keys.
on_unavailable is note or hide, because a cross-app read may legitimately be refused per tenant.
Delete
"delete": {
"kind": { "en": "supplier" },
"endpoint": "/suppliers/:id",
"blockers": [{ "entity": "supplier_prices", "column": "supplier_id",
"message": { "en": "This supplier still has agreed prices." } }],
"on_unverifiable": "block",
"on_success": "/suppliers"
}
endpoint wherever your app publishes a delete route. A plain row delete only ever meets the database's own constraints, so a guard the app holds cannot fire at all. With a route, the refusal comes back as the app's own message.on_unverifiable defaults to block when a blocker cannot be checked — the alternative is deleting on the strength of a question nobody answered.
Not found
"not_found": {
"title": { "en": "No such supplier" },
"action": { "label": { "en": "Back to suppliers" }, "kind": "navigate", "to": "/suppliers" }
}
Concurrency
"concurrency": "merge"
What a save does about a concurrent one. It applies to the edit affordance — the surface a detail view actually writes from.
| Value | Behaviour |
|---|---|
last-write-wins | The default. The form sends every field it holds and the newest write is the record. Right where one operator owns a row end to end. |
merge | Re-reads the row immediately before writing, sends only the columns this operator actually changed, and asks about a column both changed to different values. Right wherever two people work the same record — which for a catalog is the normal case. |
storage merges under both settings. Changing one key of a jsonb column means sending the whole column, so a save that did not merge would discard every other key somebody just wrote. There is no correct cheaper option, so it is not offered as one.Export
io.export puts an Export button on the view. Detail views support export only — import is a list-view affordance. See List views.
Where to go next
- Forms — the field shapes sections and
editreuse. - List views — where a detail view is reached from.
- Actions and writes — actions,
writeandendpoint. - Permissions and scope — gating the view and market-scoping the entity.