From db2c944303463cb567bbb7421a7ef4aecb88ea72 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Fri, 19 Jun 2026 22:03:20 +0800 Subject: [PATCH 1/2] feat(spec): interface pages own their view metadata directly (revise ADR-0047 iron rule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Airtable parity: an interface (list) page now defines its data surface DIRECTLY as metadata — columns, base filter, sort — instead of inheriting them from a referenced object view ("sourceView"). There is no "inherit from a separate view" concept anymore; the page IS the view definition. Spec (packages/spec/src/ui): - page.zod.ts — InterfacePageConfigSchema gains its own `columns` (string[] | ListColumnSchema[]) and `sort`; `filterBy` clarified as the always-on base filter. `sourceView` is now @deprecated, kept only as a runtime back-compat fallback. - page.form.ts — the inspector follows Airtable's IA: Data (Source → Columns → Filter By → Levels) · Appearance · User filters · User actions. The "Source View" picker is removed and replaced by a Columns field picker (widget: field-multi, dependsOn: source). Iron-rule helptext rewritten. Showcase migrated to the new model (define columns directly, drop sourceView): - task-triage.page.ts, task-workbench.page.ts. Pairs with the objectui runtime change that reads page-owned columns/sort with a view→object fallback. Browser-verified: the page editor shows Source/Columns/ Filter By (no Source View); picking columns re-renders the preview live; both showcase pages render their own column sets (workbench shows Estimate (h), which the object default view omits — proving independence). Co-Authored-By: Claude Opus 4.8 --- .../src/pages/task-triage.page.ts | 4 ++-- .../src/pages/task-workbench.page.ts | 6 +++--- packages/spec/src/ui/page.form.ts | 21 +++++++++++-------- packages/spec/src/ui/page.zod.ts | 19 ++++++++++++++--- 4 files changed, 33 insertions(+), 17 deletions(-) diff --git a/examples/app-showcase/src/pages/task-triage.page.ts b/examples/app-showcase/src/pages/task-triage.page.ts index aa7c006111..dadea085e0 100644 --- a/examples/app-showcase/src/pages/task-triage.page.ts +++ b/examples/app-showcase/src/pages/task-triage.page.ts @@ -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. diff --git a/examples/app-showcase/src/pages/task-workbench.page.ts b/examples/app-showcase/src/pages/task-workbench.page.ts index d2235586bc..44abf28a99 100644 --- a/examples/app-showcase/src/pages/task-workbench.page.ts +++ b/examples/app-showcase/src/pages/task-workbench.page.ts @@ -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: { diff --git a/packages/spec/src/ui/page.form.ts b/packages/spec/src/ui/page.form.ts index 836e7c54db..1af9477354 100644 --- a/packages/spec/src/ui/page.form.ts +++ b/packages/spec/src/ui/page.form.ts @@ -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, @@ -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). @@ -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' }, ], }, diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 983fc01635..c6a17ca3eb 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -13,6 +13,7 @@ import { UserFiltersSchema, ViewFilterRuleSchema, AddRecordConfigSchema, + ListColumnSchema, } from './view.zod'; /** @@ -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'), From cdca46fa8924a3d628745ae24fef7f9cdbea77b8 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Fri, 19 Jun 2026 22:05:54 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs(adr-0047):=20revise=20=E2=80=94=20inte?= =?UTF-8?q?rface=20pages=20own=20columns/sort/filterBy=20directly=20(super?= =?UTF-8?q?sede=20iron=20rule)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/adr/0047-object-ui-run-modes.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/adr/0047-object-ui-run-modes.md b/docs/adr/0047-object-ui-run-modes.md index 6763ba4417..005c29b07d 100644 --- a/docs/adr/0047-object-ui-run-modes.md +++ b/docs/adr/0047-object-ui-run-modes.md @@ -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 @@ -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.