Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions docs/adr/0047-object-ui-run-modes.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
# ADR-0047: Two run modes for object UI — data views vs interface pages, user filters, and runtime visualization choice

**Status**: Proposed (2026-06-12)
**Status**: Proposed (2026-06-12) · **Revised 2026-06-19**

> **Revision (2026-06-19) — the "iron rule" is superseded.** Validating the
> Studio config panel against Airtable showed Airtable has no "inherit from a
> separate view" concept: an interface page defines its data surface (columns,
> sort, base filter, visualizations, toolbar) **directly**. ObjectStack now
> matches this — `InterfacePageConfig` carries its own `columns`/`sort`/`filterBy`
> and the page editor edits them in place. `sourceView` is deprecated and kept
> only as a runtime back-compat fallback. TL;DR #2 below no longer holds; the
> rest of the ADR (two run modes, userFilters, visualization whitelist) stands.
**Deciders**: ObjectStack Protocol Architects
**Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (one Zod source per type, org overlay), [ADR-0017](./0017-object-has-many-view.md) (independent view entities, `viewKind`), [ADR-0019](./0019-app-as-consumer-unit.md) (App is the consumer-facing unit — navigation decides what users see), [ADR-0027](./0027-metadata-authoring-lifecycle.md) (draft · publish lifecycle), [ADR-0033](./0033-ai-assisted-metadata-authoring.md) (**AI is the long-term author of metadata — the design center this ADR inherits**)
**Consumers**: `@objectstack/spec` (view + page Zod schemas), `@objectstack/objectql` (registry validation/diagnostics), `../objectui` (console `ObjectView` / `PageView`, `plugin-list`), framework templates (`hotcrm`, `app-showcase`), the `objectstack-ui` authoring skill
Expand All @@ -12,7 +21,7 @@
## TL;DR

