Skip to content

List Views

List views display records in a table format.

- data_type: UiView
identifier: contact_list_view
type: List
model: Contact
arch:
- content:
- type: field
name: name
properties: {widget: Text}
- type: field
name: email
properties: {widget: Text}
- type: field
name: phone
properties: {widget: Text}
- type: field
name: active
properties: {widget: Badge}
- type: field
name: name
properties:
widget: Text
- type: field
name: status
properties:
widget: Badge
colors:
Draft: gray
Active: green
Archived: red

List columns support visibility, readonly, required, and group-based access control. These work with both boolean values and Q-expressions for conditional behavior.

PropertyTypeDescription
visibleBoolean/StringHide column or use Q-expression. Default: true
readonlyBoolean/StringMake cells read-only or use Q-expression
not_applicableBoolean/StringPer-row: this field is meaningless for this row (not merely empty or locked). Renders a dimmed on a shaded cell, and never an input; when it matches every row the column is dropped entirely. See Not applicable
na_reasonStringTooltip shown on a not_applicable cell. Default: “Not applicable”
requiredBoolean/StringMake cells required or use Q-expression
groupsArrayUser must be in one of these groups to see the column
mobileBooleanInclude field in mobile card view. Default: true
default_hiddenBooleanColumn exists but starts hidden; the user can reveal it from the columns menu. Default: false
lockedBooleanColumn can’t be hidden — always shown and kept out of the columns menu. Default: false
color_fieldStringName of a sibling field holding a color (a Mantine color name like orange, or a hex value). The cell’s text is tinted that color per row — leave the field empty to show no tint. Works on text/number/monetary cells; a Badge keeps using its own colors map.
nolabelBooleanBlanks the column header. Without it, an empty label falls back to the field’s description — set nolabel: true for an icon-only / self-explanatory column (e.g. a SummaryIndicator). Default: false

To draw the eye to specific rows without adding a flag column, point a column at a sibling field that computes a color. The field is fetched automatically even though it isn’t shown as its own column:

- type: field
name: days_overdue
properties:
widget: NumberInput
color_field: overdue_color

Here overdue_color is a (usually non-stored, computed) field that returns e.g. "orange" when the row needs attention and empty otherwise — so only those rows’ values are tinted.

- type: field
name: internal_code
properties:
widget: Text
visible: false

visible on a column is a question about the column, never about one row — a column present on one row and absent on the next leaves a ragged grid whose click targets move as you scan down it. So it takes four forms, and the last one is the one to know:

FormAnswered
visible: falseat the column, once
visible: Q(_parent.state='Draft')once, against the embedding record
visible_setting: Q(use_packaging=True)once, against configuration
visible: Q(display_type='Part') (row-rooted)against every row — the column is shown if any row wants it, and dropped when none does

The last one only drops the column: a row that fails the rule still renders its value, because that is a cell question and not_applicable is what answers it. The two compose — a column is dropped if either rule says no row needs it.

- type: field
name: cost_price
properties:
widget: Text
groups: [inventory.manager, admin]

On tablet/mobile devices, List views are rendered as card layouts (Kanban-style). Use mobile: false to exclude columns that are useful in desktop tables but not needed on compact mobile cards:

- type: field
name: internal_notes
properties:
widget: Text
mobile: false

This field will appear in the desktop table view but be hidden in the mobile card layout.

For inline editable lists, readonly can be evaluated against each row’s data:

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

A column is shown or hidden for the whole list — there is no per-row column visibility, and there shouldn’t be: a column present on one row and absent on the next leaves a ragged grid whose click targets move as you scan down it. So when a column is meaningful for some rows and meaningless for others, the column keeps its place and the cell says which it is:

- type: field
name: lot_name
properties:
widget: TextInput
not_applicable: Q(tracking='None')
na_reason: Not lot-tracked

The rule is evaluated per row against the row’s values (and _parent.* for an embedded list), exactly like readonly. A cell it matches renders a dimmed em-dash on a shaded ground with na_reason as its tooltip, and is never editable — the value would be meaningless for that row, so not_applicable implies readonly whether or not you also declared it.

