Building UIs¶
pyvelm renders four primary record-oriented views out of the box: list, form, detail, and kanban. You declare each one as a Python dict in a module's data file — no Jinja, no JSX. The framework owns the templates and dispatches every field through a widget registry to produce HTML.
Preferred style: assign a fluent views_data builder in each DATA file
(see Modules → Data files). Legacy VIEWS = [list_view(...)]
lists still load unchanged.
A new view appears in the app as soon as you bump the module version
and reinstall. Want it linked from the sidebar? Declare an
ir.ui.menu entry pointing at the URL.
Fluent builders¶
# partners/views/partner.py
from pyvelm.builders import Field, FormView, ListView, ViewsData
views_data = (
ViewsData.make()
.views(
ListView.make("partner.list")
.model("res.partner")
.columns(
[
"name",
"code",
"country_id",
Field.make("active").toggle(),
]
)
.form_view("partner.form"), # makes rows clickable
FormView.make("partner.form")
.model("res.partner")
.section("identity", "Identity", ["name", "code", "country_id", "active"]),
)
)
Field.make("name").toggle(), .widget("dialog"), .readonly(),
.columns([...]), and similar chain on field specs inside form sections.
ListView.make(...), FormView.make(...), DetailView.make(...),
KanbanView.make(...),
GraphView.make(...), and InheritView.make(...) cover the other view
types.
Menus belong on the same builder or a sibling DATA file:
from pyvelm.builders import Menus, ViewsData
m = Menus("partners")
views_data = ViewsData.make().menus(
m.group("business", "Business", icon="home").children([
m.item("business.partners", "Partners").view("partner.list"),
]),
)
See examples/modules/partners/views/ for a full module using fluent
declarations end to end.
List views¶
The fluent list view above is enough to get a sortable, paginated, searchable table at
/web/views/partners/partner.list. The toolbar above the rows ships
with:
Fixed domain¶
Pin a list to a subset of records with domain on the arch (ANDed
with toolbar search and filter chips — same as graph/pivot views):
ListView.make("partner.active")
.model("res.partner")
.columns(["name", "code"])
.form_view("partner.form")
.domain([("active", "=", True)])
Or with legacy helpers: list_view("partner.active", "res.partner", fields=[...], domain=[...]).
Raw dict form: arch={"fields": [...], "domain": [("stage", "=", "won")]}.
This applies to standalone list pages only. Embedded One2many sub-grids use
list_view / columns instead — see One2many on parent forms.
- Search — single text input, ILIKE-OR across every text field in the view. Debounced 400 ms.
- Filter — drop-down builder for per-column constraints. Booleans get checkbox toggles; Many2one fields get a searchable picker; text fields get an ILIKE match.
- Group By — collapsible groups headed by the chosen field's value, with per-group counts.
- Sort — click a header to toggle ASC → DESC → unsorted.
- Column reorder — drag header cells; the order persists per
browser via
localStoragekeyed by(module, view_name).
Add a sequence field on the model and reference it in the arch to
turn on row-level drag reorder:
ListView.make("tag.list")
.model("res.tag")
.columns(["name"])
.sequence("sequence") # field name; enables the drag handle
The renderer adds a handle column on the left and forces sort by
sequence ASC. Dropping a row POSTs the new ordering to
/web/records/{module}/{name}/reorder and the server rewrites the
field. Lists with a sequence field disable bulk selection (drag
reorder and checkboxes conflict).
Bulk actions¶
When the user can unlink records and the list has no sequence field,
the toolbar shows a bulk action bar after you select rows: checkboxes
on each row, a select-all control in the header, and a default Delete
action.
Declare custom actions on the list arch:
ListView.make("partner.list")
.model("res.partner")
.columns(["name", "code"])
.bulk_actions([
{
"action": "unlink",
"label": "Delete",
"confirm": "Delete selected partners?",
"perm": "unlink",
},
])
The browser POSTs {action, ids} to
/web/records/{module}/{name}/bulk. Built-in actions today: unlink.
Omit bulk_actions to keep the default delete-only bar when unlink is
allowed.
Detail views and row actions¶
Use a detail view for read-only record pages separate from the edit form. Row clicks open the detail view when the user has read access.
from pyvelm.builders import DetailView, ListView, ViewsData
views_data = (
ViewsData.make()
.views(
ListView.make("partner.list")
.model("res.partner")
.columns(["name", "code"])
.detail_view("partner.detail"),
DetailView.make("partner.detail")
.model("res.partner")
.form_view("partner.form") # Edit button target
.section("identity", "Identity", ["name", "code", "country_id"]),
)
)
Detail views live at /web/views/{module}/{name}/record/{id} (same URL
shape as forms). Set form_view on the detail arch so the Edit button
opens the paired form in edit mode.
Per-row toolbar buttons (resolved like form header actions):
ListView.make("partner.list")
.model("res.partner")
.columns(["name"])
.row_actions([
{"label": "Mail", "method": "POST", "url": "/web/actions/..."},
])
If detail_view is omitted, the list falls back to the first detail view
registered for the same model, then to form_view.
Available fields and widgets¶
You can write fields as bare strings or as dicts. The dict form lets you tweak per-field attributes:
| Field type | Default render | widget hints |
|---|---|---|
Char, Text |
Plain text | — |
Integer, Float |
Number | — |
Boolean |
Coloured Yes / No pill | "toggle" — animated switch |
Many2one |
Display value + separate open button (dialog) | — |
One2many, Many2many |
Up to 3 chips + "+N" overflow | — |
The bare-string sugar is just shorthand for {"name": "x"}. Use the
dict form to add widget, label, readonly, or required.
Adding a new widget is a decorator one-liner — see widgets at the end of this page.
Form views¶
A form arch declares a list of layout blocks: flat sections and/or
tabbed notebooks (Odoo-style <notebook> / <page>).
Each section(...) has a name, display title, and fields list.
Each notebook(...) has a name and pages=[page(...), ...]; each
page(...) is a tab with its own fields grid — ideal for several
One2many sub-grids with different list_view values.
from pyvelm.builders import Field, FormView, Notebook, Page, ViewsData
views_data = (
ViewsData.make()
.views(
FormView.make("partner.form")
.model("res.partner")
.section("identity", "Identity", ["name", "code"])
.notebook(
"relations",
"Relations",
[
Page.make("children", "Contacts").fields(["child_ids"]),
Page.make("tags", "Tags").fields(
[Field.make("tag_ids").widget("dialog")]
),
],
),
)
)
The form lives at /web/views/{module}/{name}/record/{id} (display
mode) and …/edit (edit mode). Flat sections render as stacked cards;
notebooks render a tab strip (the active tab is remembered per view in
localStorage). Edit mode swaps each value
for the corresponding edit-mode widget — text inputs, number
inputs, checkboxes, the Many2one combobox, and
the Many2many chip editor.
Inline validation¶
Save fails on type errors (a letter in an Integer) or missing required fields surface as red borders + per-field messages on the form itself, with the rest of the typed values preserved. ORM-level rejections (unique-constraint, downstream DB error) land in a banner at the top of the form. Nothing is lost; the user just fixes the offending field and saves again.
Autosave on navigation¶
Forms in edit and new mode opt into autosave on link clicks: if you've changed the form and then click a normal in-app link (sidebar, breadcrumb, menu), the framework POSTs the form first and only follows the link when that save succeeds. Save, Cancel, and Ctrl+S bypass this interceptor.
Browser-initiated navigation (Back, reload, tab close) cannot finish async work; a dirty form triggers the native Leave site? prompt instead.
When you open a record from a list or kanban, breadcrumbs remember where you came from (including List → Kanban → Form). Search, filters, and group-by are restored when you go back.
Sticky actions, Ctrl+S, and save toasts¶
See Form UX for:
- Sticky Edit / Save / Cancel bar while scrolling long forms
- Ctrl+S / Cmd+S to save
- Success toasts after Save / Create
- Opening related records in
PvDialog(Many2one open button, O2M rows)
Many2one combobox¶
Edit-mode Many2one fields render as a searchable combobox:
- Filter as you type against
/api/m2o/search. Initial focus pre-fetches a page so the dropdown is useful before the user types. - Create on the fly — if the typed text doesn't match any
result, the dropdown shows
Create "<query>". Clicking creates a record with justname. If the comodel needs more required fields the framework redirects to the comodel's form view in/newmode ("Create and edit…"). - Open record — a small ↗ button beside the selected value opens
the comodel form in
PvDialog(not full-page navigation). Use Open full page in the dialog title when you need the normal route. - Keyboard nav — ↑/↓ move the cursor, Enter selects (or fires Create), Esc closes.
Relational fields on forms (One2many / Many2many)¶
For One2many and Many2many on parent forms you choose:
- Edit mode —
widget="dialog"vswidget="inline"/table - Sub-grid columns —
columns=,list_view=, or defaults - Row / Add form —
form_view=on the field spec or model
Full guide: One2many on parent forms (column
precedence, view ref syntax, invoice vs entry example, FieldRef keys).
Quick reference:
Relational fields: widget="dialog" vs widget="inline"¶
| Goal | One2many |
|---|---|
| Reuse a list page's columns | list_view="that.list" on model or field(...) |
| Columns without any list view | field("ids", columns=["a", "b"]) |
| Different list per parent form | field("ids", list_view="other.list") |
| Inline edit on parent Save | widget="inline" |
| Dialog edit (default) | widget="dialog" or omit when form exists |
| Let the user pick dialog vs inline | edit_toggle=True (+ list_view / columns) |
FormView.make("note.form")
.model("my.note")
.section(
"relations",
"Relations",
[
Field.make("tag_ids").widget("dialog"),
Field.make("comment_ids")
.widget("dialog")
.edit_toggle()
.list_view("comment.compact")
.form_view("comment.form")
.columns(["body", "active"]),
],
)
Use edit_toggle=True for a Dialog | Inline grid switch on one field
instead of separate tabs (see One2many on parent forms).
If the comodel has no form view, One2many falls back to a chip summary; Many2many falls back to inline chip search.
Many2many — dialog mode¶
Selected records appear as chips. Create new opens the comodel form in the dialog; Link existing… opens a search field to pick records. Edit/remove use the chip actions.
Many2many — inline mode¶
widget="inline": removable chips plus an always-visible typeahead
input.
Kanban views¶
A kanban view renders each record as a card. With group_by, cards
are arranged in columns (one per distinct field value) — useful for
sales pipelines, ticket boards, anything with stages. Cards can be
dragged between columns (updating the grouping field) and reordered
when the arch declares a sequence integer field. Without
group_by, cards appear in a responsive grid with the same search,
filter, group-by, and pagination toolbar as a list view (field
metadata is taken from a sibling list view when one exists).
from pyvelm.builders import Field, KanbanCard, KanbanView, ViewsData
views_data = (
ViewsData.make()
.views(
KanbanView.make("lead.kanban")
.model("crm.lead")
.title("Pipeline")
.card(
KanbanCard.make()
.title("name")
.subtitle("salesperson")
.fields(["partner_id", "expected_revenue"])
.badges([Field.make("priority"), "stage"])
)
.group_by("stage")
.sequence("sequence")
.form_view("lead.form"),
)
)
title and subtitle are field references rendered through each
field's default display widget. fields is a list of label/value
pairs; badges are tighter chip-style indicators (typically
booleans or short collections).
When group_by is set, the renderer fetches every matching record
(no pagination — grouping a paginated subset is confusing UX) and
buckets them by the field's value. NULLs land in a (no value)
column. When form_view is set, each card becomes a link to
/web/views/{module}/{form_view}/record/{id}.
Page titles¶
Each view gets a heading derived from the arch. List and kanban
views read arch["title"] if you set one, otherwise the model name
is humanised (res.partner → "Partners", crm.lead → "Leads",
res.company → "Companies"). Form views show the record's display
name (name, falling back to display_name or #id).
Set title explicitly when the default is wrong:
ListView.make("lead.list")
.model("crm.lead")
.title("All Leads") # default would just be "Leads"
.columns(["name", "stage", …])
Legacy function helpers¶
The original function API remains available and delegates to the same fluent classes:
from pyvelm.builders import field, form_view, list_view, section
VIEWS = [
list_view(
"partner.list", "res.partner",
fields=["name", field("active", widget="toggle")],
form_view="partner.form",
),
form_view(
"partner.form", "res.partner",
sections=[section("identity", "Identity", ["name", "code"])],
),
]
field("x", widget="toggle") and Field.make("x").toggle() are equivalent.
New scaffolds and bundled modules use the fluent style; migrate when convenient.
Custom widgets¶
Register a renderer for a (field_class, hint) pair via the
@widget decorator:
from pyvelm.render import widget
from pyvelm.fields import Boolean
from markupsafe import Markup
@widget(Boolean, hint="led")
def render_led(value, spec, field):
color = "bg-green-500" if value else "bg-red-500"
return Markup(
f'<span class="inline-block w-3 h-3 rounded-full {color}"></span>'
)
Any field that gets widget="led" in its arch (directly, or via
view inheritance) renders through this function.
Register custom widgets at app startup, before create_app().
A renderer's signature is (value, field_spec, field) -> Markup.
Returning a bare string lets Jinja auto-escape; returning Markup
opts out for trusted HTML — that's the safety contract.
The same registry has a parallel mode="edit" registry for inline-
edit controls. Display-only widgets (toggles, chips) don't
accidentally become input controls when a row enters edit mode.
JSON over HTML¶
Every view is also reachable as JSON for callers that want to build their own UI:
GET /api/views/{module}/{name}returns the resolved arch (after view inheritance is applied).GET /api/records?model=&domain=&fields=&limit=&offset=&order=returns paginated rows.domainis a JSON list of[attr, op, value]triples — the same compiler that powers ORM searches, so dotted-path traversal (country_id.region_id.name) works.
Records are serialised with the framework's conventions:
| Field type | JSON shape |
|---|---|
| Scalars (Char, Integer, Boolean, Float, Date, Text) | Pass through |
Many2one |
[id, display_value] |
One2many, Many2many |
list[int] of related ids |
For mutation, three endpoints round-trip JSON:
| Method | URL | Body | Returns |
|---|---|---|---|
POST |
/api/records?model=… |
{…vals} |
201 + serialised record |
PATCH |
/api/records/{id}?model=… |
{…vals} |
200 + serialised record after re-running stored computes |
DELETE |
/api/records/{id}?model=… |
— | 204 |
All three run inside env.transaction(). ACL applies the same as
for HTML routes.
Where the renderer lives
The HTML side ships as Jinja templates in
pyvelm/templates/ and a widget registry in pyvelm/render.py.
The CSS stack is Tailwind v4 + Flowbite, compiled by the
npm run build step in the repo root and shipped as
pyvelm/static/dist/pyvelm.css. Anyone consuming the rendered
HTML can audit styling by reading the utility classes in the
markup — there are no hand-rolled component classes.