Kanban Views
Kanban views display records as draggable cards grouped by a field.
Basic Kanban
Section titled “Basic Kanban”- 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}Drag Fields
Section titled “Drag Fields”Define which field controls the columns:
- drag_fields: [stage]Multiple drag fields create nested grouping:
- drag_fields: [stage, priority]Per-parent columns (group_scope)
Section titled “Per-parent columns (group_scope)”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.
Editable columns (group_editable)
Section titled “Editable columns (group_editable)”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_scopeis 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.
What clicking a card does (open)
Section titled “What clicking a card does (open)”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 FETCHEDThe 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 inEither 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.
When a title isn’t enough
Section titled “When a title isn’t enough”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_identifieris 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.
Card Content
Section titled “Card Content”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}Dates on cards
Section titled “Dates on cards”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.
Field Properties
Section titled “Field Properties”Kanban card fields support visibility and group-based access control. These work with both boolean values and Q-expressions.
| Property | Type | Description |
|---|---|---|
visible | Boolean/String | Hide field or use Q-expression. Default: true |
groups | Array | User must be in one of these groups to see the field |
Conditional Visibility
Section titled “Conditional Visibility”Show fields based on record data:
- type: field name: discount properties: widget: Text visible: Q(has_discount__eq=true)Group-Restricted Field
Section titled “Group-Restricted Field”- type: field name: internal_notes properties: widget: Text groups: [sales.manager]Header Aggregates
Section titled “Header Aggregates”Show totals in column headers:
- drag_fields: [stage] header_aggregate: field: expected_revenue type: sum content: [...]Aggregate types: sum, count, avg
Money aggregates are currency-correct
Section titled “Money aggregates are currency-correct”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).
Complete Example
Section titled “Complete Example”- 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: xsKanban with Images
Section titled “Kanban with Images”- content: - type: field name: image properties: widget: Image h: 120 fit: cover - type: field name: name properties: {widget: Text, fw: "600"}Card Actions
Section titled “Card Actions”Three nodes run a model method from a Kanban, differing in where the click lands:
| Node | Declared | Renders | Runs on |
|---|---|---|---|
button (widget: Button) | inside the card’s content | a button on the card itself | that one record |
inlineButton | top level of arch | the card’s ⋮ menu | that one record |
actionButton | top level of arch | the view’s toolbar | the 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_activateAn 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.
Conditional card actions
Section titled “Conditional card actions”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.
Embedded in a form (widget: Kanban)
Section titled “Embedded in a form (widget: Kanban)”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: Personfilter: 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.
Next Steps
Section titled “Next Steps”- Stats Banner - Rich visual stats above kanban views
- Other Views - Calendar and Search views
- Widgets - All available widgets