The two signals do different jobs, which is why the cell gets both: the dash carries the meaning (“nothing belongs here”, the same mark a read-only empty form field shows), and the shading carries the scanning — the eye groups the inapplicable cells and skips them, so a mixed grid stops reading as half-filled-in. Only the tint changes; borders and row height don’t, so column alignment is untouched.

Reach for it wherever a lines grid mixes kinds of row. Use it instead of leaving a blank cell, which reads as an entry the user forgot to make, and instead of readonly alone, which says “you may not edit this” rather than “there is nothing to edit.”

not_applicable describes the mixed list, and that is the only thing you author. When the rule comes back true for every row of an embedded lines grid, the framework drops the column outright — header included — and hands its width back to the columns that carry values. A grid whose Packaging and Packs columns held a sixth of the table to say “not applicable” forty times now shows neither, and the Product cell gets the room. (A row-rooted visible: is rolled up the same way — see below.)

The two cases are different questions, which is why one draws a dash and the other disappears:

Applies to…What you seeWhy
some rowscolumn stays, inapplicable cells shaded with na_reasonthe column exists for the other rows; dropping it per row would leave a ragged grid
no rowno column at allthere is no ragged grid to fear and no information in the column — only width taken from columns that have some

The roll-up is live: it re-answers as rows are added, edited and removed, before any save, so a line whose product has just been picked can bring a column back the moment its ui_effect lands. It is judged over the whole collection, not the rows currently on screen — a rule that applies to line 90 keeps the column while lines 1–20 are showing, and typing in the grid’s search box never reshapes the table. An empty grid keeps its columns as authored: “no row needs this” and “there are no rows yet” are different statements, and a document you are about to type the first line into should show the shape it is going to have.

Three things to expect:

  • The roll-up never second-guesses your rule — it only asks it of every row, so a column arrives the moment the first row makes it apply. On a sale order, Packaging (Q(has_packagings =False)) appears as soon as a line’s product offers a pack level, while Packs (Q(packaging__isnull=True)) arrives with the first pack actually chosen. Write the per-row rule for the cell it governs and let the column follow; if you want two columns to travel together, they need to be answering the same question in the first place.
  • A row-rooted visible: Q(...) is rolled up the same way, from the other end: the column is dropped when no row wants it. visible: Q(display_type='Part') on a repair order’s Operation and Lot columns drops both from an order made only of section lines; Q(tracking__in=['Lot', 'Serial']) drops Lot from a production whose components are untracked. See Hide Column for the four forms visible takes.
  • A column dropped this way leaves the columns menu too, so the chooser never offers a checkbox that reveals nothing.

On mobile the list renders as cards, where per-row omission is correct and is what you get: a card whose field is not applicable leaves the row out entirely rather than showing a dash.

A line model routinely carries more fields than one table row can show at a readable width. Past roughly eight columns the grid stops being a grid: every value is ellipsised, combos have no room for their chevron, and the fields a user actually negotiates on (price, discount) are the same width as ones they touch once a year.

under: moves a field out of the columns and renders it as a second line inside another column’s cell — the way a document prints a line: the product, then its description beneath in smaller type.

- type: field
name: product_variant
properties:
widget: DataCombo
label: Product
- type: field
name: description
properties:
widget: TextArea
under: product_variant # renders under the Product cell, not as a column
hide_if_same_as: product_variant

The value of under is the name of a field that is a column in the same view. That column’s cell is where the sub-line is drawn, so it has to be there; naming a field the view doesn’t show is rejected at view load and by ./fullfinity-server check --only views, because the field would otherwise never appear at all. A sub-line cannot host a sub-line of its own.

A sub-line is a different place to draw a field, not a different way to edit one: it renders through the same cell machinery as a column, shows the same display value at rest, becomes the same inline editor when the row is edited, and honours readonly, required, filter, not_applicable and ctx identically. Clicking it puts the row into edit with that field focused, exactly as clicking a cell does — no extra click, no navigation.

At rest it is drawn only where it has something to say. A row whose sub-line field is empty renders as a plain single-height row, so a grid of forty lines shows the three that carry a description instead of ruling a mostly-empty column down the page. It does not depend on edit rights, so a locked or read-only record still shows its values. While the row is being edited the field is always drawn — you cannot fill in what isn’t there.

