Skip to content

Other View Types

Display date-based records on a calendar.

- data_type: UiView
identifier: meeting_calendar_view
type: Calendar
model: Meeting
arch:
- content:
- type: field
name: name
display_mode: title
- type: field
name: start_time
display_mode: start
- type: field
name: end_time
display_mode: end
ModeDescription
titleEvent title
startStart datetime
endEnd datetime

The title field may be a scalar (Char/Text/Selection) or a relation (ManyToOne): a relation is rendered as its display name, so {"name": "contact", "display_mode": "title"} shows the customer’s name on the event.

- content:
- {type: field, name: name, display_mode: title}
- {type: field, name: start_time, display_mode: start}
- {type: field, name: end_time, display_mode: end}
- type: QuickCreate
ctx: {form_identifier: meeting_quick_create_form}

Dragging across empty calendar space opens that form with the picked range prefilled — by day in the Month grid, by hour in Week and Day. Without a QuickCreate the calendar is read-only for creation: there is no form to open.

An event can be dragged to a new slot, and its edges dragged to change its duration. This needs no declaration: a drag writes the same start and end fields the view already names, so it says exactly what opening the record and editing those fields would. (A Kanban drop is opt-in by contrast, because it writes a workflow field.)

GridDragging the eventDragging an edge
MonthMoves it by whole days — the time of day is kept, so a 9am meeting dropped on Friday is still at 9amLeft/right edge moves the start/end day
Week / DayMoves it to the day and time under the cursor, snapped to 15 minutes, anchored on where inside the event it was grabbedTop/bottom edge moves the start/end time

An edge stops rather than crossing the opposite one, so an event can never be turned inside out. Escape mid-drag abandons the move.

The gesture is only offered where it would succeed, so a user isn’t invited to make an edit that is then taken back:

  • The view — not opted out with draggable: false, and declaring a start field.
  • The model — the user has update access, and the action’s ctx doesn’t set edit: false.
  • The record — the model’s write record rules, evaluated per row by the server and returned with the events. A row those rules exclude is not draggable.
  • The fields — the model’s _controlled_edits freeze rules, evaluated against the row. A move writes both ends, so it needs both writable; each edge grip needs its own.
  • The device and the moment — off on touch (press-and-drag is the scroll gesture there) and while a dialog covers the calendar.

None of this is enforcement. A drag writes through PUT /api/update, the same endpoint the record form saves through, so record rules, freeze rules, approval locks and readonly fields are all applied to the write itself exactly as they would be from the form. The checks above decide what to offer; if a write is refused anyway, the event returns to where it was and the error is shown.

To turn it off for a calendar whose dates are computed or driven by a workflow rather than chosen, declare draggable: false on the arch root:

arch:
- draggable: false
content:
- {type: field, name: name, display_mode: title}
- {type: field, name: scheduled_at, display_mode: start}

Define filters and groupings for list/kanban views.

- data_type: UiView
identifier: contact_search_view
type: Search
model: Contact
arch:
- type: filter
identifier: customers
description: Customers
filter: "(Q(is_customer=True))"
- type: filter
identifier: suppliers
description: Suppliers
filter: "(Q(is_supplier=True))"
- type: group
identifier: group_country
name: country
description: Country

Predefined filter conditions:

- type: filter
identifier: active_only
description: Active
filter: "(Q(active=True))"
"(Q(field='value'))" # String equality (shorthand)
"(Q(field__eq='value'))" # String equality (explicit)
"(Q(field=True))" # Boolean check
"(Q(field__isnull=True))" # ManyToOne null check (use field name, not field_id)
"(Q(stage__is_won=True))" # Related field lookup (traverse relation)
"(Q(active=True) & Q(status='open'))" # AND condition
"(Q(user__id__eq=uid))" # Current user - ManyToOne requires field__id__eq=uid
"(Q(company__id__eq=cid))" # Current company - ManyToOne requires field__id__eq=cid

Important for ManyToOne fields with uid/cid:

  • Use field__id__eq=uid syntax (NOT field_id=uid or field=uid)
  • This traverses the relationship to compare the related record’s ID
VariableDescription
uidCurrent logged-in user’s ID
cidCurrent active company ID (user’s selected company for the session)
D()Dynamic date helper (see below)

The D() helper generates dynamic dates for use in filters. It returns a date or datetime object that is evaluated at query time.

Basic usage:

D('today') # today's date
D('now') # current datetime

Relative days/weeks/months/years:

