Forms

The form renderer in Commerce Studio — create and edit modes, the 34 field types, sections and steps, constraints, seeds, runtime field lists, error mapping and concurrency.

A form view writes one record. Required keys: route, type, entity. mode is create (the default) or edit.

cockpit.json
{
  "route": "/suppliers/new",
  "type": "form",
  "entity": "suppliers",
  "mode": "create",
  "title": { "en": "New supplier" },
  "fields": [
    { "name": "code", "label": "Code", "type": "text", "required": true,
      "transform": "uppercase", "validation": { "pattern": "^[A-Z0-9_]{2,20}$" } },
    { "name": "name", "label": { "en": "Name" }, "type": "text", "required": true },
    { "name": "country", "label": { "en": "Country" }, "type": "text", "span": "half" },
    { "name": "status", "label": "Status", "type": "select",
      "vocabulary": "suppliers.statuses", "default": "active" }
  ],
  "submit_label": { "en": "Create" },
  "on_success": "/suppliers/:id",
  "errors": {
    "23505": { "en": "A supplier with that code already exists." }
  },
  "permissions": ["suppliers.write"]
}

Routes: a create form uses a static route (/suppliers/new); an edit form includes :id (/suppliers/:id/edit). on_success replaces :id with the created or updated id.

Fields

A field is { name, type } plus a label and its options. 34 types:

text
text        textarea    number      money       boolean     date
datetime    select      multi-select            relation    reference
json        image       email       url         tel         media
media-collection        localized-text          rules       fieldset
record-list string-list key-value   record-view editor-link secret
address     measure     price       market-assignment
tree-assign line-selection

Some of them need a companion object, and the distinctions are worth knowing:

TypeNotes
select, multi-selectNeed options, or better a vocabulary.
relationA direct read of a target table inside the same app. Needs a relation object.
referenceA searchable picker over the platform's cross-app lookup, so it may point at another app's entity. Needs a reference object, and the target entity must declare a lookup block in its schema.json — without it the picker degrades.
media, media-collectionOpen the storage media picker and store a delivery key (single, or a gallery). media config constrains the picker. Inert in the local preview.
fieldset, record-listNeed fields — the keys of the object being edited.
key-valueAn editable map, with keys_from, keys_readonly, show_missing, preserve_unknown_keys.
rulesA rule builder. Needs a rules config, and the app is the source of truth for its own rule vocabulary — the Cockpit renders what it is handed and never invents a field or an operator, so a condition the app would reject cannot be built in the UI.
secretPair it with write_only.
editor-linkPoints at the Cockpit surface that owns this document. It is the one place in a manifest that names a Cockpit route, deliberately: only the app knows that its definition column is the thing the builder edits.
measure, price, addressComposite values.
market-assignmentWhich markets the row applies to. Written through the scopes API, not as a column.
tree-assignAssign into a self-referencing hierarchy; needs tree.
line-selectionNeeds lines, naming the capability that answers the parent record's lines and how much of each is still open.
template (on any field)A read-only value composed from the field's siblings, in the closed {prefix}{counter:pad(6)} syntax, re-read on every keystroke so a preview follows what the operator types. Never written back.

fields on a jsonb column is the point, not a formality

Declaring fields on a fieldset or record-list is what turns a jsonb column into a form: the Cockpit writes exactly those keys and nothing else, so a typo an operator could make in a JSON textarea is not expressible.

line-selection reads the budget, it does not derive it

The open quantity per line is read from the capability, never computed client-side. "Ordered minus shipped minus cancelled" agrees with the route that accepts the write only by coincidence. This is the same reason GET /v1/orders/{id}/shippable exists.

Common field keys

