BuildInterface

Views

List, Form, Kanban, Calendar, Gantt and more — how every object surface is declared.

A view is how a user sees and edits records. Views are declarative metadata — same lifecycle as objects: declare them once, ship them with your package, render anywhere.

Two kinds:

  • List views — visualise many records (grid, kanban, calendar, …)
  • Form views — view / edit a single record (simple, tabbed, wizard, …)

Schema source: packages/spec/src/ui/view.zod.ts.

The shortest possible declaration

Every object automatically gets a default grid list + simple form even if you declare no views. To override or extend them, declare the object's views in a defineView container, beside the object rather than inside it: list is the default list, form the default form, and each view names its object through data.

// src/ui/views/support_ticket.view.ts
import { defineView } from '@objectstack/spec';

const data = { provider: 'object' as const, object: 'support_ticket' };

export const SupportTicketViews = defineView({
  list: {
    type: 'grid',
    data,
    columns: ['subject', 'status', 'priority', 'assignee'],
  },
  form: {
    type: 'simple',
    data,
    sections: [
      { name: 'details', label: 'Details', fields: ['subject', 'description', 'priority', 'status', 'assignee'] },
    ],
  },
});

Register the container on the stack, next to the object it describes:

// objectstack.config.ts
export default defineStack({
  // ...
  views: [SupportTicketViews],
});

For multi-view objects (e.g. a Kanban and a calendar over the same data), add named views to the same container: listViews for the view switcher, formViews for forms opened by name. Every list view keeps a top-level columns, whatever its type:

export const SupportTicketViews = defineView({
  list: { type: 'grid', data, columns: ['subject', 'status', 'priority', 'assignee'] },
  listViews: {
    by_status: {
      label: 'By status', type: 'kanban', data, columns: ['subject', 'priority'],
      kanban: { groupByField: 'status', columns: ['subject', 'priority'] },
    },
    schedule: {
      label: 'Schedule', type: 'calendar', data, columns: ['subject', 'due_at'],
      calendar: { startDateField: 'due_at', titleField: 'subject' },
    },
    by_owner: {
      label: 'By owner', type: 'grid', data, columns: ['assignee', 'subject', 'status', 'priority'],
      sort: [{ field: 'assignee', order: 'asc' }],
    },
  },
  formViews: {
    quick: { type: 'modal', data, sections: [{ name: 'quick', label: 'Quick edit', fields: ['status', 'priority', 'assignee'] }] },
    full: {
      type: 'tabbed', data,
      sections: [
        { name: 'details', label: 'Details', fields: ['subject', 'description'] },
        { name: 'triage', label: 'Triage', fields: ['status', 'priority', 'assignee'] },
      ],
    },
  },
});

The container and its rules are documented in full in ObjectStack's View metadata.

List view types

Every list view needs a top-level columns, whatever its type. The rest of a type's settings go in a config block named after it:

typeRendersConfig block, with its required keysCommon optional keys
gridData table (default)—
kanbanBoard with columnskanban: { groupByField, columns }summarizeField, titleField
galleryCard deckgallery (none required)coverField, titleField
calendarMonth / week / daycalendar: { startDateField }endDateField, titleField, colorField
timelineChronological feedtimeline: { startDateField, titleField }endDateField, groupByField
ganttProject timeline + dependenciesgantt: { startDateField, endDateField, titleField }progressField, dependenciesField
mapGeospatial pinsmap (none required)locationField, or latitudeField + longitudeField
treeSelf-referencing hierarchytree (none required)parentField, labelField
chartEmbedded chartchart: { dataset, values }chartType, dimensions

The fragments below are single list views. Each goes in a defineView container — as its list, or as a listViews entry — with data naming its object, as above.

Common list options

// One list view; the container and `data` are omitted (see above).
// P is the predicate template tag: import { P } from '@objectstack/spec'
{
  type: 'grid',
  columns: ['subject','status','priority','assignee','created_at'],

  filter: [ { field: 'archived', operator: 'equals', value: false } ],
  sort:   [ { field: 'created_at', order: 'desc' } ],
  pagination: { pageSize: 25 },
  searchableFields:   ['subject','description'],
  filterableFields:   ['status','priority','assignee'],

  navigation: { mode: 'drawer' },                       // 'page' | 'drawer' | 'modal' | 'split' | 'popover' | 'new_window' | 'none'
  selection:  { type: 'multiple' },                     // 'none' | 'single' | 'multiple'

  rowActions:  ['close_ticket','assign_to_me'],
  bulkActions: ['bulk_close','bulk_export'],

  conditionalFormatting: [
    { condition: P`record.priority == 'urgent'`, style: { background: '#fef2f2', fontWeight: '600' } }   // style values are strings
  ],

  exportOptions: ['csv','xlsx'],
  emptyState:   { title: 'No tickets yet', message: 'Create one to get started', icon: 'inbox' }
}

