Skip to content

Kanban Views

Kanban views display records as draggable cards grouped by a field.

- data_type: UiView
identifier: lead_kanban_view
type: Kanban
model: Lead
arch:
- drag_fields: [stage]
content:
- type: field
name: name
properties: {widget: Text, fw: "600"}
- type: field
name: expected_revenue
properties: {widget: Text}

Define which field controls the columns:

- drag_fields: [stage]

Multiple drag fields create nested grouping:

- drag_fields: [stage, priority]

A board draws one lane per row of the model behind drag_fields — and by default, every row of it. That is right for a stage or status list, which every record of the model shares. It is wrong when the lanes belong to a parent: a project with its own stages, a board with its own columns. Without a scope, opening one parent shows every other parent’s lanes sitting empty beside its own.

group_scope names the relation that ties a lane to that parent:

arch:
- drag_fields: [column]
group_scope: board # a column belongs to the board its cards belong to
content: [...]

The parent is read from the action’s context — the default_<scope> key a drill-down already sets when it opens the screen:

async def action_open_board(self):
return await get_action(
identifier="board_card_action",
global_filter=f"Q(board__id={self.id})",
replace_filter=True,
ctx={"default_board": self.id}, # <- what group_scope resolves against
)

Both enumerations of the group model — the empty lanes and the column order/fold rows — read the same scope, so they always draw the same set of columns.

With no group_scope, or no matching id in the context, every row is a lane. That is the long-standing behaviour and nothing about it changes.

Columns can always be reordered by dragging their headers. group_editable adds the other three: a ”+ Add column” cell after the last one, click-to-rename on a column header, and Delete in its ⋮ menu.

arch:
- drag_fields: [column]
group_scope: board
group_editable: true
content: [...]

Reach for it when the columns are the user’s own to invent — a board where adding “In Review” mid-flight is half the point. Leave it off when they are configuration a manager sets up once on the settings screen, which is the usual case for stage lists shared across an app.

  • A new column is created on the group model, sequenced after the last one, and (when group_scope is declared) attached to the same parent the board is scoped to.
  • Deleting is refused while the column still holds cards, with a message saying to move them first — the alternative is destroying work or orphaning it.
  • Access is enforced server-side. The flag is dropped during view composition for a user without create and update rights on the group model, so the affordance is never on screen for someone whose write would be refused. Record rules still judge the individual row at write time.

By default a card opens its own Form, on its own page. Two other answers, both on one key:

arch:
- drag_fields: [column]
open: drawer # the action's own Form, over the board
# open: action_open_board # …or run that method and follow what it returns
type: row
content: [...]

open: drawer keeps the reader where they are. For a screen someone is in the middle of, where navigating away costs their scroll position and their sense of where the card sat. The record is read in full before the overlay opens (the board fetched only the fields its cards draw), and the board re-reads itself when it closes. The form is the action’s own — the same one a click would have navigated to — so there is no second view to declare.

open: <method> is for a card that is a container rather than a record you read. Clicking a board should open the board, not a settings page about it; same for a project card (its tasks) or a team card (its pipeline). The method runs against the clicked record and returns an action, which the client follows:

async def action_open_board(self):
return await get_action(
identifier="board_card_action",
global_filter=f"Q(board__id={self.id})",
replace_filter=True,
ctx={"default_board": self.id},
)

A method that returns nothing simply runs; only an action is navigated to. The name is checked when the view is saved and again by check --only views, because the failure is otherwise silent — a card whose open resolves to nothing does nothing at all when clicked.

Omit it and a card click navigates, exactly as it always has. It is never implied by anything else: declaring a QuickCreate means “let me add one from here” and says nothing about where reading one should land.

The same method is reused after a QuickCreate (below): create a board and you land on it, rather than back on the shelf hunting for what you just made.

Card colour (color_field, cover, color_editable)

Section titled “Card colour (color_field, cover, color_editable)”

color_field names a field holding a colour, and the card is drawn with it:

arch:
- drag_fields: [column]
color_field: cover_color
cover: band # optional — default is an edge accent
color_editable: true # optional — offer the palette in the card's ⋮ menu
type: row
content:
- type: field
name: cover_color
properties: {visible: false} # declared so it is FETCHED

The field must also be declared in the card’s content, usually hidden. color_field names it but does not bind it, and a field no node binds is not in the view’s fetch list — so the value never reaches the card and the colour silently never draws.

Two presentations, because the colour means two different things:

  • Default — an edge accent. A 3px stripe down the leading edge. Right for a colour the system derived: CRM’s pipeline colours cards by how overdue the next activity is, and a status wants to be findable, not loud.
  • cover: band — a full-width band across the top. Right for a colour a person chose. Its job is to be seen, so a wall of cards can be scanned by colour before anything is read.