Applicability is per row. This is the sub-line’s real advantage over a column, and it’s why the two rules above finally do what they read like:

  • not_applicable: Q(...) — a column must keep its place and draw a dash, because the column exists for the other rows. A sub-line is simply not drawn on the rows it doesn’t apply to.
  • visible: Q(...) — as a column a row-rooted Q is an any-row question: it decides whether the column exists at all, and every row that stays renders its value (Hide Column above). On a sub-line it is evaluated per row, so the field is drawn on the rows that want it and omitted on the rest — the granularity such a rule reads like.

Nothing reflows when this changes: column widths are fitted from the columns alone, so a sub-line field takes no part in width fitting.

placeholder: names the empty editor. A grid draws no captions, so an empty sub-line under a value is an unlabelled box — the placeholder is what says what belongs there. Leave it off and the field’s own label is used, which is usually what you want (“Description”); set it when a prompt reads better than a noun (placeholder: Add a note for the customer…).

hide_if_same_as: <other field> suppresses the sub-line at rest when it would print exactly what the named field already prints on the same row — a description seeded from its product holds the product’s name verbatim, and printing it again directly under that name is text the reader skips past on every line. Compared on rendered text (a relation by its display name), and ignored while the row is edited so the text stays there to be re-worded. It applies only to a sub-line field; on a column it does nothing, and saying so is a view error rather than a silent no-op.

What belongs on a sub-line: free text that qualifies the value above it (a line description), an override on top of a default, a field empty on most rows. What doesn’t: the values the document is about — quantity, price, the line total — which belong in columns where they line up down the page and can be scanned and summed; anything that doesn’t qualify one particular column (put it in the row’s panel); and child collections, which are not scalar fields (the panel again).

A sub-line does not appear in the column picker and is never personalized away — it is part of its host cell, not a narrow column a user might widen. On mobile it needs no special handling: a card is already a two-dimensional layout, and the field renders in it like any other.

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

Every List lets each user choose which columns to show and how wide they are, from the columns menu (the button at the end of the header row). Those choices are remembered per user, in the browser — reopening the List restores the user’s column selection and any widths they dragged. Nothing is stored server-side, so it never affects other users and needs no configuration. (A List embedded in a form as a widget: List is keyed by its relation, so the same list view keeps independent choices in each place it’s embedded.)

On a standalone List, the columns menu also lets each user add any other field of the model that they’re allowed to see — not just the columns the view declares. Added columns are fetched on demand and remembered like any other choice; a search box helps find a field, and a field the user can’t access (via groups) is never offered. (Embedded lists inside a form are limited to the columns the view declares — their rows are live, unsaved form data, so a column can’t be added mid-edit.)

You control the starting point with two column properties:

  • default_hidden: true — the column ships hidden. It’s still listed in the columns menu, so a user who wants it can switch it on. Use this for detail columns that are useful but clutter the default table.
  • locked: true — the column can never be hidden. It’s always shown and doesn’t appear in the columns menu. Use this for the identifying column (e.g. name).
- type: field
name: email
properties:
widget: Text
default_hidden: true
- type: field
name: name
properties:
widget: Text
locked: true

Field-level security still applies: a column the user isn’t allowed to see (via groups) is never offered in the menu and can’t be revealed.

WidgetDescription
TextPlain text display
BadgeColored badge
TagsMultiple tags
AvatarUser avatar
ImageImage thumbnail; add zoom: true to pop an enlarged preview when the thumbnail is hovered
CheckboxBoolean checkbox
RatingStar rating

A column doesn’t have to declare a widget — if you omit it, the list renders a sensible default based on the field’s type, and it adapts to whether the cell is read-only or being inline-edited:

Field typeRead-only displayInline edit
Char, Textplain texttext input
Integer, Floatnumbernumber input
Monetaryformatted amountnumber input
Booleancheckboxcheckbox
Date / Datetimeformatted datedate picker
SelectionBadgeselect dropdown
ManyToOne / OneToOnelinked namecombo
ManyToManyTagsmulti-select

