Form Views
Form views display and edit single records.
Spacing is the grid’s, not yours
Section titled “Spacing is the grid’s, not yours”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.
Basic Form
Section titled “Basic Form”- 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: lgtype: 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 closedRuntime names (uid, cid, a context key) and the child-list dialect
Q(_parent.state__in=[…]) are both understood, so only genuinely malformed expressions fail.
Layout Structure
Section titled “Layout Structure”12-Column Grid
Section titled “12-Column Grid”Forms use a 12-column grid system:
- type: row content: - type: column span: 6 content: [...] - type: column span: 6 content: [...]Title header
Section titled “Title header”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.
Nested Rows
Section titled “Nested Rows”- type: column span: 6 content: - type: row content: - type: column span: 6 content: [...] - type: column span: 6 content: [...]Row Gutter (Column Spacing)
Section titled “Row Gutter (Column Spacing)”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 Value | Use Case |
|---|---|
16 | Tight spacing for related fields (e.g., City/State/Zip) |
{ base: 16, md: 100 } | Default - responsive spacing |
| Custom number | Fixed pixel spacing |
Aligning a column’s contents
Section titled “Aligning a column’s contents”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: colorThe 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: MonetaryThree things narrow the rule:
- Forms only. A
Listcolumn 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", havingcompanyin 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.
Status Bar
Section titled “Status Bar”Display status at the top:
- type: statusbar name: status properties: widget: Badge variant: dot colors: Draft: gray Confirmed: blue Done: green Cancelled: redcolors 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".
Boolean status ribbon
Section titled “Boolean status ribbon”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.
Action Buttons
Section titled “Action Buttons”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.
Action Button Properties
Section titled “Action Button Properties”| Property | Description |
|---|---|
label | Button text |
icon | Lucide icon name |
variant | primary, secondary, filled, light, outline, subtle, default |
method | Model method to call |
visible | Boolean or Q-expression — when the button is shown (see Conditional Visibility) |
disabled | Boolean or Q-expression — when the button is shown but not pressable (see Hiding vs disabling) |
hint | Hover tooltip. Doubles as the reason shown on a disabled button |
primary | Boolean or Q-expression — when this button is the header’s hero action (see below) |
ctx | Custom context passed to method |
auto_refetch | Reload after action |
instance | List/Kanban only — the button needs a selection. Derived from the method signature; declare it only to override (see Selection actions) |
confirm | Ask before running the method (see Confirmation prompts) |
Hiding vs disabling
Section titled “Hiding vs disabling”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:
| Use | Reads as | |
|---|---|---|
visible: Q(...) | The action does not belong to this record — a refund button on an unpaid invoice | The 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 — neverdisabledalone. - 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.
Confirmation prompts
Section titled “Confirmation prompts”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.
Keyboard access
Section titled “Keyboard access”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:
| Key | What it runs |
|---|---|
Ctrl/Cmd + Enter | The 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.
Primary (hero) action
Section titled “Primary (hero) action”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 value | Meaning |
|---|---|
| omitted | Never the hero — a utility/communication action (Send, Print, Cancel). Always in the dropdown. |
true | Hero 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.
Two modules, one state
Section titled “Two modules, one state”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.
Button Context
Section titled “Button Context”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: trueclass 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:
| Key | Description |
|---|---|
active_model | Model name |
active_ids | List of selected record IDs |
Link Buttons (Statistics)
Section titled “Link Buttons (Statistics)”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: customername 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_scheduleA 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}Accordions
Section titled “Accordions”Collapsible sections:
- type: accordion title: Additional Details defaultOpen: false properties: icon: Info content: - type: field name: notes properties: {widget: TextArea}Accordion and Tab Icons
Section titled “Accordion and Tab Icons”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-receiptor Tabler’sIconUsers). 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.
| Property | Description |
|---|---|
properties.icon | Lucide icon name (e.g., User, ShoppingCart, Settings). Required on tabs, optional on accordions |
defaultOpen | (Accordion only) Whether to start expanded (default: true) |
Page Tabs (Full-Width Header Tabs)
Section titled “Page Tabs (Full-Width Header Tabs)”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: [...]Key Features
Section titled “Key Features”- 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
Tab Properties
Section titled “Tab Properties”| Property | Description |
|---|---|
name | Unique identifier for the tab |
title | Display text for the tab |
properties.icon | Lucide icon name (e.g., Settings, Shield, User) |
content | Array of layout elements (paper, row, field, etc.) |
Extending pageTabs via View Inheritance
Section titled “Extending pageTabs via View Inheritance”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_settingWhen to Use pageTabs vs Regular Tabs
Section titled “When to Use pageTabs vs Regular Tabs”| Use Case | Recommended |
|---|---|
| Settings/Configuration forms | pageTabs - full-width header navigation |
| Multi-section forms with many tabs | pageTabs - handles overflow gracefully |
| Related data within a form | Regular tabs - inline with form content |
| Quick toggles between few sections | Regular tabs - simpler, inline |
Properties Panel (Details Rail)
Section titled “Properties Panel (Details Rail)”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:
side—left(default) orright.width— any CSS width (e.g."420px","clamp(340px, 40%, 560px)"); defaults to a compact rail. Use a widerrightpanel 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}Behavior
Section titled “Behavior”- 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
propertiesPaneluses the ordinary top-to-bottom flow. When present, anytabsyou declare become the canvas, with the tab strip sitting directly above its content.
Guidance
Section titled “Guidance”- The rail is narrow — stack fields in one column. Do not use
span: 6two-column rows inside it. Group related fields with acolumnthat has atitle; a two-column row is acceptable only for a tiny pair (e.g. two dates,state/zip). - Don’t set a custom
gapon a group. Acolumn’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. Overridinggap(e.g.gap: 4) makes that group’s fields tighter than everything else — the spacing reads as inconsistent. Reserve a customgapfor a deliberately compact cluster (e.g. avariant: summarytotals block), never for an ordinary field group. - Lead with the title. Put the record’s
name/number first as asize: lg,nolabel: truefield 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
Listgrids, 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).
When to use
Section titled “When to use”| Form shape | Recommended |
|---|---|
| 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 fields | No panel — the ordinary two-column layout is enough |
Fields
Section titled “Fields”Basic Field
Section titled “Basic Field”- type: field name: email properties: widget: TextInput label: Email Address placeholder: Enter email required: trueCommon Field Properties
Section titled “Common Field Properties”| Property | Description |
|---|---|
widget | Widget type (TextInput, DataCombo, etc.) |
label | Field label (overrides model description) |
hint | Help tooltip beside the label (overrides the model field’s hint, which is shown by default) |
placeholder | Input placeholder |
nolabel | Hide the label |
size | Size: xs, sm, md, lg, xl |
visible | Boolean or Q-expression for conditional visibility |
readonly | Boolean or Q-expression for read-only |
required | Boolean or Q-expression for required |
groups | List of group identifiers for access control |
filter | Q-expression to filter related records (DataCombo/MultiCombo) |
ctx | Default values for new records (see Field-Level Context) |
Embedded Lists
Section titled “Embedded Lists”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| Property | Description |
|---|---|
view | List view identifier |
create | Allow creating records |
delete | Allow deleting records |
editable | true for inline editing, "modal" for modal editing |
insertPosition | Where 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 |
filter | Q-expression restricting which records this list shows and offers |
ctx | Default 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.
Filtering an embedded relation
Section titled “Filtering an embedded relation”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: AddressTwo 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 actx: 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 thectxand 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:andnot_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 likerequired: 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 thefilter:does — and keep that discriminator as avisible: falsecolumn 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.
Alert Banner
Section titled “Alert Banner”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')| Property | Description |
|---|---|
title | Bold heading text (optional) |
text | Body text |
color | Any theme color — e.g. blue, orange, red, green |
variant | light (default), filled, outline, transparent, white, default |
icon | Icon name (e.g. Info, AlertTriangle, CircleCheck) |
closeable | true to render a close button that dismisses the banner |
visible | Standard 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_deliveryElement Properties
Section titled “Element Properties”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:
| Property | Type | Description |
|---|---|---|
visible | Boolean/String | If false, element is hidden. Can be a Q-expression for conditional visibility. Default: true |
readonly | Boolean/String | If true, field(s) are read-only. Can be a Q-expression. Propagates to children. |
required | Boolean/String | If true, field is required. Can be a Q-expression for conditional requirement. |
disabled | Boolean/String | Buttons only. If true, the button is shown but not pressable. Can be a Q-expression. See Hiding vs disabling. |
groups | Array | List 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>.
Conditional Visibility
Section titled “Conditional Visibility”Show/hide elements based on conditions:
- type: field name: company_name properties: widget: TextInput visible: Q(type__eq='company')Static Visibility
Section titled “Static Visibility”Hide an element unconditionally:
- type: field name: internal_id properties: widget: TextInput visible: falseConditional Readonly
Section titled “Conditional Readonly”Make a field read-only based on conditions:
- type: field name: price properties: widget: NumberInput readonly: Q(status__neq='Draft')Conditional Required
Section titled “Conditional Required”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 Expression Syntax
Section titled “Q Expression Syntax”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:
| Operator | Description | Example |
|---|---|---|
eq | Equals (default if omitted) | Q(state__eq='Draft') or Q(state='Draft') |
neq | Not equals | Q(code__neq='en') |
gt | Greater than | Q(amount__gt=100) |
gte | Greater than or equal | Q(amount__gte=100) |
lt | Less than | Q(amount__lt=100) |
lte | Less than or equal | Q(amount__lte=100) |
contains | String contains (case-sensitive) | Q(name__contains='test') |
icontains | String contains (case-insensitive) | Q(name__icontains='test') |
ncontains | String does not contain | Q(name__ncontains='draft') |
nicontains | String does not contain (case-insensitive) | Q(name__nicontains='draft') |
isnull | Is null or empty array | Q(user__isnull=True) |
isnotnull | Is not null and not empty | Q(user__isnotnull=True) |
in | Value in list | Q(state__in=['Draft','Pending']) |
nin | Value not in list | Q(state__nin=['Done','Cancelled']) |
startswith | String starts with | Q(name__startswith='INV') |
endswith | String ends with | Q(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)accessesrecord.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-prefetchesorder,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)
Reactive Q Expressions
Section titled “Reactive Q Expressions”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:
| Scenario | Approach |
|---|---|
| Check a field on the current form | Use 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 field | Create a related field on your model to expose it |
Group-Based Visibility
Section titled “Group-Based Visibility”Restrict to specific groups:
- type: field name: internal_notes properties: widget: TextArea groups: [sales_manager, admin]Complete Example
Section titled “Complete Example”- 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}Next Steps
Section titled “Next Steps”- List Views - Table views
- Widgets - Available widgets
- View Inheritance - Extending views