D('today', days=-30) # 30 days ago
D('today', days=7) # 7 days from now
D('today', weeks=-2) # 2 weeks ago
D('today', months=-3) # 3 months ago
D('today', years=-1) # 1 year ago

Period boundaries (offset: 0=current, -1=previous, 1=next):

ExpressionDescription
D('start_of_week', 0)Monday of this week
D('end_of_week', 0)Sunday of this week
D('start_of_month', 0)1st of this month
D('end_of_month', 0)Last day of this month
D('start_of_month', -1)Start of last month
D('end_of_month', -1)End of last month
D('start_of_quarter', 0)Start of this quarter
D('end_of_quarter', 1)End of next quarter
D('start_of_year', 0)Jan 1st this year
D('end_of_year', -1)Dec 31st last year

Example filters using D():

- type: filter
identifier: overdue
description: Overdue
filter: "(Q(state='Posted') & Q(due_date__lt=D('today')))"
- type: filter
identifier: due_this_week
description: Due This Week
filter: "(Q(due_date__gte=D('start_of_week', 0)) & Q(due_date__lte=D('end_of_week', 0)))"
- type: filter
identifier: last_30_days
description: Last 30 Days
filter: "(Q(create_date__gte=D('today', days=-30)))"
- type: filter
identifier: this_quarter
description: This Quarter
filter: "(Q(date__gte=D('start_of_quarter', 0)) & Q(date__lte=D('end_of_quarter', 0)))"
OperatorExampleDescription
= or __eqQ(state='Pending')Exact match
__neqQ(state__neq='Draft')Not equal
__isnullQ(user__isnull=True)Null check (use field name, not field_id)
__gtQ(amount__gt=100)Greater than
__gteQ(amount__gte=100)Greater than or equal
__ltQ(amount__lt=100)Less than
__lteQ(amount__lte=100)Less than or equal
__inQ(state__in=['Draft','Pending'])Value in list
__ninQ(state__nin=['Done'])Value not in list
__containsQ(name__contains='test')Contains (case-sensitive)
__icontainsQ(name__icontains='test')Contains (case-insensitive)
__iexactQ(name__iexact='test')Case-insensitive exact

For ManyToOne fields, you can filter by related record’s fields using __fieldname:

- type: filter
identifier: won_filter
description: Won
filter: "(Q(stage__is_won=True))"

Note: When filtering by user-definable data (like stages), use semantic boolean flags (e.g., is_won, is_lost) rather than names or identifiers which users can change.

Grouping options for the view — the Organize By list in the browse panel:

- type: group
identifier: group_stage
name: stage
description: Stage

Name a date field and the group covers every period. The user picks Day, Week, Month, Quarter or Year from a chip on the group’s own row, so one node replaces the five you would otherwise declare:

- type: group
identifier: sales_order_date
name: order_date # bare: the user picks the period
description: Order Date # label the FIELD, not a period
interval: month # optional — which period it opens on (default: month)

description names the field, because the period is no longer part of the group’s identity — the chip shows it, and it changes as the user picks. Writing description: Month leaves a row captioned “Month” whose chip reads “Quarter”.

To pin one period instead, put it in the name as field:interval. The group then covers that period only and shows no chip:

- type: group
identifier: sales_order_month
name: order_date:month # pinned — no period chip
description: Order Month

Pin a period only when the view genuinely means one bucket. A set of pinned groups covering the same field at different periods is the long way of writing the bare form, and it costs the user a row each.

The periods are day, week, month, quarter and year — the same set a Pivot dimension accepts.

The browse panel provides a sidebar for faceted filtering. It displays field values with record counts, allowing users to quickly filter by clicking on values.

Add browsepanel elements to your search view:

- data_type: UiView
identifier: contact_search_view
type: Search
model: Contact
arch:
- type: filter
identifier: customers
description: Customers
filter: "(Q(is_customer=True))"
- type: browsepanel
name: company_type
description: Type
expanded: true
- type: browsepanel
name: categories
description: Categories
- type: browsepanel
name: country
description: Country
PropertyTypeDefaultDescription
namestringrequiredField name to browse by
descriptionstringfield labelDisplay label in the panel
expandedbooleanfalseWhether section is expanded by default
limitnumber8Maximum values to show before “Show more”
defaultlistnoneValues ticked when the view opens (Selection/Boolean only)

The browse panel supports the following field types:

Field TypeBehavior
ManyToOneShows related record names with counts
ManyToManyShows related record names with counts
OneToOneShows related record names with counts
SelectionShows selection choices with counts
BooleanShows both values with counts (Active/Archived for active, else Yes/No)