Declaring a widget always overrides the default. Types with no meaningful flat-column representation — OneToMany, File, Binary, JSON — have no default and render empty: to show one of these in a list, give it an explicit widget (e.g. Image for a file). This keeps the cell from dumping a raw object/array/blob as text.

Enable inline editing with editable: true at the arch root level:

- data_type: UiView
type: List
model: OrderLine
arch:
- editable: true
insertPosition: bottom
content:
- type: field
name: product
properties: {widget: DataCombo}
- type: field
name: quantity
properties: {widget: NumberInput}
PropertyDescription
editabletrue to enable inline editing
insertPositionWhere new rows are added: "bottom" (default) or "top"

editable is a boolean — the insert position is a separate key. Writing editable: top does not move anything; it just isn’t true, so the list loses inline editing altogether.

When the list is reorderable (an explicit reorderable:, or a sequence field on an editable list), a newly added row is given a sequence past the end — or before the start, for insertPosition: top — so it keeps the position it was added at once saved, instead of taking the field’s default and re-sorting into the middle of the list on the next read.

An editable list can carry structural rows that group the real rows — full-width section headings and free-text note rows — like the section and note lines of a sales order. Turn it on with sectionAndNote on the arch root, set to the name of the field that stores the row’s text:

- data_type: UiView
type: List
model: OrderLine
arch:
- editable: true
sectionAndNote: description # the field holding the section/note text
content:
- type: field
name: display_type
properties: {visible: false}
- type: field
name: description
properties: {widget: TextInput}
- type: field
name: quantity
properties: {widget: NumberInput}

Requirements and behavior:

  • The model must have a display_type field (a Selection) whose value is "Section" or "Note" for structural rows (any other value — e.g. "Question", "Product" — is a normal row). Include it as a visible: false column so the client can read it.
  • The field named by sectionAndNote holds the text shown for a section/note. Section rows render as a full-width heading; note rows as full-width muted text. Normal rows render the columns as usual.
  • The list footer gains Add a section and Add a note links (alongside Add a line) that create the corresponding structural row.

Some lists want section separators but not free-text notes (or vice-versa). Two boolean arch-root flags gate which structural rows can be added:

FlagEffect
sectionsOnly: trueOffer Add a section, hide Add a note
notesOnly: trueOffer Add a note, hide Add a section

Set neither to offer both (the default). They only affect the add affordances — existing rows of either kind still render.

arch:
- editable: true
sectionAndNote: title
sectionsOnly: true # sections only — no note rows
content:
- type: field
name: display_type
properties: {visible: false}
- type: field
name: title
properties: {widget: TextInput}
- type: field
name: priority
properties:
widget: Badge
size: sm
colors:
Low: gray
Medium: blue
High: orange
Urgent: red

Display related model fields:

- type: field
name: customer
properties:
widget: DataCombo

The display name of the related record is shown.

- type: field
name: image
properties:
widget: Image
h: 40
w: 40
fit: cover

Three nodes run a model method from a List. They differ only in what the click is aimed at:

NodeDeclaredRendersRuns on
inlineButtoninside contentthe row’s ⋮ menuthat one row
buttoninside contentits own button column, on every rowthat one row
actionButtontop level of archthe view’s toolbarthe ticked rows

A row action always arrives with exactly one record, so the method it calls is an ordinary instance method — it never has to work out what was selected:

arch:
- content:
- type: field
name: name
- type: field
name: state
- type: inlineButton
anchor: quote_send
properties:
label: Send
icon: Send
method: action_send
auto_refetch: true
visible: Q(state='Draft')
groups: [sales_manager_group]

visible is evaluated per row, so the action is offered only where it applies rather than failing after the click. Two things to know when writing that rule:

  • It reads the row that was fetched, and a list fetches the fields its columns declare. A rule naming a field no column shows needs that field in the arch as a hidden one — properties: {visible: false} keeps it out of the table while keeping it in the fetch.
  • A button column renders on every row regardless; its visible is not a per-row rule. Use inlineButton whenever the action doesn’t apply to every row.