Picking the wrong one is not a style slip: a band keyed on derived urgency turns a pipeline into a screaming board, and an edge accent on a deliberate cover is a choice nobody notices they made.

color_editable: true lets the colour be SET from the card, as a palette in the card’s ⋮ menu (with a clear-it swatch first, and a tick on the one in force). Picking writes the color_field on that record and repaints the card immediately.

Declare it only for a colour a person owns — it is the other half of cover: band. A derived colour has no business being editable: whatever someone picked would be overwritten the next time the value recomputes, with nothing to say why.

The field still needs a real editor somewhere for the cases the menu cannot serve — a widget: ColorInput on the record’s form — unless the palette is deliberately the only way to set it. Use a short Char(max_length=7) and store hex: the value is shared by everyone looking at the board, so it must not resolve per-viewer the way a theme variable does.

One field, one meaning. Do not point color_field at something the system derives while the card also states that same thing (a due-date row, a status badge). The pipeline card did exactly that — a band coloured by next-activity urgency sat above an activity row colouring the same fact on a different scale, so one card said “on track” and “act today” at once. Urgency stayed in the row that spells it out; the band became the user’s.

Create in place, open in place (QuickCreate)

Section titled “Create in place, open in place (QuickCreate)”

The same node Calendar and Gantt declare, doing the same two jobs on a board. Put it in the arch root’s content, beside the card’s fields:

arch:
- group_by: column
drag_fields: [column]
type: row
content:
- type: field
name: name
properties: {widget: Text, fw: "600"}
- type: QuickCreate
field: name # the field an inline title writes
ctx:
form_identifier: board_card_form_view # the form a card opens in

Either way, the affordance is “Add New” in the column header’s ⋮ menu, beside its other actions — not a button at the foot of the column, which on a full column is a scroll away.

field opts into the inline composer: “Add New” opens a text box at the top of the column, and a title is enough to create the record. Enter commits and clears, so a list can be typed straight in; Shift+Enter is a newline, Escape closes it. Name the field the title writes (usually name).

Declare it only when one line genuinely makes a usable record. It is a judgement about the work, not about the model: an opportunity will save with nothing but a name and still be useless to whoever picks it up, so the CRM pipeline names no field and gets a short form instead.

ctx.form_identifier is the form “Add New” opens when there is no inline composer (or when one is declared but cannot make the record — see below). It is also what the toolbar’s New button opens, instead of navigating to a full-page record form.

Point it at something small: the least that makes a record worth having in a column. Not the full record form — that one is for working a record that exists, and it reads as a wall in a modal.

The view’s New button routes through the same declaration: with a form_identifier, the toolbar’s New opens that small form over the board instead of navigating to a full-page record form — the behaviour Calendar and Gantt already have. Withholding New with create: false to force a bespoke button is the wrong way round: it takes Import with it, since import is bulk creation.

Declare either, both, or neither. A Kanban with no QuickCreate behaves exactly as before.

Some models cannot be created from one field: a project task requires a project. The board works this out from the model rather than being told — a field that is required with no default, that the title doesn’t name, that the group column doesn’t supply, and that the action’s own default_* context doesn’t fill.

  • Nothing outstanding → the inline composer, as declared.
  • Something outstanding, and a form_identifier is declared → “Add New” opens that form instead, with the column already chosen.
  • Something outstanding and no form → nothing is offered, rather than a box whose every submission the server would refuse.

This is why the same view behaves differently in two places: opened from a project, the task board knows the project (default_project is in the action’s context) and offers the inline box; opened from the all-tasks menu it doesn’t, so the same affordance opens the form.

Cards contain layout elements similar to forms:

- content:
- type: field
name: priority
properties:
widget: Badge
size: xs
colors:
low: gray
high: red
- type: field
name: name
properties:
widget: Text
fw: "600"
- type: row
content:
- type: field
name: assignee
span: 6
properties: {widget: Avatar, size: sm}
- type: field
name: due_date
span: 6
properties: {widget: Text, size: xs, c: dimmed}

A date on a card is read, not edited, so it is rendered in the viewer’s own locale — their order, their month names — rather than the wire’s 2026-08-27. Nothing to declare; it applies to every Date and Datetime a card draws.

Add relative: true when the date’s meaning is its distance from today:

- type: field
name: due_date
properties:
widget: Badge
relative: true # "in 5 days", "6 days ago", "tomorrow"

It counts whole calendar days, so something due tomorrow reads “in 1 day” all day rather than sliding to “in 12 hours” over lunch, and it speaks the viewer’s language. Past about four weeks the distance stops being useful and it falls back to the plain date.

Use it for due dates and deadlines. Leave it off for a date the reader needs exactly — a signature date, an invoice date.

Kanban card fields support visibility and group-based access control. These work with both boolean values and Q-expressions.

PropertyTypeDescription
visibleBoolean/StringHide field or use Q-expression. Default: true
groupsArrayUser must be in one of these groups to see the field

