View Metadata
Configure list views and form views — grid, kanban, calendar, gantt, and more
View Metadata
A View defines how records of an Object are displayed to users. ObjectStack supports two main view categories: List Views for browsing records and Form Views for editing individual records.
The defineView Container
Views are authored per object inside a defineView({ ... }) container — the default list, named listViews, and named formViews all live in one document. The loader expands the container into independently addressable <object>.<key> view items that power the view switcher.
// src/ui/views/task.view.ts
import { defineView } from '@objectstack/spec';
const data = { provider: 'object' as const, object: 'task' };
export const TaskViews = defineView({
// Default list shown when the object is opened
list: {
label: 'All Tasks',
type: 'grid',
data,
columns: [
{ field: 'title', width: 300 },
{ field: 'status', width: 120 },
{ field: 'assignee', width: 200 },
{ field: 'due_date', width: 150 },
],
sort: [{ field: 'created_at', order: 'desc' }],
},
// Named saved views — entries in the view switcher
listViews: {
urgent: {
label: 'Urgent',
type: 'grid',
data,
columns: [{ field: 'title' }, { field: 'assignee' }, { field: 'due_date' }],
filter: [{ field: 'priority', operator: 'equals', value: 'urgent' }],
},
},
// Named form views
formViews: {
edit: {
type: 'simple',
data,
sections: [
{ label: 'Task', columns: 2, fields: ['title', 'status', 'assignee', 'due_date'] },
],
},
},
});Register the container in your stack config:
// objectstack.config.ts
export default defineStack({
// ...
views: [TaskViews],
});Do not author a flat view object — { name: 'all_tasks', label: 'All Tasks', type: 'grid', columns: [...] } at the top level is not a view
container. Nothing registers from it and no view appears in the switcher.
Every view must live under list, listViews, or formViews, and each
view binds its object via data: { provider: 'object', object: '...' }.
List View
A List View controls how a collection of records is presented. It supports multiple visualization types.
View Types
| Type | Description | Use Case |
|---|---|---|
grid | Table/spreadsheet view | Default record listing |
kanban | Card board grouped by field | Status-based workflows |
gallery | Card grid with images | Visual content browsing |
calendar | Calendar with date events | Scheduling and planning |
timeline | Horizontal timeline | Project scheduling |
gantt | Gantt chart with dependencies | Project management |
map | Geographic map pins | Location-based data |
chart | Aggregate chart visualization | Dashboards and summaries |
tree | Self-referencing hierarchy (tree-grid) | Org charts, category trees |
List View Properties
| Property | Type | Required | Description |
|---|---|---|---|
columns | string[] | ListColumn[] | ✅ | Column definitions (required on every list view, including kanban/calendar/…) |
label | string | optional | Display label shown in the view switcher |
type | enum | optional | View type (see table above; default 'grid') |
data | ViewData | optional | Data source configuration (defaults to the object provider) |
filter | array | optional | Base filter criteria |
sort | array | optional | Sort configuration |
searchableFields | string[] | optional | Fields the toolbar search scans — narrows the set the object allows, never widens it (ADR-0061). Every entry must be in that allowed set, or every toolbar search on the list returns 400 INVALID_FIELD (#4254) — see Toolbar search below |
grouping | object | optional | Row grouping configuration |
pagination | object | optional | Pagination settings |
selection | object | optional | Row selection mode |
navigation | object | optional | Row click navigation |
rowActions | array | optional | Per-row action buttons, by action name — see Actions |
bulkActions | array | optional | Bulk selection actions, by action name — see Actions |
bulkActionDefs | array | optional | Rich bulk action definitions — mass edits, and the aggregate single-call mode (below) |
inlineEdit | boolean | optional | Enable inline editing |
exportOptions | string[] | optional | Enabled export formats (csv, xlsx, pdf, json) |
The view's machine name is its key in the container (listViews.urgent on
object task becomes task.urgent); the default list claims task.default.
List and form views share that one namespace — don't reuse a key.
Toolbar search (searchableFields)
The toolbar's search box scans a set the object owns. A list view's
searchableFields narrows that set for this one list — it can never widen
it, and the runtime enforces that by refusing the request, not by quietly
dropping the extra name (ADR-0061, #4254).
What the object allows is resolved server-side, and it is the whole rule:
| The object … | The allowed set is |
|---|---|
declares searchableFields | that list, verbatim — whatever the field types are |
| declares nothing | the auto-default: the name field + the text-like columns (text / email / phone / url / autonumber / textarea / markdown / select / status) |
So field type decides only in the second row. On an object that declares
searchableFields: ['subject', 'account_id'], a view narrowing to
['account_id'] — a lookup — is accepted and scanned (a $contains over
the stored id: narrow, but the engine executes it); on that same object,
narrowing to a text column the object left out is refused. Judge every
entry against the object's allowed set, never against the type list.
A dotted path (account_id.name) is not a valid entry on either branch —
search scans this object's own columns, and the narrowing is intersected with
the allowed set by exact name. To search by a related record's title,
mirror it into a stored field
on the object and list that.
One bad entry 400s EVERY search on that list. Clients echo this
declaration verbatim as the $searchFields override — the active view's list
wins over the object's — and the ingress gate refuses any entry outside the
allowed set before the engine ever runs. The blast radius is the list's whole
search box, for every user and every term: not a narrower result, no result at
all.
| What you write on the view | os validate | Toolbar search at runtime |
|---|---|---|
| a subset of the allowed set | clean | scans exactly those columns |
key omitted, or searchableFields: [] | clean | scans the object's full allowed set |
| a renamed / mistyped column, or a dotted path | searchable-field-unknown | 400 INVALID_FIELD |
| a real column outside the allowed set | searchable-field-unsearchable | 400 INVALID_FIELD |
a virtual formula column — no stored column to scan (#6674) | searchable-field-unsearchable | 400 INVALID_FIELD |
Both diagnostics are errors, not warnings — os validate fails the build.
The object's own set, and the stored-mirror prescription, are covered under
Global search.
Column Configuration
columns: [
{
field: 'name',
label: 'Account Name',
width: 250,
pinned: 'left', // 'left' | 'right' — freeze column
sortable: true,
resizable: true,
summary: 'count', // Column footer aggregation (enum value)
},
{
field: 'annual_revenue',
label: 'Revenue',
width: 150,
align: 'right',
summary: 'sum',
},
{
field: 'opportunity_name',
prefix: { field: 'stage', type: 'badge' }, // compound cell: [Badge] Text
// Object form of `summary` — aggregate a *different* field in this footer
summary: { type: 'sum', field: 'amount_in_base_currency' },
},
]| Property | Type | Description |
|---|---|---|
field | string | Field name |
label | string | Display header |
width | number | Column width in pixels |
hidden | boolean | Hide column |
pinned | 'left' | 'right' | Freeze column position |
sortable | boolean | Allow column sorting |
resizable | boolean | Allow column resize |
wrap | boolean | Allow text wrapping |
summary | enum | { type, field? } | Footer aggregation: none, count, count_empty, count_filled, count_unique, percent_empty, percent_filled, sum, avg, min, max. The object form aggregates a different field than the column's own |
prefix | { field, type?: 'badge' | 'text' } | Compound cell — render another field's value inline before this cell's value |
align | 'left' | 'center' | 'right' | Text alignment |
link | boolean | Cell functions as the primary navigation link |
Bulk Actions Over a Selection
Two keys drive the multi-select toolbar. bulkActions names actions the object
already declares, and each selected record is dispatched once — the action
runs N times for N rows. bulkActionDefs carries richer entries: a mass edit
through the data API (operation: 'update' + a patch), or the aggregate
mode, where the action is called once for the whole selection.
bulkActions: ['mark_done'], // one dispatch per selected record
bulkActionDefs: [
// Mass edit — one bulk write, no action involved.
{ name: 'archive', operation: 'update', patch: { archived: true } },
// Aggregate — ONE call to the declared `export_zip` action, carrying every
// selected id. This is the "N devices → one zip download" shape; also batch
// print, merged-PDF export.
{ name: 'export_zip', operation: 'custom', execution: 'aggregate' },
]An aggregate dispatch delivers the selection to the handler as
params._selectedIds: string[] — read that, not recordId. Results are
all-or-nothing: a handler that cannot cover the whole selection must reject,
and per-row retry is replaced by re-running the action. batchSize does not
apply (the call is never chunked); set maxRecords when the server work is
expensive.
Pick the right key for the dispatch you mean. Per-record is
bulkActions: ['<name>'] — the bare-string form, which has nowhere to carry a
flag and is promoted with the action's own label, params and visible.
Aggregate is the def form, which is where execution lives. A def that says
operation: 'custom' without execution: 'aggregate' is rejected at parse
time: the renderer has no action attached to such a def, so it used to render a
button that reported success for every selected record and did nothing.
Gating a def by capability. An inline def takes
requiredPermissions: string[] with action.requiredPermissions semantics —
absent/empty always passes, several entries AND, unknown caller capabilities
fail open (the server stays the authority). A def promoted from
bulkActions: ['<name>'] (or an aggregate def naming a declared action)
inherits the action's own declaration instead, so this key matters chiefly for
the update/delete data-plane forms, which dispatch no action and have
nothing to inherit from. On those defs the gate governs visibility only —
the mass write itself is still authorized by the data API's object permissions.
A url or api action rendered on the list toolbar can also read the current
selection through target interpolation — ${ctx.selection.ids} (comma-joined)
and ${ctx.selection.count} — without any bulk wiring.
action.bulkEnabled was retired in spec 17. The multi-select toolbar is
driven by these two view keys only.
A bulkActionDefs entry is a typed shape — see the
BulkActionDef reference. Unknown keys are
rejected with the canonical spelling named, and so are keys the executor would
never read (patch outside an update, execution outside a custom,
batchSize on an aggregate). One key is deliberately not authorable:
actionDef is attached by the renderer when it resolves the def's name, and
writing it by hand would smuggle an action definition past the action registry.
Data Source
Views can load data from several sources (object, api, value, and schema):
// From an Object (most common)
data: { provider: 'object', object: 'task' }
// From an external API
data: {
provider: 'api',
read: { url: '/api/external/tasks', method: 'GET' },
}
// Static values
data: {
provider: 'value',
items: [{ id: '1', name: 'Item 1' }],
}Type-Specific Configuration
Each non-grid visualization reads its settings from a nested config block
named after the type (kanban, calendar, gantt, gallery, timeline,
chart, tree) — not from top-level keys.
Kanban
type: 'kanban',
columns: ['title', 'assignee', 'priority'], // still required at the top level
kanban: {
groupByField: 'status', // Field to group columns by (usually status/select)
summarizeField: 'amount', // Optional numeric field summed at top of each column
columns: ['title', 'assignee', 'priority'], // Fields shown on each card
}Calendar
type: 'calendar',
calendar: {
startDateField: 'start_date',
endDateField: 'end_date',
titleField: 'title',
colorField: 'status',
}Gantt
type: 'gantt',
gantt: {
startDateField: 'start_date',
endDateField: 'end_date',
titleField: 'title',
progressField: 'percent_complete',
dependenciesField: 'depends_on',
}Form View
A Form View defines how a single record is displayed for viewing or editing. Form views live under formViews in the same defineView container as the object's list views.
Basic Structure
formViews: {
edit: {
type: 'simple',
data: { provider: 'object', object: 'task' },
sections: [
{
label: 'Basic Information',
columns: 2,
fields: [
{ field: 'title', span: 'full' },
{ field: 'status' },
{ field: 'priority' },
{ field: 'assignee' },
{ field: 'due_date' },
],
},
{
label: 'Details',
collapsible: true,
columns: 1,
fields: [
{ field: 'description' },
],
},
],
},
},Form Types
| Type | Description |
|---|---|
simple | Single-page form |
tabbed | Form with tab navigation |
wizard | Multi-step wizard |
split | Split-pane layout |
drawer | Side drawer form |
modal | Modal dialog form |
Section Configuration
| Property | Type | Description |
|---|---|---|
name | string | Stable identifier (snake_case) for i18n lookup |
label | string | Section header |
columns | 1-4 | Grid column count |
collapsible | boolean | Can section be collapsed |
collapsed | boolean | Initially collapsed |
visibleWhen | string | CEL predicate — section shown only when TRUE |
fields | (string | FormField)[] | Fields in the section |
Form Field Configuration
Each field in a form section can be customized:
fields: [
{
field: 'title',
label: 'Task Title', // Override field label
placeholder: 'Enter title',
helpText: 'A brief description of the task',
required: true, // Override required
span: 'full', // Take the whole row at any column count
visibleWhen: "record.status != 'cancelled'",
},
]| Property | Type | Description |
|---|---|---|
field | string | Field name |
label | string | Display label override |
placeholder | string | Placeholder text |
helpText | string | Help/hint text |
readonly | boolean | Read-only override |
required | boolean | Required override |
hidden | boolean | Hidden override |
span | 'auto' | 'full' | Relative width — 'full' takes the whole row at any column count (preferred) |
colSpan | 1-4 | Legacy absolute column span — prefer span |
widget | string | Custom widget/component name |
dependsOn | string | Parent field for cascading |
visibleWhen | string | Visibility predicate (CEL); runtime forms bind record (+ previous, parent) — not current_user, which is unbound at field level and would fault the predicate open (was visibleOn, ADR-0089) |
Complete Example
One container covering a default grid, a kanban saved view, and a tabbed edit form — mirroring examples/app-showcase/src/ui/views/task.view.ts:
import { defineView } from '@objectstack/spec';
const data = { provider: 'object' as const, object: 'task' };
export const TaskViews = defineView({
list: {
label: 'All Tasks',
type: 'grid',
data,
columns: [
{ field: 'title', label: 'Title' },
{ field: 'assignee', label: 'Assignee' },
{ field: 'priority', label: 'Priority' },
{ field: 'due_date', label: 'Due Date' },
],
sort: [{ field: 'priority', order: 'desc' }],
},
listViews: {
board: {
label: 'Task Board',
type: 'kanban',
data,
columns: ['title', 'assignee', 'priority', 'due_date'],
kanban: {
groupByField: 'status',
columns: ['title', 'assignee', 'priority', 'due_date'],
},
},
},
formViews: {
edit: {
type: 'tabbed',
data,
sections: [
{
name: 'details',
label: 'Details',
columns: 2,
fields: [
{ field: 'title', span: 'full', required: true },
{ field: 'status' },
{ field: 'priority' },
{ field: 'assignee' },
{ field: 'due_date' },
{ field: 'description', span: 'full' },
],
},
{
name: 'system',
label: 'System',
collapsible: true,
collapsed: true,
columns: 2,
fields: [
{ field: 'created_at', readonly: true },
{ field: 'updated_at', readonly: true },
],
},
],
},
},
});Related
- Object Metadata — Define the data behind views
- Field Metadata — Field types that views display
- Page Metadata — Custom pages with component layouts
- Dashboard Metadata — Analytics dashboards with charts