1. **Two run modes, both first-class.** *Data mode* (navigation → object): every list view of the object renders as a switcher tab; users may create personal views; the toolbar is permissive. *Interface mode* (navigation → page): an author-curated page **references** one view as its source and exposes only the controls the author enabled — filter tabs or dropdowns, a fixed (or whitelisted) visualization, selected user actions. This mirrors Airtable's Data vs Interfaces split and Power Platform's model-driven vs canvas split.
2. **The iron rule: pages reference views, never restate them.** A page's `source` points at an object/view; columns, base filter, and sort are *inherited* from the view definition. The page schema carries presentation policy only — it has no field for columns, so the "page and view each declare columns, then drift" failure mode is unrepresentable.
2. **~~The iron rule: pages reference views, never restate them.~~** *(Superseded by the 2026-06-19 revision — see banner above.)* The page now defines `columns`/`sort`/`filterBy` directly (Airtable parity). Drift is avoided not by forbidding columns on the page, but by making the page the single place they live (no second view to drift against).
3. **`userFilters` becomes spec.** The end-user quick-filter surface (Airtable "User filters": element = `tabs | dropdown | toggle`, plus per-field config) is formalized in `ListViewSchema` and `InterfacePageConfig`. The client already implements and renders it (verified live, below); today it works only by accident of raw passthrough, with no type for authors and no Studio form.
4. **Runtime visualization choice is an author-controlled whitelist.** `userActions.visualizations?: boolean | ViewType[]` at view level, `visualizations` at page level (superseding the misplaced `userFilters.elements` enum). Effective options = author whitelist ∩ types whose required field bindings resolve (kanban needs a select `groupBy`, calendar a date field, …). Data mode defaults open; interface mode defaults locked.
5. **Defaults are asymmetric on purpose.** Data mode auto-derives quick filters from select/boolean fields and allows user views; interface mode is closed until the author opens it. An AI that emits *nothing* beyond objects + views + navigation gets a correct, complete system — "omission is correct" is the strongest guardrail we can give a generative author.
Expand Down
4 changes: 2 additions & 2 deletions examples/app-showcase/src/pages/task-triage.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ export const TaskTriagePage: Page = {
regions: [],
interfaceConfig: {
source: 'showcase_task',
// Inherit columns/filter/sort from the object's default view (ADR-0047).
sourceView: 'default',
// ADR-0047 (revised): columns defined directly on the page (no inheritance).
columns: ['title', 'project', 'assignee', 'status', 'priority', 'due_date', 'progress'],

// End-user filter element: a row of preset tabs. `showAllRecords` adds the
// leading unfiltered "All" tab automatically.
Expand Down
6 changes: 3 additions & 3 deletions examples/app-showcase/src/pages/task-workbench.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ export const TaskWorkbenchPage: Page = {
interfaceConfig: {
source: 'showcase_task',

// ADR-0047 iron rule: the page inherits columns/filter/sort from the
// referenced view and adds presentation policy only.
sourceView: 'default',
// ADR-0047 (revised): the page defines its own columns directly — no
// inheriting from a separate view.
columns: ['title', 'project', 'assignee', 'status', 'priority', 'estimate_hours'],

// End-user quick filters — the only filtering surface on this page.
userFilters: {
Expand Down
21 changes: 12 additions & 9 deletions packages/spec/src/ui/page.form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export const pageForm = defineForm({
{
name: 'interface',
label: 'Interface (list pages)',
description: 'ADR-0047 interface mode: bind a source view and curate the end-user surface — quick filters, locked visualizations, toolbar actions (Airtable Interfaces parity).',
description: 'Interface mode (Airtable parity): the page defines its own data surface directly — columns, filters, visualizations and toolbar — no inheriting from a separate view.',
collapsible: true,
// Primary content for a list page — open by default (still collapsible).
collapsed: false,
Expand All @@ -78,7 +78,7 @@ export const pageForm = defineForm({
field: 'interfaceConfig',
type: 'composite',
helpText:
'source/sourceView bind the object view (columns, base filter and sort are inherited — the iron rule); appearance.allowedVisualizations whitelists renderers (one entry = locked); userActions toggles the toolbar.',
'The page IS the view: source picks the object, columns/filterBy are defined directly here; appearance.allowedVisualizations whitelists renderers (one entry = locked); userActions toggles the toolbar.',
// Order: common authoring controls first, rarely-used ones last.
// Explicit sub-fields so `userFilters` can use the dedicated
// filter-mode selector (None / Tabs / Dropdown, ADR-0047 §3.4a).
Expand All @@ -87,22 +87,25 @@ export const pageForm = defineForm({
// (`element: 'toggle'` stays valid but deprecated — not offered.)
// Keep this list in sync with InterfacePageConfigSchema.
fields: [
{ field: 'source', widget: 'ref:object', helpText: 'Object this list reads from' },
// Pick from the source object's views instead of typing a name.
// `dependsOn: 'source'` tells the picker which object's views to list.
{ field: 'sourceView', widget: 'view-ref', dependsOn: 'source', helpText: 'Named list view to inherit columns/filter/sort from (blank = object default)' },
// ── Data ── the page defines its own data surface directly.
{ field: 'source', widget: 'ref:object', helpText: 'Object this page reads from' },
// Columns are defined ON the page (no view inheritance). `dependsOn:
// 'source'` tells the picker which object's fields to offer.
{ field: 'columns', widget: 'field-multi', dependsOn: 'source', helpText: 'Columns to show — defined directly on the page (blank = all object fields)' },
{ field: 'filterBy', type: 'repeater', helpText: 'Always-on base filter for the page' },
{ field: 'levels', helpText: 'Hierarchy levels to display (tree-like sources)' },
// ── Appearance ──
{ field: 'appearance', type: 'composite', helpText: 'Allowed visualizations (Grid / Kanban / Calendar / …) and description visibility' },
// ── User filters ──
{
field: 'userFilters',
widget: 'filter-mode',
helpText: 'End-user filter bar: None (no bar) / Tabs (named presets) / Dropdown (per-field). None removes the config.',
},
// ── User actions ──
{ field: 'userActions', type: 'composite', helpText: 'Toolbar toggles (search, sort, filter, row height)' },
{ field: 'addRecord', type: 'composite', helpText: 'Add-record entry point' },
{ field: 'showRecordCount', helpText: 'Show the record count bar' },
// Less-common — kept last.
{ field: 'filterBy', type: 'repeater', helpText: 'Always-on page filter (in addition to the source view)' },
{ field: 'levels', helpText: 'Hierarchy levels to display (tree-like sources)' },
{ field: 'allowPrinting', helpText: 'Allow users to print this page' },
],
},
Expand Down
19 changes: 16 additions & 3 deletions packages/spec/src/ui/page.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
UserFiltersSchema,
ViewFilterRuleSchema,
AddRecordConfigSchema,
ListColumnSchema,
} from './view.zod';

/**
Expand Down Expand Up @@ -226,10 +227,22 @@ export const RecordReviewConfigSchema = lazySchema(() => z.object({
export const InterfacePageConfigSchema = lazySchema(() => z.object({
/** Data binding (ADR-0047: pages REFERENCE views, never restate them) */
source: z.string().optional().describe('Source object name for the page'),
sourceView: z.string().optional()
.describe('Named list view on the source object to inherit columns/filter/sort from (ADR-0047 iron rule: the page adds presentation policy only). Omit to use the object default view'),

// ADR-0047 (revised): the page carries its OWN view metadata — columns, sort
// and base filter are defined directly here (Airtable parity: there is no
// "inherit from a named view" concept). The page IS the view definition.
columns: z.union([z.array(z.string()), z.array(ListColumnSchema)]).optional()
.describe('Columns shown by the page. Blank = all object fields. Defined directly on the page (no view inheritance).'),
sort: z.array(SortItemSchema).optional()
.describe('Default sort order for the page, defined directly on the page.'),
filterBy: z.array(ViewFilterRuleSchema).optional().describe('Always-on page filter (base filter).'),
levels: z.number().int().min(1).optional().describe('Number of hierarchy levels to display'),
filterBy: z.array(ViewFilterRuleSchema).optional().describe('Page-level filter criteria'),

/** @deprecated Back-compat only. Pre-revision pages inherited columns/filter/sort
* from a named object view; new pages define `columns`/`sort`/`filterBy` directly.
* Still honored at runtime as a fallback when the page has no own `columns`. */
sourceView: z.string().optional()
.describe('@deprecated Legacy named-view inheritance. Define columns/sort/filterBy on the page instead.'),

/** Appearance — `appearance.allowedVisualizations` is the runtime visualization whitelist */
appearance: AppearanceConfigSchema.optional().describe('Appearance and visualization configuration'),
Expand Down
Loading