View schema — UI Protocol property reference
View protocol schemas — the view metadata type and its three persisted body spellings.
View protocol schemas — the view metadata type and its three persisted body spellings.
Covers the authoring surfaces (defineView, defineViewItem), the wire
doors Studio and the REST layer write through, and
ViewMetadataSchema — the union every persisted view body is judged
by.
Name grammar depends on the body spelling
ViewMetadataSchema is a union over three persisted body shapes, and
they do not share one name grammar. Which grammar applies is decided by the
shape of the body — something an author never names explicitly — so neither
failure direction below is discoverable from the key being written:
| body spelling | recognised by | name is declared as | flat (undotted) name |
|---|---|---|---|
| standalone ViewItem record | a nested config | ViewItemNameSchema — QUALIFIED_ITEM_NAME_PATTERN, dot REQUIRED | rejected, located at ["name"] |
| flattened runtime overlay | an inline view config; no config, no container slot | z.string().optional() — no grammar at all | accepted |
defineView container | a container slot (list / form / listViews / formViews) | z.string().optional() on ViewSchema — no grammar at all | accepted, and normally IS flat |
The two permissive rows are deliberate, not gaps left to tighten later:
- A container's own name is the bare object key. ADR-0017 §3.2's
dual-read loader registers the aggregated container under
<object>and each expanded item under<object>.<viewKey>, so an object-scoped container is namedcrm_lead— a name with no dot to carry. - An overlay's name is stamped by the write path, not authored:
normalizeViewMetadataputs it on every view body at the single write chokepoint, and a personalization PUT inherits the identity of the entry it shadows.
So both of the readings an author naturally forms are wrong:
- "the dot is mandatory on every view row" — read off
ViewItemNameSchemaalone. It is not: overlay and container rows accept flat names, and rows in this repo legitimately use them (case_grid,cases, the container rowcrm_lead). Those rows are correct as written, not defects awaiting a dotted rewrite. - "flat names are fine generally" — read off one of those flat-named rows. It is not: put the same name on a standalone ViewItem record and it is refused, on the one field the flat-named row told you to fill.
This describes what the three shapes already do; it widens and narrows
nothing. The one item-name grammar itself lives in
shared/identifiers.zod.ts — grammar changes belong there, not here.
Source: packages/spec/src/ui/view.zod.ts
TypeScript Usage
import { AddRecordConfigSchema, AppearanceConfigSchema, CalendarConfigSchema, ColumnPrefixSchema, ColumnSummarySchema, ColumnSummaryConfigSchema, EmptyStateSchema, FormButtonConfigSchema, FormFieldSchema, FormSectionSchema, FormSelectOptionSchema, FormViewSchema, GalleryConfigSchema, GanttConfigSchema, GanttQuickFilterSchema, GroupingConfigSchema, GroupingFieldSchema, HttpMethodSubsetSchema, HttpRequestSchema, KanbanConfigSchema, ListChartConfigSchema, ListColumnSchema, ListMapConfigSchema, ListViewSchema, NavigationConfigSchema, NavigationModeSchema, ObjectListViewSchema, ObjectUserFiltersSchema, PaginationConfigSchema, RowColorConfigSchema, RowHeightSchema, SelectionConfigSchema, TimelineConfigSchema, TreeConfigSchema, UserActionsConfigSchema, UserFilterFieldSchema, UserFiltersSchema, ViewSchema, ViewDataSchema, ViewFilterRuleSchema, ViewItemSchema, ViewItemNameSchema, ViewItemWireSchema, ViewKindSchema, ViewScopeSchema, ViewSharingSchema, ViewTabSchema, VisualizationTypeSchema } from '@objectstack/spec/ui';
import type { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnPrefix, ColumnSummary, ColumnSummaryConfig, EmptyState, FormButtonConfig, FormField, FormSection, FormSelectOption, FormView, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, HttpMethodSubset, HttpRequest, KanbanConfig, ListChartConfig, ListColumn, ListMapConfig, ListView, NavigationConfig, NavigationMode, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterRule, ViewItem, ViewItemName, ViewItemWire, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui';
// Validate data
const result = AddRecordConfigSchema.parse(data);AddRecordConfig
Add record entry point configuration
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| enabled | boolean | optional (default: true) | Show the add record entry point |
| position | Enum<'top' | 'bottom' | 'both'> | optional (default: "bottom") | Position of the add record button |
| mode | Enum<'inline' | 'form' | 'modal'> | optional (default: "inline") | How to add a new record |
| formView | string | optional | Named form view to use when mode is "form" or "modal" |
AppearanceConfig
Appearance and visualization configuration
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| showDescription | boolean | optional (default: true) | Show the view description text |
| allowedVisualizations | Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'>[] | optional | Whitelist of visualization types users can switch between (e.g. ["grid", "gallery", "kanban"]) |
CalendarConfig
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| startDateField | string | ✅ | Field providing the event start date/time |
| endDateField | string | optional | Field providing the event end date/time (defaults to a single-day event) |
| titleField | string | optional | Field displayed as the event title. Omit to fall back to the record display name (ADR-0079 resolver chain) |
| colorField | string | optional | Field to derive each event color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the calendar theme-aware palette color hashed from the value |
| allDayField | string | optional | Field carrying the all-day flag for each event (names a boolean field, not a value): a record whose flag is true is drawn as an all-day band rather than at a clock time, and one whose flag is absent or false is not all-day. Omit to leave the renderer inference in place — an event with no end date draws as all-day |
ColumnPrefix
Compound-cell prefix configuration
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Field whose value renders before the cell value |
| type | Enum<'badge' | 'text'> | optional (default: "text") | How the prefix value is rendered |
ColumnSummary
Aggregation function for the column footer summary — and, on a grouped list view, the per-group header summary (server-side): count (COUNT(*), the group count), count_unique (count_distinct), sum, avg, min, max map onto the query AST's AggregationFunction; count_filled, count_empty, percent_filled, percent_empty derive from one COUNT(field) node (the non-null count) and the group count — count_filled = COUNT(field), count_empty = count − COUNT(field), percent_filled = COUNT(field) / count (0 when count is 0), percent_empty = 1 − percent_filled. Server-side "empty" is null on every face; the footer's client-side reading of empty strings and empty arrays as empty is the renderer's to converge
Allowed Values
nonecountcount_emptycount_filledcount_uniquepercent_emptypercent_filledsumavgminmax
ColumnSummaryConfig
Column footer summary configuration
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| type | Enum<'none' | 'count' | 'count_empty' | 'count_filled' | 'count_unique' | 'percent_empty' | 'percent_filled' | 'sum' | 'avg' | 'min' | 'max'> | ✅ | Aggregation function |
| field | string | optional | Field to aggregate (defaults to the column field) |
EmptyState
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| title | string | Record<string, string> | optional | Heading of the empty state |
| message | string | Record<string, string> | optional | Line of text below the heading |
| icon | string | optional | Icon name drawn above the heading; a name that resolves to no icon draws the default empty-state glyph |
| link | { label: string | Record<string, string>; url: string } | optional | The one next step an empty list names: a link the user follows, { label, url } — for example to the how-to page that explains what belongs here when nobody can add a row. It opens a page or an address and runs no action. The console draws it in a list's empty state (the grid and gallery visualizations) and in an object-grid block's, beside the add-record action where one is offered. |
Nested Shape: EmptyState.link
| Property | Type | Required | Description |
|---|---|---|---|
| label | string | Record<string, string> | ✅ | Text of the link — a string, or an inline locale map resolved against the display locale |
| url | string | ✅ | Where the link goes — a target a type: 'url' action opens: an in-app path that starts with / or ./ (such as /docs/connect), or an address that opens with http://, https:// or mailto:. Any other scheme, a protocol-relative //host, a bare relative path and a tab, line feed or carriage return are refused at parse; a template that opens with ${…}, such as ${ctx.origin}/docs, is judged when it is clicked, after interpolation. |
FormButtonConfig
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| show | boolean | optional | Whether the button is rendered (renderer default applies when omitted) |
| label | string | Record<string, string> | optional | Button label (i18n-capable; renderer default when omitted) |
FormField
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Field name (snake_case) |
| type | Enum<'text' | 'textarea' | 'email' | 'url' | 'phone' | 'password' | 'secret' | 'markdown' | 'html' | 'richtext' | 'number' | 'currency' | 'percent' | 'date' | … +35 more> | optional | Field type (auto-infers widget if omitted) |
| options | { label: string; value: string; description?: string; color?: string; … }[] | optional | Options for select/multiselect/radio/checkboxes fields (per-option default is not accepted here — declare the pre-selected choice on the object definition). On a metadata form (schema-bound, built by defineForm), an enum-typed row may list its members here, to give them human labels or to offer a deliberate subset. An option value is a lowercase system identifier, so a row whose members cannot be spelled as option values (a hyphen, a capital) omits options: the control derives the members from the served JSON Schema, and their meanings go in helpText. |
| reference | string | optional | Target object name for lookup/master_detail fields |
| publicPicker | never | optional | [REMOVED] view.form.sections[].fields[].publicPicker was removed in @objectstack/spec 17.6.0 (ADR-0087 D2) — an anonymous public form no longer offers record search: lookup, master_detail and user fields are always left off the anonymous rendering, and the anonymous record-search route (GET /forms/:slug/lookup/:field) no longer exists. Delete the key (the whole publicPicker block). To let a visitor choose from a fixed list, use a select field with static options; to let them pick an existing record, put the form behind sign-in. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| maxLength | integer | optional | Maximum character length (positive integer; for text/textarea/email/url/phone) |
| minLength | integer | optional | Minimum character length (positive integer; minLength: 0 is refused — express "no minimum" by omitting the key) |
| min | number | optional | Minimum value (for number/currency/percent/slider) |
| max | number | optional | Maximum value |
| precision | integer | optional | Total digits (non-negative integer; for number/currency) |
| scale | integer | optional | Decimal places (integer 0-100). The upper bound is the platform's, not a policy: renderers turn scale into fraction digits through toFixed and Intl.NumberFormat's maximumFractionDigits, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. |
| multiple | boolean | optional | Allow multiple values (for select/lookup/file/image) |
| label | string | Record<string, string> | optional | Display label override |
| placeholder | string | Record<string, string> | optional | Placeholder text |
| helpText | string | Record<string, string> | optional | Help/hint text |
| readonly | boolean | optional | Read-only override |
| immutable | boolean | optional | Editable on create, locked once the record exists (e.g. machine names). |
| required | boolean | optional | Required override |
| hidden | boolean | optional | Hidden override |
| colSpan | integer | optional | Absolute column span (1-4). The renderer clamps it to the form grid's current column count, so the cell starts at a real column boundary at every surface width and never overflows (colSpan: 4 in a 3-column grid renders as 3); a colSpan within the column count renders as authored, and colSpan: 1 emits no span class at all. |
| span | Enum<'auto' | 'full'> | optional (default: "auto") | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count — only textarea, markdown, html, richtext and repeater resolve to the full column count (repeater reaches it through the wide field:grid widget it maps to). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: the renderer emits one clamped col-span class per multi-column tier (@md:col-span-2 @2xl:col-span-3 for a 3-column grid), so the field takes the whole row at every multi-column tier, not just the widest. |
| widget | string | optional | Custom widget/component name (overrides type-based inference) |
| language | string | optional | Code editor language (for type=code) |
| keyField | { field?: string; label?: string | Record<string, string>; placeholder?: string | Record<string, string>; helpText?: string | Record<string, string>; … } | optional | Key column config for record-typed fields |
| dependsOn | string | optional | Parent field name for cascading |
| visibleWhen | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } | optional | Visibility predicate (CEL) — field shown only when TRUE. Root: record (+ previous, parent) in runtime forms, or data in metadata forms. current_user (and the ADR-0068 aliases user / ctx.user / os.user) resolves here — CLIENT-SIDE only: nothing server-side evaluates a form-view field visibleWhen, so a role test here hides the control and protects no data (declare permission-set field-level security for that), and on the public /f/:slug route no host publishes a scope, so the root is unbound and the predicate faults open. No features.* on ANY form-view predicate — refused at parse: the root is unbound on the standalone form routes (/forms/:name, /f/:slug) and the predicate would fault open there. Inside a repeater data is the ROW, but it is still spelled data — a bare identifier is unbound and faults open too. e.g. Precord.priority == 'urgent' |
| visibleOn | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } | optional | [DEPRECATED → visibleWhen] Visibility predicate (CEL). Normalized to visibleWhen at parse. |
| disclosure | Enum<'inline' | 'popover'> | optional | Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure). |
| fields | [FormField](#formfield)[] | optional | Sub-fields for composite/repeater/record types |
Allowed Values: FormField.type
texttextareaemailurlphonepasswordsecretmarkdownhtmlrichtextnumbercurrencypercentdatedatetimetimebooleantoggleselectmultiselectradiocheckboxeslookupmaster_detailtreeuserimagefileavatarvideoaudioformulasummaryautonumbercompositerepeaterrecordlocationaddresscodejsoncolorratingslidersignatureqrcodeprogresstagsvector
Nested Shape: FormField.options[number]
Form-view select option — the object-field option shape minus the per-option default key (declare the pre-selected choice on the object definition: field-level defaultValue, or default: true on that field's own options entry).
| Property | Type | Required | Description |
|---|---|---|---|
| label | string | ✅ | Display label (human-readable, any case allowed) |
| value | string | ✅ | Stored value (lowercase machine identifier) |
| description | string | optional | Optional secondary/help text for this option. Lookup option search matches it in addition to the label; renderers may show it as supporting text. |
| color | string | optional | Color code for badges/charts |
| visibleWhen | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live record plus the host predicate scope, which binds current_user. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false, or cannot be evaluated — so a user-gated CHOICE belongs here. e.g. Precord.country == 'cn' or P'admin' in current_user.positions. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (record.account.tier) would fault and every write picking the option would be refused, and objectstack validate refuses it — enforce such a restriction with a validations[] script rule, whose condition is read one hop through a reference. |
Nested Shape: FormField.keyField
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | optional (default: "name") | Property name that holds the key inside each item (defaults to "name") |
| label | string | Record<string, string> | optional | Display label for the key column |
| placeholder | string | Record<string, string> | optional | Placeholder when entering a new key |
| helpText | string | Record<string, string> | optional | Help text under the key input |
| regex | string | optional | JS regex source string the key must match (no flags) |
| immutable | boolean | optional (default: true) | If true, the key is read-only after creation |
FormSection
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| name | string | optional | Stable section identifier for i18n lookup (snake_case) |
| label | string | Record<string, string> | optional | Display label — the default-language string, or an inline locale map ({ en, "zh-CN" }) resolved at render time |
| description | string | optional | Optional description rendered under the section header. |
| collapsible | boolean | optional (default: false) | Whether the section renders a disclosure control, so a reader can close it and open it again. Default false: a section declaring neither collapse key is always open and shows no control. ⚠️ collapsed: true IMPLIES this key — an explicit collapsible: false beside it does NOT take the control away. The renderer resolves that from the DECLARATION; parse never rewrites the pair, so a parsed section still reports the false that was authored. Only true is refused on a wizard step and beside group; false is accepted in both, because it declares exactly what those surfaces already deliver. |
| collapsed | boolean | optional (default: false) | Whether the section starts closed. Default false. ⚠️ collapsed: true IMPLIES collapsible and is sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that reopens it, and it outranks an explicit collapsible: false, so writing collapsed: true alone is a correct way to say "collapsed by default". The implication is a renderer rule, never a parse-time rewrite: { collapsed: true } still parses to collapsible: false, collapsed: true, so the parsed collapsible must never be read as "a control renders". Only true is refused on a wizard step (steps do not collapse) and beside group, whose field group declares the pair. |
| visibleWhen | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: record (+ previous, parent) in runtime forms, or data in metadata forms. current_user (and the ADR-0068 aliases user / ctx.user / os.user) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section visibleWhen, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public /f/:slug route no host publishes a scope, so the root is unbound and the predicate faults open. No features.* on ANY form-view predicate — refused at parse: unbound on the standalone form routes, where the predicate would fault open. |
| visibleOn | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } | optional | [DEPRECATED → visibleWhen] Visibility predicate (CEL). Hides the whole section when false. Normalized to visibleWhen at parse. |
| columns | Enum<'1' | '2' | '3' | '4'> | 1 | 2 | 3 | 4 | optional (default: 1) | |
| pane | Enum<'primary' | 'secondary'> | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| group | string | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's fieldGroups (ADR-0085 §5 deriveFieldGroupLayout). Mutually exclusive with fields, and with every key the group itself declares (name, label, description, collapsible, collapsed, visibleWhen/visibleOn). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| fields | (string | { field: string; type?: Enum<'text' | 'textarea' | 'email' | 'url' | 'phone' | 'password' | 'secret' | …>; options?: object[]; reference?: string; … })[] | optional |
Nested Shape: FormSection.fields[number]
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Field name (snake_case) |
| type | Enum<'text' | 'textarea' | 'email' | 'url' | 'phone' | 'password' | 'secret' | …> | optional | Field type (auto-infers widget if omitted) |
| options | { label: string; value: string; description?: string; color?: string; … }[] | optional | Options for select/multiselect/radio/checkboxes fields (per-option default is not accepted here — declare the pre-selected choice on the object definition). On a metadata form (schema-bound, built by defineForm), an enum-typed row may list its members here, to give them human labels or to offer a deliberate subset. An option value is a lowercase system identifier, so a row whose members cannot be spelled as option values (a hyphen, a capital) omits options: the control derives the members from the served JSON Schema, and their meanings go in helpText. |
| reference | string | optional | Target object name for lookup/master_detail fields |
| publicPicker | never | optional | [REMOVED] view.form.sections[].fields[].publicPicker was removed in @objectstack/spec 17.6.0 (ADR-0087 D2) — an anonymous public form no longer offers record search: lookup, master_detail and user fields are always left off the anonymous rendering, and the anonymous record-search route (GET /forms/:slug/lookup/:field) no longer exists. Delete the key (the whole publicPicker block). To let a visitor choose from a fixed list, use a select field with static options; to let them pick an existing record, put the form behind sign-in. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| maxLength | integer | optional | Maximum character length (positive integer; for text/textarea/email/url/phone) |
| minLength | integer | optional | Minimum character length (positive integer; minLength: 0 is refused — express "no minimum" by omitting the key) |
| min | number | optional | Minimum value (for number/currency/percent/slider) |
| max | number | optional | Maximum value |
| precision | integer | optional | Total digits (non-negative integer; for number/currency) |
| scale | integer | optional | Decimal places (integer 0-100). The upper bound is the platform's, not a policy: renderers turn scale into fraction digits through toFixed and Intl.NumberFormat's maximumFractionDigits, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. |
| multiple | boolean | optional | Allow multiple values (for select/lookup/file/image) |
| label | string | Record<string, string> | optional | Display label override |
| placeholder | string | Record<string, string> | optional | Placeholder text |
| helpText | string | Record<string, string> | optional | Help/hint text |
| readonly | boolean | optional | Read-only override |
| immutable | boolean | optional | Editable on create, locked once the record exists (e.g. machine names). |
| required | boolean | optional | Required override |
| hidden | boolean | optional | Hidden override |
| colSpan | integer | optional | Absolute column span (1-4). The renderer clamps it to the form grid's current column count, so the cell starts at a real column boundary at every surface width and never overflows (colSpan: 4 in a 3-column grid renders as 3); a colSpan within the column count renders as authored, and colSpan: 1 emits no span class at all. |
| span | Enum<'auto' | 'full'> | optional (default: "auto") | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count — only textarea, markdown, html, richtext and repeater resolve to the full column count (repeater reaches it through the wide field:grid widget it maps to). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: the renderer emits one clamped col-span class per multi-column tier (@md:col-span-2 @2xl:col-span-3 for a 3-column grid), so the field takes the whole row at every multi-column tier, not just the widest. |
| widget | string | optional | Custom widget/component name (overrides type-based inference) |
| language | string | optional | Code editor language (for type=code) |
| keyField | { field?: string; label?: string | Record<string, string>; placeholder?: string | Record<string, string>; helpText?: string | Record<string, string>; … } | optional | Key column config for record-typed fields |
| dependsOn | string | optional | Parent field name for cascading |
| visibleWhen | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } | optional | Visibility predicate (CEL) — field shown only when TRUE. Root: record (+ previous, parent) in runtime forms, or data in metadata forms. current_user (and the ADR-0068 aliases user / ctx.user / os.user) resolves here — CLIENT-SIDE only: nothing server-side evaluates a form-view field visibleWhen, so a role test here hides the control and protects no data (declare permission-set field-level security for that), and on the public /f/:slug route no host publishes a scope, so the root is unbound and the predicate faults open. No features.* on ANY form-view predicate — refused at parse: the root is unbound on the standalone form routes (/forms/:name, /f/:slug) and the predicate would fault open there. Inside a repeater data is the ROW, but it is still spelled data — a bare identifier is unbound and faults open too. e.g. Precord.priority == 'urgent' |
| visibleOn | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } | optional | [DEPRECATED → visibleWhen] Visibility predicate (CEL). Normalized to visibleWhen at parse. |
| disclosure | Enum<'inline' | 'popover'> | optional | Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure). |
| fields | { field: string; type?: Enum<'text' | 'textarea' | 'email' | 'url' | 'phone' | 'password' | 'secret' | …>; options?: object[]; reference?: string; … }[] | optional | Sub-fields for composite/repeater/record types |
FormSelectOption
Form-view select option — the object-field option shape minus the per-option default key (declare the pre-selected choice on the object definition: field-level defaultValue, or default: true on that field's own options entry).
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| label | string | ✅ | Display label (human-readable, any case allowed) |
| value | string | ✅ | Stored value (lowercase machine identifier) |
| description | string | optional | Optional secondary/help text for this option. Lookup option search matches it in addition to the label; renderers may show it as supporting text. |
| color | string | optional | Color code for badges/charts |
| visibleWhen | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } | optional | Per-option visibility predicate (CEL) — option is offered only when TRUE (else omitted). Env: the live record plus the host predicate scope, which binds current_user. The one VISIBILITY predicate the SERVER also enforces — the rule validator refuses a write of a value whose predicate is false, or cannot be evaluated — so a user-gated CHOICE belongs here. e.g. Precord.country == 'cn' or P'admin' in current_user.positions. On an OBJECT field's option it reads the record's OWN columns: the server never reads a related record there, so a read THROUGH a reference field (record.account.tier) would fault and every write picking the option would be refused, and objectstack validate refuses it — enforce such a restriction with a validations[] script rule, whose condition is read one hop through a reference. |
FormView
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| type | Enum<'simple' | 'tabbed' | 'wizard' | 'split' | 'drawer' | 'modal'> | optional (default: "simple") | |
| layout | Enum<'vertical' | 'horizontal'> | optional | Field layout direction — 'vertical' (the renderer default) or 'horizontal'. Multi-column is not a layout value: set columns |
| columns | integer | optional | Number of columns for the form body |
| title | string | optional | Form title |
| description | string | optional | Form description |
| defaultTab | string | optional | Initially active tab (tabbed forms) |
| tabPosition | Enum<'top' | 'bottom' | 'left' | 'right'> | optional | Tab strip position (tabbed forms) |
| allowSkip | boolean | optional | Wizard step-gate opt-out: allow entering a later step without submitting the one before it. Navigation freedom, NOT a validation exemption — the final submit still re-validates every step and returns to the first failing one. Default (absent/false): steps unlock in array order as each prior step submits validly. UI admission only, never authorization. |
| showStepIndicator | boolean | optional | Show the wizard step indicator (renderer default: shown). Step progress (completed/current/upcoming/invalid) is derived from the step gate — this boolean is the only authorable progress surface. |
| splitDirection | Enum<'horizontal' | 'vertical'> | optional | Split orientation (split forms) |
| splitSize | number | optional | Primary split panel size, % (split forms) |
| splitResizable | boolean | optional | Whether the split is resizable (split forms) |
| drawerSide | Enum<'top' | 'bottom' | 'left' | 'right'> | optional | Drawer side (drawer forms) |
| drawerWidth | string | optional | [DEPRECATED → size buckets] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. |
| modalSize | Enum<'sm' | 'default' | 'lg' | 'xl' | 'full'> | optional | Modal size (modal forms) |
| data | { provider: 'object'; object: string } | { provider: 'value'; items: any[] } | { provider: 'schema'; schemaId: string; schema?: Record<string, any> } | optional | Data source configuration (defaults to "object" provider) |
| sections | { name?: string; label?: string | Record<string, string>; description?: string; collapsible?: boolean; … }[] | optional | |
| groups | { name?: string; label?: string | Record<string, string>; description?: string; collapsible?: boolean; … }[] | optional | [LEGACY ALIAS → sections] Accepted for back-compat and folded onto sections at parse; sections wins when both are present. Prefer sections. |
| subforms | { childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[] | optional | Inline master-detail child collections |
| defaultSort | never | optional | [REMOVED] form.defaultSort was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's sort. Delete the key and set the sort on the related list view instead. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| sharing | { enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … } | optional | Public sharing configuration for this form |
| submitBehavior | { kind: 'thank-you'; title?: string; message?: string } | { kind: 'redirect'; url: string; delayMs?: integer } | { kind: 'continue' } | { kind: 'next-record' } | optional | Post-submit behavior. On the redirect arm, url is relative-only and interpolates only declared record fields as {{record.field_name}}, URL-escaped. |
| buttons | { submit?: object; cancel?: object; reset?: object } | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. |
| defaults | Record<string, any> | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). |
| aria | never | optional | [REMOVED] form.aria was removed in @objectstack/spec 17.0.0 (audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
Nested Shape: FormView.data[provider='object']
| Property | Type | Required | Description |
|---|---|---|---|
| provider | 'object' | ✅ | |
| object | string | ✅ | Target object name |
Nested Shape: FormView.data[provider='value']
| Property | Type | Required | Description |
|---|---|---|---|
| provider | 'value' | ✅ | |
| items | any[] | ✅ | Static data array |
Nested Shape: FormView.data[provider='schema']
| Property | Type | Required | Description |
|---|---|---|---|
| provider | 'schema' | ✅ | |
| schemaId | string | ✅ | Schema identifier — typically the metadata type name |
| schema | Record<string, any> | optional | Inline JSON Schema (Draft 2020-12). Optional when schemaId is resolvable. |
Nested Shape: FormView.sections[number]
| Property | Type | Required | Description |
|---|---|---|---|
| name | string | optional | Stable section identifier for i18n lookup (snake_case) |
| label | string | Record<string, string> | optional | Display label — the default-language string, or an inline locale map ({ en, "zh-CN" }) resolved at render time |
| description | string | optional | Optional description rendered under the section header. |
| collapsible | boolean | optional (default: false) | Whether the section renders a disclosure control, so a reader can close it and open it again. Default false: a section declaring neither collapse key is always open and shows no control. ⚠️ collapsed: true IMPLIES this key — an explicit collapsible: false beside it does NOT take the control away. The renderer resolves that from the DECLARATION; parse never rewrites the pair, so a parsed section still reports the false that was authored. Only true is refused on a wizard step and beside group; false is accepted in both, because it declares exactly what those surfaces already deliver. |
| collapsed | boolean | optional (default: false) | Whether the section starts closed. Default false. ⚠️ collapsed: true IMPLIES collapsible and is sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that reopens it, and it outranks an explicit collapsible: false, so writing collapsed: true alone is a correct way to say "collapsed by default". The implication is a renderer rule, never a parse-time rewrite: { collapsed: true } still parses to collapsible: false, collapsed: true, so the parsed collapsible must never be read as "a control renders". Only true is refused on a wizard step (steps do not collapse) and beside group, whose field group declares the pair. |
| visibleWhen | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: record (+ previous, parent) in runtime forms, or data in metadata forms. current_user (and the ADR-0068 aliases user / ctx.user / os.user) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section visibleWhen, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public /f/:slug route no host publishes a scope, so the root is unbound and the predicate faults open. No features.* on ANY form-view predicate — refused at parse: unbound on the standalone form routes, where the predicate would fault open. |
| visibleOn | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } | optional | [DEPRECATED → visibleWhen] Visibility predicate (CEL). Hides the whole section when false. Normalized to visibleWhen at parse. |
| columns | Enum<'1' | '2' | '3' | '4'> | 1 | 2 | 3 | 4 | optional (default: 1) | |
| pane | Enum<'primary' | 'secondary'> | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| group | string | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's fieldGroups (ADR-0085 §5 deriveFieldGroupLayout). Mutually exclusive with fields, and with every key the group itself declares (name, label, description, collapsible, collapsed, visibleWhen/visibleOn). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| fields | (string | { field: string; type?: Enum<'text' | 'textarea' | 'email' | 'url' | 'phone' | 'password' | 'secret' | …>; options?: object[]; reference?: string; … })[] | optional |
Nested Shape: FormView.groups[number]
| Property | Type | Required | Description |
|---|---|---|---|
| name | string | optional | Stable section identifier for i18n lookup (snake_case) |
| label | string | Record<string, string> | optional | Display label — the default-language string, or an inline locale map ({ en, "zh-CN" }) resolved at render time |
| description | string | optional | Optional description rendered under the section header. |
| collapsible | boolean | optional (default: false) | Whether the section renders a disclosure control, so a reader can close it and open it again. Default false: a section declaring neither collapse key is always open and shows no control. ⚠️ collapsed: true IMPLIES this key — an explicit collapsible: false beside it does NOT take the control away. The renderer resolves that from the DECLARATION; parse never rewrites the pair, so a parsed section still reports the false that was authored. Only true is refused on a wizard step and beside group; false is accepted in both, because it declares exactly what those surfaces already deliver. |
| collapsed | boolean | optional (default: false) | Whether the section starts closed. Default false. ⚠️ collapsed: true IMPLIES collapsible and is sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that reopens it, and it outranks an explicit collapsible: false, so writing collapsed: true alone is a correct way to say "collapsed by default". The implication is a renderer rule, never a parse-time rewrite: { collapsed: true } still parses to collapsible: false, collapsed: true, so the parsed collapsible must never be read as "a control renders". Only true is refused on a wizard step (steps do not collapse) and beside group, whose field group declares the pair. |
| visibleWhen | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: record (+ previous, parent) in runtime forms, or data in metadata forms. current_user (and the ADR-0068 aliases user / ctx.user / os.user) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section visibleWhen, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public /f/:slug route no host publishes a scope, so the root is unbound and the predicate faults open. No features.* on ANY form-view predicate — refused at parse: unbound on the standalone form routes, where the predicate would fault open. |
| visibleOn | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } | optional | [DEPRECATED → visibleWhen] Visibility predicate (CEL). Hides the whole section when false. Normalized to visibleWhen at parse. |
| columns | Enum<'1' | '2' | '3' | '4'> | 1 | 2 | 3 | 4 | optional (default: 1) | |
| pane | Enum<'primary' | 'secondary'> | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| group | string | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's fieldGroups (ADR-0085 §5 deriveFieldGroupLayout). Mutually exclusive with fields, and with every key the group itself declares (name, label, description, collapsible, collapsed, visibleWhen/visibleOn). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| fields | (string | { field: string; type?: Enum<'text' | 'textarea' | 'email' | 'url' | 'phone' | 'password' | 'secret' | …>; options?: object[]; reference?: string; … })[] | optional |
Nested Shape: FormView.subforms[number]
| Property | Type | Required | Description |
|---|---|---|---|
| childObject | string | ✅ | Child object whose records are entered inline |
| relationshipField | string | optional | FK on the child pointing back to the parent (auto-detected when omitted) |
| columns | { name: string; label?: string; type?: Enum<'text' | 'number' | 'currency' | 'date' | 'datetime' | 'time' | 'select' | 'lookup' | 'file'>; width?: number; … }[] | optional | Editable grid columns (derived from the child object when omitted). Each entry is the strict, name-keyed inline grid column a relationship field's inlineColumns takes ({ name, label?, type?, … } — objectui GridColumn); identity-only entries ({ name }) hydrate everything else from the child object's fields. Unknown keys and the retired field spelling are refused at parse. |
| amountField | string | optional | Numeric child column summed for the running total |
| totalField | string | optional | Parent field to receive the rolled-up sum |
| title | string | optional | Section title |
| addLabel | string | optional | Add-row button label |
| minRows | number | optional | |
| maxRows | number | optional |
Nested Shape: FormView.sharing
| Property | Type | Required | Description |
|---|---|---|---|
| enabled | boolean | optional (default: false) | Enable public sharing |
| publicLink | string | optional | Public slug of the form, chosen by the author: not a URL, and never generated. /forms/x, forms/x and x name one slug, x. It is served to anonymous visitors only while enabled and allowAnonymous are both true: the REST form door answers GET /api/v1/forms/:slug (default API base), and a host that serves the console shows the form at /_console/f/:slug and redirects /forms/:slug there, query string carried. |
| password | string | optional | Password required to access shared link |
| allowedDomains | string[] | optional | Restrict access to specific email domains (e.g. ["example.com"]) |
| expiresAt | string | optional | Expiration date/time in ISO 8601 format |
| allowAnonymous | boolean | optional (default: false) | Allow access without authentication |
Nested Shape: FormView.submitBehavior[kind='redirect']
| Property | Type | Required | Description |
|---|---|---|---|
| kind | 'redirect' | ✅ | |
| url | string | ✅ | Where the browser goes after a successful submit. (1) RELATIVE paths only — it must start with /, and absolute or protocol-relative URLs are refused, which is what closes the open-redirect face; (2) interpolation ONLY from declared record fields, spelled {{record.field_name}}, and every interpolated value is URL-escaped when the redirect is built; (3) a verbatim redirect on the resolved relative path is the intended consumption. To send the browser OUT of the app, use an app navigation item ({ type: 'url', url }) instead. |
| delayMs | integer | optional |
Nested Shape: FormView.buttons
| Property | Type | Required | Description |
|---|---|---|---|
| submit | { show?: boolean; label?: string | Record<string, string> } | optional | Submit button |
| cancel | { show?: boolean; label?: string | Record<string, string> } | optional | Cancel button |
| reset | { show?: boolean; label?: string | Record<string, string> } | optional | Reset button |
GalleryConfig
Gallery/card view configuration
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| coverField | string | optional | Attachment/image field to display as card cover |
| coverFit | Enum<'cover' | 'contain'> | optional (default: "cover") | Image fit mode for card cover |
| cardSize | Enum<'small' | 'medium' | 'large'> | optional (default: "medium") | Card size in gallery view |
| titleField | string | optional | Field to display as card title |
| visibleFields | string[] | optional | Fields to display on card body |
GanttConfig
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| startDateField | string | ✅ | Field providing the task start date |
| endDateField | string | ✅ | Field providing the task end date |
| titleField | string | ✅ | Field displayed as the task title |
| progressField | string | optional | Field providing the task completion percentage |
| dependenciesField | string | optional | Field listing the task's predecessor (dependency) record ids |
| colorField | string | optional | Field to derive each bar color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else a semantic color token derived from the value |
| parentField | string | optional | Field holding the parent task id (builds the summary → step tree) |
| typeField | string | optional | Field whose value maps to task/summary/milestone |
| baselineStartField | string | optional | Baseline (planned) start field |
| baselineEndField | string | optional | Baseline (planned) end field |
| groupByField | string | optional | Field to group leaf tasks by (synthesized summary rows). NO leading or trailing whitespace: the group accessor reads this name off every task verbatim, so a padded spelling drops every task into one ungrouped bucket. |
| resourceView | boolean | optional | Render a per-resource workload histogram instead of the timeline |
| assigneeField | string | optional | Resource field to bucket load by (resource view) |
| effortField | string | optional | Per-task load units (resource view; default 1) |
| capacity | number | optional | Per-resource capacity ceiling; loads above this flag overload |
| tooltipFields | (string | { field: string; label?: string })[] | optional | Fields to surface in the hover tooltip, in display order |
| quickFilters | { field: string; label?: string; options?: (string | object)[] }[] | optional | Multi-select filter dropdowns rendered above the chart |
| autoZoomToFilter | boolean | optional | When true (default), filtering zooms the range to the filtered tasks |
| viewMode | Enum<'day' | 'week' | 'month' | 'quarter' | 'year'> | optional | Timeline granularity — one column per day/week/month/quarter/year (also the resource-view column granularity; renderer default 'day') |
| borderColorField | string | optional | Field carrying a per-task alert stroke color — any CSS color or semantic palette name (red, orange, …): the bar keeps its fill and gains an outline plus halo in that color (e.g. red for overdue, orange for due-soon; typically a server-computed alert field). Empty or null means no stroke |
| lockField | string | optional | Field marking a row view-only (truthy means locked): a locked bar cannot be dragged or resized, its progress cannot be dragged, no dependency can be drawn from it and its inline-edit and edit/delete menu entries are hidden — clicking it (open drawer, jump) still works. Independent of the global readOnly; freezes individual levels while siblings stay editable |
| objectField | string | optional | Field carrying the row's OWN object API name, for mixed-object trees (a row set composing parent-object rows with child-object rows): the detail drawer and its full-page link follow each row's real object instead of the view's bound object. Empty or missing falls back to the bound object |
| summaryExtent | Enum<'children' | 'self'> | optional | How a summary bar's span is computed. 'children' (renderer default) rolls the bar up from its children — min start, max end, duration-weighted progress — and ignores the record's own dates; 'self' renders the record's OWN start, end and progress and falls back to rollup only for records without dates (use it when the parent's schedule is authoritative, e.g. a shift plan whose work-order children are locked history) |
| defaultCollapsedDepth | integer | optional | Auto-collapse tree nodes at or below this 0-indexed depth on first render (roots are depth 0): every node at that depth or deeper that has children starts folded; the user can still expand them. Omit to start fully expanded |
| dependencyTypes | boolean | optional | Whether the backing store persists dependency link TYPES (fs, ss, ff, sf); renderer default true. Set false when dependencies are bare predecessor ids: the link menu hides the type switcher (a switch would be silently reverted on refetch) and drag-created links are always finish-to-start |
| timeZone | string | optional | Business time zone, an IANA name such as 'Asia/Shanghai': the chart's calendar — shift bands, day columns, snapping, the today line, date labels — renders in this zone's wall time for every viewer instead of the browser's zone; a datetime value is still written as the real instant, and a date value as the calendar day it was dropped on in this zone's calendar (YYYY-MM-DD). An invalid name falls back to the browser zone with a console warning |
| exportFileName | string | optional | Base name for exported PNG and PDF files (e.g. the view's display label — the host's view schema often reaches the renderer stripped of label); falls back to the object schema label, then the object API name. A timestamp suffix is always appended |
| interactions | { move?: boolean; resize?: boolean; progress?: boolean; link?: boolean } | optional | Per-interaction switches, each defaulting to true: allow bar moves but pin durations (resize: false), or keep the dependency UI read-only (link: false). They only narrow what readOnly and row locks already allow |
| timeSegments | { dayStart?: string; bands: object[]; showMidnight?: boolean } | optional | Shift segmentation for the day-mode timeline: splits each shift-day (starting at dayStart) into the configured bands — a two-tier header (date over band), per-band tints and drag/resize snapping to band boundaries. No shift concept is hardcoded; bands are pure config. Off when omitted |
Nested Shape: GanttConfig.quickFilters[number]
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Record field / dot-path the dimension filters on |
| label | string | optional | Trigger label (falls back to the field label) |
| options | (string | { value: string | number; label?: string })[] | optional | Explicit option override for fixed enums |
Nested Shape: GanttConfig.interactions
| Property | Type | Required | Description |
|---|---|---|---|
| move | boolean | optional | Bar and subtree dragging along the timeline (default true) |
| resize | boolean | optional | Edge resize grips that change a duration (default true) |
| progress | boolean | optional | The progress drag handle (default true) |
| link | boolean | optional | Dependency UI — the drag-to-link dots and the create/delete menu entries (default true) |
Nested Shape: GanttConfig.timeSegments
| Property | Type | Required | Description |
|---|---|---|---|
| dayStart | string | optional | Clock time the shift-day begins, 'HH:mm' (24h); the day column starts here and runs 24h. Renderer default '00:00' (calendar day); for an 08:00 handover set '08:00' |
| bands | { key?: string; label: string; start: string; end: string; … }[] | ✅ | Ordered bands covering the 24h shift-day, beginning at dayStart |
| showMidnight | boolean | optional | Draw the dashed calendar-midnight (00:00) cue inside cross-midnight bands; renderer default true |
GanttQuickFilter
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Record field / dot-path the dimension filters on |
| label | string | optional | Trigger label (falls back to the field label) |
| options | (string | { value: string | number; label?: string })[] | optional | Explicit option override for fixed enums |
GroupingConfig
Record grouping configuration — SERVER-SIDE: the set of groups and every number in a group header (the count and the per-column summaries) are properties of the query, not of the fetched page, answered by one aggregate query (groupBy = the fields in nesting order, count + the mapped column summaries, the view filter); rows inside a group are paged by the existing find with the group key AND-ed into the view filter. Compiled by compileListViewGroupQuery / compileListViewGroupRowsQuery
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| fields | { field: string; order?: Enum<'asc' | 'desc'>; collapsed?: boolean }[] | ✅ | Fields to group by, in nesting order — the first entry is the outermost group and each later entry nests one level deeper (at least one field); the same order as the group header query's groupBy |
Nested Shape: GroupingConfig.fields[number]
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Field name to group by — one groupBy column of the group header query; the header row carries its raw stored value (null for the empty group). NO leading or trailing whitespace: the name is compiled into the aggregate query verbatim, so a padded spelling buckets every row into one empty group instead of grouping them. |
| order | Enum<'asc' | 'desc'> | optional (default: "asc") | Group sort order — applied by the consumer over the header rows (the aggregate query carries no orderBy) |
| collapsed | boolean | optional (default: false) | Collapse groups by default (presentation only) |
GroupingField
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Field name to group by — one groupBy column of the group header query; the header row carries its raw stored value (null for the empty group). NO leading or trailing whitespace: the name is compiled into the aggregate query verbatim, so a padded spelling buckets every row into one empty group instead of grouping them. |
| order | Enum<'asc' | 'desc'> | optional (default: "asc") | Group sort order — applied by the consumer over the header rows (the aggregate query carries no orderBy) |
| collapsed | boolean | optional (default: false) | Collapse groups by default (presentation only) |
HttpMethodSubset
HTTP methods a view data source may request — the subset of HttpMethod without HEAD/OPTIONS.
Allowed Values
GETPOSTPUTPATCHDELETE
HttpRequest
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| url | string | ✅ | API endpoint URL |
| method | Enum<'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'> | optional (default: "GET") | HTTP method |
| headers | Record<string, string> | optional | Custom HTTP headers |
| params | Record<string, any> | optional | Query parameters |
| body | any | optional | Request body for POST/PUT/PATCH |
KanbanConfig
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| groupByField | string | ✅ | Field to group columns by (usually status/select). NO leading or trailing whitespace: the board reads this name off every card verbatim, so a padded spelling collapses the whole board into one Uncategorized lane. |
| summarizeField | string | optional | Field to sum at top of column (e.g. amount) |
| titleField | string | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) |
| columns | string[] | ✅ | Fields to show on cards |
ListChartConfig
List chart view configuration
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| chartType | Enum<'bar' | 'line' | 'pie' | 'area' | 'scatter'> | optional (default: "bar") | Chart visualisation type |
| dataset | string | ✅ | Dataset name to bind (ADR-0021) |
| dimensions | string[] | optional | Dimension names — X/group/split |
| values | string[] | ✅ | Measure names — Y (at least one) |
ListColumn
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Field name (snake_case) |
| label | string | Record<string, string> | optional | Display label override |
| width | number | optional | Column width in pixels |
| align | Enum<'left' | 'center' | 'right'> | optional | Text alignment |
| hidden | boolean | optional | Hide column by default |
| sortable | boolean | optional | Allow sorting by this column |
| resizable | boolean | optional | Allow resizing this column |
| wrap | boolean | optional | Allow text wrapping |
| type | string | optional | Renderer type override (e.g., "currency", "date") |
| pinned | Enum<'left' | 'right'> | optional | Pin/freeze column to left or right side |
| summary | Enum<'none' | 'count' | 'count_empty' | 'count_filled' | 'count_unique' | 'percent_empty' | 'percent_filled' | 'sum' | 'avg' | 'min' | 'max'> | { type: Enum<'none' | 'count' | 'count_empty' | 'count_filled' | 'count_unique' | …>; field?: string } | optional | Footer aggregation for this column — the function alone, or { type, field } to aggregate another field |
| prefix | { field: string; type?: Enum<'badge' | 'text'> } | optional | Field rendered inline before this cell value |
| link | boolean | optional | Functions as the primary navigation link (triggers View navigation) |
| action | string | optional | Registered Action ID to execute when clicked |
Nested Shape: ListColumn.summary
Column footer summary configuration
| Property | Type | Required | Description |
|---|---|---|---|
| type | Enum<'none' | 'count' | 'count_empty' | 'count_filled' | 'count_unique' | …> | ✅ | Aggregation function |
| field | string | optional | Field to aggregate (defaults to the column field) |
Nested Shape: ListColumn.prefix
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Field whose value renders before the cell value |
| type | Enum<'badge' | 'text'> | optional (default: "text") | How the prefix value is rendered |
ListMapConfig
Map view configuration
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| latitudeField | string | optional | Field providing the marker latitude (used with longitudeField) |
| longitudeField | string | optional | Field providing the marker longitude (used with latitudeField) |
| locationField | string | optional | Field providing a combined location — a "lat,lng" string or a { lat, lng } object — as the alternative to the latitudeField/longitudeField pair |
| titleField | string | optional | Field displayed as the marker title (popup heading, mobile record card, and what the map search box matches on) |
| descriptionField | string | optional | Field displayed as the marker description |
| zoom | number | optional | Initial zoom level (1-20). Omit to let the renderer fit the camera to the queried records |
| center | [number, number] | optional | Initial camera center as [latitude, longitude]. Omit to let the renderer fit the camera to the queried records |
| style | string | optional | Map style URL — the MapLibre style document the renderer loads in place of the public demo tiles. The component-level mapStyle is read FIRST and wins when both are present; this is NOT the inline CSS style record a component node carries |
ListView
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| name | string | optional | Internal view name (lowercase snake_case) |
| label | string | Record<string, string> | optional | Display label — the default-language string, or an inline locale map ({ en, "zh-CN" }) resolved at render time |
| type | Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'> | optional (default: "grid") | |
| data | { provider: 'object'; object: string } | { provider: 'value'; items: any[] } | { provider: 'schema'; schemaId: string; schema?: Record<string, any> } | optional | Data source configuration (defaults to "object" provider) |
| columns | string[] | { field: string; label?: string | Record<string, string>; width?: number; align?: Enum<'left' | 'center' | 'right'>; … }[] | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition columns x hiddenFields x fieldOrder: this list is the candidate set AND the baseline order; hiddenFields subtracts from it and fieldOrder orders what survives. hiddenFields and fieldOrder cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's ListView grid derives the object's default columns). |
| filter | { field: string; operator: Enum<'equals' | 'not_equals' | 'contains' | 'not_contains' | 'icontains' | …>; value?: string | number | boolean | null | (string | number)[] }[] | optional | Filter criteria (JSON Rules) |
| sort | { field: string; order: Enum<'asc' | 'desc'> }[] | optional | |
| searchableFields | string[] | optional | Fields enabled for search |
| filterableFields | string[] | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters |
| userFilters | { element?: Enum<'dropdown' | 'tabs' | 'toggle'>; fields?: object[]; tabs?: object[]; showAllRecords?: boolean; … } | optional | End-user quick-filter bar: dropdown/toggle fields or tab presets. Omit to let the renderer derive filters from select/boolean fields |
| resizable | boolean | optional | Enable column resizing |
| compactToolbar | boolean | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover |
| selection | { type?: Enum<'none' | 'single' | 'multiple'> } | optional | Row selection configuration |
| navigation | { mode?: Enum<'page' | 'drawer' | 'modal' | 'split' | 'popover' | 'new_window' | 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full'>; … } | optional | Configuration for item click navigation (page, drawer, modal, etc.) |
| pagination | { pageSize?: integer; pageSizeOptions?: integer[] } | optional | Pagination configuration |
| kanban | { groupByField: string; summarizeField?: string; titleField?: string; columns: string[] } | optional | Kanban-board configuration — applies when the view renders as a kanban layout |
| calendar | { startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … } | optional | Calendar configuration — applies when the view renders as a calendar layout |
| gantt | { startDateField: string; endDateField: string; titleField: string; progressField?: string; … } | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout |
| gallery | { coverField?: string; coverFit?: Enum<'cover' | 'contain'>; cardSize?: Enum<'small' | 'medium' | 'large'>; titleField?: string; … } | optional | Gallery/card view configuration |
| timeline | { startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … } | optional | Timeline view configuration |
| chart | { chartType?: Enum<'bar' | 'line' | 'pie' | 'area' | 'scatter'>; dataset: string; dimensions?: string[]; values: string[] } | optional | Chart binding — applies when the view renders as a chart. A type: 'chart' view must bind one: it names the ADR-0021 dataset and the measures (values) the chart plots, and there is no default binding |
| map | { latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … } | optional | Map configuration — applies when the view renders as a map layout |
| tree | { parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout |
| pageName | never | optional | [REMOVED] view.pageName was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a type: 'page' view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — { type: 'page', pageName: '<page_name>' } under the app's navigation — which is a different key on a different surface and is the page mount that has always rendered. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| description | string | Record<string, string> | optional | View description for documentation/tooltips |
| sharing | { type?: Enum<'personal' | 'collaborative'>; lockedBy?: string } | optional | View sharing and access configuration |
| requiredPermissions | string[] | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through systemPermissions, the same list as requiredPermissions on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. |
| rowHeight | Enum<'compact' | 'short' | 'medium' | 'tall' | 'extra_tall'> | optional | Row height / density setting |
| grouping | { fields: object[] } | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) |
| rowColor | { field: string; colors?: Record<string, string> } | optional | Color rows based on field value |
| hiddenFields | string[] | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition columns x hiddenFields x fieldOrder: each name listed here is removed from the columns projection before fieldOrder orders the remainder. A name columns never projected subtracts nothing. |
| fieldOrder | string[] | optional | Explicit field display order for this view — the ORDERING of the per-view field composition columns x hiddenFields x fieldOrder: it sorts what survives columns minus hiddenFields and never adds a field. A surviving column absent from fieldOrder sorts LAST, after every listed one, keeping its columns-relative order; a name listed here that did not survive orders nothing. |
| rowActions | string[] | optional | Actions available for individual row items |
| bulkActions | string[] | optional | Actions available when multiple rows are selected |
| bulkActionDefs | { name: string; label?: string; icon?: string; variant?: Enum<'primary' | 'secondary' | 'danger' | 'ghost' | 'outline'>; … }[] | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a patch / 'delete') that no action expresses, or for an operation: 'custom' + execution: 'aggregate' entry that dispatches the action it NAMES once for the whole selection — the renderer injects params._selectedIds: string[] (read that on the server, not recordId) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate 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 on defs whose server work is expensive. For the PER-RECORD dispatch use bulkActions: ['<name>'] instead — the bare-string form, promoted with the action's own label, params and visible; a 'custom' def without execution: 'aggregate' has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via ${ctx.selection.ids} / ${ctx.selection.count}. |
| conditionalFormatting | { condition: string | object; style: Record<string, string> }[] | optional | Conditional formatting rules for list rows |
| inlineEdit | boolean | optional | Allow inline editing of records directly in the list view |
| exportOptions | Enum<'csv' | 'xlsx' | 'json'>[] | { formats?: Enum<'csv' | 'xlsx' | 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … } | optional | Export configuration for the list toolbar export menu: { formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }. A bare format array is the legacy spelling and lifts to { formats: [...] } at parse. |
| userActions | { sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … } | optional | User action toggles for the view toolbar |
| appearance | { showDescription?: boolean; allowedVisualizations?: Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'>[] } | optional | Appearance and visualization configuration |
| tabs | never | optional | [REMOVED] view.list.tabs was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's listViews instead: the tab's name becomes the entry's key, its label the entry's label, and its filter rules join the view's own filter on that entry (copy the view's columns too); a tab whose view already named a list view needs nothing more. Every listViews entry renders as a tab in the switcher. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| addRecord | { enabled?: boolean; position?: Enum<'top' | 'bottom' | 'both'>; mode?: Enum<'inline' | 'form' | 'modal'>; formView?: string } | optional | Add record entry point configuration |
| showRecordCount | boolean | optional | Show record count at the bottom of the list |
| allowPrinting | boolean | optional | Allow users to print the view |
| emptyState | { title?: string | Record<string, string>; message?: string | Record<string, string>; icon?: string; link?: object } | optional | Empty state configuration when no records found |
| aria | { ariaLabel?: string | Record<string, string>; ariaDescribedBy?: string; role?: string } | optional | ARIA accessibility attributes for the list view |
| responsive | never | optional | [REMOVED] view.responsive was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| performance | never | optional | [REMOVED] view.performance was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| striped | never | optional | [REMOVED] view.striped was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| bordered | never | optional | [REMOVED] view.bordered was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| virtualScroll | never | optional | [REMOVED] view.virtualScroll was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via pagination. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
Nested Shape: ListView.data[provider='object']
| Property | Type | Required | Description |
|---|---|---|---|
| provider | 'object' | ✅ | |
| object | string | ✅ | Target object name |
Nested Shape: ListView.data[provider='value']
| Property | Type | Required | Description |
|---|---|---|---|
| provider | 'value' | ✅ | |
| items | any[] | ✅ | Static data array |
Nested Shape: ListView.data[provider='schema']
| Property | Type | Required | Description |
|---|---|---|---|
| provider | 'schema' | ✅ | |
| schemaId | string | ✅ | Schema identifier — typically the metadata type name |
| schema | Record<string, any> | optional | Inline JSON Schema (Draft 2020-12). Optional when schemaId is resolvable. |
Nested Shape: ListView.columns[number]
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Field name (snake_case) |
| label | string | Record<string, string> | optional | Display label override |
| width | number | optional | Column width in pixels |
| align | Enum<'left' | 'center' | 'right'> | optional | Text alignment |
| hidden | boolean | optional | Hide column by default |
| sortable | boolean | optional | Allow sorting by this column |
| resizable | boolean | optional | Allow resizing this column |
| wrap | boolean | optional | Allow text wrapping |
| type | string | optional | Renderer type override (e.g., "currency", "date") |
| pinned | Enum<'left' | 'right'> | optional | Pin/freeze column to left or right side |
| summary | Enum<'none' | 'count' | 'count_empty' | 'count_filled' | 'count_unique' | …> | { type: Enum<'none' | 'count' | 'count_empty' | 'count_filled' | 'count_unique' | …>; field?: string } | optional | Footer aggregation for this column — the function alone, or { type, field } to aggregate another field |
| prefix | { field: string; type?: Enum<'badge' | 'text'> } | optional | Field rendered inline before this cell value |
| link | boolean | optional | Functions as the primary navigation link (triggers View navigation) |
| action | string | optional | Registered Action ID to execute when clicked |
Nested Shape: ListView.filter[number]
View filter rule
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Field name to filter on |
| operator | Enum<'equals' | 'not_equals' | 'contains' | 'not_contains' | 'icontains' | …> | ✅ | Filter operator |
| value | string | number | boolean | null | (string | number)[] | optional | Filter value. The accepted SHAPE depends on the operator: in / not_in take an array (any length, including []), between takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: icontains takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. |
Nested Shape: ListView.userFilters
| Property | Type | Required | Description |
|---|---|---|---|
| element | Enum<'dropdown' | 'tabs' | 'toggle'> | optional (default: "dropdown") | Filter control style: "dropdown" (per-field value selectors) or "tabs" (named presets). "toggle" is deprecated. |
| fields | { field: string; label?: string | Record<string, string>; type?: Enum<'select' | 'multi-select' | 'boolean' | 'date-range' | 'text'>; options?: object[]; … }[] | optional | Fields exposed as quick filters (dropdown/toggle elements) |
| tabs | { name: string; label?: string | Record<string, string>; icon?: string; view?: string; … }[] | optional | Named filter presets rendered as tabs (tabs element). Reuses ViewTabSchema |
| showAllRecords | boolean | optional | Show an "All records" tab before the presets (tabs element) |
| allowAddTab | boolean | optional | Let end users add their own tab after the presets (tabs element): the affordance asks for a name and snapshots the filters currently applied as a new tab. SESSION-SCOPED — an added tab lives only for the current mount, is never written back as metadata (ADR-0047), and carries a remove control the authored presets do not. Page lists only — object views use listViews for named presets |
Nested Shape: ListView.selection
| Property | Type | Required | Description |
|---|---|---|---|
| type | Enum<'none' | 'single' | 'multiple'> | optional (default: "none") | Selection mode |
Nested Shape: ListView.navigation
| Property | Type | Required | Description |
|---|---|---|---|
| mode | Enum<'page' | 'drawer' | 'modal' | 'split' | 'popover' | 'new_window' | 'none'> | optional (default: "page") | |
| view | never | optional | [REMOVED] view.list.navigation.view was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the form view to open for a record detail, and no layer resolved a view by that name: the value was passed straight into the navigation-MODE argument of the console's onNavigate, where anything other than edit or view matched no branch, so the key selected nothing and could silently deaden the row click. Delete the key; to choose what opens for a record, assign a record page to the object and let isDefault pick the one that opens — page assignment is the machinery that resolves a detail layout, and a list view's navigation block only decides HOW the detail is surfaced (mode, size). |
| preventNavigation | boolean | optional (default: false) | Disable standard navigation entirely |
| openNewTab | boolean | optional (default: false) | Force open in new tab (applies to page mode) |
| size | Enum<'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full'> | optional (default: "auto") | Overlay size bucket for drawer/modal detail: 'auto' (default — renderer derives from field count + viewport; AI writes nothing) or a coarse override sm/md/lg/xl/full. Prefer this over the pixel width; page mode ignores it. |
| width | string | number | optional | [DEPRECATED → size] Pixel/percent width of the drawer/modal (e.g. "600px"). A pixel width cannot be chosen at authoring time without knowing the client viewport — use the size bucket. |
Nested Shape: ListView.pagination
| Property | Type | Required | Description |
|---|---|---|---|
| pageSize | integer | optional (default: 50) | Number of records per page. On a view with no pager (kanban, gallery, timeline) it is the fetch ceiling, and the renderer owes two things: bound its fetch at this number, and, when the filtered set is larger than it, show a visible truncation signal saying what is on screen is not the whole set |
| pageSizeOptions | integer[] | optional | Available page size options |
Nested Shape: ListView.kanban
| Property | Type | Required | Description |
|---|---|---|---|
| groupByField | string | ✅ | Field to group columns by (usually status/select). NO leading or trailing whitespace: the board reads this name off every card verbatim, so a padded spelling collapses the whole board into one Uncategorized lane. |
| summarizeField | string | optional | Field to sum at top of column (e.g. amount) |
| titleField | string | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) |
| columns | string[] | ✅ | Fields to show on cards |
Nested Shape: ListView.calendar
| Property | Type | Required | Description |
|---|---|---|---|
| startDateField | string | ✅ | Field providing the event start date/time |
| endDateField | string | optional | Field providing the event end date/time (defaults to a single-day event) |
| titleField | string | optional | Field displayed as the event title. Omit to fall back to the record display name (ADR-0079 resolver chain) |
| colorField | string | optional | Field to derive each event color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the calendar theme-aware palette color hashed from the value |
| allDayField | string | optional | Field carrying the all-day flag for each event (names a boolean field, not a value): a record whose flag is true is drawn as an all-day band rather than at a clock time, and one whose flag is absent or false is not all-day. Omit to leave the renderer inference in place — an event with no end date draws as all-day |
Nested Shape: ListView.gantt
| Property | Type | Required | Description |
|---|---|---|---|
| startDateField | string | ✅ | Field providing the task start date |
| endDateField | string | ✅ | Field providing the task end date |
| titleField | string | ✅ | Field displayed as the task title |
| progressField | string | optional | Field providing the task completion percentage |
| dependenciesField | string | optional | Field listing the task's predecessor (dependency) record ids |
| colorField | string | optional | Field to derive each bar color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else a semantic color token derived from the value |
| parentField | string | optional | Field holding the parent task id (builds the summary → step tree) |
| typeField | string | optional | Field whose value maps to task/summary/milestone |
| baselineStartField | string | optional | Baseline (planned) start field |
| baselineEndField | string | optional | Baseline (planned) end field |
| groupByField | string | optional | Field to group leaf tasks by (synthesized summary rows). NO leading or trailing whitespace: the group accessor reads this name off every task verbatim, so a padded spelling drops every task into one ungrouped bucket. |
| resourceView | boolean | optional | Render a per-resource workload histogram instead of the timeline |
| assigneeField | string | optional | Resource field to bucket load by (resource view) |
| effortField | string | optional | Per-task load units (resource view; default 1) |
| capacity | number | optional | Per-resource capacity ceiling; loads above this flag overload |
| tooltipFields | (string | { field: string; label?: string })[] | optional | Fields to surface in the hover tooltip, in display order |
| quickFilters | { field: string; label?: string; options?: (string | object)[] }[] | optional | Multi-select filter dropdowns rendered above the chart |
| autoZoomToFilter | boolean | optional | When true (default), filtering zooms the range to the filtered tasks |
| viewMode | Enum<'day' | 'week' | 'month' | 'quarter' | 'year'> | optional | Timeline granularity — one column per day/week/month/quarter/year (also the resource-view column granularity; renderer default 'day') |
| borderColorField | string | optional | Field carrying a per-task alert stroke color — any CSS color or semantic palette name (red, orange, …): the bar keeps its fill and gains an outline plus halo in that color (e.g. red for overdue, orange for due-soon; typically a server-computed alert field). Empty or null means no stroke |
| lockField | string | optional | Field marking a row view-only (truthy means locked): a locked bar cannot be dragged or resized, its progress cannot be dragged, no dependency can be drawn from it and its inline-edit and edit/delete menu entries are hidden — clicking it (open drawer, jump) still works. Independent of the global readOnly; freezes individual levels while siblings stay editable |
| objectField | string | optional | Field carrying the row's OWN object API name, for mixed-object trees (a row set composing parent-object rows with child-object rows): the detail drawer and its full-page link follow each row's real object instead of the view's bound object. Empty or missing falls back to the bound object |
| summaryExtent | Enum<'children' | 'self'> | optional | How a summary bar's span is computed. 'children' (renderer default) rolls the bar up from its children — min start, max end, duration-weighted progress — and ignores the record's own dates; 'self' renders the record's OWN start, end and progress and falls back to rollup only for records without dates (use it when the parent's schedule is authoritative, e.g. a shift plan whose work-order children are locked history) |
| defaultCollapsedDepth | integer | optional | Auto-collapse tree nodes at or below this 0-indexed depth on first render (roots are depth 0): every node at that depth or deeper that has children starts folded; the user can still expand them. Omit to start fully expanded |
| dependencyTypes | boolean | optional | Whether the backing store persists dependency link TYPES (fs, ss, ff, sf); renderer default true. Set false when dependencies are bare predecessor ids: the link menu hides the type switcher (a switch would be silently reverted on refetch) and drag-created links are always finish-to-start |
| timeZone | string | optional | Business time zone, an IANA name such as 'Asia/Shanghai': the chart's calendar — shift bands, day columns, snapping, the today line, date labels — renders in this zone's wall time for every viewer instead of the browser's zone; a datetime value is still written as the real instant, and a date value as the calendar day it was dropped on in this zone's calendar (YYYY-MM-DD). An invalid name falls back to the browser zone with a console warning |
| exportFileName | string | optional | Base name for exported PNG and PDF files (e.g. the view's display label — the host's view schema often reaches the renderer stripped of label); falls back to the object schema label, then the object API name. A timestamp suffix is always appended |
| interactions | { move?: boolean; resize?: boolean; progress?: boolean; link?: boolean } | optional | Per-interaction switches, each defaulting to true: allow bar moves but pin durations (resize: false), or keep the dependency UI read-only (link: false). They only narrow what readOnly and row locks already allow |
| timeSegments | { dayStart?: string; bands: object[]; showMidnight?: boolean } | optional | Shift segmentation for the day-mode timeline: splits each shift-day (starting at dayStart) into the configured bands — a two-tier header (date over band), per-band tints and drag/resize snapping to band boundaries. No shift concept is hardcoded; bands are pure config. Off when omitted |
Nested Shape: ListView.gallery
| Property | Type | Required | Description |
|---|---|---|---|
| coverField | string | optional | Attachment/image field to display as card cover |
| coverFit | Enum<'cover' | 'contain'> | optional (default: "cover") | Image fit mode for card cover |
| cardSize | Enum<'small' | 'medium' | 'large'> | optional (default: "medium") | Card size in gallery view |
| titleField | string | optional | Field to display as card title |
| visibleFields | string[] | optional | Fields to display on card body |
Nested Shape: ListView.timeline
| Property | Type | Required | Description |
|---|---|---|---|
| startDateField | string | ✅ | Field for timeline item start date |
| endDateField | string | optional | Field for timeline item end date |
| titleField | string | ✅ | Field to display as timeline item title |
| groupByField | string | optional | Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this name off every row verbatim, so a padded spelling drops every row into one ungrouped band. |
| colorField | string | optional | Field to derive each item color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the timeline default marker color |
| scale | Enum<'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'> | optional (default: "week") | Default timeline scale |
Nested Shape: ListView.chart
| Property | Type | Required | Description |
|---|---|---|---|
| chartType | Enum<'bar' | 'line' | 'pie' | 'area' | 'scatter'> | optional (default: "bar") | Chart visualisation type |
| dataset | string | ✅ | Dataset name to bind (ADR-0021) |
| dimensions | string[] | optional | Dimension names — X/group/split |
| values | string[] | ✅ | Measure names — Y (at least one) |
Nested Shape: ListView.map
| Property | Type | Required | Description |
|---|---|---|---|
| latitudeField | string | optional | Field providing the marker latitude (used with longitudeField) |
| longitudeField | string | optional | Field providing the marker longitude (used with latitudeField) |
| locationField | string | optional | Field providing a combined location — a "lat,lng" string or a { lat, lng } object — as the alternative to the latitudeField/longitudeField pair |
| titleField | string | optional | Field displayed as the marker title (popup heading, mobile record card, and what the map search box matches on) |
| descriptionField | string | optional | Field displayed as the marker description |
| zoom | number | optional | Initial zoom level (1-20). Omit to let the renderer fit the camera to the queried records |
| center | [number, number] | optional | Initial camera center as [latitude, longitude]. Omit to let the renderer fit the camera to the queried records |
| style | string | optional | Map style URL — the MapLibre style document the renderer loads in place of the public demo tiles. The component-level mapStyle is read FIRST and wins when both are present; this is NOT the inline CSS style record a component node carries |
Nested Shape: ListView.tree
| Property | Type | Required | Description |
|---|---|---|---|
| parentField | string | optional | Single-parent pointer field (auto-detected from the object schema when omitted) |
| labelField | string | optional | Field rendered indented in the first column (defaults to "name") |
| fields | string[] | optional | Additional fields rendered as flat columns alongside the label |
| defaultExpandedDepth | integer | optional | Initial expansion depth (0 = roots only; omit = expand all) |
Nested Shape: ListView.sharing
| Property | Type | Required | Description |
|---|---|---|---|
| type | Enum<'personal' | 'collaborative'> | optional (default: "collaborative") | View ownership type |
| lockedBy | string | optional | User who locked the view configuration |
Nested Shape: ListView.grouping
| Property | Type | Required | Description |
|---|---|---|---|
| fields | { field: string; order?: Enum<'asc' | 'desc'>; collapsed?: boolean }[] | ✅ | Fields to group by, in nesting order — the first entry is the outermost group and each later entry nests one level deeper (at least one field); the same order as the group header query's groupBy |
Nested Shape: ListView.rowColor
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Field whose value is looked up in the colors map below to pick a row colour (typically a select/status field). The map is what does the colouring — with no colors, no row is ever coloured, whatever this field holds. Author-time diagnostic view/row-color-without-colors reports that combination. |
| colors | Record<string, string> | optional | Map of field value to row colour. The spellings that actually paint a row are not free-form: objectui plugin-grid's useRowColor hands a value already written as a complete Tailwind background class (bg-red-200) straight through, otherwise lower-cases and trims it and resolves it through its own closed vocabulary of colour NAMES (red, blue, slate, … each mapping to bg-NAME-100), and returns undefined for anything else. A hex, an rgb() or a CSS variable parses here, publishes, and colours no row — Tailwind v4 has no runtime, so no class can be fabricated from one. Author-time diagnostic view/row-color-unresolvable-value reports a value that cannot resolve. |
Nested Shape: ListView.bulkActionDefs[number]
| Property | Type | Required | Description |
|---|---|---|---|
| name | string | ✅ | Stable identifier — the audit-log action key, and (for an aggregate def) the name of the object action to dispatch. |
| label | string | optional | Button + dialog-header text. Plain string: an authored def is not i18n-resolved (declare a real action and name it in bulkActions to get localization). |
| icon | string | optional | Lucide icon name (e.g. "user-check", "trash"). |
| variant | Enum<'primary' | 'secondary' | 'danger' | 'ghost' | 'outline'> | optional | Visual treatment of the button. |
| operation | Enum<'update' | 'delete' | 'custom'> | ✅ | What the executor does: 'update'/'delete' are data-plane mass mutations; 'custom' dispatches an object action (see execution). |
| execution | Enum<'perRecord' | 'aggregate'> | optional | For operation: 'custom' — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in params._selectedIds. Required on a custom def: the per-record form is declared as bulkActions: ['<name>'] instead. |
| patch | Record<string, any> | optional | For operation: 'update' — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. |
| params | { name: string; label?: string; help?: string; type: Enum<'text' | 'textarea' | 'email' | 'url' | 'phone' | 'password' | 'secret' | …>; … }[] | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. |
| confirmText | string | optional | Confirmation text shown above the affected-record summary. |
| confirmLabel | string | optional | Custom Confirm button label (default: "Run"). |
| visible | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } | optional | Eligibility predicate (CEL) — a string or a {dialect, source} envelope, i.e. action.visible without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (features.x, current_user.y) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. |
| requiredPermissions | string[] | optional | [ADR-0066 D4] Capability gate on the button, action.requiredPermissions semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the update/delete data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from bulkActions: ['<name>'] (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks. |
| maxRecords | integer | optional | Selection size above which the run is blocked. Set it on defs whose server work is expensive — an aggregate def carries every selected id in one request. |
| batchSize | integer | optional | Records per executor batch (default 200). Data-plane operations only — an aggregate run is a single call by definition. |
Nested Shape: ListView.conditionalFormatting[number]
| Property | Type | Required | Description |
|---|---|---|---|
| condition | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } | ✅ | Predicate (CEL) to evaluate. |
| style | Record<string, string> | ✅ | CSS styles to apply when condition is true |
Nested Shape: ListView.exportOptions
| Property | Type | Required | Description |
|---|---|---|---|
| formats | Enum<'csv' | 'xlsx' | 'json'>[] | optional | Formats offered in the export menu (default: ['csv', 'json']). XLSX is delivered by the server stream only. |
| maxRecords | integer | optional | Maximum number of records to export; 0 or absent = unlimited |
| includeHeaders | boolean | optional | Include column headers in the exported file (default true) |
| fileNamePrefix | string | optional | Download file name prefix — replaces the object label and suppresses the view label in the generated file name |
| streaming | boolean | optional | Set false to force the client-side export path (csv/json only) instead of the server stream |
Nested Shape: ListView.userActions
| Property | Type | Required | Description |
|---|---|---|---|
| sort | boolean | optional (default: true) | Allow users to sort records |
| search | boolean | optional (default: true) | Allow users to search records |
| filter | boolean | optional (default: true) | Allow users to filter records |
| refresh | boolean | optional (default: true) | Allow users to reload the view data from the backend without a full page reload |
| rowHeight | boolean | optional (default: true) | Allow users to toggle row height/density |
| group | boolean | optional (default: true) | Allow users to change record grouping from the toolbar. Toggle only — the grouping itself is configured in the view-level grouping block. |
| addRecordForm | boolean | optional (default: false) | Add records through a form instead of inline |
| editInline | boolean | optional (default: true) | Allow users to edit records inline. On by default: a user who may update the object edits a cell in place with the field's type-aware widget (the same control the form uses), under the permission gate that already decides whether the object is editable at all. Declare editInline: false to make the list read-only in place. |
| hideFields | boolean | optional (default: false) | Allow users to hide/show fields from the toolbar (the affordance behind the view-level hiddenFields list). Boolean toggle — distinct from the record-details component's hideFields, which is an array of field names to omit. Off by default: column hiding is opt-in. |
| rowColor | boolean | optional (default: false) | Allow users to configure row colouring from the toolbar. Boolean toggle — the colour rules themselves live in the view-level rowColor block. Off by default: row colouring is opt-in. |
| buttons | string[] | optional | Custom action button IDs to show in the toolbar |
Nested Shape: ListView.appearance
| Property | Type | Required | Description |
|---|---|---|---|
| showDescription | boolean | optional (default: true) | Show the view description text |
| allowedVisualizations | Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'>[] | optional | Whitelist of visualization types users can switch between (e.g. ["grid", "gallery", "kanban"]) |
Nested Shape: ListView.addRecord
| Property | Type | Required | Description |
|---|---|---|---|
| enabled | boolean | optional (default: true) | Show the add record entry point |
| position | Enum<'top' | 'bottom' | 'both'> | optional (default: "bottom") | Position of the add record button |
| mode | Enum<'inline' | 'form' | 'modal'> | optional (default: "inline") | How to add a new record |
| formView | string | optional | Named form view to use when mode is "form" or "modal" |
Nested Shape: ListView.emptyState
| Property | Type | Required | Description |
|---|---|---|---|
| title | string | Record<string, string> | optional | Heading of the empty state |
| message | string | Record<string, string> | optional | Line of text below the heading |
| icon | string | optional | Icon name drawn above the heading; a name that resolves to no icon draws the default empty-state glyph |
| link | { label: string | Record<string, string>; url: string } | optional | The one next step an empty list names: a link the user follows, { label, url } — for example to the how-to page that explains what belongs here when nobody can add a row. It opens a page or an address and runs no action. The console draws it in a list's empty state (the grid and gallery visualizations) and in an object-grid block's, beside the add-record action where one is offered. |
Nested Shape: ListView.aria
| Property | Type | Required | Description |
|---|---|---|---|
| ariaLabel | string | Record<string, string> | optional | Accessible label for screen readers (WAI-ARIA aria-label). Plain string, or an inline locale map — no translation-bundle slot addresses this key, so a plain string is announced in the source language. |
| ariaDescribedBy | string | optional | ID of element providing additional description (WAI-ARIA aria-describedby) |
| role | string | optional | WAI-ARIA role attribute (e.g., "dialog", "navigation", "alert") |
NavigationConfig
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| mode | Enum<'page' | 'drawer' | 'modal' | 'split' | 'popover' | 'new_window' | 'none'> | optional (default: "page") | |
| view | never | optional | [REMOVED] view.list.navigation.view was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the form view to open for a record detail, and no layer resolved a view by that name: the value was passed straight into the navigation-MODE argument of the console's onNavigate, where anything other than edit or view matched no branch, so the key selected nothing and could silently deaden the row click. Delete the key; to choose what opens for a record, assign a record page to the object and let isDefault pick the one that opens — page assignment is the machinery that resolves a detail layout, and a list view's navigation block only decides HOW the detail is surfaced (mode, size). |
| preventNavigation | boolean | optional (default: false) | Disable standard navigation entirely |
| openNewTab | boolean | optional (default: false) | Force open in new tab (applies to page mode) |
| size | Enum<'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full'> | optional (default: "auto") | Overlay size bucket for drawer/modal detail: 'auto' (default — renderer derives from field count + viewport; AI writes nothing) or a coarse override sm/md/lg/xl/full. Prefer this over the pixel width; page mode ignores it. |
| width | string | number | optional | [DEPRECATED → size] Pixel/percent width of the drawer/modal (e.g. "600px"). A pixel width cannot be chosen at authoring time without knowing the client viewport — use the size bucket. |
NavigationMode
Allowed Values
pagedrawermodalsplitpopovernew_windownone
ObjectListView
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| name | string | optional | Internal view name (lowercase snake_case) |
| label | string | Record<string, string> | optional | Display label — the default-language string, or an inline locale map ({ en, "zh-CN" }) resolved at render time |
| type | Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'> | optional (default: "grid") | |
| data | { provider: 'object'; object: string } | { provider: 'value'; items: any[] } | { provider: 'schema'; schemaId: string; schema?: Record<string, any> } | optional | Data source configuration (defaults to "object" provider) |
| columns | string[] | { field: string; label?: string | Record<string, string>; width?: number; align?: Enum<'left' | 'center' | 'right'>; … }[] | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition columns x hiddenFields x fieldOrder: this list is the candidate set AND the baseline order; hiddenFields subtracts from it and fieldOrder orders what survives. hiddenFields and fieldOrder cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's ListView grid derives the object's default columns). |
| filter | { field: string; operator: Enum<'equals' | 'not_equals' | 'contains' | 'not_contains' | 'icontains' | …>; value?: string | number | boolean | null | (string | number)[] }[] | optional | Filter criteria (JSON Rules) |
| sort | { field: string; order: Enum<'asc' | 'desc'> }[] | optional | |
| searchableFields | string[] | optional | Fields enabled for search |
| filterableFields | string[] | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters |
| resizable | boolean | optional | Enable column resizing |
| compactToolbar | boolean | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover |
| selection | { type?: Enum<'none' | 'single' | 'multiple'> } | optional | Row selection configuration |
| navigation | { mode?: Enum<'page' | 'drawer' | 'modal' | 'split' | 'popover' | 'new_window' | 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full'>; … } | optional | Configuration for item click navigation (page, drawer, modal, etc.) |
| pagination | { pageSize?: integer; pageSizeOptions?: integer[] } | optional | Pagination configuration |
| kanban | { groupByField: string; summarizeField?: string; titleField?: string; columns: string[] } | optional | Kanban-board configuration — applies when the view renders as a kanban layout |
| calendar | { startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … } | optional | Calendar configuration — applies when the view renders as a calendar layout |
| gantt | { startDateField: string; endDateField: string; titleField: string; progressField?: string; … } | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout |
| gallery | { coverField?: string; coverFit?: Enum<'cover' | 'contain'>; cardSize?: Enum<'small' | 'medium' | 'large'>; titleField?: string; … } | optional | Gallery/card view configuration |
| timeline | { startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … } | optional | Timeline view configuration |
| chart | { chartType?: Enum<'bar' | 'line' | 'pie' | 'area' | 'scatter'>; dataset: string; dimensions?: string[]; values: string[] } | optional | Chart binding — applies when the view renders as a chart. A type: 'chart' view must bind one: it names the ADR-0021 dataset and the measures (values) the chart plots, and there is no default binding |
| map | { latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … } | optional | Map configuration — applies when the view renders as a map layout |
| tree | { parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout |
| pageName | never | optional | [REMOVED] view.pageName was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a type: 'page' view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — { type: 'page', pageName: '<page_name>' } under the app's navigation — which is a different key on a different surface and is the page mount that has always rendered. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| description | string | Record<string, string> | optional | View description for documentation/tooltips |
| sharing | { type?: Enum<'personal' | 'collaborative'>; lockedBy?: string } | optional | View sharing and access configuration |
| requiredPermissions | string[] | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through systemPermissions, the same list as requiredPermissions on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. |
| rowHeight | Enum<'compact' | 'short' | 'medium' | 'tall' | 'extra_tall'> | optional | Row height / density setting |
| grouping | { fields: object[] } | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) |
| rowColor | { field: string; colors?: Record<string, string> } | optional | Color rows based on field value |
| hiddenFields | string[] | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition columns x hiddenFields x fieldOrder: each name listed here is removed from the columns projection before fieldOrder orders the remainder. A name columns never projected subtracts nothing. |
| fieldOrder | string[] | optional | Explicit field display order for this view — the ORDERING of the per-view field composition columns x hiddenFields x fieldOrder: it sorts what survives columns minus hiddenFields and never adds a field. A surviving column absent from fieldOrder sorts LAST, after every listed one, keeping its columns-relative order; a name listed here that did not survive orders nothing. |
| rowActions | string[] | optional | Actions available for individual row items |
| bulkActions | string[] | optional | Actions available when multiple rows are selected |
| bulkActionDefs | { name: string; label?: string; icon?: string; variant?: Enum<'primary' | 'secondary' | 'danger' | 'ghost' | 'outline'>; … }[] | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a patch / 'delete') that no action expresses, or for an operation: 'custom' + execution: 'aggregate' entry that dispatches the action it NAMES once for the whole selection — the renderer injects params._selectedIds: string[] (read that on the server, not recordId) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate 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 on defs whose server work is expensive. For the PER-RECORD dispatch use bulkActions: ['<name>'] instead — the bare-string form, promoted with the action's own label, params and visible; a 'custom' def without execution: 'aggregate' has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via ${ctx.selection.ids} / ${ctx.selection.count}. |
| conditionalFormatting | { condition: string | object; style: Record<string, string> }[] | optional | Conditional formatting rules for list rows |
| inlineEdit | boolean | optional | Allow inline editing of records directly in the list view |
| exportOptions | Enum<'csv' | 'xlsx' | 'json'>[] | { formats?: Enum<'csv' | 'xlsx' | 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … } | optional | Export configuration for the list toolbar export menu: { formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }. A bare format array is the legacy spelling and lifts to { formats: [...] } at parse. |
| userActions | { sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … } | optional | User action toggles for the view toolbar |
| appearance | { showDescription?: boolean; allowedVisualizations?: Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'>[] } | optional | Appearance and visualization configuration |
| tabs | never | optional | [REMOVED] view.list.tabs was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's listViews instead: the tab's name becomes the entry's key, its label the entry's label, and its filter rules join the view's own filter on that entry (copy the view's columns too); a tab whose view already named a list view needs nothing more. Every listViews entry renders as a tab in the switcher. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| addRecord | { enabled?: boolean; position?: Enum<'top' | 'bottom' | 'both'>; mode?: Enum<'inline' | 'form' | 'modal'>; formView?: string } | optional | Add record entry point configuration |
| showRecordCount | boolean | optional | Show record count at the bottom of the list |
| allowPrinting | boolean | optional | Allow users to print the view |
| emptyState | { title?: string | Record<string, string>; message?: string | Record<string, string>; icon?: string; link?: object } | optional | Empty state configuration when no records found |
| aria | { ariaLabel?: string | Record<string, string>; ariaDescribedBy?: string; role?: string } | optional | ARIA accessibility attributes for the list view |
| responsive | never | optional | [REMOVED] view.responsive was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| performance | never | optional | [REMOVED] view.performance was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| striped | never | optional | [REMOVED] view.striped was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| bordered | never | optional | [REMOVED] view.bordered was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| virtualScroll | never | optional | [REMOVED] view.virtualScroll was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via pagination. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| userFilters | { element?: Enum<'dropdown' | 'toggle'>; fields?: object[] } | optional |
Nested Shape: ObjectListView.data[provider='object']
| Property | Type | Required | Description |
|---|---|---|---|
| provider | 'object' | ✅ | |
| object | string | ✅ | Target object name |
Nested Shape: ObjectListView.data[provider='value']
| Property | Type | Required | Description |
|---|---|---|---|
| provider | 'value' | ✅ | |
| items | any[] | ✅ | Static data array |
Nested Shape: ObjectListView.data[provider='schema']
| Property | Type | Required | Description |
|---|---|---|---|
| provider | 'schema' | ✅ | |
| schemaId | string | ✅ | Schema identifier — typically the metadata type name |
| schema | Record<string, any> | optional | Inline JSON Schema (Draft 2020-12). Optional when schemaId is resolvable. |
Nested Shape: ObjectListView.columns[number]
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Field name (snake_case) |
| label | string | Record<string, string> | optional | Display label override |
| width | number | optional | Column width in pixels |
| align | Enum<'left' | 'center' | 'right'> | optional | Text alignment |
| hidden | boolean | optional | Hide column by default |
| sortable | boolean | optional | Allow sorting by this column |
| resizable | boolean | optional | Allow resizing this column |
| wrap | boolean | optional | Allow text wrapping |
| type | string | optional | Renderer type override (e.g., "currency", "date") |
| pinned | Enum<'left' | 'right'> | optional | Pin/freeze column to left or right side |
| summary | Enum<'none' | 'count' | 'count_empty' | 'count_filled' | 'count_unique' | …> | { type: Enum<'none' | 'count' | 'count_empty' | 'count_filled' | 'count_unique' | …>; field?: string } | optional | Footer aggregation for this column — the function alone, or { type, field } to aggregate another field |
| prefix | { field: string; type?: Enum<'badge' | 'text'> } | optional | Field rendered inline before this cell value |
| link | boolean | optional | Functions as the primary navigation link (triggers View navigation) |
| action | string | optional | Registered Action ID to execute when clicked |
Nested Shape: ObjectListView.filter[number]
View filter rule
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Field name to filter on |
| operator | Enum<'equals' | 'not_equals' | 'contains' | 'not_contains' | 'icontains' | …> | ✅ | Filter operator |
| value | string | number | boolean | null | (string | number)[] | optional | Filter value. The accepted SHAPE depends on the operator: in / not_in take an array (any length, including []), between takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: icontains takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. |
Nested Shape: ObjectListView.selection
| Property | Type | Required | Description |
|---|---|---|---|
| type | Enum<'none' | 'single' | 'multiple'> | optional (default: "none") | Selection mode |
Nested Shape: ObjectListView.navigation
| Property | Type | Required | Description |
|---|---|---|---|
| mode | Enum<'page' | 'drawer' | 'modal' | 'split' | 'popover' | 'new_window' | 'none'> | optional (default: "page") | |
| view | never | optional | [REMOVED] view.list.navigation.view was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the form view to open for a record detail, and no layer resolved a view by that name: the value was passed straight into the navigation-MODE argument of the console's onNavigate, where anything other than edit or view matched no branch, so the key selected nothing and could silently deaden the row click. Delete the key; to choose what opens for a record, assign a record page to the object and let isDefault pick the one that opens — page assignment is the machinery that resolves a detail layout, and a list view's navigation block only decides HOW the detail is surfaced (mode, size). |
| preventNavigation | boolean | optional (default: false) | Disable standard navigation entirely |
| openNewTab | boolean | optional (default: false) | Force open in new tab (applies to page mode) |
| size | Enum<'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full'> | optional (default: "auto") | Overlay size bucket for drawer/modal detail: 'auto' (default — renderer derives from field count + viewport; AI writes nothing) or a coarse override sm/md/lg/xl/full. Prefer this over the pixel width; page mode ignores it. |
| width | string | number | optional | [DEPRECATED → size] Pixel/percent width of the drawer/modal (e.g. "600px"). A pixel width cannot be chosen at authoring time without knowing the client viewport — use the size bucket. |
Nested Shape: ObjectListView.pagination
| Property | Type | Required | Description |
|---|---|---|---|
| pageSize | integer | optional (default: 50) | Number of records per page. On a view with no pager (kanban, gallery, timeline) it is the fetch ceiling, and the renderer owes two things: bound its fetch at this number, and, when the filtered set is larger than it, show a visible truncation signal saying what is on screen is not the whole set |
| pageSizeOptions | integer[] | optional | Available page size options |
Nested Shape: ObjectListView.kanban
| Property | Type | Required | Description |
|---|---|---|---|
| groupByField | string | ✅ | Field to group columns by (usually status/select). NO leading or trailing whitespace: the board reads this name off every card verbatim, so a padded spelling collapses the whole board into one Uncategorized lane. |
| summarizeField | string | optional | Field to sum at top of column (e.g. amount) |
| titleField | string | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) |
| columns | string[] | ✅ | Fields to show on cards |
Nested Shape: ObjectListView.calendar
| Property | Type | Required | Description |
|---|---|---|---|
| startDateField | string | ✅ | Field providing the event start date/time |
| endDateField | string | optional | Field providing the event end date/time (defaults to a single-day event) |
| titleField | string | optional | Field displayed as the event title. Omit to fall back to the record display name (ADR-0079 resolver chain) |
| colorField | string | optional | Field to derive each event color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the calendar theme-aware palette color hashed from the value |
| allDayField | string | optional | Field carrying the all-day flag for each event (names a boolean field, not a value): a record whose flag is true is drawn as an all-day band rather than at a clock time, and one whose flag is absent or false is not all-day. Omit to leave the renderer inference in place — an event with no end date draws as all-day |
Nested Shape: ObjectListView.gantt
| Property | Type | Required | Description |
|---|---|---|---|
| startDateField | string | ✅ | Field providing the task start date |
| endDateField | string | ✅ | Field providing the task end date |
| titleField | string | ✅ | Field displayed as the task title |
| progressField | string | optional | Field providing the task completion percentage |
| dependenciesField | string | optional | Field listing the task's predecessor (dependency) record ids |
| colorField | string | optional | Field to derive each bar color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else a semantic color token derived from the value |
| parentField | string | optional | Field holding the parent task id (builds the summary → step tree) |
| typeField | string | optional | Field whose value maps to task/summary/milestone |
| baselineStartField | string | optional | Baseline (planned) start field |
| baselineEndField | string | optional | Baseline (planned) end field |
| groupByField | string | optional | Field to group leaf tasks by (synthesized summary rows). NO leading or trailing whitespace: the group accessor reads this name off every task verbatim, so a padded spelling drops every task into one ungrouped bucket. |
| resourceView | boolean | optional | Render a per-resource workload histogram instead of the timeline |
| assigneeField | string | optional | Resource field to bucket load by (resource view) |
| effortField | string | optional | Per-task load units (resource view; default 1) |
| capacity | number | optional | Per-resource capacity ceiling; loads above this flag overload |
| tooltipFields | (string | { field: string; label?: string })[] | optional | Fields to surface in the hover tooltip, in display order |
| quickFilters | { field: string; label?: string; options?: (string | object)[] }[] | optional | Multi-select filter dropdowns rendered above the chart |
| autoZoomToFilter | boolean | optional | When true (default), filtering zooms the range to the filtered tasks |
| viewMode | Enum<'day' | 'week' | 'month' | 'quarter' | 'year'> | optional | Timeline granularity — one column per day/week/month/quarter/year (also the resource-view column granularity; renderer default 'day') |
| borderColorField | string | optional | Field carrying a per-task alert stroke color — any CSS color or semantic palette name (red, orange, …): the bar keeps its fill and gains an outline plus halo in that color (e.g. red for overdue, orange for due-soon; typically a server-computed alert field). Empty or null means no stroke |
| lockField | string | optional | Field marking a row view-only (truthy means locked): a locked bar cannot be dragged or resized, its progress cannot be dragged, no dependency can be drawn from it and its inline-edit and edit/delete menu entries are hidden — clicking it (open drawer, jump) still works. Independent of the global readOnly; freezes individual levels while siblings stay editable |
| objectField | string | optional | Field carrying the row's OWN object API name, for mixed-object trees (a row set composing parent-object rows with child-object rows): the detail drawer and its full-page link follow each row's real object instead of the view's bound object. Empty or missing falls back to the bound object |
| summaryExtent | Enum<'children' | 'self'> | optional | How a summary bar's span is computed. 'children' (renderer default) rolls the bar up from its children — min start, max end, duration-weighted progress — and ignores the record's own dates; 'self' renders the record's OWN start, end and progress and falls back to rollup only for records without dates (use it when the parent's schedule is authoritative, e.g. a shift plan whose work-order children are locked history) |
| defaultCollapsedDepth | integer | optional | Auto-collapse tree nodes at or below this 0-indexed depth on first render (roots are depth 0): every node at that depth or deeper that has children starts folded; the user can still expand them. Omit to start fully expanded |
| dependencyTypes | boolean | optional | Whether the backing store persists dependency link TYPES (fs, ss, ff, sf); renderer default true. Set false when dependencies are bare predecessor ids: the link menu hides the type switcher (a switch would be silently reverted on refetch) and drag-created links are always finish-to-start |
| timeZone | string | optional | Business time zone, an IANA name such as 'Asia/Shanghai': the chart's calendar — shift bands, day columns, snapping, the today line, date labels — renders in this zone's wall time for every viewer instead of the browser's zone; a datetime value is still written as the real instant, and a date value as the calendar day it was dropped on in this zone's calendar (YYYY-MM-DD). An invalid name falls back to the browser zone with a console warning |
| exportFileName | string | optional | Base name for exported PNG and PDF files (e.g. the view's display label — the host's view schema often reaches the renderer stripped of label); falls back to the object schema label, then the object API name. A timestamp suffix is always appended |
| interactions | { move?: boolean; resize?: boolean; progress?: boolean; link?: boolean } | optional | Per-interaction switches, each defaulting to true: allow bar moves but pin durations (resize: false), or keep the dependency UI read-only (link: false). They only narrow what readOnly and row locks already allow |
| timeSegments | { dayStart?: string; bands: object[]; showMidnight?: boolean } | optional | Shift segmentation for the day-mode timeline: splits each shift-day (starting at dayStart) into the configured bands — a two-tier header (date over band), per-band tints and drag/resize snapping to band boundaries. No shift concept is hardcoded; bands are pure config. Off when omitted |
Nested Shape: ObjectListView.gallery
| Property | Type | Required | Description |
|---|---|---|---|
| coverField | string | optional | Attachment/image field to display as card cover |
| coverFit | Enum<'cover' | 'contain'> | optional (default: "cover") | Image fit mode for card cover |
| cardSize | Enum<'small' | 'medium' | 'large'> | optional (default: "medium") | Card size in gallery view |
| titleField | string | optional | Field to display as card title |
| visibleFields | string[] | optional | Fields to display on card body |
Nested Shape: ObjectListView.timeline
| Property | Type | Required | Description |
|---|---|---|---|
| startDateField | string | ✅ | Field for timeline item start date |
| endDateField | string | optional | Field for timeline item end date |
| titleField | string | ✅ | Field to display as timeline item title |
| groupByField | string | optional | Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this name off every row verbatim, so a padded spelling drops every row into one ungrouped band. |
| colorField | string | optional | Field to derive each item color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the timeline default marker color |
| scale | Enum<'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'> | optional (default: "week") | Default timeline scale |
Nested Shape: ObjectListView.chart
| Property | Type | Required | Description |
|---|---|---|---|
| chartType | Enum<'bar' | 'line' | 'pie' | 'area' | 'scatter'> | optional (default: "bar") | Chart visualisation type |
| dataset | string | ✅ | Dataset name to bind (ADR-0021) |
| dimensions | string[] | optional | Dimension names — X/group/split |
| values | string[] | ✅ | Measure names — Y (at least one) |
Nested Shape: ObjectListView.map
| Property | Type | Required | Description |
|---|---|---|---|
| latitudeField | string | optional | Field providing the marker latitude (used with longitudeField) |
| longitudeField | string | optional | Field providing the marker longitude (used with latitudeField) |
| locationField | string | optional | Field providing a combined location — a "lat,lng" string or a { lat, lng } object — as the alternative to the latitudeField/longitudeField pair |
| titleField | string | optional | Field displayed as the marker title (popup heading, mobile record card, and what the map search box matches on) |
| descriptionField | string | optional | Field displayed as the marker description |
| zoom | number | optional | Initial zoom level (1-20). Omit to let the renderer fit the camera to the queried records |
| center | [number, number] | optional | Initial camera center as [latitude, longitude]. Omit to let the renderer fit the camera to the queried records |
| style | string | optional | Map style URL — the MapLibre style document the renderer loads in place of the public demo tiles. The component-level mapStyle is read FIRST and wins when both are present; this is NOT the inline CSS style record a component node carries |
Nested Shape: ObjectListView.tree
| Property | Type | Required | Description |
|---|---|---|---|
| parentField | string | optional | Single-parent pointer field (auto-detected from the object schema when omitted) |
| labelField | string | optional | Field rendered indented in the first column (defaults to "name") |
| fields | string[] | optional | Additional fields rendered as flat columns alongside the label |
| defaultExpandedDepth | integer | optional | Initial expansion depth (0 = roots only; omit = expand all) |
Nested Shape: ObjectListView.sharing
| Property | Type | Required | Description |
|---|---|---|---|
| type | Enum<'personal' | 'collaborative'> | optional (default: "collaborative") | View ownership type |
| lockedBy | string | optional | User who locked the view configuration |
Nested Shape: ObjectListView.grouping
| Property | Type | Required | Description |
|---|---|---|---|
| fields | { field: string; order?: Enum<'asc' | 'desc'>; collapsed?: boolean }[] | ✅ | Fields to group by, in nesting order — the first entry is the outermost group and each later entry nests one level deeper (at least one field); the same order as the group header query's groupBy |
Nested Shape: ObjectListView.rowColor
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Field whose value is looked up in the colors map below to pick a row colour (typically a select/status field). The map is what does the colouring — with no colors, no row is ever coloured, whatever this field holds. Author-time diagnostic view/row-color-without-colors reports that combination. |
| colors | Record<string, string> | optional | Map of field value to row colour. The spellings that actually paint a row are not free-form: objectui plugin-grid's useRowColor hands a value already written as a complete Tailwind background class (bg-red-200) straight through, otherwise lower-cases and trims it and resolves it through its own closed vocabulary of colour NAMES (red, blue, slate, … each mapping to bg-NAME-100), and returns undefined for anything else. A hex, an rgb() or a CSS variable parses here, publishes, and colours no row — Tailwind v4 has no runtime, so no class can be fabricated from one. Author-time diagnostic view/row-color-unresolvable-value reports a value that cannot resolve. |
Nested Shape: ObjectListView.bulkActionDefs[number]
| Property | Type | Required | Description |
|---|---|---|---|
| name | string | ✅ | Stable identifier — the audit-log action key, and (for an aggregate def) the name of the object action to dispatch. |
| label | string | optional | Button + dialog-header text. Plain string: an authored def is not i18n-resolved (declare a real action and name it in bulkActions to get localization). |
| icon | string | optional | Lucide icon name (e.g. "user-check", "trash"). |
| variant | Enum<'primary' | 'secondary' | 'danger' | 'ghost' | 'outline'> | optional | Visual treatment of the button. |
| operation | Enum<'update' | 'delete' | 'custom'> | ✅ | What the executor does: 'update'/'delete' are data-plane mass mutations; 'custom' dispatches an object action (see execution). |
| execution | Enum<'perRecord' | 'aggregate'> | optional | For operation: 'custom' — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in params._selectedIds. Required on a custom def: the per-record form is declared as bulkActions: ['<name>'] instead. |
| patch | Record<string, any> | optional | For operation: 'update' — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. |
| params | { name: string; label?: string; help?: string; type: Enum<'text' | 'textarea' | 'email' | 'url' | 'phone' | 'password' | 'secret' | …>; … }[] | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. |
| confirmText | string | optional | Confirmation text shown above the affected-record summary. |
| confirmLabel | string | optional | Custom Confirm button label (default: "Run"). |
| visible | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } | optional | Eligibility predicate (CEL) — a string or a {dialect, source} envelope, i.e. action.visible without its boolean-literal arm: a per-record predicate has nothing to say as a constant. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (features.x, current_user.y) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. |
| requiredPermissions | string[] | optional | [ADR-0066 D4] Capability gate on the button, action.requiredPermissions semantics verbatim: absent or empty always passes, several are AND-ed, and a client that cannot resolve the caller's capabilities fails OPEN (the server stays the authority). This key exists for INLINE defs — notably the update/delete data-plane forms, which dispatch no action and so have nothing to inherit a gate from; a def promoted from bulkActions: ['<name>'] (or an aggregate def naming a declared action) inherits the action's own declaration instead. On a data-plane def the gate governs visibility only — the write itself is still authorized by the data API's object permissions and server hooks. |
| maxRecords | integer | optional | Selection size above which the run is blocked. Set it on defs whose server work is expensive — an aggregate def carries every selected id in one request. |
| batchSize | integer | optional | Records per executor batch (default 200). Data-plane operations only — an aggregate run is a single call by definition. |
Nested Shape: ObjectListView.conditionalFormatting[number]
| Property | Type | Required | Description |
|---|---|---|---|
| condition | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } | ✅ | Predicate (CEL) to evaluate. |
| style | Record<string, string> | ✅ | CSS styles to apply when condition is true |
Nested Shape: ObjectListView.exportOptions
| Property | Type | Required | Description |
|---|---|---|---|
| formats | Enum<'csv' | 'xlsx' | 'json'>[] | optional | Formats offered in the export menu (default: ['csv', 'json']). XLSX is delivered by the server stream only. |
| maxRecords | integer | optional | Maximum number of records to export; 0 or absent = unlimited |
| includeHeaders | boolean | optional | Include column headers in the exported file (default true) |
| fileNamePrefix | string | optional | Download file name prefix — replaces the object label and suppresses the view label in the generated file name |
| streaming | boolean | optional | Set false to force the client-side export path (csv/json only) instead of the server stream |
Nested Shape: ObjectListView.userActions
| Property | Type | Required | Description |
|---|---|---|---|
| sort | boolean | optional (default: true) | Allow users to sort records |
| search | boolean | optional (default: true) | Allow users to search records |
| filter | boolean | optional (default: true) | Allow users to filter records |
| refresh | boolean | optional (default: true) | Allow users to reload the view data from the backend without a full page reload |
| rowHeight | boolean | optional (default: true) | Allow users to toggle row height/density |
| group | boolean | optional (default: true) | Allow users to change record grouping from the toolbar. Toggle only — the grouping itself is configured in the view-level grouping block. |
| addRecordForm | boolean | optional (default: false) | Add records through a form instead of inline |
| editInline | boolean | optional (default: true) | Allow users to edit records inline. On by default: a user who may update the object edits a cell in place with the field's type-aware widget (the same control the form uses), under the permission gate that already decides whether the object is editable at all. Declare editInline: false to make the list read-only in place. |
| hideFields | boolean | optional (default: false) | Allow users to hide/show fields from the toolbar (the affordance behind the view-level hiddenFields list). Boolean toggle — distinct from the record-details component's hideFields, which is an array of field names to omit. Off by default: column hiding is opt-in. |
| rowColor | boolean | optional (default: false) | Allow users to configure row colouring from the toolbar. Boolean toggle — the colour rules themselves live in the view-level rowColor block. Off by default: row colouring is opt-in. |
| buttons | string[] | optional | Custom action button IDs to show in the toolbar |
Nested Shape: ObjectListView.appearance
| Property | Type | Required | Description |
|---|---|---|---|
| showDescription | boolean | optional (default: true) | Show the view description text |
| allowedVisualizations | Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'>[] | optional | Whitelist of visualization types users can switch between (e.g. ["grid", "gallery", "kanban"]) |
Nested Shape: ObjectListView.addRecord
| Property | Type | Required | Description |
|---|---|---|---|
| enabled | boolean | optional (default: true) | Show the add record entry point |
| position | Enum<'top' | 'bottom' | 'both'> | optional (default: "bottom") | Position of the add record button |
| mode | Enum<'inline' | 'form' | 'modal'> | optional (default: "inline") | How to add a new record |
| formView | string | optional | Named form view to use when mode is "form" or "modal" |
Nested Shape: ObjectListView.emptyState
| Property | Type | Required | Description |
|---|---|---|---|
| title | string | Record<string, string> | optional | Heading of the empty state |
| message | string | Record<string, string> | optional | Line of text below the heading |
| icon | string | optional | Icon name drawn above the heading; a name that resolves to no icon draws the default empty-state glyph |
| link | { label: string | Record<string, string>; url: string } | optional | The one next step an empty list names: a link the user follows, { label, url } — for example to the how-to page that explains what belongs here when nobody can add a row. It opens a page or an address and runs no action. The console draws it in a list's empty state (the grid and gallery visualizations) and in an object-grid block's, beside the add-record action where one is offered. |
Nested Shape: ObjectListView.aria
| Property | Type | Required | Description |
|---|---|---|---|
| ariaLabel | string | Record<string, string> | optional | Accessible label for screen readers (WAI-ARIA aria-label). Plain string, or an inline locale map — no translation-bundle slot addresses this key, so a plain string is announced in the source language. |
| ariaDescribedBy | string | optional | ID of element providing additional description (WAI-ARIA aria-describedby) |
| role | string | optional | WAI-ARIA role attribute (e.g., "dialog", "navigation", "alert") |
Nested Shape: ObjectListView.userFilters
| Property | Type | Required | Description |
|---|---|---|---|
| element | Enum<'dropdown' | 'toggle'> | optional (default: "dropdown") | Filter control style on object views: "dropdown" (per-field value chips). "toggle" is deprecated. "tabs" is page-only — use listViews for named presets. |
| fields | { field: string; label?: string | Record<string, string>; type?: Enum<'select' | 'multi-select' | 'boolean' | 'date-range' | 'text'>; options?: object[]; … }[] | optional | Fields exposed as quick filters (dropdown/toggle elements) |
ObjectUserFilters
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| element | Enum<'dropdown' | 'toggle'> | optional (default: "dropdown") | Filter control style on object views: "dropdown" (per-field value chips). "toggle" is deprecated. "tabs" is page-only — use listViews for named presets. |
| fields | { field: string; label?: string | Record<string, string>; type?: Enum<'select' | 'multi-select' | 'boolean' | 'date-range' | 'text'>; options?: object[]; … }[] | optional | Fields exposed as quick filters (dropdown/toggle elements) |
Nested Shape: ObjectUserFilters.fields[number]
Quick-filter field configuration
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Field name on the source object (must exist — checked by reference diagnostics) |
| label | string | Record<string, string> | optional | Display label override (defaults to the field label) |
| type | Enum<'select' | 'multi-select' | 'boolean' | 'date-range' | 'text'> | optional | Filter control type. Omit to infer from the field definition |
| options | { value: string | number | boolean; label: string | Record<string, string>; color?: string }[] | optional | Static options. Omit to derive from the field definition (select options / lookup records) |
| showCount | boolean | optional | Show per-option record counts |
| defaultValues | (string | number | boolean)[] | optional | Pre-selected values when the view loads |
PaginationConfig
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| pageSize | integer | optional (default: 50) | Number of records per page. On a view with no pager (kanban, gallery, timeline) it is the fetch ceiling, and the renderer owes two things: bound its fetch at this number, and, when the filtered set is larger than it, show a visible truncation signal saying what is on screen is not the whole set |
| pageSizeOptions | integer[] | optional | Available page size options |
RowColorConfig
Row color configuration based on field values
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Field whose value is looked up in the colors map below to pick a row colour (typically a select/status field). The map is what does the colouring — with no colors, no row is ever coloured, whatever this field holds. Author-time diagnostic view/row-color-without-colors reports that combination. |
| colors | Record<string, string> | optional | Map of field value to row colour. The spellings that actually paint a row are not free-form: objectui plugin-grid's useRowColor hands a value already written as a complete Tailwind background class (bg-red-200) straight through, otherwise lower-cases and trims it and resolves it through its own closed vocabulary of colour NAMES (red, blue, slate, … each mapping to bg-NAME-100), and returns undefined for anything else. A hex, an rgb() or a CSS variable parses here, publishes, and colours no row — Tailwind v4 has no runtime, so no class can be fabricated from one. Author-time diagnostic view/row-color-unresolvable-value reports a value that cannot resolve. |
RowHeight
Row height / density setting for list view
Allowed Values
compactshortmediumtallextra_tall
SelectionConfig
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| type | Enum<'none' | 'single' | 'multiple'> | optional (default: "none") | Selection mode |
TimelineConfig
Timeline view configuration
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| startDateField | string | ✅ | Field for timeline item start date |
| endDateField | string | optional | Field for timeline item end date |
| titleField | string | ✅ | Field to display as timeline item title |
| groupByField | string | optional | Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this name off every row verbatim, so a padded spelling drops every row into one ungrouped band. |
| colorField | string | optional | Field to derive each item color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the timeline default marker color |
| scale | Enum<'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'> | optional (default: "week") | Default timeline scale |
TreeConfig
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| parentField | string | optional | Single-parent pointer field (auto-detected from the object schema when omitted) |
| labelField | string | optional | Field rendered indented in the first column (defaults to "name") |
| fields | string[] | optional | Additional fields rendered as flat columns alongside the label |
| defaultExpandedDepth | integer | optional | Initial expansion depth (0 = roots only; omit = expand all) |
UserActionsConfig
User action toggles for the view toolbar
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| sort | boolean | optional (default: true) | Allow users to sort records |
| search | boolean | optional (default: true) | Allow users to search records |
| filter | boolean | optional (default: true) | Allow users to filter records |
| refresh | boolean | optional (default: true) | Allow users to reload the view data from the backend without a full page reload |
| rowHeight | boolean | optional (default: true) | Allow users to toggle row height/density |
| group | boolean | optional (default: true) | Allow users to change record grouping from the toolbar. Toggle only — the grouping itself is configured in the view-level grouping block. |
| addRecordForm | boolean | optional (default: false) | Add records through a form instead of inline |
| editInline | boolean | optional (default: true) | Allow users to edit records inline. On by default: a user who may update the object edits a cell in place with the field's type-aware widget (the same control the form uses), under the permission gate that already decides whether the object is editable at all. Declare editInline: false to make the list read-only in place. |
| hideFields | boolean | optional (default: false) | Allow users to hide/show fields from the toolbar (the affordance behind the view-level hiddenFields list). Boolean toggle — distinct from the record-details component's hideFields, which is an array of field names to omit. Off by default: column hiding is opt-in. |
| rowColor | boolean | optional (default: false) | Allow users to configure row colouring from the toolbar. Boolean toggle — the colour rules themselves live in the view-level rowColor block. Off by default: row colouring is opt-in. |
| buttons | string[] | optional | Custom action button IDs to show in the toolbar |
UserFilterField
Quick-filter field configuration
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Field name on the source object (must exist — checked by reference diagnostics) |
| label | string | Record<string, string> | optional | Display label override (defaults to the field label) |
| type | Enum<'select' | 'multi-select' | 'boolean' | 'date-range' | 'text'> | optional | Filter control type. Omit to infer from the field definition |
| options | { value: string | number | boolean; label: string | Record<string, string>; color?: string }[] | optional | Static options. Omit to derive from the field definition (select options / lookup records) |
| showCount | boolean | optional | Show per-option record counts |
| defaultValues | (string | number | boolean)[] | optional | Pre-selected values when the view loads |
Nested Shape: UserFilterField.options[number]
| Property | Type | Required | Description |
|---|---|---|---|
| value | string | number | boolean | ✅ | Option value |
| label | string | Record<string, string> | ✅ | Option label |
| color | string | optional | Option color token/hex |
UserFilters
End-user quick-filter configuration (Airtable "User filters" parity)
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| element | Enum<'dropdown' | 'tabs' | 'toggle'> | optional (default: "dropdown") | Filter control style: "dropdown" (per-field value selectors) or "tabs" (named presets). "toggle" is deprecated. |
| fields | { field: string; label?: string | Record<string, string>; type?: Enum<'select' | 'multi-select' | 'boolean' | 'date-range' | 'text'>; options?: object[]; … }[] | optional | Fields exposed as quick filters (dropdown/toggle elements) |
| tabs | { name: string; label?: string | Record<string, string>; icon?: string; view?: string; … }[] | optional | Named filter presets rendered as tabs (tabs element). Reuses ViewTabSchema |
| showAllRecords | boolean | optional | Show an "All records" tab before the presets (tabs element) |
| allowAddTab | boolean | optional | Let end users add their own tab after the presets (tabs element): the affordance asks for a name and snapshots the filters currently applied as a new tab. SESSION-SCOPED — an added tab lives only for the current mount, is never written back as metadata (ADR-0047), and carries a remove control the authored presets do not. Page lists only — object views use listViews for named presets |
Nested Shape: UserFilters.fields[number]
Quick-filter field configuration
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Field name on the source object (must exist — checked by reference diagnostics) |
| label | string | Record<string, string> | optional | Display label override (defaults to the field label) |
| type | Enum<'select' | 'multi-select' | 'boolean' | 'date-range' | 'text'> | optional | Filter control type. Omit to infer from the field definition |
| options | { value: string | number | boolean; label: string | Record<string, string>; color?: string }[] | optional | Static options. Omit to derive from the field definition (select options / lookup records) |
| showCount | boolean | optional | Show per-option record counts |
| defaultValues | (string | number | boolean)[] | optional | Pre-selected values when the view loads |
Nested Shape: UserFilters.tabs[number]
Tab configuration for multi-tab view interface
| Property | Type | Required | Description |
|---|---|---|---|
| name | string | ✅ | Tab identifier (snake_case) |
| label | string | Record<string, string> | optional | Display label |
| icon | string | optional | Tab icon name |
| view | string | optional | Referenced list view name from listViews |
| filter | { field: string; operator: Enum<'equals' | 'not_equals' | 'contains' | 'not_contains' | 'icontains' | …>; value?: string | number | boolean | null | (string | number)[] }[] | optional | Tab-specific filter criteria |
| order | integer | optional | Tab display order |
| pinned | boolean | optional (default: false) | Pin tab (cannot be removed by users) |
| isDefault | boolean | optional (default: false) | Set as the default active tab |
| visible | boolean | optional (default: true) | Tab visibility |
View
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| name | string | optional | Item name — supplied by the metadata door; for an object-scoped container it is the object name. |
| label | string | Record<string, string> | optional | Human-readable label shown in metadata lists. |
| object | string | optional | Object this container binds to — how a stack-level views: [...] entry says which object its views belong to; read by getViewsByObject() / GET /meta/view?object=. |
| list | { name?: string; label?: string | Record<string, string>; type?: Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'>; data?: object | object | object; … } | optional | |
| form | { type?: Enum<'simple' | 'tabbed' | 'wizard' | 'split' | 'drawer' | 'modal'>; layout?: Enum<'vertical' | 'horizontal'>; columns?: integer; title?: string; … } | optional | |
| listViews | Record<string, { name?: string; label?: string | Record<string, string>; type?: Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'>; data?: object | object | object; … }> | optional | Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047) |
| formViews | Record<string, { type?: Enum<'simple' | 'tabbed' | 'wizard' | 'split' | 'drawer' | 'modal'>; layout?: Enum<'vertical' | 'horizontal'>; columns?: integer; title?: string; … }> | optional | Additional named form views |
| protection | { lock: Enum<'none' | 'no-overlay' | 'no-delete' | 'full'>; reason: string; docsUrl?: string } | optional | Package author protection block — lock policy for this view. |
| _lock | Enum<'none' | 'no-overlay' | 'no-delete' | 'full'> | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| _lockReason | string | optional | Human-readable reason shown when a write is refused by _lock. |
| _lockSource | Enum<'artifact' | 'package' | 'env-forced'> | optional | Layer that set _lock (artifact | package | env-forced). |
| _provenance | Enum<'package' | 'org' | 'env-forced'> | optional | Origin of the item (package | org | env-forced). |
| _packageId | string | optional | Owning package machine id. |
| _packageVersion | string | optional | Owning package version. |
| _lockDocsUrl | string | optional | Optional documentation link surfaced next to _lockReason. |
Nested Shape: View.list
| Property | Type | Required | Description |
|---|---|---|---|
| name | string | optional | Internal view name (lowercase snake_case) |
| label | string | Record<string, string> | optional | Display label — the default-language string, or an inline locale map ({ en, "zh-CN" }) resolved at render time |
| type | Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'> | optional (default: "grid") | |
| data | { provider: 'object'; object: string } | { provider: 'value'; items: any[] } | { provider: 'schema'; schemaId: string; schema?: Record<string, any> } | optional | Data source configuration (defaults to "object" provider) |
| columns | string[] | { field: string; label?: string | Record<string, string>; width?: number; align?: Enum<'left' | 'center' | 'right'>; … }[] | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition columns x hiddenFields x fieldOrder: this list is the candidate set AND the baseline order; hiddenFields subtracts from it and fieldOrder orders what survives. hiddenFields and fieldOrder cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's ListView grid derives the object's default columns). |
| filter | { field: string; operator: Enum<'equals' | 'not_equals' | 'contains' | 'not_contains' | 'icontains' | …>; value?: string | number | boolean | null | (string | number)[] }[] | optional | Filter criteria (JSON Rules) |
| sort | { field: string; order: Enum<'asc' | 'desc'> }[] | optional | |
| searchableFields | string[] | optional | Fields enabled for search |
| filterableFields | string[] | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters |
| resizable | boolean | optional | Enable column resizing |
| compactToolbar | boolean | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover |
| selection | { type?: Enum<'none' | 'single' | 'multiple'> } | optional | Row selection configuration |
| navigation | { mode?: Enum<'page' | 'drawer' | 'modal' | 'split' | 'popover' | 'new_window' | 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full'>; … } | optional | Configuration for item click navigation (page, drawer, modal, etc.) |
| pagination | { pageSize?: integer; pageSizeOptions?: integer[] } | optional | Pagination configuration |
| kanban | { groupByField: string; summarizeField?: string; titleField?: string; columns: string[] } | optional | Kanban-board configuration — applies when the view renders as a kanban layout |
| calendar | { startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … } | optional | Calendar configuration — applies when the view renders as a calendar layout |
| gantt | { startDateField: string; endDateField: string; titleField: string; progressField?: string; … } | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout |
| gallery | { coverField?: string; coverFit?: Enum<'cover' | 'contain'>; cardSize?: Enum<'small' | 'medium' | 'large'>; titleField?: string; … } | optional | Gallery/card view configuration |
| timeline | { startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … } | optional | Timeline view configuration |
| chart | { chartType?: Enum<'bar' | 'line' | 'pie' | 'area' | 'scatter'>; dataset: string; dimensions?: string[]; values: string[] } | optional | Chart binding — applies when the view renders as a chart. A type: 'chart' view must bind one: it names the ADR-0021 dataset and the measures (values) the chart plots, and there is no default binding |
| map | { latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … } | optional | Map configuration — applies when the view renders as a map layout |
| tree | { parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout |
| pageName | never | optional | [REMOVED] view.pageName was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a type: 'page' view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — { type: 'page', pageName: '<page_name>' } under the app's navigation — which is a different key on a different surface and is the page mount that has always rendered. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| description | string | Record<string, string> | optional | View description for documentation/tooltips |
| sharing | { type?: Enum<'personal' | 'collaborative'>; lockedBy?: string } | optional | View sharing and access configuration |
| requiredPermissions | string[] | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through systemPermissions, the same list as requiredPermissions on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. |
| rowHeight | Enum<'compact' | 'short' | 'medium' | 'tall' | 'extra_tall'> | optional | Row height / density setting |
| grouping | { fields: object[] } | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) |
| rowColor | { field: string; colors?: Record<string, string> } | optional | Color rows based on field value |
| hiddenFields | string[] | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition columns x hiddenFields x fieldOrder: each name listed here is removed from the columns projection before fieldOrder orders the remainder. A name columns never projected subtracts nothing. |
| fieldOrder | string[] | optional | Explicit field display order for this view — the ORDERING of the per-view field composition columns x hiddenFields x fieldOrder: it sorts what survives columns minus hiddenFields and never adds a field. A surviving column absent from fieldOrder sorts LAST, after every listed one, keeping its columns-relative order; a name listed here that did not survive orders nothing. |
| rowActions | string[] | optional | Actions available for individual row items |
| bulkActions | string[] | optional | Actions available when multiple rows are selected |
| bulkActionDefs | { name: string; label?: string; icon?: string; variant?: Enum<'primary' | 'secondary' | 'danger' | 'ghost' | 'outline'>; … }[] | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a patch / 'delete') that no action expresses, or for an operation: 'custom' + execution: 'aggregate' entry that dispatches the action it NAMES once for the whole selection — the renderer injects params._selectedIds: string[] (read that on the server, not recordId) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate 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 on defs whose server work is expensive. For the PER-RECORD dispatch use bulkActions: ['<name>'] instead — the bare-string form, promoted with the action's own label, params and visible; a 'custom' def without execution: 'aggregate' has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via ${ctx.selection.ids} / ${ctx.selection.count}. |
| conditionalFormatting | { condition: string | object; style: Record<string, string> }[] | optional | Conditional formatting rules for list rows |
| inlineEdit | boolean | optional | Allow inline editing of records directly in the list view |
| exportOptions | Enum<'csv' | 'xlsx' | 'json'>[] | { formats?: Enum<'csv' | 'xlsx' | 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … } | optional | Export configuration for the list toolbar export menu: { formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }. A bare format array is the legacy spelling and lifts to { formats: [...] } at parse. |
| userActions | { sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … } | optional | User action toggles for the view toolbar |
| appearance | { showDescription?: boolean; allowedVisualizations?: Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'>[] } | optional | Appearance and visualization configuration |
| tabs | never | optional | [REMOVED] view.list.tabs was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's listViews instead: the tab's name becomes the entry's key, its label the entry's label, and its filter rules join the view's own filter on that entry (copy the view's columns too); a tab whose view already named a list view needs nothing more. Every listViews entry renders as a tab in the switcher. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| addRecord | { enabled?: boolean; position?: Enum<'top' | 'bottom' | 'both'>; mode?: Enum<'inline' | 'form' | 'modal'>; formView?: string } | optional | Add record entry point configuration |
| showRecordCount | boolean | optional | Show record count at the bottom of the list |
| allowPrinting | boolean | optional | Allow users to print the view |
| emptyState | { title?: string | Record<string, string>; message?: string | Record<string, string>; icon?: string; link?: object } | optional | Empty state configuration when no records found |
| aria | { ariaLabel?: string | Record<string, string>; ariaDescribedBy?: string; role?: string } | optional | ARIA accessibility attributes for the list view |
| responsive | never | optional | [REMOVED] view.responsive was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| performance | never | optional | [REMOVED] view.performance was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| striped | never | optional | [REMOVED] view.striped was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| bordered | never | optional | [REMOVED] view.bordered was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| virtualScroll | never | optional | [REMOVED] view.virtualScroll was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via pagination. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| userFilters | { element?: Enum<'dropdown' | 'toggle'>; fields?: object[] } | optional |
Nested Shape: View.form
| Property | Type | Required | Description |
|---|---|---|---|
| type | Enum<'simple' | 'tabbed' | 'wizard' | 'split' | 'drawer' | 'modal'> | optional (default: "simple") | |
| layout | Enum<'vertical' | 'horizontal'> | optional | Field layout direction — 'vertical' (the renderer default) or 'horizontal'. Multi-column is not a layout value: set columns |
| columns | integer | optional | Number of columns for the form body |
| title | string | optional | Form title |
| description | string | optional | Form description |
| defaultTab | string | optional | Initially active tab (tabbed forms) |
| tabPosition | Enum<'top' | 'bottom' | 'left' | 'right'> | optional | Tab strip position (tabbed forms) |
| allowSkip | boolean | optional | Wizard step-gate opt-out: allow entering a later step without submitting the one before it. Navigation freedom, NOT a validation exemption — the final submit still re-validates every step and returns to the first failing one. Default (absent/false): steps unlock in array order as each prior step submits validly. UI admission only, never authorization. |
| showStepIndicator | boolean | optional | Show the wizard step indicator (renderer default: shown). Step progress (completed/current/upcoming/invalid) is derived from the step gate — this boolean is the only authorable progress surface. |
| splitDirection | Enum<'horizontal' | 'vertical'> | optional | Split orientation (split forms) |
| splitSize | number | optional | Primary split panel size, % (split forms) |
| splitResizable | boolean | optional | Whether the split is resizable (split forms) |
| drawerSide | Enum<'top' | 'bottom' | 'left' | 'right'> | optional | Drawer side (drawer forms) |
| drawerWidth | string | optional | [DEPRECATED → size buckets] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. |
| modalSize | Enum<'sm' | 'default' | 'lg' | 'xl' | 'full'> | optional | Modal size (modal forms) |
| data | { provider: 'object'; object: string } | { provider: 'value'; items: any[] } | { provider: 'schema'; schemaId: string; schema?: Record<string, any> } | optional | Data source configuration (defaults to "object" provider) |
| sections | { name?: string; label?: string | Record<string, string>; description?: string; collapsible?: boolean; … }[] | optional | |
| groups | { name?: string; label?: string | Record<string, string>; description?: string; collapsible?: boolean; … }[] | optional | [LEGACY ALIAS → sections] Accepted for back-compat and folded onto sections at parse; sections wins when both are present. Prefer sections. |
| subforms | { childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[] | optional | Inline master-detail child collections |
| defaultSort | never | optional | [REMOVED] form.defaultSort was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's sort. Delete the key and set the sort on the related list view instead. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| sharing | { enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … } | optional | Public sharing configuration for this form |
| submitBehavior | { kind: 'thank-you'; title?: string; message?: string } | { kind: 'redirect'; url: string; delayMs?: integer } | { kind: 'continue' } | { kind: 'next-record' } | optional | Post-submit behavior. On the redirect arm, url is relative-only and interpolates only declared record fields as {{record.field_name}}, URL-escaped. |
| buttons | { submit?: object; cancel?: object; reset?: object } | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. |
| defaults | Record<string, any> | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). |
| aria | never | optional | [REMOVED] form.aria was removed in @objectstack/spec 17.0.0 (audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
Nested Shape: View.listViews[string]
| Property | Type | Required | Description |
|---|---|---|---|
| name | string | optional | Internal view name (lowercase snake_case) |
| label | string | Record<string, string> | optional | Display label — the default-language string, or an inline locale map ({ en, "zh-CN" }) resolved at render time |
| type | Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'> | optional (default: "grid") | |
| data | { provider: 'object'; object: string } | { provider: 'value'; items: any[] } | { provider: 'schema'; schemaId: string; schema?: Record<string, any> } | optional | Data source configuration (defaults to "object" provider) |
| columns | string[] | { field: string; label?: string | Record<string, string>; width?: number; align?: Enum<'left' | 'center' | 'right'>; … }[] | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition columns x hiddenFields x fieldOrder: this list is the candidate set AND the baseline order; hiddenFields subtracts from it and fieldOrder orders what survives. hiddenFields and fieldOrder cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's ListView grid derives the object's default columns). |
| filter | { field: string; operator: Enum<'equals' | 'not_equals' | 'contains' | 'not_contains' | 'icontains' | …>; value?: string | number | boolean | null | (string | number)[] }[] | optional | Filter criteria (JSON Rules) |
| sort | { field: string; order: Enum<'asc' | 'desc'> }[] | optional | |
| searchableFields | string[] | optional | Fields enabled for search |
| filterableFields | string[] | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters |
| resizable | boolean | optional | Enable column resizing |
| compactToolbar | boolean | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover |
| selection | { type?: Enum<'none' | 'single' | 'multiple'> } | optional | Row selection configuration |
| navigation | { mode?: Enum<'page' | 'drawer' | 'modal' | 'split' | 'popover' | 'new_window' | 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full'>; … } | optional | Configuration for item click navigation (page, drawer, modal, etc.) |
| pagination | { pageSize?: integer; pageSizeOptions?: integer[] } | optional | Pagination configuration |
| kanban | { groupByField: string; summarizeField?: string; titleField?: string; columns: string[] } | optional | Kanban-board configuration — applies when the view renders as a kanban layout |
| calendar | { startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … } | optional | Calendar configuration — applies when the view renders as a calendar layout |
| gantt | { startDateField: string; endDateField: string; titleField: string; progressField?: string; … } | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout |
| gallery | { coverField?: string; coverFit?: Enum<'cover' | 'contain'>; cardSize?: Enum<'small' | 'medium' | 'large'>; titleField?: string; … } | optional | Gallery/card view configuration |
| timeline | { startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … } | optional | Timeline view configuration |
| chart | { chartType?: Enum<'bar' | 'line' | 'pie' | 'area' | 'scatter'>; dataset: string; dimensions?: string[]; values: string[] } | optional | Chart binding — applies when the view renders as a chart. A type: 'chart' view must bind one: it names the ADR-0021 dataset and the measures (values) the chart plots, and there is no default binding |
| map | { latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … } | optional | Map configuration — applies when the view renders as a map layout |
| tree | { parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout |
| pageName | never | optional | [REMOVED] view.pageName was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a type: 'page' view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — { type: 'page', pageName: '<page_name>' } under the app's navigation — which is a different key on a different surface and is the page mount that has always rendered. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| description | string | Record<string, string> | optional | View description for documentation/tooltips |
| sharing | { type?: Enum<'personal' | 'collaborative'>; lockedBy?: string } | optional | View sharing and access configuration |
| requiredPermissions | string[] | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through systemPermissions, the same list as requiredPermissions on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. |
| rowHeight | Enum<'compact' | 'short' | 'medium' | 'tall' | 'extra_tall'> | optional | Row height / density setting |
| grouping | { fields: object[] } | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) |
| rowColor | { field: string; colors?: Record<string, string> } | optional | Color rows based on field value |
| hiddenFields | string[] | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition columns x hiddenFields x fieldOrder: each name listed here is removed from the columns projection before fieldOrder orders the remainder. A name columns never projected subtracts nothing. |
| fieldOrder | string[] | optional | Explicit field display order for this view — the ORDERING of the per-view field composition columns x hiddenFields x fieldOrder: it sorts what survives columns minus hiddenFields and never adds a field. A surviving column absent from fieldOrder sorts LAST, after every listed one, keeping its columns-relative order; a name listed here that did not survive orders nothing. |
| rowActions | string[] | optional | Actions available for individual row items |
| bulkActions | string[] | optional | Actions available when multiple rows are selected |
| bulkActionDefs | { name: string; label?: string; icon?: string; variant?: Enum<'primary' | 'secondary' | 'danger' | 'ghost' | 'outline'>; … }[] | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a patch / 'delete') that no action expresses, or for an operation: 'custom' + execution: 'aggregate' entry that dispatches the action it NAMES once for the whole selection — the renderer injects params._selectedIds: string[] (read that on the server, not recordId) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate 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 on defs whose server work is expensive. For the PER-RECORD dispatch use bulkActions: ['<name>'] instead — the bare-string form, promoted with the action's own label, params and visible; a 'custom' def without execution: 'aggregate' has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via ${ctx.selection.ids} / ${ctx.selection.count}. |
| conditionalFormatting | { condition: string | object; style: Record<string, string> }[] | optional | Conditional formatting rules for list rows |
| inlineEdit | boolean | optional | Allow inline editing of records directly in the list view |
| exportOptions | Enum<'csv' | 'xlsx' | 'json'>[] | { formats?: Enum<'csv' | 'xlsx' | 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … } | optional | Export configuration for the list toolbar export menu: { formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }. A bare format array is the legacy spelling and lifts to { formats: [...] } at parse. |
| userActions | { sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … } | optional | User action toggles for the view toolbar |
| appearance | { showDescription?: boolean; allowedVisualizations?: Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'>[] } | optional | Appearance and visualization configuration |
| tabs | never | optional | [REMOVED] view.list.tabs was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's listViews instead: the tab's name becomes the entry's key, its label the entry's label, and its filter rules join the view's own filter on that entry (copy the view's columns too); a tab whose view already named a list view needs nothing more. Every listViews entry renders as a tab in the switcher. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| addRecord | { enabled?: boolean; position?: Enum<'top' | 'bottom' | 'both'>; mode?: Enum<'inline' | 'form' | 'modal'>; formView?: string } | optional | Add record entry point configuration |
| showRecordCount | boolean | optional | Show record count at the bottom of the list |
| allowPrinting | boolean | optional | Allow users to print the view |
| emptyState | { title?: string | Record<string, string>; message?: string | Record<string, string>; icon?: string; link?: object } | optional | Empty state configuration when no records found |
| aria | { ariaLabel?: string | Record<string, string>; ariaDescribedBy?: string; role?: string } | optional | ARIA accessibility attributes for the list view |
| responsive | never | optional | [REMOVED] view.responsive was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| performance | never | optional | [REMOVED] view.performance was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| striped | never | optional | [REMOVED] view.striped was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| bordered | never | optional | [REMOVED] view.bordered was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| virtualScroll | never | optional | [REMOVED] view.virtualScroll was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via pagination. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| userFilters | { element?: Enum<'dropdown' | 'toggle'>; fields?: object[] } | optional |
Nested Shape: View.formViews[string]
| Property | Type | Required | Description |
|---|---|---|---|
| type | Enum<'simple' | 'tabbed' | 'wizard' | 'split' | 'drawer' | 'modal'> | optional (default: "simple") | |
| layout | Enum<'vertical' | 'horizontal'> | optional | Field layout direction — 'vertical' (the renderer default) or 'horizontal'. Multi-column is not a layout value: set columns |
| columns | integer | optional | Number of columns for the form body |
| title | string | optional | Form title |
| description | string | optional | Form description |
| defaultTab | string | optional | Initially active tab (tabbed forms) |
| tabPosition | Enum<'top' | 'bottom' | 'left' | 'right'> | optional | Tab strip position (tabbed forms) |
| allowSkip | boolean | optional | Wizard step-gate opt-out: allow entering a later step without submitting the one before it. Navigation freedom, NOT a validation exemption — the final submit still re-validates every step and returns to the first failing one. Default (absent/false): steps unlock in array order as each prior step submits validly. UI admission only, never authorization. |
| showStepIndicator | boolean | optional | Show the wizard step indicator (renderer default: shown). Step progress (completed/current/upcoming/invalid) is derived from the step gate — this boolean is the only authorable progress surface. |
| splitDirection | Enum<'horizontal' | 'vertical'> | optional | Split orientation (split forms) |
| splitSize | number | optional | Primary split panel size, % (split forms) |
| splitResizable | boolean | optional | Whether the split is resizable (split forms) |
| drawerSide | Enum<'top' | 'bottom' | 'left' | 'right'> | optional | Drawer side (drawer forms) |
| drawerWidth | string | optional | [DEPRECATED → size buckets] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. |
| modalSize | Enum<'sm' | 'default' | 'lg' | 'xl' | 'full'> | optional | Modal size (modal forms) |
| data | { provider: 'object'; object: string } | { provider: 'value'; items: any[] } | { provider: 'schema'; schemaId: string; schema?: Record<string, any> } | optional | Data source configuration (defaults to "object" provider) |
| sections | { name?: string; label?: string | Record<string, string>; description?: string; collapsible?: boolean; … }[] | optional | |
| groups | { name?: string; label?: string | Record<string, string>; description?: string; collapsible?: boolean; … }[] | optional | [LEGACY ALIAS → sections] Accepted for back-compat and folded onto sections at parse; sections wins when both are present. Prefer sections. |
| subforms | { childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[] | optional | Inline master-detail child collections |
| defaultSort | never | optional | [REMOVED] form.defaultSort was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's sort. Delete the key and set the sort on the related list view instead. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| sharing | { enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … } | optional | Public sharing configuration for this form |
| submitBehavior | { kind: 'thank-you'; title?: string; message?: string } | { kind: 'redirect'; url: string; delayMs?: integer } | { kind: 'continue' } | { kind: 'next-record' } | optional | Post-submit behavior. On the redirect arm, url is relative-only and interpolates only declared record fields as {{record.field_name}}, URL-escaped. |
| buttons | { submit?: object; cancel?: object; reset?: object } | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. |
| defaults | Record<string, any> | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). |
| aria | never | optional | [REMOVED] form.aria was removed in @objectstack/spec 17.0.0 (audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
Nested Shape: View.protection
| Property | Type | Required | Description |
|---|---|---|---|
| lock | Enum<'none' | 'no-overlay' | 'no-delete' | 'full'> | ✅ | Lock policy — none | no-overlay | no-delete | full. |
| reason | string | ✅ | User-visible reason shown when the lock blocks an action. |
| docsUrl | string | optional | Optional URL the Studio banner links to for more context. |
ViewData
Union Options
This schema accepts one of the following structures:
Option 1
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| provider | 'object' | ✅ | |
| object | string | ✅ | Target object name |
Option 2
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| provider | 'value' | ✅ | |
| items | any[] | ✅ | Static data array |
Option 3
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| provider | 'schema' | ✅ | |
| schemaId | string | ✅ | Schema identifier — typically the metadata type name |
| schema | Record<string, any> | optional | Inline JSON Schema (Draft 2020-12). Optional when schemaId is resolvable. |
ViewFilterRule
View filter rule
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Field name to filter on |
| operator | Enum<'equals' | 'not_equals' | 'contains' | 'not_contains' | 'icontains' | 'starts_with' | 'ends_with' | 'greater_than' | 'less_than' | 'greater_than_or_equal' | … +10 more> | ✅ | Filter operator |
| value | string | number | boolean | null | (string | number)[] | optional | Filter value. The accepted SHAPE depends on the operator: in / not_in take an array (any length, including []), between takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: icontains takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. |
Allowed Values: ViewFilterRule.operator
equalsnot_equalscontainsnot_containsicontainsstarts_withends_withgreater_thanless_thangreater_than_or_equalless_than_or_equalinnot_inis_emptyis_not_emptyis_nullis_not_nullbeforeafterbetween
ViewItem
Union Options
This schema accepts one of the following structures:
Option 1
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| viewKind | 'list' | ✅ | |
| config | { name?: string; label?: string | Record<string, string>; type?: Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'>; data?: object | object | object; … } | ✅ | List-family view configuration. |
| name | string | ✅ | Globally-unique view id, <object>.<viewKey>. |
| object | string | ✅ | Bound object name — the foreign key used to aggregate views. |
| label | string | Record<string, string> | optional | Display label (supports i18n). |
| isDefault | boolean | optional | Whether this is the object's default view in the switcher. |
| order | integer | optional | Sort order within the object's view switcher / left rail. |
| scope | Enum<'package' | 'shared' | 'personal'> | optional | Identity layer (defaults to package for source-loaded views). |
| owner | never | optional | [REMOVED] view.owner was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the user a personal view item belonged to, and nothing ever read it: the view switcher (GET /meta/view?object=) serves every item bound to the object without looking at owner, so a view marked as one user's was listed for every user who can read the object. Delete the key. Nothing restricts a view item to one user today — per-user view scoping is a parked direction (ADR-0017), not a shipped mechanism — so a view item is visible to everyone who can read its object. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| hidden | never | optional | [REMOVED] view.hidden was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it promised to hide a view item from the switcher, and nothing ever read it: GET /meta/view?object= and the console's view switcher list every item bound to the object, hidden: true included. Delete the key; to take a view out of the switcher, delete the view item itself (or stop shipping it from source). Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| protection | { lock: Enum<'none' | 'no-overlay' | 'no-delete' | 'full'>; reason: string; docsUrl?: string } | optional | Package author protection block — lock policy for this view. |
| _lock | Enum<'none' | 'no-overlay' | 'no-delete' | 'full'> | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| _lockReason | string | optional | Human-readable reason shown when a write is refused by _lock. |
| _lockSource | Enum<'artifact' | 'package' | 'env-forced'> | optional | Layer that set _lock (artifact | package | env-forced). |
| _provenance | Enum<'package' | 'org' | 'env-forced'> | optional | Origin of the item (package | org | env-forced). |
| _packageId | string | optional | Owning package machine id. |
| _packageVersion | string | optional | Owning package version. |
| _lockDocsUrl | string | optional | Optional documentation link surfaced next to _lockReason. |
Nested Shape: ViewItem[viewKind='list'].config
| Property | Type | Required | Description |
|---|---|---|---|
| name | string | optional | Internal view name (lowercase snake_case) |
| label | string | Record<string, string> | optional | Display label — the default-language string, or an inline locale map ({ en, "zh-CN" }) resolved at render time |
| type | Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'> | optional (default: "grid") | |
| data | { provider: 'object'; object: string } | { provider: 'value'; items: any[] } | { provider: 'schema'; schemaId: string; schema?: Record<string, any> } | optional | Data source configuration (defaults to "object" provider) |
| columns | string[] | { field: string; label?: string | Record<string, string>; width?: number; align?: Enum<'left' | 'center' | 'right'>; … }[] | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition columns x hiddenFields x fieldOrder: this list is the candidate set AND the baseline order; hiddenFields subtracts from it and fieldOrder orders what survives. hiddenFields and fieldOrder cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's ListView grid derives the object's default columns). |
| filter | { field: string; operator: Enum<'equals' | 'not_equals' | 'contains' | 'not_contains' | 'icontains' | …>; value?: string | number | boolean | null | (string | number)[] }[] | optional | Filter criteria (JSON Rules) |
| sort | { field: string; order: Enum<'asc' | 'desc'> }[] | optional | |
| searchableFields | string[] | optional | Fields enabled for search |
| filterableFields | string[] | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters |
| userFilters | { element?: Enum<'dropdown' | 'tabs' | 'toggle'>; fields?: object[]; tabs?: object[]; showAllRecords?: boolean; … } | optional | End-user quick-filter bar: dropdown/toggle fields or tab presets. Omit to let the renderer derive filters from select/boolean fields |
| resizable | boolean | optional | Enable column resizing |
| compactToolbar | boolean | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover |
| selection | { type?: Enum<'none' | 'single' | 'multiple'> } | optional | Row selection configuration |
| navigation | { mode?: Enum<'page' | 'drawer' | 'modal' | 'split' | 'popover' | 'new_window' | 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full'>; … } | optional | Configuration for item click navigation (page, drawer, modal, etc.) |
| pagination | { pageSize?: integer; pageSizeOptions?: integer[] } | optional | Pagination configuration |
| kanban | { groupByField: string; summarizeField?: string; titleField?: string; columns: string[] } | optional | Kanban-board configuration — applies when the view renders as a kanban layout |
| calendar | { startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … } | optional | Calendar configuration — applies when the view renders as a calendar layout |
| gantt | { startDateField: string; endDateField: string; titleField: string; progressField?: string; … } | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout |
| gallery | { coverField?: string; coverFit?: Enum<'cover' | 'contain'>; cardSize?: Enum<'small' | 'medium' | 'large'>; titleField?: string; … } | optional | Gallery/card view configuration |
| timeline | { startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … } | optional | Timeline view configuration |
| chart | { chartType?: Enum<'bar' | 'line' | 'pie' | 'area' | 'scatter'>; dataset: string; dimensions?: string[]; values: string[] } | optional | Chart binding — applies when the view renders as a chart. A type: 'chart' view must bind one: it names the ADR-0021 dataset and the measures (values) the chart plots, and there is no default binding |
| map | { latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … } | optional | Map configuration — applies when the view renders as a map layout |
| tree | { parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout |
| pageName | never | optional | [REMOVED] view.pageName was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a type: 'page' view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — { type: 'page', pageName: '<page_name>' } under the app's navigation — which is a different key on a different surface and is the page mount that has always rendered. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| description | string | Record<string, string> | optional | View description for documentation/tooltips |
| sharing | { type?: Enum<'personal' | 'collaborative'>; lockedBy?: string } | optional | View sharing and access configuration |
| requiredPermissions | string[] | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through systemPermissions, the same list as requiredPermissions on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. |
| rowHeight | Enum<'compact' | 'short' | 'medium' | 'tall' | 'extra_tall'> | optional | Row height / density setting |
| grouping | { fields: object[] } | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) |
| rowColor | { field: string; colors?: Record<string, string> } | optional | Color rows based on field value |
| hiddenFields | string[] | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition columns x hiddenFields x fieldOrder: each name listed here is removed from the columns projection before fieldOrder orders the remainder. A name columns never projected subtracts nothing. |
| fieldOrder | string[] | optional | Explicit field display order for this view — the ORDERING of the per-view field composition columns x hiddenFields x fieldOrder: it sorts what survives columns minus hiddenFields and never adds a field. A surviving column absent from fieldOrder sorts LAST, after every listed one, keeping its columns-relative order; a name listed here that did not survive orders nothing. |
| rowActions | string[] | optional | Actions available for individual row items |
| bulkActions | string[] | optional | Actions available when multiple rows are selected |
| bulkActionDefs | { name: string; label?: string; icon?: string; variant?: Enum<'primary' | 'secondary' | 'danger' | 'ghost' | 'outline'>; … }[] | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a patch / 'delete') that no action expresses, or for an operation: 'custom' + execution: 'aggregate' entry that dispatches the action it NAMES once for the whole selection — the renderer injects params._selectedIds: string[] (read that on the server, not recordId) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate 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 on defs whose server work is expensive. For the PER-RECORD dispatch use bulkActions: ['<name>'] instead — the bare-string form, promoted with the action's own label, params and visible; a 'custom' def without execution: 'aggregate' has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via ${ctx.selection.ids} / ${ctx.selection.count}. |
| conditionalFormatting | { condition: string | object; style: Record<string, string> }[] | optional | Conditional formatting rules for list rows |
| inlineEdit | boolean | optional | Allow inline editing of records directly in the list view |
| exportOptions | Enum<'csv' | 'xlsx' | 'json'>[] | { formats?: Enum<'csv' | 'xlsx' | 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … } | optional | Export configuration for the list toolbar export menu: { formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }. A bare format array is the legacy spelling and lifts to { formats: [...] } at parse. |
| userActions | { sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … } | optional | User action toggles for the view toolbar |
| appearance | { showDescription?: boolean; allowedVisualizations?: Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'>[] } | optional | Appearance and visualization configuration |
| tabs | never | optional | [REMOVED] view.list.tabs was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's listViews instead: the tab's name becomes the entry's key, its label the entry's label, and its filter rules join the view's own filter on that entry (copy the view's columns too); a tab whose view already named a list view needs nothing more. Every listViews entry renders as a tab in the switcher. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| addRecord | { enabled?: boolean; position?: Enum<'top' | 'bottom' | 'both'>; mode?: Enum<'inline' | 'form' | 'modal'>; formView?: string } | optional | Add record entry point configuration |
| showRecordCount | boolean | optional | Show record count at the bottom of the list |
| allowPrinting | boolean | optional | Allow users to print the view |
| emptyState | { title?: string | Record<string, string>; message?: string | Record<string, string>; icon?: string; link?: object } | optional | Empty state configuration when no records found |
| aria | { ariaLabel?: string | Record<string, string>; ariaDescribedBy?: string; role?: string } | optional | ARIA accessibility attributes for the list view |
| responsive | never | optional | [REMOVED] view.responsive was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| performance | never | optional | [REMOVED] view.performance was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| striped | never | optional | [REMOVED] view.striped was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| bordered | never | optional | [REMOVED] view.bordered was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| virtualScroll | never | optional | [REMOVED] view.virtualScroll was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via pagination. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
Nested Shape: ViewItem[viewKind='list'].protection
| Property | Type | Required | Description |
|---|---|---|---|
| lock | Enum<'none' | 'no-overlay' | 'no-delete' | 'full'> | ✅ | Lock policy — none | no-overlay | no-delete | full. |
| reason | string | ✅ | User-visible reason shown when the lock blocks an action. |
| docsUrl | string | optional | Optional URL the Studio banner links to for more context. |
Option 2
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| viewKind | 'form' | ✅ | |
| config | { type?: Enum<'simple' | 'tabbed' | 'wizard' | 'split' | 'drawer' | 'modal'>; layout?: Enum<'vertical' | 'horizontal'>; columns?: integer; title?: string; … } | ✅ | Form view configuration. |
| name | string | ✅ | Globally-unique view id, <object>.<viewKey>. |
| object | string | ✅ | Bound object name — the foreign key used to aggregate views. |
| label | string | Record<string, string> | optional | Display label (supports i18n). |
| isDefault | boolean | optional | Whether this is the object's default view in the switcher. |
| order | integer | optional | Sort order within the object's view switcher / left rail. |
| scope | Enum<'package' | 'shared' | 'personal'> | optional | Identity layer (defaults to package for source-loaded views). |
| owner | never | optional | [REMOVED] view.owner was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the user a personal view item belonged to, and nothing ever read it: the view switcher (GET /meta/view?object=) serves every item bound to the object without looking at owner, so a view marked as one user's was listed for every user who can read the object. Delete the key. Nothing restricts a view item to one user today — per-user view scoping is a parked direction (ADR-0017), not a shipped mechanism — so a view item is visible to everyone who can read its object. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| hidden | never | optional | [REMOVED] view.hidden was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it promised to hide a view item from the switcher, and nothing ever read it: GET /meta/view?object= and the console's view switcher list every item bound to the object, hidden: true included. Delete the key; to take a view out of the switcher, delete the view item itself (or stop shipping it from source). Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| protection | { lock: Enum<'none' | 'no-overlay' | 'no-delete' | 'full'>; reason: string; docsUrl?: string } | optional | Package author protection block — lock policy for this view. |
| _lock | Enum<'none' | 'no-overlay' | 'no-delete' | 'full'> | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| _lockReason | string | optional | Human-readable reason shown when a write is refused by _lock. |
| _lockSource | Enum<'artifact' | 'package' | 'env-forced'> | optional | Layer that set _lock (artifact | package | env-forced). |
| _provenance | Enum<'package' | 'org' | 'env-forced'> | optional | Origin of the item (package | org | env-forced). |
| _packageId | string | optional | Owning package machine id. |
| _packageVersion | string | optional | Owning package version. |
| _lockDocsUrl | string | optional | Optional documentation link surfaced next to _lockReason. |
Nested Shape: ViewItem[viewKind='form'].config
| Property | Type | Required | Description |
|---|---|---|---|
| type | Enum<'simple' | 'tabbed' | 'wizard' | 'split' | 'drawer' | 'modal'> | optional (default: "simple") | |
| layout | Enum<'vertical' | 'horizontal'> | optional | Field layout direction — 'vertical' (the renderer default) or 'horizontal'. Multi-column is not a layout value: set columns |
| columns | integer | optional | Number of columns for the form body |
| title | string | optional | Form title |
| description | string | optional | Form description |
| defaultTab | string | optional | Initially active tab (tabbed forms) |
| tabPosition | Enum<'top' | 'bottom' | 'left' | 'right'> | optional | Tab strip position (tabbed forms) |
| allowSkip | boolean | optional | Wizard step-gate opt-out: allow entering a later step without submitting the one before it. Navigation freedom, NOT a validation exemption — the final submit still re-validates every step and returns to the first failing one. Default (absent/false): steps unlock in array order as each prior step submits validly. UI admission only, never authorization. |
| showStepIndicator | boolean | optional | Show the wizard step indicator (renderer default: shown). Step progress (completed/current/upcoming/invalid) is derived from the step gate — this boolean is the only authorable progress surface. |
| splitDirection | Enum<'horizontal' | 'vertical'> | optional | Split orientation (split forms) |
| splitSize | number | optional | Primary split panel size, % (split forms) |
| splitResizable | boolean | optional | Whether the split is resizable (split forms) |
| drawerSide | Enum<'top' | 'bottom' | 'left' | 'right'> | optional | Drawer side (drawer forms) |
| drawerWidth | string | optional | [DEPRECATED → size buckets] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. |
| modalSize | Enum<'sm' | 'default' | 'lg' | 'xl' | 'full'> | optional | Modal size (modal forms) |
| data | { provider: 'object'; object: string } | { provider: 'value'; items: any[] } | { provider: 'schema'; schemaId: string; schema?: Record<string, any> } | optional | Data source configuration (defaults to "object" provider) |
| sections | { name?: string; label?: string | Record<string, string>; description?: string; collapsible?: boolean; … }[] | optional | |
| groups | { name?: string; label?: string | Record<string, string>; description?: string; collapsible?: boolean; … }[] | optional | [LEGACY ALIAS → sections] Accepted for back-compat and folded onto sections at parse; sections wins when both are present. Prefer sections. |
| subforms | { childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[] | optional | Inline master-detail child collections |
| defaultSort | never | optional | [REMOVED] form.defaultSort was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's sort. Delete the key and set the sort on the related list view instead. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| sharing | { enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … } | optional | Public sharing configuration for this form |
| submitBehavior | { kind: 'thank-you'; title?: string; message?: string } | { kind: 'redirect'; url: string; delayMs?: integer } | { kind: 'continue' } | { kind: 'next-record' } | optional | Post-submit behavior. On the redirect arm, url is relative-only and interpolates only declared record fields as {{record.field_name}}, URL-escaped. |
| buttons | { submit?: object; cancel?: object; reset?: object } | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. |
| defaults | Record<string, any> | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). |
| aria | never | optional | [REMOVED] form.aria was removed in @objectstack/spec 17.0.0 (audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
Nested Shape: ViewItem[viewKind='form'].protection
| Property | Type | Required | Description |
|---|---|---|---|
| lock | Enum<'none' | 'no-overlay' | 'no-delete' | 'full'> | ✅ | Lock policy — none | no-overlay | no-delete | full. |
| reason | string | ✅ | User-visible reason shown when the lock blocks an action. |
| docsUrl | string | optional | Optional URL the Studio banner links to for more context. |
ViewItemName
Globally-unique view id, <object>.<viewKey>.
Type: string
ViewItemWire
Union Options
This schema accepts one of the following structures:
Option 1
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| viewKind | 'list' | ✅ | |
| config | { name?: string; label?: string | Record<string, string>; type?: Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'>; data?: object | object | object; … } | ✅ | List-family view configuration. |
| name | string | ✅ | Globally-unique view id, <object>.<viewKey>. |
| object | string | ✅ | Bound object name — the foreign key used to aggregate views. |
| label | string | Record<string, string> | optional | Display label (supports i18n). |
| isDefault | boolean | optional | Whether this is the object's default view in the switcher. |
| order | integer | optional | Sort order within the object's view switcher / left rail. |
| scope | Enum<'package' | 'shared' | 'personal'> | optional | Identity layer (defaults to package for source-loaded views). |
| owner | never | optional | [REMOVED] view.owner was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the user a personal view item belonged to, and nothing ever read it: the view switcher (GET /meta/view?object=) serves every item bound to the object without looking at owner, so a view marked as one user's was listed for every user who can read the object. Delete the key. Nothing restricts a view item to one user today — per-user view scoping is a parked direction (ADR-0017), not a shipped mechanism — so a view item is visible to everyone who can read its object. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| hidden | never | optional | [REMOVED] view.hidden was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it promised to hide a view item from the switcher, and nothing ever read it: GET /meta/view?object= and the console's view switcher list every item bound to the object, hidden: true included. Delete the key; to take a view out of the switcher, delete the view item itself (or stop shipping it from source). Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| protection | { lock: Enum<'none' | 'no-overlay' | 'no-delete' | 'full'>; reason: string; docsUrl?: string } | optional | Package author protection block — lock policy for this view. |
| _lock | Enum<'none' | 'no-overlay' | 'no-delete' | 'full'> | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| _lockReason | string | optional | Human-readable reason shown when a write is refused by _lock. |
| _lockSource | Enum<'artifact' | 'package' | 'env-forced'> | optional | Layer that set _lock (artifact | package | env-forced). |
| _provenance | Enum<'package' | 'org' | 'env-forced'> | optional | Origin of the item (package | org | env-forced). |
| _packageId | string | optional | Owning package machine id. |
| _packageVersion | string | optional | Owning package version. |
| _lockDocsUrl | string | optional | Optional documentation link surfaced next to _lockReason. |
| isPinned | boolean | optional | Console round-trip: the view is pinned in the object's view switcher. Written by the console's pin toggle through the view metadata API and read back to draw the pinned group. Stored on the view's row, which has no per-user scope. Not authored. |
| sortOrder | integer | optional | Console round-trip: the view's position among the object's saved views in the switcher, 0-based and counted over saved views only (a code-defined view carries none). Written by the console's drag-reorder and read back to order the tabs. Not authored: order is the authored default position. |
| visibility | Enum<'private' | 'team' | 'organization' | 'public'> | optional | Console round-trip: the group the switcher files this view's tab under (private, team, organization or public). Display grouping only, NOT access control: nothing restricts who can list or open the view by this value. No console control sets it; the console carries a stored value forward when it re-saves the row. Not authored. |
| columnState | { order?: string[]; widths?: Record<string, number> } | optional | Studio round-trip: column order/widths (runtime-only state, written by the console grid and stored on the view's row, which has no per-user scope — not authored) |
| options | never | optional | Refused: a view item record carries no top-level options bag. Its per-kind blocks live under config (config.kanban, config.timeline, …); the legacy options.KIND bag belongs to the flattened list overlay only. |
Nested Shape: ViewItemWire[viewKind='list'].config
| Property | Type | Required | Description |
|---|---|---|---|
| name | string | optional | Internal view name (lowercase snake_case) |
| label | string | Record<string, string> | optional | Display label — the default-language string, or an inline locale map ({ en, "zh-CN" }) resolved at render time |
| type | Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'> | optional (default: "grid") | |
| data | { provider: 'object'; object: string } | { provider: 'value'; items: any[] } | { provider: 'schema'; schemaId: string; schema?: Record<string, any> } | optional | Data source configuration (defaults to "object" provider) |
| columns | string[] | { field: string; label?: string | Record<string, string>; width?: number; align?: Enum<'left' | 'center' | 'right'>; … }[] | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition columns x hiddenFields x fieldOrder: this list is the candidate set AND the baseline order; hiddenFields subtracts from it and fieldOrder orders what survives. hiddenFields and fieldOrder cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's ListView grid derives the object's default columns). |
| filter | { field: string; operator: Enum<'equals' | 'not_equals' | 'contains' | 'not_contains' | 'icontains' | …>; value?: string | number | boolean | null | (string | number)[] }[] | optional | Filter criteria (JSON Rules) |
| sort | { field: string; order: Enum<'asc' | 'desc'> }[] | optional | |
| searchableFields | string[] | optional | Fields enabled for search |
| filterableFields | string[] | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters |
| userFilters | { element?: Enum<'dropdown' | 'tabs' | 'toggle'>; fields?: object[]; tabs?: object[]; showAllRecords?: boolean; … } | optional | End-user quick-filter bar: dropdown/toggle fields or tab presets. Omit to let the renderer derive filters from select/boolean fields |
| resizable | boolean | optional | Enable column resizing |
| compactToolbar | boolean | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover |
| selection | { type?: Enum<'none' | 'single' | 'multiple'> } | optional | Row selection configuration |
| navigation | { mode?: Enum<'page' | 'drawer' | 'modal' | 'split' | 'popover' | 'new_window' | 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full'>; … } | optional | Configuration for item click navigation (page, drawer, modal, etc.) |
| pagination | { pageSize?: integer; pageSizeOptions?: integer[] } | optional | Pagination configuration |
| kanban | { groupByField: string; summarizeField?: string; titleField?: string; columns: string[] } | optional | Kanban-board configuration — applies when the view renders as a kanban layout |
| calendar | { startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … } | optional | Calendar configuration — applies when the view renders as a calendar layout |
| gantt | { startDateField: string; endDateField: string; titleField: string; progressField?: string; … } | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout |
| gallery | { coverField?: string; coverFit?: Enum<'cover' | 'contain'>; cardSize?: Enum<'small' | 'medium' | 'large'>; titleField?: string; … } | optional | Gallery/card view configuration |
| timeline | { startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … } | optional | Timeline view configuration |
| chart | { chartType?: Enum<'bar' | 'line' | 'pie' | 'area' | 'scatter'>; dataset: string; dimensions?: string[]; values: string[] } | optional | Chart binding — applies when the view renders as a chart. A type: 'chart' view must bind one: it names the ADR-0021 dataset and the measures (values) the chart plots, and there is no default binding |
| map | { latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … } | optional | Map configuration — applies when the view renders as a map layout |
| tree | { parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout |
| pageName | never | optional | [REMOVED] view.pageName was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a type: 'page' view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — { type: 'page', pageName: '<page_name>' } under the app's navigation — which is a different key on a different surface and is the page mount that has always rendered. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| description | string | Record<string, string> | optional | View description for documentation/tooltips |
| sharing | { type?: Enum<'personal' | 'collaborative'>; lockedBy?: string } | optional | View sharing and access configuration |
| requiredPermissions | string[] | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through systemPermissions, the same list as requiredPermissions on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. |
| rowHeight | Enum<'compact' | 'short' | 'medium' | 'tall' | 'extra_tall'> | optional | Row height / density setting |
| grouping | { fields: object[] } | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) |
| rowColor | { field: string; colors?: Record<string, string> } | optional | Color rows based on field value |
| hiddenFields | string[] | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition columns x hiddenFields x fieldOrder: each name listed here is removed from the columns projection before fieldOrder orders the remainder. A name columns never projected subtracts nothing. |
| fieldOrder | string[] | optional | Explicit field display order for this view — the ORDERING of the per-view field composition columns x hiddenFields x fieldOrder: it sorts what survives columns minus hiddenFields and never adds a field. A surviving column absent from fieldOrder sorts LAST, after every listed one, keeping its columns-relative order; a name listed here that did not survive orders nothing. |
| rowActions | string[] | optional | Actions available for individual row items |
| bulkActions | string[] | optional | Actions available when multiple rows are selected |
| bulkActionDefs | { name: string; label?: string; icon?: string; variant?: Enum<'primary' | 'secondary' | 'danger' | 'ghost' | 'outline'>; … }[] | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a patch / 'delete') that no action expresses, or for an operation: 'custom' + execution: 'aggregate' entry that dispatches the action it NAMES once for the whole selection — the renderer injects params._selectedIds: string[] (read that on the server, not recordId) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate 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 on defs whose server work is expensive. For the PER-RECORD dispatch use bulkActions: ['<name>'] instead — the bare-string form, promoted with the action's own label, params and visible; a 'custom' def without execution: 'aggregate' has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via ${ctx.selection.ids} / ${ctx.selection.count}. |
| conditionalFormatting | { condition: string | object; style: Record<string, string> }[] | optional | Conditional formatting rules for list rows |
| inlineEdit | boolean | optional | Allow inline editing of records directly in the list view |
| exportOptions | Enum<'csv' | 'xlsx' | 'json'>[] | { formats?: Enum<'csv' | 'xlsx' | 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … } | optional | Export configuration for the list toolbar export menu: { formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }. A bare format array is the legacy spelling and lifts to { formats: [...] } at parse. |
| userActions | { sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … } | optional | User action toggles for the view toolbar |
| appearance | { showDescription?: boolean; allowedVisualizations?: Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'>[] } | optional | Appearance and visualization configuration |
| tabs | never | optional | [REMOVED] view.list.tabs was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's listViews instead: the tab's name becomes the entry's key, its label the entry's label, and its filter rules join the view's own filter on that entry (copy the view's columns too); a tab whose view already named a list view needs nothing more. Every listViews entry renders as a tab in the switcher. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| addRecord | { enabled?: boolean; position?: Enum<'top' | 'bottom' | 'both'>; mode?: Enum<'inline' | 'form' | 'modal'>; formView?: string } | optional | Add record entry point configuration |
| showRecordCount | boolean | optional | Show record count at the bottom of the list |
| allowPrinting | boolean | optional | Allow users to print the view |
| emptyState | { title?: string | Record<string, string>; message?: string | Record<string, string>; icon?: string; link?: object } | optional | Empty state configuration when no records found |
| aria | { ariaLabel?: string | Record<string, string>; ariaDescribedBy?: string; role?: string } | optional | ARIA accessibility attributes for the list view |
| responsive | never | optional | [REMOVED] view.responsive was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| performance | never | optional | [REMOVED] view.performance was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| striped | never | optional | [REMOVED] view.striped was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| bordered | never | optional | [REMOVED] view.bordered was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| virtualScroll | never | optional | [REMOVED] view.virtualScroll was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via pagination. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
Nested Shape: ViewItemWire[viewKind='list'].protection
| Property | Type | Required | Description |
|---|---|---|---|
| lock | Enum<'none' | 'no-overlay' | 'no-delete' | 'full'> | ✅ | Lock policy — none | no-overlay | no-delete | full. |
| reason | string | ✅ | User-visible reason shown when the lock blocks an action. |
| docsUrl | string | optional | Optional URL the Studio banner links to for more context. |
Nested Shape: ViewItemWire[viewKind='list'].columnState
| Property | Type | Required | Description |
|---|---|---|---|
| order | string[] | optional | Column order as field names, leftmost first (runtime-only state — written by the console grid, never authored). |
| widths | Record<string, number> | optional | Column widths in pixels, keyed by field name (runtime-only state — written by the console grid, never authored). |
Option 2
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| viewKind | 'form' | ✅ | |
| config | { type?: Enum<'simple' | 'tabbed' | 'wizard' | 'split' | 'drawer' | 'modal'>; layout?: Enum<'vertical' | 'horizontal'>; columns?: integer; title?: string; … } | ✅ | Form view configuration. |
| name | string | ✅ | Globally-unique view id, <object>.<viewKey>. |
| object | string | ✅ | Bound object name — the foreign key used to aggregate views. |
| label | string | Record<string, string> | optional | Display label (supports i18n). |
| isDefault | boolean | optional | Whether this is the object's default view in the switcher. |
| order | integer | optional | Sort order within the object's view switcher / left rail. |
| scope | Enum<'package' | 'shared' | 'personal'> | optional | Identity layer (defaults to package for source-loaded views). |
| owner | never | optional | [REMOVED] view.owner was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the user a personal view item belonged to, and nothing ever read it: the view switcher (GET /meta/view?object=) serves every item bound to the object without looking at owner, so a view marked as one user's was listed for every user who can read the object. Delete the key. Nothing restricts a view item to one user today — per-user view scoping is a parked direction (ADR-0017), not a shipped mechanism — so a view item is visible to everyone who can read its object. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| hidden | never | optional | [REMOVED] view.hidden was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it promised to hide a view item from the switcher, and nothing ever read it: GET /meta/view?object= and the console's view switcher list every item bound to the object, hidden: true included. Delete the key; to take a view out of the switcher, delete the view item itself (or stop shipping it from source). Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| protection | { lock: Enum<'none' | 'no-overlay' | 'no-delete' | 'full'>; reason: string; docsUrl?: string } | optional | Package author protection block — lock policy for this view. |
| _lock | Enum<'none' | 'no-overlay' | 'no-delete' | 'full'> | optional | Item-level lock — controls overlay & delete (ADR-0010). |
| _lockReason | string | optional | Human-readable reason shown when a write is refused by _lock. |
| _lockSource | Enum<'artifact' | 'package' | 'env-forced'> | optional | Layer that set _lock (artifact | package | env-forced). |
| _provenance | Enum<'package' | 'org' | 'env-forced'> | optional | Origin of the item (package | org | env-forced). |
| _packageId | string | optional | Owning package machine id. |
| _packageVersion | string | optional | Owning package version. |
| _lockDocsUrl | string | optional | Optional documentation link surfaced next to _lockReason. |
| isPinned | boolean | optional | Console round-trip: the view is pinned in the object's view switcher. Written by the console's pin toggle through the view metadata API and read back to draw the pinned group. Stored on the view's row, which has no per-user scope. Not authored. |
| sortOrder | integer | optional | Console round-trip: the view's position among the object's saved views in the switcher, 0-based and counted over saved views only (a code-defined view carries none). Written by the console's drag-reorder and read back to order the tabs. Not authored: order is the authored default position. |
| visibility | Enum<'private' | 'team' | 'organization' | 'public'> | optional | Console round-trip: the group the switcher files this view's tab under (private, team, organization or public). Display grouping only, NOT access control: nothing restricts who can list or open the view by this value. No console control sets it; the console carries a stored value forward when it re-saves the row. Not authored. |
| columnState | { order?: string[]; widths?: Record<string, number> } | optional | Studio round-trip: column order/widths (runtime-only state, written by the console grid and stored on the view's row, which has no per-user scope — not authored) |
| options | never | optional | Refused: a view item record carries no top-level options bag. Its per-kind blocks live under config (config.kanban, config.timeline, …); the legacy options.KIND bag belongs to the flattened list overlay only. |
Nested Shape: ViewItemWire[viewKind='form'].config
| Property | Type | Required | Description |
|---|---|---|---|
| type | Enum<'simple' | 'tabbed' | 'wizard' | 'split' | 'drawer' | 'modal'> | optional (default: "simple") | |
| layout | Enum<'vertical' | 'horizontal'> | optional | Field layout direction — 'vertical' (the renderer default) or 'horizontal'. Multi-column is not a layout value: set columns |
| columns | integer | optional | Number of columns for the form body |
| title | string | optional | Form title |
| description | string | optional | Form description |
| defaultTab | string | optional | Initially active tab (tabbed forms) |
| tabPosition | Enum<'top' | 'bottom' | 'left' | 'right'> | optional | Tab strip position (tabbed forms) |
| allowSkip | boolean | optional | Wizard step-gate opt-out: allow entering a later step without submitting the one before it. Navigation freedom, NOT a validation exemption — the final submit still re-validates every step and returns to the first failing one. Default (absent/false): steps unlock in array order as each prior step submits validly. UI admission only, never authorization. |
| showStepIndicator | boolean | optional | Show the wizard step indicator (renderer default: shown). Step progress (completed/current/upcoming/invalid) is derived from the step gate — this boolean is the only authorable progress surface. |
| splitDirection | Enum<'horizontal' | 'vertical'> | optional | Split orientation (split forms) |
| splitSize | number | optional | Primary split panel size, % (split forms) |
| splitResizable | boolean | optional | Whether the split is resizable (split forms) |
| drawerSide | Enum<'top' | 'bottom' | 'left' | 'right'> | optional | Drawer side (drawer forms) |
| drawerWidth | string | optional | [DEPRECATED → size buckets] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. |
| modalSize | Enum<'sm' | 'default' | 'lg' | 'xl' | 'full'> | optional | Modal size (modal forms) |
| data | { provider: 'object'; object: string } | { provider: 'value'; items: any[] } | { provider: 'schema'; schemaId: string; schema?: Record<string, any> } | optional | Data source configuration (defaults to "object" provider) |
| sections | { name?: string; label?: string | Record<string, string>; description?: string; collapsible?: boolean; … }[] | optional | |
| groups | { name?: string; label?: string | Record<string, string>; description?: string; collapsible?: boolean; … }[] | optional | [LEGACY ALIAS → sections] Accepted for back-compat and folded onto sections at parse; sections wins when both are present. Prefer sections. |
| subforms | { childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[] | optional | Inline master-detail child collections |
| defaultSort | never | optional | [REMOVED] form.defaultSort was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's sort. Delete the key and set the sort on the related list view instead. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| sharing | { enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … } | optional | Public sharing configuration for this form |
| submitBehavior | { kind: 'thank-you'; title?: string; message?: string } | { kind: 'redirect'; url: string; delayMs?: integer } | { kind: 'continue' } | { kind: 'next-record' } | optional | Post-submit behavior. On the redirect arm, url is relative-only and interpolates only declared record fields as {{record.field_name}}, URL-escaped. |
| buttons | { submit?: object; cancel?: object; reset?: object } | optional | Form action-button visibility & labels; folded onto the flat renderer props by ObjectUI ObjectForm. |
| defaults | Record<string, any> | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values;). |
| aria | never | optional | [REMOVED] form.aria was removed in @objectstack/spec 17.0.0 (audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
Nested Shape: ViewItemWire[viewKind='form'].protection
| Property | Type | Required | Description |
|---|---|---|---|
| lock | Enum<'none' | 'no-overlay' | 'no-delete' | 'full'> | ✅ | Lock policy — none | no-overlay | no-delete | full. |
| reason | string | ✅ | User-visible reason shown when the lock blocks an action. |
| docsUrl | string | optional | Optional URL the Studio banner links to for more context. |
Nested Shape: ViewItemWire[viewKind='form'].columnState
| Property | Type | Required | Description |
|---|---|---|---|
| order | string[] | optional | Column order as field names, leftmost first (runtime-only state — written by the console grid, never authored). |
| widths | Record<string, number> | optional | Column widths in pixels, keyed by field name (runtime-only state — written by the console grid, never authored). |
ViewKind
Whether config is a ListView (list family) or a FormView.
Allowed Values
listform
ViewScope
View identity layer: package | shared | personal.
Allowed Values
packagesharedpersonal
ViewSharing
View sharing and access configuration
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| type | Enum<'personal' | 'collaborative'> | optional (default: "collaborative") | View ownership type |
| lockedBy | string | optional | User who locked the view configuration |
ViewTab
Tab configuration for multi-tab view interface
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| name | string | ✅ | Tab identifier (snake_case) |
| label | string | Record<string, string> | optional | Display label |
| icon | string | optional | Tab icon name |
| view | string | optional | Referenced list view name from listViews |
| filter | { field: string; operator: Enum<'equals' | 'not_equals' | 'contains' | 'not_contains' | 'icontains' | …>; value?: string | number | boolean | null | (string | number)[] }[] | optional | Tab-specific filter criteria |
| order | integer | optional | Tab display order |
| pinned | boolean | optional (default: false) | Pin tab (cannot be removed by users) |
| isDefault | boolean | optional (default: false) | Set as the default active tab |
| visible | boolean | optional (default: true) | Tab visibility |
Nested Shape: ViewTab.filter[number]
View filter rule
| Property | Type | Required | Description |
|---|---|---|---|
| field | string | ✅ | Field name to filter on |
| operator | Enum<'equals' | 'not_equals' | 'contains' | 'not_contains' | 'icontains' | …> | ✅ | Filter operator |
| value | string | number | boolean | null | (string | number)[] | optional | Filter value. The accepted SHAPE depends on the operator: in / not_in take an array (any length, including []), between takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: icontains takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. |
VisualizationType
Visualization type that users can switch to
Allowed Values
gridkanbangallerycalendartimelineganttmapcharttree