Detail views

The detail renderer in Commerce Studio — sections and their variants, tabs, headers, embedded child lists, banners, metrics, referenced-by, delete guards and concurrency.

A detail view is one record. Required keys: route, type, entity. Content comes from sections.

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

KeyDoes
title, descriptionHeading and intro.
fieldsThe record's fields — same shapes as a form.
variantHow the section renders. See below.
columnmain or aside.
columnsHow many field columns inside the card.
count_fromA count shown in the heading.
actionsButtons on this section.
flushDrop the card padding.
footnoteA sentence under the section — what the data means, or what it deliberately does not cover. Microcopy is product.
fields_fromFields 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

text
definition-list    timeline    status-flow    activity
comments           app-panel   market-assignment
VariantRenders
definition-listThe default label → value grid.
timelineAn event feed, configured with timeline.
status-flowThe record's position in a state machine, configured with status_flow.
activityA logged-activity feed, configured with activity.
commentsA notes thread.
app-panelA worklist the app computes, hanging off this record — the detail-view twin of a list panels entry.
market-assignmentWhich 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

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

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

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

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

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

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

cockpit.json
"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_actionThe child row's id.
actionsThe parent record's id — so a create-form route can pre-bind the child's parent_key.

Notable keys:

KeyDoes
parent_keyThe child column holding the parent's id. An array covers a polymorphic owner — an address hanging off either an organization or a contact.
currency_fromThe 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.
modetable, grid, cards, matrix or pivot.
edit, create, delete, reorderIn-place affordances on the collection.
searchA 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_byRows under headings — a column on the row, or a value a join away.
row_toneRows worth setting apart — a quantity tier under the SKU it belongs to.
bulk_adjustMove one numeric column across the rows currently on screen — a repricing pass, scoped to what the search left visible on purpose.
row_lockRows 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.
writeWhere 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_unavailableA cross-app child read may legitimately be denied per tenant. A muted note is almost always right; an error card is not.
footerTotals under the collection.
through, unique, assign, matrix, pivot, card, empty, badgeJunction tables, uniqueness hints, assignment pickers and the alternate renderings.

Banners

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

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

Declare 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

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

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

cockpit.json
"not_found": {
  "title": { "en": "No such supplier" },
  "action": { "label": { "en": "Back to suppliers" }, "kind": "navigate", "to": "/suppliers" }
}

Concurrency

cockpit.json
"concurrency": "merge"

What a save does about a concurrent one. It applies to the edit affordance — the surface a detail view actually writes from.

ValueBehaviour
last-write-winsThe 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.
mergeRe-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.
A field writing through 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

Was this page helpful?