Skip to content

Commit 04ed478

Browse files
os-zhuangclaude
andauthored
feat(spec): interface pages own their view metadata directly (revise ADR-0047 iron rule) (#2050)
* feat(spec): interface pages own their view metadata directly (revise ADR-0047 iron rule) 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 <noreply@anthropic.com> * docs(adr-0047): revise — interface pages own columns/sort/filterBy directly (supersede iron rule) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent dbc749e commit 04ed478

5 files changed

Lines changed: 44 additions & 19 deletions

File tree

docs/adr/0047-object-ui-run-modes.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
# ADR-0047: Two run modes for object UI — data views vs interface pages, user filters, and runtime visualization choice
22

3-
**Status**: Proposed (2026-06-12)
3+
**Status**: Proposed (2026-06-12) · **Revised 2026-06-19**
4+
5+
> **Revision (2026-06-19) — the "iron rule" is superseded.** Validating the
6+
> Studio config panel against Airtable showed Airtable has no "inherit from a
7+
> separate view" concept: an interface page defines its data surface (columns,
8+
> sort, base filter, visualizations, toolbar) **directly**. ObjectStack now
9+
> matches this — `InterfacePageConfig` carries its own `columns`/`sort`/`filterBy`
10+
> and the page editor edits them in place. `sourceView` is deprecated and kept
11+
> only as a runtime back-compat fallback. TL;DR #2 below no longer holds; the
12+
> rest of the ADR (two run modes, userFilters, visualization whitelist) stands.
413
**Deciders**: ObjectStack Protocol Architects
514
**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**)
615
**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 @@
1221
## TL;DR
1322

1423
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.
15-
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.
24+
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).
1625
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.
1726
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.
1827
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.