default pre-ticks facet values, so the view opens already filtered. Entries are the field’s own values — the stored text for a Selection, true/false for a Boolean:

- type: browsepanel
name: active
description: Records
default: [true, false]

The user can untick them like any other selection; nothing is locked.

Only Selection and Boolean facets may declare a default, and a relation facet is rejected: it selects record ids, which differ from one database to the next, so a shipped default would tick whichever record happens to hold that id on the customer’s install. To open a view narrowed to particular related records, use a filter node keyed on a stable value (Q(stage__is_won=True)) — the same rule that applies to filters generally.

Every archivable model (one with an active field) gets a Records facet — Active / Archived — added automatically, with no view configuration. Reads hide archived records by default, so this facet is how a user reaches them.

Declare the facet yourself only to change its defaults, as above. Ticking both values is the supported way to show archived rows alongside active ones — do not hand-write a filter chip for it. This matters on models where active means not switched on yet rather than retired (currencies, languages): the list is opened precisely to find the row the default read hides, so it should open showing both.

  1. Multi-select: Users can select multiple values within a field (OR logic)
  2. Cross-field AND: Selections across different fields use AND logic
  3. Dynamic counts: Record counts update based on other active filters
  4. Self-exclusion: A facet is never narrowed by its own selection — selecting a value keeps that facet fully browsable while still cross-narrowing the others, so the counts stay meaningful
  5. Toggle visibility: Users can show/hide the panel via the toolbar button

When a browsepanel field is a ManyToOne/ManyToMany whose target model is a tree (it opts into the hierarchy primitive via _parent_field), the panel automatically renders that facet as an expandable, multi-level tree instead of a flat list. No extra view configuration is needed.

  • Nodes nest under their parents to any depth, with per-node expand/collapse.
  • Counts are rolled up the tree, so a parent shows the total for itself plus all descendants. A roll-up is always a count of records, never of links: on a ManyToMany, a record carrying two sibling nodes counts once under their shared parent, so a parent’s count never exceeds the number of records that selecting it would show.
  • Selecting a node filters the record list to that node and its entire subtree (matched via the materialised parent_path), so picking a top-level category captures everything beneath it.
  • Only branches that lead to matching records appear; an ancestor with no direct records is still shown so users can drill through it.

For example, a category browse field on Product becomes a drill-down tree as soon as ProductCategory declares _parent_field = "parent" — the search view entry stays exactly the same {"type": "browsepanel", "name": "category"}.

- data_type: UiView
identifier: product_search_view
type: Search
model: Product
arch:
- type: browsepanel
name: category
description: Category
expanded: true
- type: browsepanel
name: brand
description: Brand
- type: browsepanel
name: product_type
description: Type
limit: 5
- type: filter
identifier: in_stock
description: In Stock
filter: "(Q(quantity__gt=0))"
- data_type: UiView
identifier: lead_search_view
type: Search
model: CrmLead
arch:
- type: filter
identifier: my_opportunities
description: My Opportunities
filter: "(Q(user__id__eq=uid))"
- type: filter
identifier: unassigned
description: Unassigned
filter: "(Q(user__isnull=True))"
- type: filter
identifier: won_filter
description: Won
filter: "(Q(stage__is_won=True))"
- type: filter
identifier: lost_filter
description: Lost
filter: "(Q(stage__is_lost=True))"
- type: group
identifier: group_stage
name: stage
description: Stage
- type: group
identifier: group_user
name: user
description: Salesperson
- type: group
identifier: group_source
name: source
description: Source

Wizard views define the UI for transient models (wizards). Wizards are temporary forms used for user input before executing an action.

- data_type: UiView
identifier: record_payment_wizard_view
type: Wizard
model: RecordPaymentWizard
arch:
- type: row
content:
- type: column
span: 6
content:
- {type: field, name: amount, properties: {widget: NumberInput}}
- type: column
span: 6
content:
- {type: field, name: date, properties: {widget: DatePickerInput}}
- type: footer
buttons:
- label: Confirm
method: action_confirm
variant: primary

Control how the wizard opens with view-level properties:

PropertyValuesDefaultDescription
sizexs, sm, md, lg, xl, fullscreenAutoModal/drawer size
displayModedrawer, modalAutoHow the wizard opens

Default behavior (when not specified):

  • Multi-step wizards (has stepper): Fullscreen modal (xl)
  • Single-step wizards: Drawer (md)
- data_type: UiView
identifier: large_wizard_view
type: Wizard
model: MyWizard
size: lg
displayMode: drawer
arch: [...]

You don’t choose between a drawer and a modal — the framework decides, so wizards stay consistent across the app:

SituationPresentation
Single-stepDrawer, at the shared drawer-panel width
Multi-step (has a stepper)Fullscreen modal
Opened while another overlay is already openModal, whatever it would otherwise have been

That last rule matters and is deliberately not yours to make: you cannot know that someone will one day open your wizard from inside a drawer, and a drawer sliding over a drawer is never the intent. It is resolved at the moment the wizard opens.

Set display_mode only when the result genuinely reads wrong. It chooses how the wizard presents when it opens free-standing — the stacking rule still applies on top of it, so Drawer on a wizard opened from inside another overlay still yields a modal. That is deliberate: a drawer over a drawer is a defect regardless of who asked for it.

- data_type: UiView
identifier: my_wizard_view
type: Wizard
model: MyWizard
display_mode: Modal # or Drawer; leave it out to let the rules above decide

Use a stepper element for multi-step wizards:

- data_type: UiView
identifier: onboarding_wizard_view
type: Wizard
model: OnboardingWizard
arch:
- type: stepper
steps:
- {label: Company Info, key: company}
- {label: Users, key: users}
- {label: Settings, key: settings}
- type: step
key: company
content:
- {type: field, name: company_name}
- type: step
key: users
content:
- {type: field, name: admin_email}
- type: step
key: settings
content:
- {type: field, name: timezone}
- type: footer
buttons:
- {label: Complete, method: action_confirm, variant: primary}

The footer element defines action buttons under buttons:. Unlike the rest of a view, a footer button’s keys sit at the node level — there is no properties: wrapper:

- type: footer
buttons:
- label: Confirm
method: action_confirm
variant: primary
- label: Save Draft
method: action_save_draft
variant: outline

A Cancel button is automatically added to all wizards.

Footer buttons take visible and disabled as true/false/Q-expressions, resolved against the wizard’s live inputs — the values the user is typing, not a saved record:

- type: footer
buttons:
- label: Add to Order
method: action_add
variant: filled
disabled: Q(is_complete=False)

This is where disabled earns its keep most obviously. A wizard is a short, fixed sequence, so its footer must not change shape as the form fills in: the confirm button belongs there from the first render, greyed until the inputs justify it. Hiding it instead leaves the user looking for the way forward. Keep the method’s own validation regardless — the gate is an affordance, not a check (see Hiding vs disabling).

Connect views to the application:

- data_type: WindowAction
identifier: leads_action
name: Leads
model: CrmLead
modes: Kanban,List,Form
default_view: lead_kanban_view
views:
- - R
- - lead_kanban_view
- lead_list_view
- lead_form_view
search_view: lead_search_view
action_ctx:
view: {filters: [my_leads]}
PropertyDescription
nameAction display name
modelModel to display
modesAvailable view modes
default_viewDefault view identifier
viewsList of view identifiers
search_viewSearch view identifier
action_ctxDefault context
limitRecords per page
global_filterAlways-applied filter

Context: field prefills vs. view directives

Section titled “Context: field prefills vs. view directives”

The action action_ctx carries two distinct kinds of entry, kept in separate namespaces so they can never be confused:

  • default_<field> — pre-fills <field> on a new record created from the action (e.g. default_company: 3, default_type: "Opportunity", or a relational command-form default like default_lines: [["create", {...}]]). The create path maps every default_<field> straight onto the model field of that name.
  • view: { filters: [...], groups: [...] }list/search view directives: which search-view filter / group identifiers start active. These describe the view, not a record, and are read only by the list/search layer.

Keeping view directives under view (rather than default_filters / default_groups) is deliberate: their names reference data dimensions and would otherwise collide with a same-named model field. For example User owns a filters relation, so a default_filters key would be mis-read as a field prefill and inject bogus relational command data on user creation. The view namespace makes that collision structurally impossible — no reserved-key list to maintain.

Alongside those, action_ctx takes a small set of booleans that withhold a standard toolbar affordance for this action. Each defaults to true; only false is meaningful. They shape the UI on top of model permissions — a flag can take an affordance away, never grant one the user’s ACL withholds.

KeyWithholds
createThe New button — and with it Import, which is bulk creation
editForm editing (the record opens read-only)
deleteDelete in the record’s three-dot menu
cloneDuplicate in the record’s three-dot menu

Use them where records exist only because something else produced them — a work order raised by a confirmed manufacturing order, an entry posted by a workflow:

- data_type: WindowAction
identifier: work_order_action
model: WorkOrder
action_ctx:
create: false # no New, and no Import
clone: false # duplicating would invent an operation nothing asked for