disabled takes the same true/false/Q grammar and is also evaluated per row — the action stays listed, greyed, instead of disappearing:

- type: inlineButton
anchor: quote_send
properties:
label: Send
method: action_send
disabled: Q(email__isnull=True)
hint: This customer has no email address yet.

Prefer it to visible when disappearing would make the rows read inconsistently — every row in a list is scanned against its neighbours, so an action that comes and going down the column is harder to trust than one that is visibly unavailable on some rows. In a row menu the hint renders as a second line under the label; on a button column it becomes the cell’s tooltip. That column is in fact the one place disabled is the only per-row rule available, since the column itself is structural.

Full guidance on choosing between the two: Hiding vs disabling.

A toolbar actionButton runs against the rows the user ticked, so its method receives the whole selection (for record in self:):

arch:
- type: actionButton
anchor: mark_paid
properties:
label: Mark Paid
icon: Check
method: action_mark_paid
- content:
- type: field
name: name

The button stays hidden until at least one row is ticked — you don’t declare that. A method written async def action_mark_paid(self) can only run against records, and the framework reads that off the signature when it builds the view. A cls-first method needs no selection, so its button is always offered.

Override it with instance: when the signature can’t tell the truth — a cls-first method that reads context["active_ids"] is a selection action (instance: true), and false forces a self-first one to stay offered.

Reach for a toolbar action only when acting on several records at once is the point. When one record is enough, a row action is the better shape: it can’t be clicked with nothing selected, can’t be aimed at records the method would reject, and needs no guard for either case. Button properties (confirm, auto_refetch, icon, color, groups) are the same ones documented under Action Buttons.

- data_type: UiView
identifier: product_list_view
type: List
model: Product
arch:
- content:
- type: field
name: image
properties:
widget: Image
h: 40
w: 40
fit: contain
- type: field
name: name
properties:
widget: Text
fw: '600'
- type: field
name: sku
properties:
widget: Text
c: dimmed
- type: field
name: category
properties:
widget: Text
- type: field
name: price
properties:
widget: Text
- type: field
name: stock_quantity
properties:
widget: Text
- type: field
name: active
properties:
widget: Badge
colors: {'true': green, 'false': gray}
Section titled “Aggregates (footer totals & group subtotals)”

A List can total a column two ways:

  • Footer total — set a column’s aggregate property. A totals row appears at the bottom, summing (or averaging/min/max) that column across the whole filtered dataset, not just the loaded page.

    - type: field
    name: total_amount
    properties: {widget: Monetary, aggregate: sum}
  • Group subtotal — when the list is grouped, declare group_aggregates on the arch root and each group header shows its subtotal.

    - type: List
    group_aggregates:
    - {field: total_amount, type: sum}
    content: [...]

Aggregate types: sum, average, min, max, count.

For a Monetary column the total is resolved to a single currency for you — raw amounts in different currencies are never added together:

  • One currency in the set → total shown in that currency (exact sum, no conversion).
  • Multiple currencies → each amount is converted to the active company’s currency and the total is shown in it, using the recorded exchange rates. Individual rows keep their own currency.

This holds for both footer totals and group subtotals, and matches Kanban header aggregates. Embedded (in-form) list footers total live in the browser as you edit lines: they show the currency when all lines share one, and show if lines somehow span multiple currencies (there are no exchange rates in the form to convert with).

List views can enable drag-and-drop row reordering via the reorderable property. When enabled, a drag handle column appears and users can reorder rows by dragging.

- data_type: UiView
type: List
model: CrmStage
arch:
- reorderable: sequence
content:
- type: field
name: sequence
- type: field
name: name
- type: field
name: probability
ValueDescription
false or omittedDrag-and-drop disabled (default)
"sequence"Enable reordering, update the sequence field
"priority"Enable reordering, update the priority field
Any field nameEnable reordering, update that Integer field

Drag-and-drop reordering is automatically disabled when:

  • List is grouped (group-by is active)
  • Column sorting is active (user clicked a column header to sort)
  • Inline editing mode is active
  • Action context has editable: false

Lists inside form views for related records:

- type: field
name: order_lines
properties:
widget: List
create: true
delete: true
editable: true
view: order_line_inline_view
ctx:
form_identifier: order_line_form_view
OptionDescription
createAllow adding new records
deleteAllow deleting records — true/false, or a Q(...) rule evaluated per row (see below)
editabletrue for inline editing, "modal" for modal editing
insertPositionWhere new items are added: "bottom" (default) or "top"
viewList view to use
ctxAdditional context (form_identifier)

delete accepts a Q-expression as well as a boolean. The rule is evaluated per row, against that row’s own values (and _parent.<field> for the enclosing form’s), exactly like a cell’s readonly/visible/required — so a grid can let the user remove the rows they added while keeping the ones the record came with:

- type: field
name: lines
properties:
widget: List
editable: true
delete: Q(is_manual=True) # only rows flagged as user-added show the delete icon
view: my_line_list_view

Two things to know:

  • A rule can only take deletion away. It is ANDed with the existing gate — model permissions, a locked (controlled-edit) parent, a read-only embed — so it never grants a delete those withhold. delete: false still removes the affordance outright.
  • Name a field the row actually carries. The rule is evaluated client-side against the row, so every field it references must be a column of the embedded list view (a visible: false column is fine — the value is still fetched). Fields are validated against the child model when the view loads.

The rule applies wherever a row can be removed: the grid’s delete icon, and the record modal a row opens in editable: "modal" / on mobile.

Removal follows the relation, not the column definition:

Field typeRemoving a rowWhy
OneToManyDeletes the child recordThe children are the parent’s — a line, an address, a config sub-row has no life of its own
ManyToManyDetaches the link only; the record survivesThe record belongs to the target model; only the link is yours

So a delete: true embed of a OneToMany is genuinely destructive: the row leaves the grid and the record is gone when the form is saved. If the child is referenced elsewhere by a RESTRICT foreign key, the save reports that error rather than removing it.

Removal is staged, not immediate — nothing happens until the parent form is saved, and Discard abandons it. A row that was never saved (one just added to the grid) is simply dropped; there is no record to destroy.

If a collection’s members genuinely outlive the parent, it is a ManyToMany, not a OneToMany whose removal should spare them — model the relation for what it is rather than reaching for a per-view opt-out. To show children without offering removal at all, use delete: false.

Some fields belong to the line but not to the scan: a per-line override, a child collection, a note nobody reads while checking totals. detail: true keeps them off the columns and puts them in the row’s panel — opened from a bare icon in the row’s actions column.

- type: field
name: analytic_distribution
properties:
detail: true
widget: List # a child collection is fine here
view: analytic_distribution_list_view
label: Analytic Distribution
editable: true

The panel is an extension of the grid, not a form over the record. Its fields render through the same cell the columns do, bound to the same row, which is the whole point:

  • They are declared in this view, so they are fetched with the row and written by the document’s save. There is no second save, and nothing to keep in sync.
  • They obey the same rules as the columns beside themreadonly, required, not_applicable, and the controlled-edit freeze a parent record puts on its children. A field the grid has locked is locked in the panel, because it is the same cell.
  • A row that has never been saved works — the panel edits the row in the form, not a record fetched by id.

Opening the panel puts the row into edit, exactly as clicking a cell does. The icon tints when the row already carries something in the panel, so a grid shows which lines have one without opening any of them. Per-row rules apply as they do to a sub-line: a field that is not_applicable to this row, or whose visible: Q(...) is false for it, simply isn’t drawn.

The panel is a flat list of fields — label above value, wrapping at a readable width. It is the row’s overflow, not a second layout to design; a field carrying its own grid takes the full width. If a panel is growing a layout, that is usually a sign the record wants its own form rather than more panel.

What belongs in the panel: child collections (the only place they can go — a collection is not a scalar and cannot be a column or a sub-line), per-line overrides, and fields a user touches once in twenty lines. What doesn’t: anything worth scanning down the page, which is what columns are for; and text that qualifies one particular column, which is what under: is for.

Embedded lists (OneToMany fields in forms) also support drag-and-drop reordering. Configure reorderable on the referenced list view:

