Skip to content

Form Views

Form views display and edit single records.

A form row takes no spacing properties. Write the structure and stop:

- type: row
content:
- type: column
span: 6
content: [...]
- type: column
span: 6
content: [...]

Do not set gutter — nor margins or padding on rows and columns. The form grid already spaces them, consistently, everywhere. A row that names its own gutter is one form deciding to look different from every other form in the product, and it usually happens by copying a value out of a view that had already made that mistake.

If a layout genuinely cannot be expressed without custom spacing, that is a sign the structure is wrong — reach for a different arrangement of rows and columns, or a fieldset or paper section, rather than nudging pixels.

- data_type: UiView
identifier: contact_form_view
type: Form
model: Contact
arch:
- type: row
content:
- type: column
span: 6
content:
- type: field
name: name
properties:
widget: TextInput
size: lg

type: is a closed vocabulary — and it is checked

Section titled “type: is a closed vocabulary — and it is checked”

Every renderer dispatches on type and ignores what it does not recognise. An invented or misspelled type therefore renders nothing — the element and everything inside it — with no error at load, none at composition and none in the console. Three of those were live in the shipped views when this check was added: a form whose “Mark Done” button was wrapped in type: action_buttons (so the button had never existed), a type: StatusBar that drew no status ribbon, and a type: separator that drew no rule.

So the vocabulary is now enforced, at YAML load and by ./fullfinity-server check --only views:

- type: actionButton # ✅ a button on a form, at the arch root
- type: action_buttons # ❌ (T1) rejected — nothing renders it
- type: statusbar # ✅
- type: StatusBar # ❌ (T1) not a type, and not a widget either
- type: divider # ✅ a rule; its caption goes in properties.label
- type: separator # ❌ (T1)

What a Form or Wizard knows: row, column, group, field, statusbar, actionButton, linkButton, button, inlineButton, text, divider, title, alert, footer, stepper, tabs, tab, pageTabs, paper, fieldset, accordion, propertiesPanel, settingsPanel, settingsRow, settingsDivider, settingsLink, pageBuilderPanel. Other view types have their own sets (a List adds sum and activities; a Search view is filter/group/browsepanel/field; a Dashboard is row/column/widget). When in doubt, copy the type from a working view rather than inferring it from what the element is called.

A visible: expression that cannot parse is rejected too

Section titled “A visible: expression that cannot parse is rejected too”

visible, readonly, required and filter hold Q-expression strings. A malformed one does not raise — it fails to evaluate and the element is treated as hidden, and “never appears” looks exactly like “correctly hidden”. Both checks run in the same pass (T2):

