ObjectStackObjectStack

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

TypeDescriptionUse Case
gridTable/spreadsheet viewDefault record listing
kanbanCard board grouped by fieldStatus-based workflows
galleryCard grid with imagesVisual content browsing
calendarCalendar with date eventsScheduling and planning
timelineHorizontal timelineProject scheduling
ganttGantt chart with dependenciesProject management
mapGeographic map pinsLocation-based data
chartAggregate chart visualizationDashboards and summaries
treeSelf-referencing hierarchy (tree-grid)Org charts, category trees

List View Properties

PropertyTypeRequiredDescription
columnsstring[] | ListColumn[]Column definitions (required on every list view, including kanban/calendar/…)
labelstringoptionalDisplay label shown in the view switcher
typeenumoptionalView type (see table above; default 'grid')
dataViewDataoptionalData source configuration (defaults to the object provider)
filterarrayoptionalBase filter criteria
sortarrayoptionalSort configuration
searchableFieldsstring[]optionalFields 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
groupingobjectoptionalRow grouping configuration
paginationobjectoptionalPagination settings
selectionobjectoptionalRow selection mode
navigationobjectoptionalRow click navigation
rowActionsarrayoptionalPer-row action buttons, by action name — see Actions
bulkActionsarrayoptionalBulk selection actions, by action name — see Actions
bulkActionDefsarrayoptionalRich bulk action definitions — mass edits, and the aggregate single-call mode (below)
inlineEditbooleanoptionalEnable inline editing
exportOptionsstring[]optionalEnabled 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 searchableFieldsthat list, verbatim — whatever the field types are
declares nothingthe 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 viewos validateToolbar search at runtime
a subset of the allowed setcleanscans exactly those columns
key omitted, or searchableFields: []cleanscans the object's full allowed set
a renamed / mistyped column, or a dotted pathsearchable-field-unknown400 INVALID_FIELD
a real column outside the allowed setsearchable-field-unsearchable400 INVALID_FIELD
a virtual formula column — no stored column to scan (#6674)searchable-field-unsearchable400 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' },
  },
]
PropertyTypeDescription
fieldstringField name
labelstringDisplay header
widthnumberColumn width in pixels
hiddenbooleanHide column
pinned'left' | 'right'Freeze column position
sortablebooleanAllow column sorting
resizablebooleanAllow column resize
wrapbooleanAllow text wrapping
summaryenum | { 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
linkbooleanCell 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

TypeDescription
simpleSingle-page form
tabbedForm with tab navigation
wizardMulti-step wizard
splitSplit-pane layout
drawerSide drawer form
modalModal dialog form

Section Configuration

PropertyTypeDescription
namestringStable identifier (snake_case) for i18n lookup
labelstringSection header
columns1-4Grid column count
collapsiblebooleanCan section be collapsed
collapsedbooleanInitially collapsed
visibleWhenstringCEL 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'",
  },
]
PropertyTypeDescription
fieldstringField name
labelstringDisplay label override
placeholderstringPlaceholder text
helpTextstringHelp/hint text
readonlybooleanRead-only override
requiredbooleanRequired override
hiddenbooleanHidden override
span'auto' | 'full'Relative width — 'full' takes the whole row at any column count (preferred)
colSpan1-4Legacy absolute column span — prefer span
widgetstringCustom widget/component name
dependsOnstringParent field for cascading
visibleWhenstringVisibility 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 },
          ],
        },
      ],
    },
  },
});

On this page