filter and sort compile to ObjectQL; rowActions and bulkActions reference actions by name.

Kanban

// One list view; the container and `data` are omitted (see above).
{
  type: 'kanban',
  columns: ['subject', 'priority', 'assignee'],  // still required at the top level
  kanban: {
    groupByField:   'status',                // discrete field — usually a select
    summarizeField: 'amount',                // optional total per column
    columns: ['subject', 'priority', 'assignee']  // fields shown on each card
  }
}

The board's columns come from the groupByField's values; their order and colour are taken from that field's select option definitions.

Drag-drop between columns issues an update setting the grouped field — same permission rules as a manual edit.

Calendar

// One list view; the container and `data` are omitted (see above).
{
  type: 'calendar',
  columns: ['subject', 'start_at', 'end_at', 'priority'],
  calendar: {
    startDateField: 'start_at',
    endDateField:   'end_at',                // optional — single-point if omitted
    titleField:     'subject',
    colorField:     'priority'               // optional — colours events by value
  }
}

Gantt

// One list view; the container and `data` are omitted (see above).
{
  type: 'gantt',
  columns: ['name', 'start_at', 'due_at', 'percent_complete'],
  gantt: {
    startDateField:    'start_at',
    endDateField:      'due_at',
    titleField:        'name',
    progressField:     'percent_complete',   // optional, drives the progress bar
    dependenciesField: 'depends_on'          // optional — multiselect lookup to same object
  }
}

Tree

// One list view; the container and `data` are omitted (see above).
{
  type: 'tree',
  columns: ['name'],
  tree: {
    parentField: 'parent_id',                // self-lookup that builds the hierarchy
    labelField:  'name',                     // shown indented in the first column
    defaultExpandedDepth: 1                   // optional — 0 = roots only, omit = expand all
  }
}

For a self-referencing object (categories, BOMs, org units, nested comments) the tree-grid nests rows by their parentField pointer.

Chart (inline)

A chart list view binds a named dataset — the semantic analytics layer (ADR-0021) — and selects its dimensions and measures by name. Define a metric once and every chart, dashboard, and report bound to the same dataset stays consistent:

// One list view; the container and `data` are omitted (see above).
{
  type: 'chart',
  columns: ['status'],
  chart: {
    chartType:  'bar',                       // 'bar' | 'line' | 'pie' | 'area' | 'scatter'
    dataset:    'tickets_by_status',         // a dataset declared with defineDataset(...)
    dimensions: ['status'],                  // dataset dimension name(s) — X / group / split
    values:     ['ticket_count']             // dataset measure name(s) — the value axis
  }
}

The inline xAxisField / yAxisFields / aggregation / groupByField shape was removed in 9.0 — every chart, dashboard widget, and report now binds a dataset. For richer, cross-object analytics use the dedicated reports surface.

Form view types

typeLayout
simpleSingle column or sections (default)
tabbedTabbed sections
wizardStep-by-step flow
splitMaster-detail two-pane
drawerSide panel form
modalDialog form
// One form view; the container and `data` are omitted (see above).
{
  type: 'tabbed',
  sections: [
    { label: 'Overview', fields: ['subject','status','priority','assignee'] },
    { label: 'Customer', fields: ['customer','contact','email','phone'] },
    { label: 'Resolution', fields: ['resolution_notes','resolved_at'] }
  ],
  submitBehavior: { kind: 'next-record' }   // 'thank-you' | 'redirect' | 'continue' | 'next-record'
}

Public forms

Forms can be made anonymous-accessible:

// One form view; the container and `data` are omitted (see above).
{
  type: 'simple',
  sections: [ { fields: ['name','email','message'] } ],
  sharing: {
    enabled: true,
    publicLink: 'contact-us',                // slug under /forms/
    allowAnonymous: true
  },
  submitBehavior: { kind: 'thank-you' }
}

This automatically exposes GET /api/v1/forms/contact-us and POST /api/v1/forms/contact-us/submit — public form routes that don't require authentication. See REST API → Public forms.

Visibility, ARIA, theming

  • visibleOn: P\...`` — on form sections and fields, hide them based on user / record / env (CEL predicate)
  • aria: { label, description, ... } — ARIA attributes for screen readers (list and form views)
  • appearance: { showDescription, allowedVisualizations: [...] } — on list views, restrict which list types end users can flip to

Build by chat

You usually don't write view metadata by hand. Tell the AI Builder:

"Add a kanban view of the support_ticket object grouped by status, with cards showing subject and priority. Colour the columns red / amber / green by status."

It calls the metadata tools, queues the diff, and once you approve the view shows up. See AI Builder.

See also