visible: Q(state='Done') & Q(active=True) # ✅
visible: Q(state='Done') and Q(active=True) # ❌ (T2) `and` is not the connector — use `&`
visible: Q(state='Done' # ❌ (T2) never closed

Runtime names (uid, cid, a context key) and the child-list dialect Q(_parent.state__in=[…]) are both understood, so only genuinely malformed expressions fail.

Forms use a 12-column grid system:

- type: row
content:
- type: column
span: 6
content: [...]
- type: column
span: 6
content: [...]

A form usually leads with a header row: one span: 6 column holding the record’s name as a large, unlabelled field. The size: lg typography is what marks it as the title, so don’t widen the column to make the point, and don’t squeeze a second column in beside it.

- type: row
content:
- type: column
span: 6
content:
- type: field
name: name
properties: {widget: TextInput, size: lg, nolabel: true}

The title field must be editable — otherwise leave the row out. The record’s display name (its _name_field) is already on screen twice as chrome: in the breadcrumb and in the record header beside the status badge. An unlabelled title field bound to that same field earns its slot only by being the place you type the name. Give it a display-only widget (Text, Badge, …) or readonly: true and it becomes a third copy of a string the user is already reading — in a slot that looks like an input and isn’t.

So when the name is derived rather than entered — a sequence number, a computed label — the form has no title row at all. That is how the order and invoice forms are laid out: their number appears in the header and nowhere in the body.

A labelled readonly field bound to the name field is a different thing and stays fine — the caption gives it meaning in context (a frozen “Product” on a picking line, a computed “Name” beside a variant’s image). The rule is about the unlabelled title slot only, and it is pinned by modules/core/tests/test_form_title_not_duplicated.py.

- type: column
span: 6
content:
- type: row
content:
- type: column
span: 6
content: [...]
- type: column
span: 6
content: [...]

Rows use a responsive gutter (spacing between columns) that defaults to { base: 16, md: 100 }. You can override this per-row using the properties.gutter property:

- type: row
properties:
gutter: 16
content:
- type: column
span: 4
content: [...]
- type: column
span: 4
content: [...]
- type: column
span: 4
content: [...]
Gutter ValueUse Case
16Tight spacing for related fields (e.g., City/State/Zip)
{ base: 16, md: 100 }Default - responsive spacing
Custom numberFixed pixel spacing

A column stacks its children vertically, so properties.align places them horizontally — which is how you dock something narrower than its column (an icon tile, a logo, an avatar) to the column’s right edge instead of letting it hug the left:

- type: row
content:
- type: column
span: 6
content:
- type: field
name: name
properties: {widget: TextInput, size: lg}
- type: column
span: 6
properties:
align: end # start | center | end | stretch
content:
- type: field
name: icon
properties:
widget: BoxedIcon
w: 72
nolabel: true
background_field: color

The same key on a row aligns that row’s columns on the cross axis (align: center vertically centres columns of different heights), and it works identically on a Kanban card’s columns. Note that CSS grid properties such as justifyItems do nothing here — these are flex containers; use align.

Money: lay out the currency beside the amount

Section titled “Money: lay out the currency beside the amount”

A Form that shows a Monetary field must also lay out the field its currency comes from. The currency reaches the client only when its own field is in the layout, so an amount without it renders as a bare number with no symbol.

This is enforced, not advisory: the view is refused when it is imported, which aborts your module’s install. check --only views reports it at build time so you find it before a user does.

- type: field
name: currency # carried hidden — the company's, not a per-record choice
properties:
visible: false
- type: field
name: price
properties:
widget: Monetary

Three things narrow the rule:

  • Forms only. A List column or an embedded line view resolves currency per row, so the rule does not apply there.
  • A nested currency needs only its root. For currency_field="company__currency", having company in the view is enough.
  • Inherited views count as one. A view’s fields are unioned with its whole inheritance chain, so contributing an amount to a base form that already carries the currency is fine.

An amount that declares no currency_field at all is exempt — that field has opted out of currency formatting.

Display status at the top:

- type: statusbar
name: status
properties:
widget: Badge
variant: dot
colors:
Draft: gray
Confirmed: blue
Done: green
Cancelled: red

colors keys are the field’s stored values — for a Selection field the readable choice values ("Draft", "Posted"), for a Boolean field the strings "true" and "false".

A status bar can also ride a Boolean field (e.g. an active / is_published flag). The dot color carries on/off; the label names the dimension. Give it a labels map so each state reads clearly — without it the label defaults to the humanized field name (is_published → “Published”), which reads oddly for the false state:

- type: statusbar
name: is_published
properties:
widget: Badge
variant: dot
colors: {'true': green, 'false': gray}
labels: {'true': Published, 'false': Draft}

active belongs on the ribbon, never in the body

Section titled “active belongs on the ribbon, never in the body”

active is the archive flag: declaring it on a model is what turns on soft-delete, and the user retires a record through the record’s three-dot menu — Archive / Unarchive. So a form shows that state as a ribbon and never as an editable control:

- type: statusbar # correct — chrome, read at a glance, cannot be typed into
name: active
properties:
widget: Badge
variant: dot
labels: {'true': Active, 'false': Archived}
colors: {'true': green, 'false': red}

A Switch bound to active in the form body is a second door onto the same state, sitting among the record’s real data as though it were one of them. Don’t add one — a form that carries it fails the build.

Naming the flag is_active does not sidestep the rule, it makes things worse: soft-delete is auto-detected from a field named exactly active, so an is_active model gets no archive action at all and the toggle becomes the only way to deactivate. Name it active and the menu action arrives for free.

A genuine capability switch is a different thing and is welcome on a form — name it for the capability it controls (enabled, sync_enabled, auto_confirm), not for “active”. A record that is live but not currently published, or kept but not synced, is expressing something archiving cannot.

Trigger model methods. An actionButton lives at the arch root — a sibling of your row and tabs nodes, never inside one:

arch:
- type: actionButton # root level
anchor: confirm_order
properties:
label: Confirm
icon: Check
variant: primary
method: confirm_order
visible: Q(status__eq='Draft')
- type: row # ...alongside the layout, not within it
content: [...]

The action bar is collected from the top-level nodes only. A button written inside a column or a tab renders nowhere at all: the form body deliberately skips actionButton because it expects the action bar to own it, so the button is present in the view, valid, and invisible. check --only views rejects it.

A button that belongs to one tab stays associated through its visible: condition rather than its position — give it the same gate the tab carries. To contribute a button to someone else’s form, see targeting the arch root.

PropertyDescription
labelButton text
iconLucide icon name
variantprimary, secondary, filled, light, outline, subtle, default
methodModel method to call
visibleBoolean or Q-expression — when the button is shown (see Conditional Visibility)
disabledBoolean or Q-expression — when the button is shown but not pressable (see Hiding vs disabling)
hintHover tooltip. Doubles as the reason shown on a disabled button
primaryBoolean or Q-expression — when this button is the header’s hero action (see below)
ctxCustom context passed to method
auto_refetchReload after action
instanceList/Kanban only — the button needs a selection. Derived from the method signature; declare it only to override (see Selection actions)
confirmAsk before running the method (see Confirmation prompts)

visible and disabled take the same true/false/Q-expression grammar and are resolved against the same record — but they answer different questions, and picking between them is a design decision, not a preference:

UseReads as
visible: Q(...)The action does not belong to this record — a refund button on an unpaid invoiceThe record simply doesn’t have that action
disabled: Q(...)The action belongs but isn’t available yet — Post while a required field is empty, Activate on the theme that’s already active”This is a thing you can do here, just not now”
- type: actionButton
properties:
label: Activate
method: action_activate
disabled: Q(is_current_theme='Current Theme')
hint: This theme is already active on your website.

Write a hint whenever you write a disabled rule. A greyed button with no explanation is worse than a hidden one — the user can see the thing they want and is told nothing about why it won’t respond. The framework shows the hint as the button’s tooltip (and as a second line under the label where the button lives in a menu), so one sentence covers it.

Reach for visible when hiding keeps the screen honest, and disabled when hiding would make the screen lie about its own shape — a card whose siblings all offer an Activate button, a workflow bar that changes length as you edit, a step in a fixed sequence.

Two rules that hold everywhere:

  • It is an affordance, not a permission gate. The greying happens in the browser, so the method still validates on the server. Use groups: for who may run something, and record rules for what they may reach — never disabled alone.
  • A rule can only read fields the view fetched. In a list or kanban that means the row’s own columns; add the field as a hidden one (properties: {visible: false}) if the rule needs something no column shows.

The same prop, with the same meaning, is available on every button the framework renders — form header buttons, list toolbar and row actions, kanban card buttons, LinkedRecords row actions, FieldDeck worksheet buttons and wizard footer buttons. See Running a method from a list and FieldDeck.

Put confirm on a button and the method only runs once the user agrees. The short form is the question itself:

- type: actionButton
properties:
label: Post
method: action_post
confirm: Post this invoice? Posting can't be undone.

The long form adds a heading, the confirm button’s own label, and its tone:

- type: actionButton
properties:
label: Archive
method: action_archive
confirm:
title: Archive Leads
message: Archive {count} lead(s)?
label: Archive
variant: danger # primary (default) | warning | danger

{count} is replaced with the number of records the action will run on, so one button reads correctly from a form (one record) and from a list selection (many).

The same confirm grammar works on every button that calls a method — an actionButton, a widget: Button column, a LinkedRecords row action, a portal action button — and confirm: false removes one that a base view declared, which is how an inheriting view drops a prompt it doesn’t want. The prompt’s text is translated like any other view string.

A confirmation is a misclick guard, not a permission gate: it exists only in the UI, so a method that must never run unchecked still validates on the server.

Every action button is reachable from the keyboard, and you declare nothing to make that happen.

Holding Alt draws a letter on each button, taken from the button’s own label — create on Confirm, S on Send. Press the letter and the button runs. Because the key is shown on the button itself, there is nothing to memorise and nothing to look up first: a user reads it off the control they were already reaching for, and after a few repetitions stops holding Alt at all.

The same overlay covers the record toolbar, the form’s page tabs, the view switcher and the app’s menu entries, so one held key exposes every route out of the screen.

Two fixed keys complete it:

KeyWhat it runs
Ctrl/Cmd + EnterThe record’s current hero action — whichever button primary resolves to for the record’s live state
Ctrl/Cmd + .A searchable list of every currently-visible button, for a long action list

Both resolve exactly the buttons a visible: rule allows on that record, so they never offer an action the record can’t take, and running one behaves the same as clicking it — the confirmation prompt, the wizard, and the list selection all still apply. A button held back by a disabled rule stays listed in the searchable palette (greyed, with its hint) but does not run, and Ctrl/Cmd + Enter does nothing while the hero action is the disabled one — the keyboard and the pointer are gated identically.

Letters are assigned from the labels you already wrote, in a fixed order, so a form’s keys don’t shuffle between visits. Two buttons whose labels start with the same letter simply take different letters from within their words (Create Invoice takes set when create is already spoken for). There is no property to set and no collision to resolve.

Because the key is read rather than remembered, it does not need to stay the same as the record moves through its states: a button that only appears on a confirmed order can take whatever letter is free at that point without breaking anyone’s habits.

A form’s header renders its action buttons as a split button: one large primary (hero) button plus a dropdown holding the rest. The hero should be the record’s next obvious, state-advancing action — for a confirmed order that’s Create Invoice, not Send.

Which button is the hero is chosen against the live record, not fixed at design time. Use the primary property to declare when a button leads:

primary valueMeaning
omittedNever the hero — a utility/communication action (Send, Print, Cancel). Always in the dropdown.
trueHero whenever the button is visible. Use this when the button only appears in the states where it should lead.
Q(...)Hero only when the Q-expression matches the record. Use when the button is visible in more states than it should lead in. Same syntax and fields as visible (see Q Expression Syntax); its referenced fields must exist in the view and are fetched automatically.

Among the currently-visible buttons the framework picks the hero and drops the rest into the dropdown; if none is eligible, the first visible button leads. Convention: mark each state-advancing action primary, and leave communication/utility/destructive actions (Send, Print, Duplicate, Cancel, Delete) without a primary key so they live in the dropdown.

Because visibility is per-state, several buttons can each carry primary: true safely as long as their visible conditions don’t overlap — only one is ever visible (and thus hero) at a time:

- type: actionButton
properties:
label: Confirm
method: action_confirm
variant: primary
visible: Q(state__in=['Quote','Sent'])
primary: true
- type: actionButton
properties:
label: Create Invoice
method: action_create_invoice
variant: primary
visible: Q(state='Confirmed') & Q(invoice_status__neq='Fully Invoiced')
primary: true
- type: actionButton
properties:
label: Send
method: action_send
variant: secondary
visible: Q(state__in=['Quote','Sent','Confirmed'])

Here Confirm leads on a quote, Create Invoice leads once confirmed, and Send — visible throughout — always sits in the dropdown.

Within a single view, don’t declare two buttons primary in the same state — scope one with a Q(...) so at most one leads per state (the view gate rejects a same-view double-hero at commit time). Across different modules, though, this is expected and allowed: a module that extends a form you depend on may add its own primary action that leads in a state the base form already leads in. When two eligible heroes collide in one state, the one contributed by the more dependent module wins (the module that comes later in the dependency order) — so an add-on can override the base form’s hero just by declaring its own, without the base having to yield. Order among unrelated modules follows the same dependency ranking used for all view inheritance.

Action buttons automatically pass context to backend methods. Access via self._ctx:

- type: actionButton
properties:
label: Send Email
method: action_send_email
ctx:
template: welcome_email
send_copy: true
class Contact(Model):
async def action_send_email(self):
# Auto-provided context
active_model = self._ctx.get('active_model') # 'Contact'
active_ids = self._ctx.get('active_ids') # Selected record IDs
# Custom context from button
template = self._ctx.get('template') # 'welcome_email'
send_copy = self._ctx.get('send_copy') # True
for record_id in active_ids:
record = await Contact.get(record_id)
await record.send_email(template=template)
return {'type': 'close', 'reload': True}

Auto-provided context keys:

KeyDescription
active_modelModel name
active_idsList of selected record IDs

Show a stat (a count, a quantity, an amount) that reads from a model field, and jump to the related records:

- type: linkButton
name: order_count
properties:
label: Orders
icon: ShoppingCart
action: orders_action
filter: customer

name must be a real field on the view’s model. The number a link button shows is read from the field named by name (a Float, Monetary, or count field — typically a computed, non-stored stat). Naming a field the model does not define is rejected loudly when the view is saved and by the check --only views gate — this prevents a button that silently renders with no count. The field also drives formatting (its precision, suffix_field/prefix_field) and the button always shows a real 0 when the stat is zero.

Navigation-only buttons omit name. For a link button that just navigates (no count to display — e.g. “Review on Gantt”, “Credit Note”), leave name out entirely and drive it with method (or action):

- type: linkButton
properties:
label: Review on Gantt
icon: GanttChart
method: action_review_schedule

A button that opens ANOTHER app declares that app’s groups

Section titled “A button that opens ANOTHER app declares that app’s groups”

A link button is the one place a form reaches out of its own app: a sale order shows its Manufacturing Orders, a purchase order its Receipts, a contact its Invoices. To a user who does not run the app on the other end, that button is not merely clutter — clicking it answers with a permission error, because opening those records is a read of a model they have no grant on.

Declare the group of the app the button opens, not the one the form belongs to:

- type: linkButton
name: mo_count
properties:
label: Manufacturing
icon: Factory
method: action_view_manufacturing_orders
groups:
- mrp_user_group # the app this button OPENS
visible: Q(mo_count__gt=0)

A button that stays inside its own app needs none — a manufacturing order linking to its child orders is already behind the same grant as the form showing it.

groups here is enforced during view composition, and it removes two things: the button, and the read behind it. The stat a link button shows is a field on the record, and the server fetches every field a view names — so a count of another app’s records is produced for whoever opens the form, gated button or not. Without the group the whole record becomes unopenable for that user, not just the button unusable. Declaring it is what stops the underlying field from being fetched at all.

The value is still dropped only when nothing else asks for it: a hidden field node binding the same name (to drive a visible rule, say) keeps it in the fetch list.

Organize content in tabs:

- type: tab
title: Contact Info
anchor: contact_info
properties:
icon: User
content:
- type: field
name: email
properties: {widget: TextInput}
- type: field
name: phone
properties: {widget: TextInput}

Collapsible sections:

- type: accordion
title: Additional Details
defaultOpen: false
properties:
icon: Info
content:
- type: field
name: notes
properties: {widget: TextArea}

Icons are named with Lucide names and go under properties.

Every tab must carry one. A tab with no icon renders perfectly well on its own, which is exactly the problem — the omission is invisible while authoring and only shows up as a form whose navigation reads inconsistently beside every other app. It is required, not recommended, and enforced. On an accordion the icon stays optional.

- type: tab
title: SEO
anchor: seo
properties: {icon: Search}

Two mistakes are rejected rather than shipped, both at view-load time and by ./fullfinity-server check --only views (CI + pre-commit):

  • A tab with no properties.icon.
  • A name Lucide doesn’t ship (a typo, or an icon from another set such as Bootstrap’s bi-receipt or Tabler’s IconUsers). These render a fallback glyph that looks identical to a correct icon until the screen is open. Names are validated against the exact Lucide build the client renders from, and the error names its version. Alias spellings (PackageIcon, LucidePackage) and kebab/snake forms (arrow-left) all resolve.

Putting icon at the tab’s top level (next to title/anchor) is silently ignored by the renderer, so that is rejected too — nest it under properties.

Server-rendered portal views are exempt from the name check: they draw from a different icon set and never reach the client’s registry.

PropertyDescription
properties.iconLucide icon name (e.g., User, ShoppingCart, Settings). Required on tabs, optional on accordions
defaultOpen(Accordion only) Whether to start expanded (default: true)

Page tabs provide a full-width tab navigation bar rendered in the header area (beneath the secondary header). This is ideal for settings pages or forms with multiple sections that need prominent navigation. Tabs automatically collapse into a “More” dropdown when they overflow the available width.

- type: pageTabs
content:
- type: tab
name: general
anchor: general
title: General
properties:
icon: DatabaseZap
content:
- type: paper
title: System Identity
content:
- type: row
content:
- type: column
span: 6
content:
- type: field
name: system_name
- type: tab
name: security
anchor: security
title: Security
properties:
icon: Shield
content: [...]
  • Header rendering: Tabs appear in the header area (full-width, not inside form Paper)
  • Pill-shaped styling: Active tab has a light background with pill-shaped borders
  • Overflow handling: Tabs that don’t fit collapse into a “More” dropdown automatically
  • Icon support: Each tab can have an icon via properties.icon (Lucide icon names)
  • Paper cards: Each tab’s content is rendered as separate Paper cards
PropertyDescription
nameUnique identifier for the tab
titleDisplay text for the tab
properties.iconLucide icon name (e.g., Settings, Shield, User)
contentArray of layout elements (paper, row, field, etc.)

Other modules can add tabs to an existing pageTabs using view inheritance:

- data_type: UiView
name: configuration_form_view_mymodule
identifier: configuration_form_view_mymodule
inherited_view: configuration_form_view
type: Form
model: Configuration
arch:
operations:
- action: add
target:
type: pageTabs
position: inside
value:
type: tab
name: mymodule
anchor: mymodule
title: My Module
properties:
icon: Settings
content:
- type: paper
title: My Settings
content:
- type: field
name: my_setting
Use CaseRecommended
Settings/Configuration formspageTabs - full-width header navigation
Multi-section forms with many tabspageTabs - handles overflow gracefully
Related data within a formRegular tabs - inline with form content
Quick toggles between few sectionsRegular tabs - simpler, inline

A propertiesPanel pulls a record’s identity/summary fields into a fixed-width details rail beside the form, so the main area (the canvas) stays focused on the record’s primary content — an embedded line grid, a tabbed detail area, or a long rich-text body.

It is a single, opt-in, top-level arch element (a sibling of statusbar, tabs, action buttons — not nested in a row/column). Its content is the curated summary; everything else you leave at the top level becomes the canvas.

By default the rail docks on the left at a compact width. Two optional properties override that:

  • sideleft (default) or right.
  • width — any CSS width (e.g. "420px", "clamp(340px, 40%, 560px)"); defaults to a compact rail. Use a wider right panel when the pane holds a persistent document/preview beside the working canvas (e.g. a PDF preview next to an e-signature form’s tabs) rather than a metadata summary.
- type: propertiesPanel
properties:
side: right
width: 'clamp(340px, 40%, 560px)'
content:
- type: field
name: name
properties: {widget: TextInput, size: lg, nolabel: true}
- type: field
name: contact
properties: {widget: DataCombo, label: Customer}
- type: field
name: date
properties: {widget: DatePickerInput, label: Order Date}
- type: column
title: Assignment
content:
- type: field
name: user
properties: {widget: DataCombo}
- type: field
name: team
properties: {widget: DataCombo}
  • Desktop: the rail is a fixed-width column docked to the side with its own scroll, separated from the canvas by a divider; the canvas (tabs / lines / body) scrolls independently.
  • Mobile / narrow: everything stacks and the rail renders inline after the form content, never between the title and the tabs. Stacked, it is no longer a side rail: anything placed above the tabs pushes every field down by its own full height, and a rail holding a document preview buries the form entirely. The form comes first; the rail is what you scroll on to.
  • Opt-in: a form without a propertiesPanel uses the ordinary top-to-bottom flow. When present, any tabs you declare become the canvas, with the tab strip sitting directly above its content.
  • The rail is narrow — stack fields in one column. Do not use span: 6 two-column rows inside it. Group related fields with a column that has a title; a two-column row is acceptable only for a tiny pair (e.g. two dates, state/zip).
  • Don’t set a custom gap on a group. A column’s default vertical rhythm (md) is the same spacing the rail uses between its items, so titled groups line up with the loose fields around them. Overriding gap (e.g. gap: 4) makes that group’s fields tighter than everything else — the spacing reads as inconsistent. Reserve a custom gap for a deliberately compact cluster (e.g. a variant: summary totals block), never for an ordinary field group.
  • Lead with the title. Put the record’s name/number first as a size: lg, nolabel: true field so the rail reads as an identity card.
  • Rail vs canvas. Rail = identity/summary metadata (partner, dates, references, owner, terms, currency, totals, priority). Canvas = the primary content: embedded List grids, a description/notes rich-text body, or tabbed detail. Never put a wide embedded list or the main body in the rail.
  • Workflow state still belongs in a top-level statusbar, not the rail (see Status Bar).
Form shapeRecommended
Document with a dominant line grid (orders, invoices, entries)propertiesPanel — header identity → rail, lines → canvas
Record with a long rich-text body (articles, tickets, meetings)propertiesPanel — metadata → rail, body → canvas
Master/profile with related-record tabs (contacts, products)propertiesPanel — identity card → rail, tabs → canvas
A short form with only a handful of scalar fieldsNo panel — the ordinary two-column layout is enough
- type: field
name: email
properties:
widget: TextInput
label: Email Address
placeholder: Enter email
required: true
PropertyDescription
widgetWidget type (TextInput, DataCombo, etc.)
labelField label (overrides model description)
hintHelp tooltip beside the label (overrides the model field’s hint, which is shown by default)
placeholderInput placeholder
nolabelHide the label
sizeSize: xs, sm, md, lg, xl
visibleBoolean or Q-expression for conditional visibility
readonlyBoolean or Q-expression for read-only
requiredBoolean or Q-expression for required
groupsList of group identifiers for access control
filterQ-expression to filter related records (DataCombo/MultiCombo)
ctxDefault values for new records (see Field-Level Context)

Display related records:

- type: field
name: order_lines
properties:
widget: List
create: true
delete: true
view: order_line_list_view
ctx:
form_identifier: order_line_form_view
default_qty: 1
PropertyDescription
viewList view identifier
createAllow creating records
deleteAllow deleting records
editabletrue for inline editing, "modal" for modal editing
insertPositionWhere new items are added: "bottom" (default) or "top" — a new line on a reorderable list is given a sequence past the end (or before the start) so it keeps that position after save
filterQ-expression restricting which records this list shows and offers
ctxDefault values and view references

The ctx object supports:

  • default_<field>: Pre-populate field when adding new rows — a literal (default_qty: 1) or the name of a field on the current record, resolved to its value (default_product: product, default_company: _parent.company)
  • form_identifier: Form view for modal editing

See Field-Level Context for more details.

filter: on an embedded List or Kanban is a Q-expression evaluated against each row. It governs both which of the related records the widget displays and which records its “Add” picker offers — one expression, both surfaces, so a list can never be handed a record it would then refuse to show.

That symmetry lets a single relation be presented as two views on the same form, each scoped to one kind of child, instead of one undifferentiated pile. Give each its own filter: and a matching ctx: default_<field> so what it creates satisfies its own filter:

- type: column
span: 12
title: People
content:
- type: field
name: contacts
anchor: contact_people
properties:
widget: Kanban
view: contact_kanban_view
create: true
filter: Q(entity_type='Person')
ctx:
default_entity_type: Person
- type: column
span: 12
title: Addresses
content:
- type: field
name: contacts
anchor: contact_addresses
properties:
widget: Kanban
view: contact_kanban_view
create: true
filter: Q(entity_type='Address')
ctx:
default_entity_type: Address

Two things to know:

  • Hiding is display-only. The filter never rewrites the form value, so a row it hides still saves exactly as it would have — the same contract the list’s search box has. It is a presentation rule, not a data rule; enforce data rules on the model.

  • A new row is exempt only while the rule has nothing to go on. A row starts empty and would fail any predicate on its first render, vanishing before it could be filled in — so it stays visible until it carries a value for every field the filter reads. After that the filter judges it like any other row, saved or not.

    This is why every filter: must be paired with a ctx: default_<field> that satisfies it. The default comes back with the new row’s values, so the row is judged from its very first render — it appears in the list that created it and, just as importantly, not in the sibling lists over the same relation. Omit the ctx and a new row stays exempt in every one of them until it is filled in, so a row added under one heading also shows up under the others.

    (_parent. clauses are answered by the embedding form, not the row, so they never hold a row in the exempt state.)

  • required: is form-wide; scope it by the same field you filtered on. filter: and not_applicable: are display rules — they run while a list draws its own rows, so the filtered field is implied and you needn’t repeat it. Save validation is different: it walks the whole relation against the rules registered by every list bound to it. A rule like required: Q(action='Add') in one list therefore fires on the other list’s rows too, and the record fails to save citing a field the user cannot even see.

    Write required: Q(kind='Component') & Q(action='Add') — naming the discriminator, exactly as the filter: does — and keep that discriminator as a visible: false column in both views so the rule has it to read.

The expression supports the same operators and _parent. paths as visible:/readonly: — see Q Expression Syntax.

The embedded list’s columns are personalized per user exactly like a standalone List: the referenced list view’s default_hidden / locked column properties set the defaults, and each user’s show/hide and column-width choices are remembered in the browser. An embedded list is keyed by its relation (the parent model + the relation field), not by the list view id — so the same list view embedded in several forms keeps independent column choices in each place. See Personalized Columns.

Show an inline informational, warning, or error banner. An alert is a static element — it is not bound to a model field. Drop it into any column (or at the top of a form) to call attention to a state, a caveat, or a next step.

- type: alert
properties:
title: Draft not yet confirmed
text: Confirm this order to reserve stock and lock in pricing.
color: orange
variant: light
icon: AlertTriangle
closeable: true
visible: Q(status__eq='Draft')
PropertyDescription
titleBold heading text (optional)
textBody text
colorAny theme color — e.g. blue, orange, red, green
variantlight (default), filled, outline, transparent, white, default
iconIcon name (e.g. Info, AlertTriangle, CircleCheck)
closeabletrue to render a close button that dismisses the banner
visibleStandard conditional visibility Q-expression

The banner is styled by the theme (light/dark aware) — set only color/variant, never hard-coded hex. For a richer banner, give the alert a nested content array of child elements (fields, text, buttons) instead of a flat text; they render inside the banner:

- type: alert
properties: {color: blue, variant: light, icon: Info}
content:
- type: text
properties: {text: Shipping is calculated at checkout.}
- type: field
name: estimated_delivery

Use fieldset for a titled group of related fields — a section title above its fields. It is a lightweight grouping (a bold title heading, no box in the printed form; a dashed outline shows only in the Builder editor while authoring). paper is the card/tile primitive used on dashboards; use fieldset to group fields in a form.

All layout elements (row, column, accordion, tabs, tab, fieldset, paper, group), fields, and action buttons support behavioral properties inside the properties object:

PropertyTypeDescription
visibleBoolean/StringIf false, element is hidden. Can be a Q-expression for conditional visibility. Default: true
readonlyBoolean/StringIf true, field(s) are read-only. Can be a Q-expression. Propagates to children.
requiredBoolean/StringIf true, field is required. Can be a Q-expression for conditional requirement.
disabledBoolean/StringButtons only. If true, the button is shown but not pressable. Can be a Q-expression. See Hiding vs disabling.
groupsArrayList of group identifiers. Element is visible only if user belongs to at least one group.

Note: All field configuration goes in properties. Structural attributes (type, name, span, content, title, anchor) stay at the top level.

This is enforced, not just a convention. A behavioral key written beside type/span instead of inside properties is read by nothing — it parses, it reads as authored, and at runtime it does nothing, so a visible: Q would never hide and a visible_setting: would never gate. Authoring one is rejected when the view is saved and by check --only views:

# ✗ rejected — silently ignored at runtime
- type: column
span: 6
visible: Q(state='Draft')
# ✓
- type: column
span: 6
properties:
visible: Q(state='Draft')

The rule covers visible, readonly, required, disabled, invisible, groups, visible_setting, gutter, justify, label, color, variant and text, each on the node types whose renderer reads it from properties. A few keys genuinely are node-level and stay that way — field.display_mode (Calendar), widget.widget/widget.groups (Dashboard), and everything inside a FieldDeck view, which resolves node.<key> before node.properties.<key>.

Show/hide elements based on conditions:

- type: field
name: company_name
properties:
widget: TextInput
visible: Q(type__eq='company')

Hide an element unconditionally:

- type: field
name: internal_id
properties:
widget: TextInput
visible: false

Make a field read-only based on conditions:

- type: field
name: price
properties:
widget: NumberInput
readonly: Q(status__neq='Draft')

Make a field required based on conditions. The Q-expression is evaluated dynamically whenever form values change, so validation adapts in real-time:

- type: field
name: shipping_address
properties:
widget: DataCombo
required: Q(requires_shipping__eq=true)

When requires_shipping becomes true, the field shows the required asterisk and form submission validates it. When requires_shipping is false, the field is optional.

Q expressions use the format Q(field__operator=value):

- Q(status__eq='Draft')
- Q(is_customer=True)
- Q(amount__gt=1000)
- Q(stage__is_done__eq=false)
- Q(type__eq='company') & Q(active__eq=True)
- Q(status__in=['Draft', 'Sent'])
- (Q(a__eq=1) | Q(b__eq=2)) & Q(c__eq=3)

Operators:

OperatorDescriptionExample
eqEquals (default if omitted)Q(state__eq='Draft') or Q(state='Draft')
neqNot equalsQ(code__neq='en')
gtGreater thanQ(amount__gt=100)
gteGreater than or equalQ(amount__gte=100)
ltLess thanQ(amount__lt=100)
lteLess than or equalQ(amount__lte=100)
containsString contains (case-sensitive)Q(name__contains='test')
icontainsString contains (case-insensitive)Q(name__icontains='test')
ncontainsString does not containQ(name__ncontains='draft')
nicontainsString does not contain (case-insensitive)Q(name__nicontains='draft')
isnullIs null or empty arrayQ(user__isnull=True)
isnotnullIs not null and not emptyQ(user__isnotnull=True)
inValue in listQ(state__in=['Draft','Pending'])
ninValue not in listQ(state__nin=['Done','Cancelled'])
startswithString starts withQ(name__startswith='INV')
endswithString ends withQ(name__endswith='.pdf')

Value types:

  • Booleans: True, False, true, false
  • Numbers: 100, 45.67
  • Strings: 'value' or "value"
  • Arrays (for in/nin): ['Draft', 'Pending']

Shorthand equality: Q(is_customer=True) is equivalent to Q(is_customer__eq=True)

Nested field access (deep nesting supported):

  • Access related fields: Q(stage__is_done__eq=true) accesses record.stage.is_done
  • Deep nesting is fully supported: Q(contact__company__currency__code__eq='USD')
  • The system automatically extracts and prefetches all intermediate relation paths
  • Example: Q(order__partner__country__code__in=['US','CA']) auto-prefetches order, order__partner, order__partner__country

Logical operators:

  • AND: Q(a__eq=1) & Q(b__eq=2)
  • OR: Q(a__eq=1) | Q(b__eq=2)
  • Precedence: & binds tighter than |
  • Grouping: (Q(a__eq=1) | Q(b__eq=2)) & Q(c__eq=3)

Q expressions in visible, readonly, required, and primary properties are re-evaluated whenever form data changes. For conditions that need to update reactively when a field changes, use flat field names that exist directly on the form.

Recommended (reactive):

visible: Q(track_inventory__eq=true)

Not recommended for reactive use:

visible: Q(product__track_inventory__eq=true)

Why? Nested traversal (e.g., product__track_inventory) accesses cached nested objects that are snapshots from when the record was loaded. When you edit a related field like track_inventory, the flat field updates but the nested product object doesn’t refresh. This means:

  • Initial load: Traversal works correctly
  • After editing: Nested object has stale data, condition may not update

Best practices:

ScenarioApproach
Check a field on the current formUse flat field name: Q(status__eq='Draft')
Check a related field (has related_field attribute)Use the flat field name: Q(track_inventory__eq=true)
Check a truly nested value (display only)Traversal works for initial state, won’t be reactive
Need reactive behavior for nested calculated fieldCreate a related field on your model to expose it

Restrict to specific groups:

- type: field
name: internal_notes
properties:
widget: TextArea
groups: [sales_manager, admin]
- data_type: UiView
identifier: order_form_view
type: Form
model: Order
arch:
- type: statusbar
name: status
properties:
widget: Badge
variant: dot
colors:
Draft: gray
Confirmed: blue
Shipped: orange
Delivered: green
Cancelled: red
- type: actionButton
properties:
label: Confirm
icon: Check
variant: primary
method: confirm_order
visible: Q(status__eq='Draft')
- type: actionButton
properties:
label: Ship
icon: Truck
variant: primary
method: ship_order
visible: Q(status__eq='Confirmed')
- type: actionButton
properties:
label: Cancel
icon: X
variant: outline
method: cancel_order
visible: Q(status__in=['Draft', 'Confirmed'])
# Title header: ONE span-6 column. The `size: lg` typography is what marks the
# field as the title — width must not double as the signal — and nothing is
# squeezed into a column beside it (order_date belongs in the body below).
- type: row
content:
- type: column
span: 6
content:
- type: field
name: name
properties: {widget: TextInput, size: lg, nolabel: true}
- type: row
content:
- type: column
span: 6
content:
- type: field
name: customer
properties: {widget: DataCombo}
- type: field
name: shipping_address
properties: {widget: DataCombo}
- type: column
span: 6
content:
- type: field
name: order_date
properties: {widget: DatePickerInput}
- type: field
name: payment_terms
properties: {widget: DataCombo}
- type: field
name: salesperson
properties: {widget: DataCombo}
- type: tab
title: Order Lines
anchor: order_lines
content:
- type: field
name: lines
properties:
widget: List
create: true
delete: true
view: order_line_list_view
- type: tab
title: Notes
anchor: notes
content:
- type: field
name: notes
properties: {widget: RichTextEditor}
- type: row
content:
- type: column
span: 6
content: []
- type: column
span: 6
content:
- type: field
name: subtotal
properties: {widget: NumberInput, readonly: true}
- type: field
name: tax_amount
properties: {widget: NumberInput, readonly: true}
- type: field
name: total
properties: {widget: NumberInput, size: lg, readonly: true}