examples/app-showcase/src/pages/task-triage.page.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ export const TaskTriagePage: Page = {
2424
regions: [],
2525
interfaceConfig: {
2626
source: 'showcase_task',
27-
// Inherit columns/filter/sort from the object's default view (ADR-0047).
28-
sourceView: 'default',
27+
// ADR-0047 (revised): columns defined directly on the page (no inheritance).
28+
columns: ['title', 'project', 'assignee', 'status', 'priority', 'due_date', 'progress'],
2929

3030
// End-user filter element: a row of preset tabs. `showAllRecords` adds the
3131
// leading unfiltered "All" tab automatically.

examples/app-showcase/src/pages/task-workbench.page.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ export const TaskWorkbenchPage: Page = {
3434
interfaceConfig: {
3535
source: 'showcase_task',
3636

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

4141
// End-user quick filters — the only filtering surface on this page.
4242
userFilters: {

packages/spec/src/ui/page.form.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export const pageForm = defineForm({
6868
{
6969
name: 'interface',
7070
label: 'Interface (list pages)',
71-
description: 'ADR-0047 interface mode: bind a source view and curate the end-user surface — quick filters, locked visualizations, toolbar actions (Airtable Interfaces parity).',
71+
description: 'Interface mode (Airtable parity): the page defines its own data surface directly — columns, filters, visualizations and toolbar — no inheriting from a separate view.',
7272
collapsible: true,
7373
// Primary content for a list page — open by default (still collapsible).
7474
collapsed: false,
@@ -78,7 +78,7 @@ export const pageForm = defineForm({
7878
field: 'interfaceConfig',
7979
type: 'composite',
8080
helpText:
81-
'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.',
81+
'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.',
8282
// Order: common authoring controls first, rarely-used ones last.
8383
// Explicit sub-fields so `userFilters` can use the dedicated
8484
// filter-mode selector (None / Tabs / Dropdown, ADR-0047 §3.4a).
@@ -87,22 +87,25 @@ export const pageForm = defineForm({
8787
// (`element: 'toggle'` stays valid but deprecated — not offered.)
8888
// Keep this list in sync with InterfacePageConfigSchema.
8989
fields: [
90-
{ field: 'source', widget: 'ref:object', helpText: 'Object this list reads from' },
91-
// Pick from the source object's views instead of typing a name.
92-
// `dependsOn: 'source'` tells the picker which object's views to list.
93-
{ field: 'sourceView', widget: 'view-ref', dependsOn: 'source', helpText: 'Named list view to inherit columns/filter/sort from (blank = object default)' },
90+
// ── Data ── the page defines its own data surface directly.
91+
{ field: 'source', widget: 'ref:object', helpText: 'Object this page reads from' },
92+
// Columns are defined ON the page (no view inheritance). `dependsOn:
93+
// 'source'` tells the picker which object's fields to offer.
94+
{ field: 'columns', widget: 'field-multi', dependsOn: 'source', helpText: 'Columns to show — defined directly on the page (blank = all object fields)' },
95+
{ field: 'filterBy', type: 'repeater', helpText: 'Always-on base filter for the page' },
96+
{ field: 'levels', helpText: 'Hierarchy levels to display (tree-like sources)' },
97+
// ── Appearance ──
9498
{ field: 'appearance', type: 'composite', helpText: 'Allowed visualizations (Grid / Kanban / Calendar / …) and description visibility' },
99+
// ── User filters ──
95100
{
96101
field: 'userFilters',
97102
widget: 'filter-mode',
98103
helpText: 'End-user filter bar: None (no bar) / Tabs (named presets) / Dropdown (per-field). None removes the config.',
99104
},
105+
// ── User actions ──
100106
{ field: 'userActions', type: 'composite', helpText: 'Toolbar toggles (search, sort, filter, row height)' },
101107
{ field: 'addRecord', type: 'composite', helpText: 'Add-record entry point' },
102108
{ field: 'showRecordCount', helpText: 'Show the record count bar' },
103-
// Less-common — kept last.
104-
{ field: 'filterBy', type: 'repeater', helpText: 'Always-on page filter (in addition to the source view)' },
105-
{ field: 'levels', helpText: 'Hierarchy levels to display (tree-like sources)' },
106109
{ field: 'allowPrinting', helpText: 'Allow users to print this page' },
107110
],
108111
},

packages/spec/src/ui/page.zod.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
UserFiltersSchema,
1414
ViewFilterRuleSchema,
1515
AddRecordConfigSchema,
16+
ListColumnSchema,
1617
} from './view.zod';
1718

1819
/**
@@ -226,10 +227,22 @@ export const RecordReviewConfigSchema = lazySchema(() => z.object({
226227
export const InterfacePageConfigSchema = lazySchema(() => z.object({
227228
/** Data binding (ADR-0047: pages REFERENCE views, never restate them) */
228229
source: z.string().optional().describe('Source object name for the page'),
229-
sourceView: z.string().optional()
230-
.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'),
230+
231+
// ADR-0047 (revised): the page carries its OWN view metadata — columns, sort
232+
// and base filter are defined directly here (Airtable parity: there is no
233+
// "inherit from a named view" concept). The page IS the view definition.
234+
columns: z.union([z.array(z.string()), z.array(ListColumnSchema)]).optional()
235+
.describe('Columns shown by the page. Blank = all object fields. Defined directly on the page (no view inheritance).'),
236+
sort: z.array(SortItemSchema).optional()
237+
.describe('Default sort order for the page, defined directly on the page.'),
238+
filterBy: z.array(ViewFilterRuleSchema).optional().describe('Always-on page filter (base filter).'),
231239
levels: z.number().int().min(1).optional().describe('Number of hierarchy levels to display'),
232-
filterBy: z.array(ViewFilterRuleSchema).optional().describe('Page-level filter criteria'),
240+
241+
/** @deprecated Back-compat only. Pre-revision pages inherited columns/filter/sort
242+
* from a named object view; new pages define `columns`/`sort`/`filterBy` directly.
243+
* Still honored at runtime as a fallback when the page has no own `columns`. */
244+
sourceView: z.string().optional()
245+
.describe('@deprecated Legacy named-view inheritance. Define columns/sort/filterBy on the page instead.'),
233246

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

0 commit comments

Comments
 (0)