- data_type: UiView
identifier: order_line_inline_view
type: List
model: OrderLine
arch:
- reorderable: sequence
content:
- type: field
name: sequence
- type: field
name: product
- type: field
name: quantity

Then reference this view in your form:

- type: field
name: order_lines
properties:
widget: List
editable: true
view: order_line_inline_view

Key differences from main List views:

  • Updates form state directly (no API call on drag)
  • Sequence field is updated for all rows in form state
  • Disabled while editing a row inline

On tablet and mobile devices (≤1024px), List views automatically render as a card layout (similar to Kanban) for better touch interaction and readability.

  1. Desktop: Displays as a traditional data table with columns
  2. Tablet/Mobile: Displays as stacked cards, with each row becoming a card

When no explicit card layout is provided, the List columns are converted automatically:

  • The first visible column becomes the card title (bold, larger, no label)
  • Every remaining column becomes a labelled row — the column’s label on the left, its value on the right — so each value is named instead of stacked anonymously
  • Input widgets are converted to display widgets (e.g., TextInputText); Badge, Monetary, Tags/MultiCombo, Avatar, Rating are preserved
  • Fields with visible: false, mobile: false, or default_hidden: true are excluded

This automatic layout is always readable, but generic. For a line/table field where the mobile card deserves a designed layout (a receipt-style line, grouped metrics), author a card: block instead — see below.

Use the mobile property to control which fields appear in the mobile card:

- arch:
- content:
- type: field
name: name
properties: {widget: Text}
- type: field
name: customer
properties: {widget: Text}
- type: field
name: total
properties: {widget: Text}
- type: field
name: internal_ref
properties: {widget: Text, mobile: false}
- type: field
name: notes
properties: {widget: Text, mobile: false}

In this example, name, customer, and total appear on mobile cards, while internal_ref and notes are desktop-only.

For full control over the mobile card of a List — most useful for an embedded lines field (a OneToMany/ManyToMany rendered as widget: List inside a Form) — add a card: block on the List arch root. It uses the same arch grammar as a Kanban card (row / column with span, horizontal group, static text, and field elements with any display widget), so it has the full expressiveness of a Kanban card while living right next to the columns it complements. Desktop keeps rendering the columns; the card: block is used only on the mobile/tablet card.

Elements support visible: Q(...) (evaluated per row), so one card can branch on the row’s data — e.g. a product line versus a section/note line.

- data_type: UiView
identifier: order_line_list_view
type: List
model: OrderLine
arch:
- editable: true
card:
- type: row
content:
- type: column
span: 12
content:
# Product name (left) and line total (right), on one row
- type: group
properties:
justify: space-between
wrap: false
content:
- type: field
name: product
properties: {widget: Text, size: sm, fw: '600'}
- type: field
name: subtotal
properties: {widget: Monetary, size: sm, fw: '700'}
# Compact "qty uom × unit price" metrics + tax badge
- type: group
properties: {gap: 6}
content:
- type: field
name: quantity
properties: {widget: Text, size: xs, c: dimmed}
- type: field
name: uom
properties: {widget: Text, size: xs, c: dimmed}
- type: text
properties: {text: '×', size: xs, c: dimmed}
- type: field
name: unit_price
properties: {widget: Monetary, size: xs, c: dimmed}
- type: field
name: taxes
properties: {widget: MultiCombo, size: xs}
content:
- type: field
name: product
properties: {widget: DataCombo, label: Product}
# ...remaining columns...

The mobile card layout is resolved in this order: an explicit card: block wins; otherwise a Kanban view of the same model (if one is preloaded) is used; otherwise the columns are auto-converted to the labelled layout above.

For a top-level List view (an app screen, not an embedded lines field), you can alternatively define a separate Kanban view. The system checks for a Kanban view before converting the List view:

- data_type: UiView
identifier: order_kanban_view
type: Kanban
model: Order
arch:
- type: row
content:
- type: column
span: 12
content:
- type: field
name: number
properties: {widget: Text, size: sm, fw: '600'}
- type: field
name: customer
properties: {widget: Text, size: xs, c: dimmed}
- type: field
name: total
properties: {widget: Text, size: xs}