KeyDoes
required, default, placeholder, description, hintThe obvious ones.
readonlySurfaces the value and disables editing. Detail views default to readonly when omitted.
readonly_when + readonly_reasonConditional, with the reason said out loud.
write_onceEditable on create, fixed afterwards.
write_onlyNever read back, replaced rather than edited. A secret the Cockpit could display is a secret already lost, so this also keeps the column out of every read. Pair with replace_action and replace_warning.
visible_whenShow only while the predicate holds. A field whose parent choice makes it meaningless is hidden and nulled — that is what keeps a matrix basis off a fixed-price method.
on_emptyWhat an empty value writes: omit (drop the key so the database applies its default), null, or empty. Inferred from the schema when absent. This is the difference between letting the database decide and writing '' into a constrained column.
transformuppercase, lowercase or trim.
validationmin, max, min_length, max_length, pattern, min_items, max_items.
spanauto, half or full.
vocabulary{app}.{name}. Prefer it for any enum an app enforcesoptions stays valid as a literal fallback.
options_from, on_pick, derives, from, link, junctionOptions from another entity, side effects of a pick, derived siblings, reading out of a jsonb document, links, junction tables.
currency_fieldFor money: the column naming the currency. A field that cannot say gets labelled EUR, which on an order priced in anything else is the wrong number, not a formatting detail.
item_labelThe singular noun for one row of a record-list or key-valueunit, entry, key.
localized, channel_scopedThe value is held per locale, or per channel, rather than once per record. Used to explain why the field cannot be edited on a surface that named no locale, and to keep it out of a rule — a rule has no scope to evaluate it in.
storageWhere the value is written, when it is not a column of its own — a path inside a jsonb document. The write-side pair of from, and what a custom-field app needs.
sourceThe value comes from the app rather than from the record or a sibling. The read-side counterpart of write, and just as narrow: for the cases where an app route is the honest origin and a row read is a plausible wrong answer — an order position's unit price taken off the price book.

Sections

cockpit.json
"sections": [
  { "title": { "en": "Identity" }, "fields": [] },
  { "title": { "en": "Commercial terms" }, "columns": 2, "fields": [] }
]

Group the form. Same shape as a detail view's sections — see Detail views.

Steps — a wizard

cockpit.json
"steps": [
  { "key": "basics", "title": { "en": "Basics" }, "fields": [] },
  { "key": "terms", "title": { "en": "Terms" }, "fields": [],
    "when": { "kind": "contract" }, "display": "cards" }
]

A form that splits across steps declares its fields there rather than at the top level. when skips a step conditionally; display is fields or cards.

Constraints — cross-field rules

cockpit.json
"constraints": [
  { "op": "lte", "left": "min_order_value", "right": "max_order_value",
    "message": { "en": "The minimum cannot exceed the maximum." } },
  { "op": "requires", "left": "kind", "when": { "kind": "psp" }, "require": ["provider"],
    "message": { "en": "A PSP method needs a provider." } }
]

op is lte, gte, lt, gt, eq, neq or requires. Every constraint carries a message — the sentence the operator sees.

Seeds

cockpit.json
"seed": { "from_query": ["supplier_id"], "values": { "status": "draft" } }

Pre-fills a create form — from the route's query parameters, or with fixed values. This is how a child list's "Add" action pre-binds the child's parent_key.

Runtime field lists

cockpit.json
"fields_from": { "capability": "products.attribute-schema", "params": { "family_code": ":family_code" } }

Fields resolved at runtime and appended to the declared ones. This is the only way to express a form whose shape is tenant data rather than app metadata — an attribute-driven product form is exactly that, which is why GET /v1/products/attribute-schema exists.

The same seam is section.fields_from on a detail view; here it is on the surface that actually writes.

Error messages

cockpit.json
"errors": {
  "23505": { "en": "A supplier with that code already exists.",
             "de": "Ein Lieferant mit diesem Code existiert bereits." },
  "23503": { "en": "That supplier group no longer exists." }
}
Declare these. Without them a unique violation reaches the operator as 23505 duplicate key value violates unique constraint …. Map the codes your form can actually provoke.

After a successful save

cockpit.json
"submit_label": { "en": "Create" },
"success_message": { "en": "Supplier created." },
"on_success": "/suppliers/:id",
"then": [
  { "kind": "api", "method": "POST", "endpoint": "/suppliers/:id/sync" }
]

then runs follow-up actions after the write lands — a sync, a recompute, a notification. submit_label defaults to "Save".

Where the save goes

By default the form writes the entity: a create is a row insert, an edit a row update.

cockpit.json
"write": { "method": "POST", "endpoint": "/suppliers" }

Declare write when the change has a side effect the database cannot produce on its own. See Actions and writes.

Concurrency

cockpit.json
"concurrency": "merge"

last-write-wins (default) or merge. Same semantics as on a detail view — see Detail views. For a catalog form two people work at once, merge is the right answer.

Where to go next

Was this page helpful?