Table filters and forms
The filters, tabs and displays of a table view, and the add, edit and view forms it generates.
These are the options of a table view that narrow and show its rows, and the forms it generates from the controller's fields.
Filters, tabs and displays
defaultFilters seeds the visible filter bar. queryParamFilters and routeParamFilters add hidden filters instead: each is a Record<string, QueryParamFilter> / Record<string, RouteParamFilter> mapping a URL parameter name onto { field: string; mode?: string }, filtering the table without appearing in the UI or in the viewer's stored preferences. A QueryParamFilter also takes visible?: boolean and label?: string: a visible one shows as a removable chip of the filters row while the URL applies it. queryParamFilters reads the page's query string, routeParamFilters reads the parameters of the page slug (for example :id on a detail page). Both also seed submitDefaults on the generated forms, so a row created from a filtered view inherits the filter's value. An active queryParamFilters filter makes the table read as filtered (its empty body is the filtered state, without a clear action), and a change of its value in the URL lists from page 1.
static table = TableView(invoiceDataAPI, {
routeParamFilters: { id: { field: "customerId" } },
defaultSort: { field: "createdAt", desc: true },
tabs: [
{ id: "open", label: "Open", filter: { accessorKey: "status", value: "open", mode: "is" } },
{ id: "paid", label: "Paid", filter: { accessorKey: "status", value: "paid", mode: "is" } },
],
});
TableViewTab is { id, label, filter?: TableViewTabFilter, icon?, textColor?: ColorValue, iconColor?: ColorValue } plus the link fields (to, countFrom), the gate (permission, permissionId) and navBadge?: boolean | TableViewTabNavBadge ({ tone?: Tone }), and TableViewTabFilter is { accessorKey: string; value?: string; mode: string }. A tab gives a filter or a to, never both (registration throws). permission is relative to the table view's permission id ("list" → <table permission id>.list) or an Action; permissionId is absolute and wins; a link tab to a page controller follows that page's own permission without either. In the page editor, the tab's permission picker sets permissionId, and permission is the advanced "Action" text field.
navBadge: true publishes the tab's counter as the navigation badge of the page it stands for: this table's page for a filter tab, the linked page for a link tab, which then needs a page controller to and a data controller countFrom; a path or a location logs a warning and publishes nothing. The server counts it on each page visit (the page payload's navBadges, never the cached site layout), with the caller's own list permission on that table, for the pages the caller can open only; the badge shows in the main and the settings navigation. A count of zero shows no badge, a page's static MenuOptions.badge wins over it, and while the table is on screen its own counter keeps it fresh. TableView.fromSource tables do not support it. navBadge: { tone } draws the badge in that tone instead of neutral; counts landing on one page add up, in the strongest tone. Declaring at least one tab also surfaces an implicit "all" tab that applies no filter.
displays declares alternative renderings of the same data:
| Field | Type | Description |
|---|---|---|
id | string | Display identifier. Required. |
options | Record<string, unknown> | Options handed to the display. |
component | Component | Frontend component, resolved by name from the global registry. |
selfManagedData | boolean | The display fetches its own data. |
capabilities | TableViewDisplayCapabilities | Which toolbar features the display supports: columnManagement, filters, search, sorting, tabs, all optional booleans. |
TableViewDisplayOptionSerialized is the same with component serialized.
kanban is a shorthand for one particular display: the builder appends a displays entry with id "kanban" carrying the kanban options, unless displays already places { id: "kanban" }, which then receives them.
| Option | Type | Description |
|---|---|---|
groupByField | string | Column the cards are grouped by. Must be a non-multiple SelectType, a BooleanType or a StatusType column. Required. |
draggable | boolean | Allow dragging a card to another column to change the group field. Defaults to true. |
columnMaxHeight | string | CSS max-height of a column's card list before it scrolls. Defaults to "60vh". |
KanbanOptionsSerialized is KanbanOptions plus the serialized card (TableViewCardOptionsSerialized).
card (TableViewCardOptions: { fields?: string[]; component?: Component; minWidth?: string }) is the card the kanban and cards displays draw for each row; it joins the options of both display entries. The component receives the same props in both: row, rowId, columns, labelKey, actions, selected, select(value?), open(), on the kanban groupValue, and on the cards display of a table ordered by hand reorder ({ enabled, dragging, handle }, the card's move handle).
Generated forms
TableView builds up to three forms from the columns, one per FormMode, using each column's data type for the input and the controller's mandatory and access metadata for required and disabled state. The new form fetches from <location>/get?id={{query.duplicate}} and submits to <location>/new; the edit form fetches from <location>/get?id={{params.id}}&_presence=1 (the extra parameter makes the Get route record the viewer's presence on that row) and submits to <location>/edit?id={{params.id}}; the view form only fetches, from <location>/get?id={{params.id}}. A mode whose column set is empty produces no form and no matching action.
formContainer decides where they open:
type FormContainer =
| { type: "drawer"; pages?: FormContainerPages<FormContainerPageTexts> }
| { type: "modal"; size?: ModalSize; pages?: FormContainerPages<FormContainerPageTexts> }
| { type: "page"; pages?: FormContainerPages<FormContainerPageConfig> };
Page mode is the default, used when formContainer is omitted. In page mode the builder registers a hidden sub-page per form under the table's page, each guarded by the matching action's permission.
The sub-pages are named after the table view, not after the page: their default slugs are <key>/new, <key>/:id/edit and <key>/:id/view, where <key> is the key the table view is mounted under, so static invoices = TableView(...) on /billing gives /billing/invoices/new. It is the simple key even for a table view nested deep in a layout (.child("invoiceTable", TableView(...)) gives invoiceTable, not layout/body/main/invoiceTable), so the URL stays readable and survives the layout being reorganised. That is what lets one page carry several table views, nested or not, each with form pages of its own. The key does not depend on the page declaring a permission.id of its own. A table view instance belongs to the first page that mounts it: its form routes cannot be keyed on a second page mounting the same instance in page mode, which is an error, so give each page its own TableView(...).
pages ({ new?, edit?, details? }) titles the forms in every container: a FormContainerPageTexts entry is { displayName?, description? } ($-prefixed: i18n keys), falling back to "New entry", "Edit entry" and "Entry details". In page mode an entry is a FormContainerPageConfig, which also overrides the sub-page:
| Option | Type | Description |
|---|---|---|
urlSlug | string | Slug of the sub-page, relative to the page. No key segment is inserted, so a declared slug is full control over the URL. The edit and details slugs must contain :id, otherwise TableView throws. A slug starting with / addresses a page of its own; leading .. segments each drop one segment of the carrying page. |
displayName | string | Form and sub-page title. |
description | string | Form and sub-page description. |
customPage | boolean | Do not create the sub-page, because you registered your own page at that URL. The slug is still used for navigation. |
Whichever slugs apply, the builder resolves them against the slug of the page
carrying the table view and serializes the three URLs as formPages
({ new, edit, view }), :id placeholder included. The frontend navigates to
those URLs as they stand, substituting the row id, and the current route
params for the placeholders the carrying page contributes, rather than
rebuilding them from the browser path. A customPage entry registers no
sub-page and still gets its URL serialized: it is navigated to all the same.
A page below a :id page therefore carries two :id placeholders
(/workspaces/:id/invoiceTable/:id/view). The row id takes the last one
and the route params of the carrying page fill the rest, so the form reads the
row id from params.id, the same thing it means on a flat /customers/:id/edit,
which is what the generated fetchUrl and submitUrl count on.
The id of the page above is not lost to the repetition: when a route repeats a
placeholder name, every occurrence is also exposed under <name>:<n>, numbered
from 1 in the order they appear in the pattern. On
/workspaces/:id/invoiceTable/:id/view a form reads {{params.id}} or
{{params.id:2}} for the row and {{params.id:1}} for the workspace carrying
it. A name that occurs once gets no indexed key. {{params.id}} is all there
is to read on every single-placeholder route.
Two page-mode table views on one page whose form URLs resolve to the same path
(the same explicit urlSlug, or the same key under two different parents)
fail the registration of the page, naming both table views and the path.
The check covers the page's own components and the table views a page extension
contributes later. Give one of them a urlSlug of its own to resolve it.