From 864c7e29bf5bad63ee6a5ba956b15d2f7b41da85 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:58:35 +0500 Subject: [PATCH 1/3] =?UTF-8?q?feat(spec):=20ADR-0047=20phase=201=20?= =?UTF-8?q?=E2=80=94=20UserFiltersSchema,=20visualization=20whitelist,=20v?= =?UTF-8?q?iew=20reference=20diagnostics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserFilterFieldSchema/UserFiltersSchema in ui/view.zod.ts, attached as ListViewSchema.userFilters (blesses the shape objectui already renders) - 'chart' added to VisualizationTypeSchema (8-type parity with ListView.type); appearance.allowedVisualizations remains the runtime whitelist - InterfacePageConfig: userFilters now uses the real UserFiltersSchema (replaces the misplaced elements visualization enum, zero prior usage); new sourceView for the ADR-0047 iron rule (pages reference views) - computeViewReferenceDiagnostics in objectql: cross-document checks Zod cannot express (userFilters/tabs fields exist on source object, kanban groupBy is select-like), merged into _diagnostics on view getMetaItem Co-Authored-By: Claude Fable 5 --- .../objectql/src/metadata-diagnostics.test.ts | 83 +++++++++++++++++++ packages/objectql/src/metadata-diagnostics.ts | 80 ++++++++++++++++++ packages/objectql/src/protocol.ts | 37 ++++++++- packages/spec/src/ui/page.test.ts | 4 +- packages/spec/src/ui/page.zod.ts | 17 ++-- packages/spec/src/ui/view.test.ts | 67 +++++++++++++++ packages/spec/src/ui/view.zod.ts | 54 +++++++++++- 7 files changed, 329 insertions(+), 13 deletions(-) create mode 100644 packages/objectql/src/metadata-diagnostics.test.ts diff --git a/packages/objectql/src/metadata-diagnostics.test.ts b/packages/objectql/src/metadata-diagnostics.test.ts new file mode 100644 index 0000000000..d503cf829a --- /dev/null +++ b/packages/objectql/src/metadata-diagnostics.test.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0047 — reference-integrity diagnostics for list views. + * + * `computeViewReferenceDiagnostics` covers what the per-type Zod schema + * cannot: every field referenced by the user-facing filter surface must + * exist on the source object, and binding-dependent visualizations must + * have resolvable bindings. + */ + +import { describe, expect, it } from 'vitest'; +import { computeViewReferenceDiagnostics } from './metadata-diagnostics.js'; + +const objectDef = { + fields: { + name: { type: 'text' }, + industry: { type: 'select' }, + status: { type: 'select' }, + is_active: { type: 'boolean' }, + due_date: { type: 'date' }, + }, +}; + +describe('computeViewReferenceDiagnostics (ADR-0047)', () => { + it('passes when every reference resolves', () => { + const result = computeViewReferenceDiagnostics({ + userFilters: { + element: 'dropdown', + fields: [{ field: 'industry' }, { field: 'is_active' }], + tabs: [{ name: 't', filter: [{ field: 'status', operator: 'equals', value: 'x' }] }], + }, + tabs: [{ name: 'a', filter: [{ field: 'industry', operator: 'equals', value: 'technology' }] }], + filterableFields: ['status'], + kanban: { groupByField: 'status', columns: ['name'] }, + }, objectDef); + expect(result.valid).toBe(true); + }); + + it('flags userFilters fields missing on the object', () => { + const result = computeViewReferenceDiagnostics({ + userFilters: { element: 'dropdown', fields: [{ field: 'no_such_field' }] }, + }, objectDef); + expect(result.valid).toBe(false); + expect(result.errors?.[0]).toMatchObject({ + path: 'userFilters.fields.0.field', + code: 'reference_not_found', + }); + }); + + it('flags tab filter rules pointing at unknown fields', () => { + const result = computeViewReferenceDiagnostics({ + tabs: [{ name: 'bad', filter: [{ field: 'ghost', operator: 'equals', value: 1 }] }], + }, objectDef); + expect(result.valid).toBe(false); + expect(result.errors?.[0].path).toBe('tabs.0.filter.0.field'); + }); + + it('flags kanban groupBy on a non-select-like field', () => { + const result = computeViewReferenceDiagnostics({ + kanban: { groupByField: 'due_date', columns: ['name'] }, + }, objectDef); + expect(result.valid).toBe(false); + expect(result.errors?.[0]).toMatchObject({ + path: 'kanban.groupByField', + code: 'invalid_binding', + }); + }); + + it('supports array-shaped field definitions', () => { + const result = computeViewReferenceDiagnostics({ + filterableFields: ['priority', 'missing'], + }, { fields: [{ name: 'priority', type: 'select' }] }); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors?.[0].path).toBe('filterableFields.1'); + }); + + it('is permissive when the view has no filter surface', () => { + expect(computeViewReferenceDiagnostics({}, objectDef).valid).toBe(true); + expect(computeViewReferenceDiagnostics({}, {}).valid).toBe(true); + }); +}); diff --git a/packages/objectql/src/metadata-diagnostics.ts b/packages/objectql/src/metadata-diagnostics.ts index 1949bdf0d1..77b09d71d1 100644 --- a/packages/objectql/src/metadata-diagnostics.ts +++ b/packages/objectql/src/metadata-diagnostics.ts @@ -114,3 +114,83 @@ export function decorateMetadataItems(type: string, items: T[]): T[] { if (!Array.isArray(items)) return items; return items.map((item) => decorateMetadataItem(type, item)); } + +// --------------------------------------------------------------------------- +// ADR-0047 — reference-integrity diagnostics for list views +// --------------------------------------------------------------------------- + +/** Minimal object-definition shape the reference checker needs. */ +interface ObjectDefLike { + fields?: Record | Array<{ name: string; type?: string }>; +} + +function fieldMap(objectDef: ObjectDefLike): Map { + const map = new Map(); + const fields = objectDef?.fields; + if (Array.isArray(fields)) { + for (const f of fields) if (f?.name) map.set(f.name, f); + } else if (fields && typeof fields === 'object') { + for (const [name, f] of Object.entries(fields)) map.set(name, f ?? {}); + } + return map; +} + +/** + * Cross-document reference checks Zod cannot express: every field a list + * view's user-facing filter surface points at must exist on the source + * object, and binding-dependent visualizations must have resolvable + * bindings (kanban → select-like `groupByField`). + * + * Pure function — callers (read decoration, the ADR-0033 AI apply loop) + * supply the already-resolved object definition. Returns `{ valid: true }` + * when every reference resolves; errors use the same wire shape as + * {@link computeMetadataDiagnostics} so consumers can merge the two. + * + * Spec-shape validation stays in `computeMetadataDiagnostics`; this only + * covers what a schema alone cannot see. + */ +export function computeViewReferenceDiagnostics( + view: Record, + objectDef: ObjectDefLike, +): MetadataDiagnostics { + const fields = fieldMap(objectDef); + const errors: NonNullable = []; + const requireField = (name: unknown, path: string) => { + if (typeof name !== 'string' || !name) return; + if (!fields.has(name)) { + errors.push({ + path, + message: `Field "${name}" does not exist on the source object`, + code: 'reference_not_found', + }); + } + }; + + const userFilters = view?.userFilters as + | { fields?: Array<{ field?: string }>; tabs?: Array<{ filter?: Array<{ field?: string }> }> } + | undefined; + userFilters?.fields?.forEach((f, i) => requireField(f?.field, `userFilters.fields.${i}.field`)); + userFilters?.tabs?.forEach((t, i) => + t?.filter?.forEach((r, j) => requireField(r?.field, `userFilters.tabs.${i}.filter.${j}.field`))); + + (view?.tabs as Array<{ filter?: Array<{ field?: string }> }> | undefined)?.forEach((t, i) => + t?.filter?.forEach((r, j) => requireField(r?.field, `tabs.${i}.filter.${j}.field`))); + + (view?.filterableFields as string[] | undefined)?.forEach((f, i) => + requireField(f, `filterableFields.${i}`)); + + const kanban = view?.kanban as { groupByField?: string } | undefined; + if (kanban?.groupByField) { + requireField(kanban.groupByField, 'kanban.groupByField'); + const def = fields.get(kanban.groupByField); + if (def && def.type && !['select', 'multi-select', 'boolean', 'lookup', 'master_detail'].includes(def.type)) { + errors.push({ + path: 'kanban.groupByField', + message: `Field "${kanban.groupByField}" (type "${def.type}") cannot group a kanban — use a select-like field`, + code: 'invalid_binding', + }); + } + } + + return errors.length ? { valid: false, errors } : { valid: true }; +} diff --git a/packages/objectql/src/protocol.ts b/packages/objectql/src/protocol.ts index 3311af5fda..282c95b04e 100644 --- a/packages/objectql/src/protocol.ts +++ b/packages/objectql/src/protocol.ts @@ -32,6 +32,7 @@ import { import { z } from 'zod'; import { computeMetadataDiagnostics, + computeViewReferenceDiagnostics, decorateMetadataItem, decorateMetadataItems, type MetadataDiagnostics, @@ -1568,10 +1569,44 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { // declaration; we must consult the in-memory artifact registry // directly and let its protection envelope override. const artifactItem = this.lookupArtifactItem(request.type, request.name); - const decorated = decorateMetadataItem( + let decorated = decorateMetadataItem( request.type, mergeArtifactProtection(item, artifactItem), ); + // ADR-0047 — list views additionally get reference-integrity + // diagnostics (userFilters/tabs fields must exist on the source + // object, kanban groupBy must be select-like). Zod cannot see + // across documents; merge the cross-document errors into the + // same `_diagnostics` envelope. Defensive: a failed lookup must + // never break a read. + if ((request.type === 'view' || request.type === 'views') && decorated && typeof decorated === 'object') { + try { + const viewDoc = decorated as Record; + const sourceObject = viewDoc?.object + ?? viewDoc?.data?.object + ?? viewDoc?.objectName + ?? viewDoc?.list?.data?.object; + const objectDef = typeof sourceObject === 'string' + ? this.engine.registry.getObject(sourceObject) + : undefined; + if (objectDef) { + const refs = computeViewReferenceDiagnostics(viewDoc, objectDef as any); + if (!refs.valid) { + const prior = viewDoc._diagnostics; + decorated = { + ...viewDoc, + _diagnostics: { + valid: false, + errors: [ + ...(prior && prior.valid === false && Array.isArray(prior.errors) ? prior.errors : []), + ...(refs.errors ?? []), + ], + }, + } as typeof decorated; + } + } + } catch { /* reference diagnostics are best-effort */ } + } // ADR-0010 — surface lock/provenance flags so Studio can render // the correct affordances without a second round trip. const artifactBacked = this.isArtifactBacked(request.type, request.name); diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index 2ce30d1881..609c1230b5 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -1017,7 +1017,7 @@ describe('InterfacePageConfigSchema', () => { allowedVisualizations: ['grid', 'gallery', 'kanban'], }, userFilters: { - elements: ['grid', 'gallery', 'kanban'], + element: 'tabs', tabs: [ { name: 'my_customers', label: 'my customers', isDefault: true }, { name: 'all_records', label: 'All records' }, @@ -1085,7 +1085,7 @@ describe('PageSchema with interfaceConfig', () => { allowedVisualizations: ['grid', 'gallery', 'kanban'], }, userFilters: { - elements: ['grid', 'gallery', 'kanban'], + element: 'tabs', tabs: [ { name: 'my_customers', label: 'my customers', isDefault: true, pinned: true }, { name: 'all_records', label: 'All records' }, diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 198e6af430..d48db2f950 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -10,7 +10,7 @@ import { ResponsiveConfigSchema } from './responsive.zod'; import { UserActionsConfigSchema, AppearanceConfigSchema, - ViewTabSchema, + UserFiltersSchema, ViewFilterRuleSchema, AddRecordConfigSchema, } from './view.zod'; @@ -213,20 +213,19 @@ export const RecordReviewConfigSchema = lazySchema(() => z.object({ * @see Airtable Interface → right panel (Page / Data / Appearance / User filters / User actions / Advanced) */ export const InterfacePageConfigSchema = lazySchema(() => z.object({ - /** Data binding */ + /** 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'), levels: z.number().int().min(1).optional().describe('Number of hierarchy levels to display'), filterBy: z.array(ViewFilterRuleSchema).optional().describe('Page-level filter criteria'), - /** Appearance */ + /** Appearance — `appearance.allowedVisualizations` is the runtime visualization whitelist */ appearance: AppearanceConfigSchema.optional().describe('Appearance and visualization configuration'), - /** User filters */ - userFilters: z.object({ - elements: z.array(z.enum(['grid', 'gallery', 'kanban'])).optional() - .describe('Visualization element types available in user filter bar'), - tabs: z.array(ViewTabSchema).optional().describe('User-configurable tabs'), - }).optional().describe('User filter configuration'), + /** User filters (ADR-0047) */ + userFilters: UserFiltersSchema.optional() + .describe('End-user quick-filter bar for this page (overrides the source view\'s userFilters)'), /** User actions */ userActions: UserActionsConfigSchema.optional().describe('User action toggles'), diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 1dccaff91e..2b06d126c6 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -26,6 +26,7 @@ import { UserActionsConfigSchema, AppearanceConfigSchema, ViewTabSchema, + UserFiltersSchema, ViewFilterRuleSchema, AddRecordConfigSchema, type View, @@ -2098,6 +2099,72 @@ describe('ViewTabSchema', () => { }); }); +describe('UserFiltersSchema (ADR-0047)', () => { + it('should default element to dropdown', () => { + const uf = UserFiltersSchema.parse({}); + expect(uf.element).toBe('dropdown'); + }); + + it('should accept dropdown fields with inference defaults', () => { + const uf = UserFiltersSchema.parse({ + element: 'dropdown', + fields: [ + { field: 'industry' }, + { field: 'rating', label: '评级', showCount: true }, + ], + }); + expect(uf.fields).toHaveLength(2); + expect(uf.fields?.[0].type).toBeUndefined(); // inferred by renderer + }); + + it('should accept tabs element reusing ViewTabSchema presets', () => { + const uf = UserFiltersSchema.parse({ + element: 'tabs', + showAllRecords: true, + tabs: [ + { name: 'tech_companies', label: '科技公司', filter: [{ field: 'industry', operator: 'equals', value: 'technology' }] }, + { name: 'finance_companies', label: '金融公司', filter: [{ field: 'industry', operator: 'equals', value: 'finance' }], isDefault: true }, + ], + }); + expect(uf.tabs).toHaveLength(2); + expect(uf.tabs?.[1].isDefault).toBe(true); + }); + + it('should accept static options with values and colors', () => { + const uf = UserFiltersSchema.parse({ + fields: [{ + field: 'status', + type: 'select', + options: [ + { value: 'active', label: 'Active', color: '#22c55e' }, + { value: 1, label: 'One' }, + { value: true, label: 'Yes' }, + ], + defaultValues: ['active'], + }], + }); + expect(uf.fields?.[0].options).toHaveLength(3); + expect(uf.fields?.[0].defaultValues).toEqual(['active']); + }); + + it('should reject unknown element style', () => { + expect(() => UserFiltersSchema.parse({ element: 'sidebar' })).toThrow(); + }); + + it('should attach to ListViewSchema.userFilters', () => { + const view = ListViewSchema.parse({ + type: 'grid', + columns: ['name', 'industry'], + userFilters: { + element: 'dropdown', + fields: [{ field: 'industry' }], + }, + }); + expect(view.userFilters?.element).toBe('dropdown'); + expect(view.userFilters?.fields?.[0].field).toBe('industry'); + }); +}); + describe('AddRecordConfigSchema', () => { it('should apply default values', () => { const config = AddRecordConfigSchema.parse({}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index e1a4e404ce..20540d6f37 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -224,6 +224,7 @@ export const VisualizationTypeSchema = lazySchema(() => z.enum([ 'timeline', 'gantt', 'map', + 'chart', ]).describe('Visualization type that users can switch to')); /** @@ -273,6 +274,51 @@ export const ViewTabSchema = lazySchema(() => z.object({ visible: z.boolean().default(true).describe('Tab visibility'), }).describe('Tab configuration for multi-tab view interface')); +/** + * User Filter Field Schema (ADR-0047) + * One field exposed as a quick-filter control in the end-user filter bar. + * Rendering details (widget, options) default to inference from the field + * definition on the source object — authors only override when needed. + * + * @see Airtable Interface → "User filters" panel (Dropdowns element) + */ +export const UserFilterFieldSchema = lazySchema(() => z.object({ + field: z.string().describe('Field name on the source object (must exist — checked by reference diagnostics)'), + label: I18nLabelSchema.optional().describe('Display label override (defaults to the field label)'), + type: z.enum(['select', 'multi-select', 'boolean', 'date-range', 'text']).optional() + .describe('Filter control type. Omit to infer from the field definition'), + options: z.array(z.object({ + value: z.union([z.string(), z.number(), z.boolean()]).describe('Option value'), + label: I18nLabelSchema.describe('Option label'), + color: z.string().optional().describe('Option color token/hex'), + })).optional().describe('Static options. Omit to derive from the field definition (select options / lookup records)'), + showCount: z.boolean().optional().describe('Show per-option record counts'), + defaultValues: z.array(z.union([z.string(), z.number(), z.boolean()])).optional() + .describe('Pre-selected values when the view loads'), +}).describe('Quick-filter field configuration')); + +/** + * User Filters Schema (ADR-0047, Airtable Interface parity) + * The end-user-facing quick-filter surface above a list. The author picks the + * element style and which fields/presets are exposed; end users combine them + * at runtime (session-scoped — selections never persist as metadata). + * + * Distinct from `ListView.filter` (the always-on base criteria) and from the + * advanced filter builder (`userActions.filter` toggle). + * + * @see Airtable Interface → "User filters" panel (Elements: tabs / dropdowns) + */ +export const UserFiltersSchema = lazySchema(() => z.object({ + element: z.enum(['dropdown', 'tabs', 'toggle']).default('dropdown') + .describe('Filter control style: dropdown selectors per field, tab presets, or on/off toggles'), + fields: z.array(UserFilterFieldSchema).optional() + .describe('Fields exposed as quick filters (dropdown/toggle elements)'), + tabs: z.array(ViewTabSchema).optional() + .describe('Named filter presets rendered as tabs (tabs element). Reuses ViewTabSchema'), + showAllRecords: z.boolean().optional() + .describe('Show an "All records" tab before the presets (tabs element)'), +}).describe('End-user quick-filter configuration (Airtable "User filters" parity)')); + /** * Add Record Configuration Schema (Airtable Interface parity) * Configures the "Add Record" entry point for a list view. @@ -429,7 +475,11 @@ export const ListViewSchema = lazySchema(() => z.object({ /** Search & Filter */ searchableFields: z.array(z.string()).optional().describe('Fields enabled for search'), - filterableFields: z.array(z.string()).optional().describe('Fields enabled for end-user filtering in the top bar'), + filterableFields: z.array(z.string()).optional().describe('Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters'), + + /** User Filters (ADR-0047, Airtable Interface parity) */ + userFilters: UserFiltersSchema.optional() + .describe('End-user quick-filter bar: dropdown/toggle fields or tab presets. Omit to let the renderer derive filters from select/boolean fields'), /** Grid Features */ resizable: z.boolean().optional().describe('Enable column resizing'), @@ -1166,4 +1216,6 @@ export type VisualizationType = z.infer; export type UserActionsConfig = z.infer; export type AppearanceConfig = z.infer; export type ViewTab = z.infer; +export type UserFilterField = z.infer; +export type UserFilters = z.infer; export type AddRecordConfig = z.infer; From 4dda3929cced31da515aa8530f0aa04ac3134c54 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:10:40 +0500 Subject: [PATCH 2/3] =?UTF-8?q?feat(showcase):=20ADR-0047=20phase=203=20?= =?UTF-8?q?=E2=80=94=20userFilters/tabs/visualizations=20examples=20+=20sk?= =?UTF-8?q?ill=20decision=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - showcase_task default view gains filter tabs, userFilters dropdowns (status/priority/done) and an allowedVisualizations whitelist — the few-shot corpus for AI authors was previously empty - new Task Workbench interface page: references the default view (sourceView), locked grid, author-curated dropdowns, filter:false — the canonical interface-mode example; nav entry added next to the data-mode Tasks entry - objectstack-ui skill: two-run-modes decision table, default-to-data-mode rule, the iron rule, and the userFilters/tabs/appearance authoring block Co-Authored-By: Claude Fable 5 --- examples/app-showcase/src/apps/index.ts | 2 + examples/app-showcase/src/pages/index.ts | 1 + .../src/pages/task-workbench.page.ts | 66 ++++++++++++++++ examples/app-showcase/src/views/task.view.ts | 29 +++++++ skills/objectstack-ui/SKILL.md | 76 +++++++++++++++++++ 5 files changed, 174 insertions(+) create mode 100644 examples/app-showcase/src/pages/task-workbench.page.ts diff --git a/examples/app-showcase/src/apps/index.ts b/examples/app-showcase/src/apps/index.ts index a0655affd8..8f39642116 100644 --- a/examples/app-showcase/src/apps/index.ts +++ b/examples/app-showcase/src/apps/index.ts @@ -51,6 +51,8 @@ export const ShowcaseApp = App.create({ children: [ { id: 'nav_gallery', type: 'page', pageName: 'showcase_component_gallery', label: 'Component Gallery', icon: 'layout-template' }, { id: 'nav_project_workspace', type: 'page', pageName: 'showcase_project_workspace', label: 'New Project + Tasks', icon: 'folder-plus' }, + // ADR-0047 interface mode: same object as nav_tasks, curated surface. + { id: 'nav_task_workbench', type: 'page', pageName: 'showcase_task_workbench', label: 'Task Workbench', icon: 'sliders-horizontal' }, ], }, ], diff --git a/examples/app-showcase/src/pages/index.ts b/examples/app-showcase/src/pages/index.ts index 3dfb36a70e..039e1ba9e2 100644 --- a/examples/app-showcase/src/pages/index.ts +++ b/examples/app-showcase/src/pages/index.ts @@ -4,6 +4,7 @@ import type { Page } from '@objectstack/spec/ui'; export { ProjectWorkspacePage } from './project-workspace.page.js'; export { ProjectDetailPage } from './project-detail.page.js'; +export { TaskWorkbenchPage } from './task-workbench.page.js'; /** * Component Gallery — a custom page that places a spread of standard page diff --git a/examples/app-showcase/src/pages/task-workbench.page.ts b/examples/app-showcase/src/pages/task-workbench.page.ts new file mode 100644 index 0000000000..d2235586bc --- /dev/null +++ b/examples/app-showcase/src/pages/task-workbench.page.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Page } from '@objectstack/spec/ui'; + +/** + * Task Workbench — the canonical **interface page** example (ADR-0047). + * + * Demonstrates the second run mode for object UI: where the object nav + * entry ("Tasks") shows every list view as switcher tabs and lets users + * create their own views (data mode), this page is an author-curated + * surface (interface mode): + * + * • it REFERENCES the object's default list view (`sourceView` — + * columns/filter/sort are inherited, never restated here); + * • end users get exactly the quick filters the author enabled + * (status + priority dropdowns) — nothing else; + * • the visualization is locked to grid (no switcher); + * • view creation / advanced filtering are not offered. + * + * Mirrors Airtable's Interfaces right panel: Data (source), User filters + * (Elements: dropdowns), Appearance (Visualizations), User actions. + */ +export const TaskWorkbenchPage: Page = { + name: 'showcase_task_workbench', + label: 'Task Workbench', + type: 'list', + object: 'showcase_task', + kind: 'full', + template: 'default', + isDefault: false, + // Interface pages carry no regions — the list surface is generated from + // `interfaceConfig` (ADR-0047), not composed from components. + regions: [], + 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', + + // End-user quick filters — the only filtering surface on this page. + userFilters: { + element: 'dropdown', + fields: [ + { field: 'status' }, + { field: 'priority', showCount: true }, + ], + }, + + // Locked visualization: a single-entry whitelist renders no switcher. + appearance: { + showDescription: true, + allowedVisualizations: ['grid'], + }, + + userActions: { + sort: true, + search: true, + filter: false, // no advanced filter builder on a curated page + rowHeight: false, + addRecordForm: false, + }, + + showRecordCount: true, + }, +}; diff --git a/examples/app-showcase/src/views/task.view.ts b/examples/app-showcase/src/views/task.view.ts index 83db745682..0df925717c 100644 --- a/examples/app-showcase/src/views/task.view.ts +++ b/examples/app-showcase/src/views/task.view.ts @@ -25,6 +25,35 @@ export const TaskViews = defineView({ { field: 'due_date' }, { field: 'progress' }, ], + + // ADR-0047 — in-view filter tabs (ViewTab presets). Each tab applies + // its own filter rules on top of the view's base criteria; the first + // tab is the unfiltered default. + tabs: [ + { name: 'all_tasks', label: 'All', isDefault: true }, + { name: 'in_progress', label: 'In Progress', filter: [{ field: 'status', operator: 'equals', value: 'in_progress' }] }, + { name: 'urgent', label: 'Urgent', icon: 'flame', filter: [{ field: 'priority', operator: 'equals', value: 'urgent' }] }, + { name: 'done', label: 'Done', filter: [{ field: 'status', operator: 'equals', value: 'done' }] }, + ], + + // ADR-0047 — end-user quick-filter dropdowns (Airtable "User filters"). + // Options/labels are inferred from the field definitions; `priority` + // shows per-option record counts. + userFilters: { + element: 'dropdown', + fields: [ + { field: 'status' }, + { field: 'priority', showCount: true }, + { field: 'done', type: 'boolean' }, + ], + }, + + // ADR-0047 — runtime visualization whitelist (Airtable "Appearance → + // Visualizations"). Users can flip between these renderers; types + // whose bindings don't resolve are hidden by the client regardless. + appearance: { + allowedVisualizations: ['grid', 'kanban', 'gallery', 'calendar'], + }, }, listViews: { diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index bb1309fe03..ec77068779 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -236,6 +236,37 @@ Common operators: `equals`, `not_equals`, `contains`, `starts_with`, > **`$currentUser`** is a runtime variable — the logged-in user's ID. +### End-User Quick Filters (`userFilters`, ADR-0047) + +`filter` is the always-on base criteria. For the *end-user-facing* filter bar +(Airtable "User filters") use `userFilters` — dropdowns, filter tabs, or +toggles the user combines at runtime: + +```typescript +userFilters: { + element: 'dropdown', // 'dropdown' | 'tabs' | 'toggle' + fields: [ + { field: 'status' }, // options/labels inferred from field def + { field: 'priority', showCount: true }, + ], +}, + +// In-view filter tabs (presets on top of the base filter): +tabs: [ + { name: 'all', label: 'All', isDefault: true }, + { name: 'urgent', label: 'Urgent', filter: [{ field: 'priority', operator: 'equals', value: 'urgent' }] }, +], + +// Runtime visualization whitelist (Airtable "Appearance → Visualizations"): +appearance: { allowedVisualizations: ['grid', 'kanban', 'gallery'] }, +``` + +Rules: +- Every `field` MUST exist on the source object — reference diagnostics + (`_diagnostics`) flag unknown fields; treat `valid: false` as a failed write. +- Omit `userFilters` entirely when unsure: the renderer auto-derives dropdowns + from select/boolean fields. **Omission is correct.** + ### Sorting ```typescript @@ -429,6 +460,51 @@ export const PipelineCoverageReport: ReportInput = { --- +## Two Run Modes: Object Nav vs Interface Pages (ADR-0047) + +Object list UI has **two run modes**, selected by the navigation item type: + +| | Data mode (`type: 'object'`) | Interface mode (`type: 'page'`) | +|:--|:--|:--| +| What renders | ALL list views as switcher tabs | One curated page referencing ONE view | +| User-created views | Allowed | Never | +| Quick filters | Auto-derived (or view `userFilters`) | Only what the author enabled | +| Visualization | Switchable (whitelist) | Locked unless whitelisted | + +**Decision rule — default to data mode.** Generate ONLY objects + list views + +navigation pointing at objects. Generate an interface page ONLY on explicit +signals in the requirement: + +- persona split ("sales reps see…", customer portal, 给业务部门的简化界面); +- capability narrowing ("users must not change views", "only filter by X"); +- curation language (workspace / 工作台 / "Airtable interface-like"). + +Ambiguity resolves to **no page** — data mode is a functional superset; a +missing page costs polish, a superfluous page is a permanently-maintained +duplicate asset. + +**The iron rule:** an interface page REFERENCES a view (`interfaceConfig.source` ++ `sourceView`) and adds presentation policy only (`userFilters`, +`appearance.allowedVisualizations`, `userActions`). It has NO columns/filter/sort +of its own — never restate what the view already defines. + +```typescript +export const TaskWorkbenchPage: Page = { + name: 'task_workbench', + type: 'list', + object: 'task', + interfaceConfig: { + source: 'task', + sourceView: 'default', // inherit columns/filter/sort + userFilters: { element: 'dropdown', fields: [{ field: 'status' }] }, + appearance: { allowedVisualizations: ['grid'] }, // locked + userActions: { sort: true, search: true, filter: false }, + }, +}; +``` + +--- + ## Pages — Lightning-Style Page Layouts A **Page** is a Salesforce-Lightning-style layout composed of **regions** From 6276db81e40d09ba8a98d67cabf566027c6b2cac Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:55:28 +0500 Subject: [PATCH 3/3] =?UTF-8?q?feat(spec):=20ADR-0047=20phase=205=20?= =?UTF-8?q?=E2=80=94=20End-user=20controls=20section=20in=20the=20view=20a?= =?UTF-8?q?uthoring=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Studio view inspector is spec-form-driven, so adding the section here renders the full authoring UI (userFilters fields/tabs repeaters with the element style selector, appearance.allowedVisualizations, userActions, addRecord, showRecordCount) with zero client code — verified in the browser against the showcase. Also registers TaskWorkbenchPage in the showcase stack config (the phase-3 nav entry referenced it). Co-Authored-By: Claude Fable 5 --- examples/app-showcase/objectstack.config.ts | 4 ++-- packages/spec/src/ui/view.form.ts | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index e82187a552..1534e82b1e 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -18,7 +18,7 @@ import { ChartGalleryDashboard } from './src/dashboards/index.js'; import { ShowcaseTaskDataset, ShowcaseProjectDataset } from './src/datasets/index.js'; import { allReports } from './src/reports/index.js'; import { allActions } from './src/actions/index.js'; -import { ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage } from './src/pages/index.js'; +import { ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage } from './src/pages/index.js'; import { allFlows } from './src/flows/index.js'; import { allWebhooks } from './src/webhooks/index.js'; import { allJobs } from './src/jobs/index.js'; @@ -141,7 +141,7 @@ export default defineStack({ apps: [ShowcaseApp], portals: allPortals, views: [TaskViews, ProjectViews], - pages: [ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage], + pages: [ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage], dashboards: [ChartGalleryDashboard], datasets: [ShowcaseTaskDataset, ShowcaseProjectDataset], reports: allReports, diff --git a/packages/spec/src/ui/view.form.ts b/packages/spec/src/ui/view.form.ts index 5242bd78cf..2a8592a9dc 100644 --- a/packages/spec/src/ui/view.form.ts +++ b/packages/spec/src/ui/view.form.ts @@ -103,6 +103,21 @@ export const viewForm = defineForm({ visibleOn: "data.type == 'chart'", fields: [{ field: 'chart', type: 'composite' }], }, + { + name: 'end_user_controls', + label: 'End-user controls', + description: 'What end users can do on this view — quick filters, filter tabs, visualization switching (ADR-0047, Airtable Interface parity).', + collapsible: true, + collapsed: true, + fields: [ + { field: 'userFilters', type: 'composite', helpText: 'Quick-filter bar: element style (dropdown / tabs / toggle) + exposed fields or tab presets' }, + { field: 'tabs', type: 'repeater', helpText: 'In-view filter tabs — each tab applies its own filter rules' }, + { field: 'appearance', type: 'composite', helpText: 'allowedVisualizations: which renderers users may switch between' }, + { field: 'userActions', type: 'composite', helpText: 'Toolbar toggles: sort / search / filter / row height' }, + { field: 'addRecord', type: 'composite' }, + { field: 'showRecordCount' }, + ], + }, { name: 'navigation_sharing', label: 'Navigation & sharing',