Show fields based on record data:

- type: field
name: discount
properties:
widget: Text
visible: Q(has_discount__eq=true)
- type: field
name: internal_notes
properties:
widget: Text
groups: [sales.manager]

Show totals in column headers:

- drag_fields: [stage]
header_aggregate:
field: expected_revenue
type: sum
content: [...]

Aggregate types: sum, count, avg

When the aggregated field is a Monetary, the column total is resolved to a single currency for you — you never add raw amounts across different currencies:

  • If every card in the column shares one currency, the total is shown in that currency (an exact sum, no conversion).
  • If the column mixes currencies (e.g. a multi-company pipeline with GBP, EUR and USD opportunities), each amount is converted to the active company’s currency and the total is shown in it. Conversion uses the recorded exchange rates; the individual cards keep displaying their own currency.

This is automatic — declare header_aggregate on a Monetary field and the header picks the right currency. The same rule applies to List group-by subtotals and List footer totals (see List Views).

- data_type: UiView
identifier: opportunity_kanban_view
type: Kanban
model: CrmLead
arch:
- drag_fields: [stage]
header_aggregate:
field: opportunity_amount
type: sum
content:
- type: row
content:
- type: field
name: priority
span: 6
properties:
widget: Badge
size: xs
variant: dot
colors:
low: gray
medium: blue
high: orange
urgent: red
- type: field
name: probability
span: 6
properties:
widget: Text
size: xs
c: dimmed
suffix: "%"
- type: field
name: name
properties:
widget: Text
fw: "600"
lineClamp: 2
- type: field
name: contact
properties:
widget: Text
size: sm
c: dimmed
- type: row
properties: {mt: sm}
content:
- type: field
name: opportunity_amount
span: 6
properties:
widget: Text
size: sm
fw: "500"
- type: field
name: expected_close_date
span: 6
properties:
widget: Text
size: xs
c: dimmed
- type: row
properties: {mt: xs}
content:
- type: field
name: salesperson
span: 6
properties:
widget: Avatar
size: sm
- type: field
name: tags
span: 6
properties:
widget: Tags
size: xs
- content:
- type: field
name: image
properties:
widget: Image
h: 120
fit: cover
- type: field
name: name
properties: {widget: Text, fw: "600"}

Three nodes run a model method from a Kanban, differing in where the click lands:

NodeDeclaredRendersRuns on
button (widget: Button)inside the card’s contenta button on the card itselfthat one record
inlineButtontop level of archthe card’s ⋮ menuthat one record
actionButtontop level of archthe view’s toolbarthe whole view

A button on the card, laid out like any other card content:

- content:
- type: field
name: name
properties: {widget: Text}
- type: button
properties:
widget: Button
label: Activate
size: compact-sm
variant: light
method: action_activate

An inlineButton is different: it goes at the top level of arch, a sibling of the container node — not inside content. The card reads its menu items off the arch root, so one nested among the fields renders nothing at all, with no error anywhere.

arch:
- type: inlineButton
anchor: delete_card
properties:
label: Delete
icon: Trash2
color: red
method: action_delete_card
confirm: This deletes the card. This cannot be undone.
- drag_fields: [column]
type: row
content: [...]

Give a destructive one a confirm: and color: red. Clicking a card opens it, so the ⋮ menu is the only place a card action can live that isn’t also a way to open the record.

visible and disabled are both evaluated per card, against that record:

- type: button
properties:
widget: Button
label: Activate
method: action_activate
disabled: Q(is_current_theme='Current Theme')
hint: This theme is already active on your website.

A card grid is read by comparison — the cards sit side by side and the eye expects them to have the same shape. That makes disabled usually the better of the two here: hiding a button leaves one card visibly short of an action its neighbours have, which reads as a fault rather than as a state. The hint becomes the greyed button’s tooltip (or a second line under a ⋮ menu item), so the card explains itself.

See Hiding vs disabling for the full rule.

A relational field can be drawn as a board instead of a grid — the same card layout, bound to the parent’s children rather than to an action:

- type: field
name: contacts
properties:
widget: Kanban
view: contact_person_kanban_view
create: true
filter: Q(entity_type='Person')
ctx:
form_identifier: contact_person_form_view
default_entity_type: Person

filter: narrows what the board shows and what its Add offers, so one relation can be presented as two boards (people and addresses) that each create the right kind of child. ctx.default_<field> is what stamps that kind onto a new card.

Adding and removing follow the relation, exactly as they do for an embedded list — a OneToMany card is an owned child, so removing it deletes the record, while a ManyToMany card is a link and removal only detaches it. See What removing a row does to the record.

Everything on a card needs a saved record behind it, so per-card affordances that write elsewhere — scheduling an activity, for one — are unavailable on a card that is still pending the parent’s save.