Skip to content

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.

apps/web/src/resources/products/product-list.tsx
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.

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> 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.

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.

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.

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.

  • 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.