Lists
A list body declares columns. That is all it declares. The framework mounts
<ListPage> for the resource’s list route, and that page owns the chrome — page
header and Create button, view tabs, search box, filter bar, pager. Your
component renders the table inside it.
import type { ProductSchema } from "@onerp/master/product/schema";import { DataTable } from "@onerp/react/lists";
export const ProductList = () => ( <DataTable<typeof ProductSchema> columns={["sku", "name", "type", "isPhysical", "baseUnit"]} />);Pass the schema as the type parameter — it is what checks every column.
Columns
Section titled “Columns”A column names a leaf field path — bare, or as
{ source, label?, render?, align?, sortable?, width? } — or the record’s
system id: { system: "id", render?, align?, sortable?, width? }. A system
column’s header is chrome (onerp.system.id), not a field label; its default
cell shows the id in monospace. Every other header label comes from the field
(<FieldLabel field={def} /> renders the same label anywhere).
A bare string resolves everything from the field definition — header label,
alignment (integer, decimal and date right-align; datetime does not),
and click-to-sort. A bare referenceOne column needs no help either: it resolves
the target record’s label, batched into one list call per target business object,
and links to it. Pass an object only for what the schema cannot know.
render replaces what the value cell draws, not the column: source stays
required and must name a real field, because the label, the alignment and the
sort still resolve from it — a source naming no field throws, render or not.
row is the full BusinessObject<S>, so a render may read fields the
table does not show:
{ source: "netAmount", render: (row) => <Money currency={row.currency} value={row.netAmount} /> }Dotted paths descend through struct only. "address.city" is a valid
column on a business partner because address is a struct, and its members
are columns of the same row; "customer.name" is a compile error, not a
blank cell, because a reference is one id, not a joined row. The header resolves
like any other: the label is the member field’s own. A struct or hasMany path
itself is a column only with render and sortable: false — a nested value has
no default cell and no sort.
<DataTable>, <TreeTable> and <RelatedList> share this one column model.
Row clicks route to the record by default; rowClick takes a function returning
a path, or false to make rows inert.
<DataTable> and <TreeTable>
Section titled “<DataTable> and <TreeTable>”<DataTable> is the canonical list primitive: a flat list of records from one
resource, one row per record. Reach for something else when rows must be
editable in place (<f.HasMany> — a data
table is read-oriented), when a row would stitch together several records, or
when the rows form a hierarchy through a single self-referential parent — a
category tree, a location tree, a chart of accounts. That last one is
<TreeTable>:
export const LocationList = () => ( <TreeTable<typeof LocationSchema> columns={["code", "name", "storable"]} parentField="parent" />);parentField must name a treeParent field — the runtime resolves it through
the meta schema and filters it out of the rendered columns. Pointing it at a
referenceOne compiles but does not assemble a tree.
A tree renders its whole hierarchy in one read of up to 1000 rows, because a
page boundary would cut branches rather than rows — so it is right for bounded
reference data and wrong for open-ended tables. Its headers are static: a tree’s
order is the hierarchy, so every column renders sortable={false} whatever the
column config says, as <RelatedList> does. Rows start collapsed with no prop to
seed the expansion; under a filter or search the server returns the matches plus
the ancestors that place them, and those branches open.
Search, filters, and views
Section titled “Search, filters, and views”None of these are yours to mount, and all of them narrow the same query.
Search appears when the schema declares at least one searchable: true
field at its root; a schema declaring none renders no search box at all.
The term matches case-insensitively against every searchable field — struct
members included — plus an exact match on the record id. It never
matches “every column on screen”: a visible column not declared searchable will
not respond.
Filters are derived from the listed object’s schema — resources declare
none, and there is no filterable flag in meta. Every root field of a column
kind is filterable, with an editor chosen by kind; struct, hasMany,
referenceMany and treeParent are out of scope.
Views are named scopes declared on the resource, rendered as a tab row in
the page header. Each ANDs its filter with whatever the user typed, so refining
never un-lights the active view. All is an implicit first tab carrying no
filter, and defaultView names the tab the list opens on:
views: [ { name: "open", label: "onerp.salesOrder.views.open", filter: { execution: { equals: "open" } } },],defaultView: "open",Omit defaultView and the list lands on All — the right call when the list
has to read gapless, as the invoice journal does. An undeclared view throws.
Show archived is a toggle, not a filter: archived records are a world, and
status can never appear in ?filter=. It renders only for an archivable
business object, so the Sales Order and Sales Invoice lists — both
archivable: false — have none. See
Reads: browse vs. lookup.
The URL is the list state
Section titled “The URL is the list state”View, search, filter, sort, page, page size and the archived toggle all live in
the query string — ?view=open&search=acme&sort=documentDate:desc&page=2 — so
every list state is a link. A missing, malformed or unknown value falls back to
its default rather than widening the query.
Dialog resources
Section titled “Dialog resources”defineResource({ dialog: true }) changes the whole page, not just the table.
No /create or /:id route is emitted; Create opens a dialog over the list
and a row click opens the edit dialog. Reach for it when the form is short and
self-contained — product types, product categories, payment terms. A reference
pointing at one renders its label without the open-in-new-tab link, since there
is no record route.
Anti-patterns
Section titled “Anti-patterns”- Each row is one record. Don’t reach across resources for a column — expose the value as a determination on the listed object so it reads as a flat field.
- Don’t hand-roll a toolbar out of
useList. The chrome is already mounted and observes the same context; a parallel toolbar drifts from it. - Don’t fake a hierarchy in a flat table. Sorting parents above children gives a tree with no chevron, no collapse and no indentation.