From 547b0e03fbe53cc93c4a6654987d947d3183cdb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Tue, 7 Jul 2026 23:28:14 -0700 Subject: [PATCH 1/3] feat(spec,lint): allow dropdown userFilters on object list views (#2679) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Airtable-style quick-filter chips (`userFilters`, `element: 'dropdown'`) were blanket-suppressed on object list views ("views" mode) — omitted from `ObjectListViewSchema`, errored by the `validate` list-view-mode rule, and dropped at render. That over-corrected against ADR-0047 TL;DR #5 (data mode is meant to expose quick filters). Narrow the rule: an object list view MAY carry a `dropdown` (value-chip) userFilters; only the `tabs` preset style stays page-only, because the saved-view ViewTabBar already owns the tab-bar role (need presets on an object → use `listViews`). - spec: new `ObjectUserFiltersSchema` (element narrowed to dropdown/ toggle, tabs/showAllRecords omitted); `ObjectListViewSchema` extends `userFilters` back in with it. - lint: `validate-list-view-mode` flags `quickFilters` always, but `userFilters` only when `element: 'tabs'` / carrying `tabs`. - app-showcase: dropdown userFilters (status + health) on the default `showcase_project` list view. - docs: ADR-0047 amendment note + regenerated view reference. Companion objectui runtime change: objectstack-ai/objectui#2338. --- content/docs/references/ui/view.mdx | 16 +++- docs/adr/0047-object-ui-run-modes.md | 14 ++++ .../app-showcase/src/ui/views/project.view.ts | 11 +++ .../lint/src/validate-list-view-mode.test.ts | 38 +++++++--- packages/lint/src/validate-list-view-mode.ts | 75 ++++++++++++------- packages/spec/src/data/object.zod.ts | 10 +-- packages/spec/src/ui/object-list-view.test.ts | 57 ++++++++++---- packages/spec/src/ui/view.zod.ts | 39 +++++++--- 8 files changed, 191 insertions(+), 69 deletions(-) diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index c78d8e2130..fee7198aea 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -16,8 +16,8 @@ Migrated to shared/http.zod.ts. Re-exported here for backward compatibility. ## TypeScript Usage ```typescript -import { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnSummary, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, GroupingField, KanbanConfig, ListChartConfig, ListColumn, NavigationConfig, NavigationMode, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, ViewData, ViewFilterRule, ViewItemName, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; -import type { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnSummary, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, GroupingField, KanbanConfig, ListChartConfig, ListColumn, NavigationConfig, NavigationMode, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, ViewData, ViewFilterRule, ViewItemName, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; +import { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnSummary, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, GroupingField, KanbanConfig, ListChartConfig, ListColumn, NavigationConfig, NavigationMode, ObjectUserFilters, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, ViewData, ViewFilterRule, ViewItemName, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; +import type { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnSummary, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, GroupingField, KanbanConfig, ListChartConfig, ListColumn, NavigationConfig, NavigationMode, ObjectUserFilters, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, ViewData, ViewFilterRule, ViewItemName, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; // Validate data const result = AddRecordConfig.parse(data); @@ -255,6 +255,18 @@ List chart view configuration * `none` +--- + +## ObjectUserFilters + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **element** | `Enum<'dropdown' \| 'toggle'>` | ✅ | Filter control style on object views: "dropdown" (per-field value chips). "toggle" is deprecated. "tabs" is page-only — use `listViews` for named presets. | +| **fields** | `Object[]` | optional | Fields exposed as quick filters (dropdown/toggle elements) | + + --- ## PaginationConfig diff --git a/docs/adr/0047-object-ui-run-modes.md b/docs/adr/0047-object-ui-run-modes.md index 005c29b07d..5af79c685f 100644 --- a/docs/adr/0047-object-ui-run-modes.md +++ b/docs/adr/0047-object-ui-run-modes.md @@ -10,6 +10,20 @@ > 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. +> +> **Amendment (2026-07-07) — `dropdown` user filters are allowed on object list +> views again (framework #2679 / objectui #2338).** The phase-4 rollout had +> blanket-suppressed *all* `userFilters` on object list views ("views" mode): +> `ObjectListViewSchema` omitted the field, the `validate` list-view-mode rule +> errored on it, and objectui's `ObjectView` forced `userFilters: undefined` at +> render. That over-corrected against TL;DR #5 (data mode is *supposed* to expose +> quick filters). The narrowed rule: an object list view MAY carry a `dropdown` +> (per-field value-chip) `userFilters`; only the `tabs` preset style stays +> page-only, because an object view's saved-view `ViewTabBar` already owns the +> tab-bar role and a `tabs` user-filter would render a second, conflicting bar +> (need named presets on an object → use `listViews`). Enforced by a new +> `ObjectUserFiltersSchema` (element narrowed to `dropdown`/`toggle`, `tabs`/ +> `showAllRecords` omitted), which `ObjectListViewSchema` now `.extend`s back in. **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 diff --git a/examples/app-showcase/src/ui/views/project.view.ts b/examples/app-showcase/src/ui/views/project.view.ts index 7baf77729a..dc8f533743 100644 --- a/examples/app-showcase/src/ui/views/project.view.ts +++ b/examples/app-showcase/src/ui/views/project.view.ts @@ -10,6 +10,17 @@ export const ProjectViews = defineView({ label: 'All Projects', type: 'grid', data, + // Airtable-style quick-filter chips on the DEFAULT object list view + // (ADR-0047 amendment, framework #2679 / objectui #2338). `dropdown` value + // chips are allowed on object views; `tabs` presets stay page-only (they'd + // collide with the saved-view ViewTabBar — use `listViews` for those). + userFilters: { + element: 'dropdown', + fields: [ + { field: 'status' }, + { field: 'health', showCount: true }, + ], + }, columns: [ { field: 'name' }, { field: 'account' }, diff --git a/packages/lint/src/validate-list-view-mode.test.ts b/packages/lint/src/validate-list-view-mode.test.ts index 419573b2cc..c28e7ff1d2 100644 --- a/packages/lint/src/validate-list-view-mode.test.ts +++ b/packages/lint/src/validate-list-view-mode.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { validateListViewMode, LIST_VIEW_FILTERS_IN_VIEWS_MODE } from './validate-list-view-mode.js'; -describe('validateListViewMode (ADR-0053 views-mode guardrail)', () => { +describe('validateListViewMode (ADR-0047 views-mode guardrail)', () => { it('passes a clean stack — object listViews without page-only filters', () => { const findings = validateListViewMode({ objects: [ @@ -11,10 +11,19 @@ describe('validateListViewMode (ADR-0053 views-mode guardrail)', () => { expect(findings).toHaveLength(0); }); - it('flags userFilters on an object built-in list view, with location + hint', () => { + it('allows a dropdown userFilters on an object built-in list view', () => { const findings = validateListViewMode({ objects: [ - { name: 'task', listViews: { tabular: { label: 'Tabular', userFilters: { element: 'dropdown' } } } }, + { name: 'task', listViews: { tabular: { label: 'Tabular', userFilters: { element: 'dropdown', fields: [{ field: 'status' }] } } } }, + ], + }); + expect(findings).toHaveLength(0); + }); + + it('flags a tabs userFilters on an object built-in list view, with location + hint', () => { + const findings = validateListViewMode({ + objects: [ + { name: 'task', listViews: { tabular: { label: 'Tabular', userFilters: { element: 'tabs' } } } }, ], }); expect(findings).toHaveLength(1); @@ -25,7 +34,17 @@ describe('validateListViewMode (ADR-0053 views-mode guardrail)', () => { }); expect(findings[0].where).toContain('task'); expect(findings[0].message).toContain('views'); - expect(findings[0].hint).toContain('filters'); + expect(findings[0].hint).toContain('listViews'); + }); + + it('flags a userFilters that carries a tabs array even without element: "tabs"', () => { + const findings = validateListViewMode({ + objects: [ + { name: 'task', listViews: { t: { userFilters: { tabs: [{ label: 'Mine', filter: [] }] } } } }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('objects[0].listViews.t.userFilters'); }); it('flags quickFilters too', () => { @@ -38,7 +57,7 @@ describe('validateListViewMode (ADR-0053 views-mode guardrail)', () => { expect(findings[0].path).toBe('objects[0].listViews.all.quickFilters'); }); - it('flags userFilters on a defineView default list AND named listViews', () => { + it('flags a tabs userFilters on a defineView default list but allows a dropdown named listView', () => { const findings = validateListViewMode({ views: [ { @@ -48,15 +67,14 @@ describe('validateListViewMode (ADR-0053 views-mode guardrail)', () => { }, ], }); - expect(findings).toHaveLength(2); - const paths = findings.map((f) => f.path).sort(); - expect(paths).toEqual(['views[0].list.userFilters', 'views[0].listViews.mine.userFilters']); - expect(findings.every((f) => f.where.includes('task'))).toBe(true); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('views[0].list.userFilters'); + expect(findings[0].where).toContain('task'); }); it('handles the name-keyed map form of objects', () => { const findings = validateListViewMode({ - objects: { task: { listViews: { t: { userFilters: { element: 'dropdown' } } } } }, + objects: { task: { listViews: { t: { userFilters: { element: 'tabs' } } } } }, }); expect(findings).toHaveLength(1); expect(findings[0].where).toContain('task'); diff --git a/packages/lint/src/validate-list-view-mode.ts b/packages/lint/src/validate-list-view-mode.ts index 7e5907dd5b..ceb2da31dc 100644 --- a/packages/lint/src/validate-list-view-mode.ts +++ b/packages/lint/src/validate-list-view-mode.ts @@ -1,21 +1,24 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// Build-time guardrail for ADR-0053 list-view navigation modes. +// Build-time guardrail for ADR-0047 list-view navigation modes. // // A pure `(stack) => Finding[]` rule (ADR-0019), run from `os validate` and // reusable by AI authoring. It catches the "wrong context" authoring mistake -// the type system alone cannot surface at author time: `userFilters` / -// `quickFilters` placed on an object list view ("views" mode — where the -// ViewTabBar is the only nav control), where they are silently dropped. Those -// controls belong to a page list (InterfaceListPage, "filters" mode) only. +// the type system alone cannot surface at author time on an object list view +// ("views" mode — where the ViewTabBar owns the tab-bar role): +// - `quickFilters` — never valid on an object list view; +// - `userFilters` with `element: 'tabs'` (or carrying `tabs`) — the tab-bar +// preset style is page-only; it would collide with the ViewTabBar. +// A `dropdown` (value-chip) `userFilters` IS allowed on object views since the +// ADR-0047 amendment (framework #2679 / objectui #2338) and is NOT flagged. // // Runs PRE-parse (on the normalizeStackInput output, before the // ObjectStackDefinition parse): the object-list schema (ObjectListViewSchema) -// OMITS `userFilters`, so a post-parse stack has already had the field -// stripped and this rule would never see it. The layering is deliberate — -// tsc rejects it at author time, the schema strips it at runtime (no throw, -// back-compat), and this rule reports it at `os validate` with a fix hint. -// See objectui #2219 / #2220 and ADR-0053 phase 4. +// narrows `userFilters` to ObjectUserFiltersSchema (dropdown/toggle only), so a +// post-parse stack has already had a `tabs` user-filter stripped and this rule +// would never see it. The layering is deliberate — tsc rejects it at author +// time, the schema strips it at runtime (no throw, back-compat), and this rule +// reports it at `os validate` with a fix hint. See objectui #2338 and ADR-0047. export type ListViewModeSeverity = 'error' | 'warning'; @@ -35,9 +38,6 @@ export const LIST_VIEW_FILTERS_IN_VIEWS_MODE = 'list-view-filters-in-views-mode' type AnyRec = Record; -/** Page filters-mode controls that must not appear on an object list view. */ -const FORBIDDEN_FIELDS = ['userFilters', 'quickFilters'] as const; - /** Coerce an array-or-name-keyed-map collection to an array (name injected). */ function asArray(v: unknown): AnyRec[] { if (Array.isArray(v)) return v as AnyRec[]; @@ -50,7 +50,7 @@ function asArray(v: unknown): AnyRec[] { return []; } -/** Emit a finding for each forbidden field present on a single list-view def. */ +/** Emit a finding for each wrong-context filter control on a single list-view def. */ function scanView( view: unknown, where: string, @@ -59,21 +59,45 @@ function scanView( ): void { if (!view || typeof view !== 'object') return; const rec = view as AnyRec; - for (const field of FORBIDDEN_FIELDS) { - if (rec[field] == null) continue; + + // `quickFilters` is never valid on an object list view. + if (rec.quickFilters != null) { out.push({ severity: 'error', rule: LIST_VIEW_FILTERS_IN_VIEWS_MODE, where, - path: `${path}.${field}`, + path: `${path}.quickFilters`, message: - `\`${field}\` is a page filters-mode control and is ignored on an object ` + - `list view ("views" mode) — the ViewTabBar is the only nav control here.`, + '`quickFilters` is a page filters-mode control and is ignored on an object ' + + 'list view ("views" mode) — the ViewTabBar owns nav here.', hint: - `Move \`${field}\` to a page list (InterfaceListPage, "filters" mode), or ` + - `remove it. See ADR-0053.`, + 'Move `quickFilters` to a page list (InterfaceListPage, "filters" mode), or ' + + 'remove it. See ADR-0047.', }); } + + // `userFilters` is allowed on object views ONLY as `dropdown` (value chips). + // The `tabs` preset style — or any `userFilters` carrying `tabs` — collides + // with the ViewTabBar and stays page-only. + const uf = rec.userFilters; + if (uf && typeof uf === 'object') { + const ufRec = uf as AnyRec; + if (ufRec.element === 'tabs' || ufRec.tabs != null) { + out.push({ + severity: 'error', + rule: LIST_VIEW_FILTERS_IN_VIEWS_MODE, + where, + path: `${path}.userFilters`, + message: + '`userFilters` with `element: "tabs"` is page-only and is ignored on an ' + + 'object list view ("views" mode) — it would collide with the ViewTabBar.', + hint: + 'Use `listViews` for named presets on an object (each becomes a segmented ' + + 'tab), switch to `element: "dropdown"` for value chips, or move the `tabs` ' + + 'filter to a page list (InterfaceListPage, "filters" mode). See ADR-0047.', + }); + } + } } /** Scan a `listViews` record (name → list-view def). */ @@ -95,10 +119,11 @@ function scanListViews( } /** - * Flag ADR-0053 "views" mode violations: `userFilters` / `quickFilters` on an - * object's built-in named views or a `defineView` default `list` / named - * `listViews`. Returns the list of findings (empty = clean). Caller decides how - * to surface / whether to fail the build. + * Flag ADR-0047 "views" mode violations on an object's built-in named views or a + * `defineView` default `list` / named `listViews`: `quickFilters`, or a `tabs` + * `userFilters`. A `dropdown` `userFilters` is allowed and not flagged. Returns + * the list of findings (empty = clean). Caller decides how to surface / whether + * to fail the build. * * Feed the PRE-parse stack (normalizeStackInput output) — see file header. */ diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 4d61915bd6..e59f6b5baf 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -629,10 +629,10 @@ const ObjectSchemaBase = z.object({ * business context — e.g. an approval-request list should ship with * "My pending", "I submitted", "Completed" tabs out of the box. * - * Each value is an `ObjectListViewSchema` (a `ListViewSchema` minus the - * page-only `userFilters` — ADR-0053 "views" mode, where the `ViewTabBar` is - * the only nav control) so authors get the full tab/filter/sort/grouping - * vocabulary without the wrong-context filter bar. + * Each value is an `ObjectListViewSchema` (a `ListViewSchema` whose `userFilters` + * is narrowed to dropdown value chips — ADR-0047 "views" mode, where the + * `ViewTabBar` owns the tab-bar role so `tabs` presets stay page-only) so authors + * get the full filter/sort/grouping vocabulary plus quick-filter dropdowns. * * @example * ```ts @@ -646,7 +646,7 @@ const ObjectSchemaBase = z.object({ * } * ``` */ - listViews: z.record(z.string(), ObjectListViewSchema).optional().describe('Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, no page-only userFilters (ADR-0053)'), + listViews: z.record(z.string(), ObjectListViewSchema).optional().describe('Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047)'), /** * Search Engine Config diff --git a/packages/spec/src/ui/object-list-view.test.ts b/packages/spec/src/ui/object-list-view.test.ts index 81c2d19fa0..e835c25480 100644 --- a/packages/spec/src/ui/object-list-view.test.ts +++ b/packages/spec/src/ui/object-list-view.test.ts @@ -1,24 +1,46 @@ import { describe, it, expect } from 'vitest'; -import { ObjectListViewSchema, ListViewSchema } from './view.zod'; +import { ObjectListViewSchema, ObjectUserFiltersSchema, ListViewSchema } from './view.zod'; /** - * ADR-0053 phase 4 — the object list view ("views" mode) must not carry the - * page-only `userFilters` control. The guardrail is layered: the field is - * OMITTED from ObjectListViewSchema (untypable at author time), STRIPPED at - * parse (no throw — runtime back-compat), while the full ListViewSchema used by - * page lists ("filters" mode) still accepts it. See objectui #2219 / #2220. + * ADR-0047 amendment (framework #2679 / objectui #2338) — an object list view + * ("views" mode) MAY carry a `dropdown` (value-chip) `userFilters`, but NOT the + * page-only `tabs` preset bar (it would collide with the ViewTabBar). The + * guardrail is layered: `ObjectUserFiltersSchema` narrows `element` to + * dropdown/toggle (a `tabs` element is untypable at author time and rejected at + * parse), while the full `ListViewSchema` used by page lists ("filters" mode) + * still accepts the tabs style. */ -describe('ObjectListViewSchema (ADR-0053 "views" mode)', () => { +describe('ObjectListViewSchema (ADR-0047 "views" mode)', () => { const base = { columns: ['name'] }; - it('omits userFilters from its shape (untypable at author time)', () => { - expect('userFilters' in (ObjectListViewSchema as unknown as { shape: Record }).shape).toBe(false); + it('exposes userFilters on its shape (dropdown chips are allowed)', () => { + expect('userFilters' in (ObjectListViewSchema as unknown as { shape: Record }).shape).toBe(true); }); - it('strips an authored userFilters at parse instead of throwing (runtime back-compat)', () => { - const parsed = ObjectListViewSchema.parse({ ...base, userFilters: { element: 'dropdown' } } as never); - expect(parsed).not.toHaveProperty('userFilters'); - expect((parsed as { columns: string[] }).columns).toEqual(['name']); // sibling survives + it('preserves a dropdown userFilters at parse', () => { + const uf = { element: 'dropdown', fields: [{ field: 'status' }] }; + const parsed = ObjectListViewSchema.parse({ ...base, userFilters: uf } as never); + expect((parsed as { userFilters?: unknown }).userFilters).toMatchObject(uf); + }); + + it('drops the page-only tabs/showAllRecords keys from a dropdown userFilters', () => { + const parsed = ObjectListViewSchema.parse({ + ...base, + userFilters: { element: 'dropdown', tabs: [{ name: 'mine', label: 'Mine', filter: [] }], showAllRecords: true }, + } as never); + const parsedUf = (parsed as { userFilters?: Record }).userFilters!; + expect(parsedUf).not.toHaveProperty('tabs'); + expect(parsedUf).not.toHaveProperty('showAllRecords'); + expect(parsedUf.element).toBe('dropdown'); + }); + + it('rejects a tabs-element userFilters (page-only, would collide with ViewTabBar)', () => { + expect(() => + ObjectUserFiltersSchema.parse({ element: 'tabs' } as never), + ).toThrow(); + expect(() => + ObjectListViewSchema.parse({ ...base, userFilters: { element: 'tabs' } } as never), + ).toThrow(); }); it('accepts a clean object list view unchanged', () => { @@ -26,8 +48,11 @@ describe('ObjectListViewSchema (ADR-0053 "views" mode)', () => { expect((parsed as { label?: string }).label).toBe('All'); }); - it('ListViewSchema (page "filters" mode) still accepts userFilters', () => { - const parsed = ListViewSchema.parse({ ...base, userFilters: { element: 'dropdown' } } as never); - expect((parsed as { userFilters?: unknown }).userFilters).toEqual({ element: 'dropdown' }); + it('ListViewSchema (page "filters" mode) still accepts the tabs style', () => { + const parsed = ListViewSchema.parse({ + ...base, + userFilters: { element: 'tabs', tabs: [{ name: 'mine', label: 'Mine', filter: [] }] }, + } as never); + expect((parsed as { userFilters?: { element?: string } }).userFilters?.element).toBe('tabs'); }); }); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 49bfaa49b0..e18ea8ae83 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -912,17 +912,34 @@ export const FormViewSchema = lazySchema(() => z.object({ })); /** - * ADR-0053 "views" mode — an object's default list + named list views. + * Object-scoped user filters (ADR-0047 amendment, framework #2679 / objectui #2338). * - * Structurally a {@link ListViewSchema} MINUS the page-only control `userFilters`: - * that field belongs to a page list (`InterfaceListPage`, "filters" mode), never an - * object list view, whose only nav control is the `ViewTabBar`. Omitting the field - * makes the wrong-context state untypable at author time (tsc). Runtime parse still - * STRIPS an authored `userFilters` silently (default strip, no throw) for back-compat, - * and the CLI `validate` list-view-mode rule reports it pre-parse. See objectui #2219 - * and ADR-0053 phase 4. + * A {@link UserFiltersSchema} restricted to the styles that make sense on an object + * list view: `dropdown` (per-field value chips — the Airtable "quick filter" pills) + * and the deprecated `toggle`. The `tabs` element and its `showAllRecords` companion + * are OMITTED because an object view's saved-view switcher (`ViewTabBar`) already owns + * the tab-bar role — a `tabs` user-filter would render a second, conflicting tab bar. + * Need named presets on an object? Use `listViews` (each becomes a segmented tab). */ -export const ObjectListViewSchema = lazySchema(() => ListViewSchema.omit({ userFilters: true })); +export const ObjectUserFiltersSchema = lazySchema(() => + UserFiltersSchema.omit({ tabs: true, showAllRecords: true }).extend({ + element: z.enum(['dropdown', 'toggle']).default('dropdown') + .describe('Filter control style on object views: "dropdown" (per-field value chips). "toggle" is deprecated. "tabs" is page-only — use `listViews` for named presets.'), + })); + +/** + * ADR-0047 "views" mode — an object's default list + named list views. + * + * Structurally a {@link ListViewSchema} whose `userFilters` is narrowed to + * {@link ObjectUserFiltersSchema}: `dropdown` value chips are allowed (ADR-0047 + * amendment, framework #2679), but the page-only `tabs` preset bar is not — that + * belongs to a page list (`InterfaceListPage`, "filters" mode), because an object + * view's only tab-bar nav is the `ViewTabBar`. The narrowed schema makes a `tabs` + * user-filter untypable at author time (tsc); the CLI `validate` list-view-mode rule + * reports it pre-parse. See objectui #2338. + */ +export const ObjectListViewSchema = lazySchema(() => + ListViewSchema.omit({ userFilters: true }).extend({ userFilters: ObjectUserFiltersSchema.optional() })); /** * Master View Schema @@ -943,9 +960,9 @@ export const ObjectListViewSchema = lazySchema(() => ListViewSchema.omit({ userF * } */ export const ViewSchema = lazySchema(() => z.object({ - list: ObjectListViewSchema.optional(), // Default list view (views mode — no userFilters, ADR-0053) + list: ObjectListViewSchema.optional(), // Default list view (views mode — dropdown userFilters allowed, no tabs; ADR-0047) form: FormViewSchema.optional(), // Default form view - listViews: z.record(z.string(), ObjectListViewSchema).optional().describe('Additional named list views (views mode — no userFilters, ADR-0053)'), + listViews: z.record(z.string(), ObjectListViewSchema).optional().describe('Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047)'), formViews: z.record(z.string(), FormViewSchema).optional().describe('Additional named form views'), /** * ADR-0010 §3.7 — Package-level protection envelope. Package From bfe54430fdaa4729eca4fa71f82c1d9875aefa36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Wed, 8 Jul 2026 01:08:29 -0700 Subject: [PATCH 2/3] docs(skills): note object list views allow dropdown userFilters, tabs page-only (#2679) --- skills/objectstack-ui/SKILL.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index a442cbd254..aa78498e07 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -289,6 +289,16 @@ Rules: filter element style (Airtable's Elements choice). If a view configures both `tabs` and `userFilters`, tabs win and the dropdowns never render. Want both demos? Put them on different views. +- **On an object list view (`*.view.ts` `list` / `listViews`), only + `element: 'dropdown'` (value chips) is allowed — `tabs` is page-only** + (ADR-0047 amendment, framework #2679 / objectui #2338). An object view's + saved-view `ViewTabBar` already owns the tab-bar role, so a `tabs` + user-filter would render a second, colliding tab bar. The spec narrows it + (`ObjectUserFiltersSchema` — a `tabs` element is untypable at author time + and dropped at parse) and the `validate` list-view-mode lint reports it. + Need named presets on an object? Add a `listViews` entry instead. The full + `dropdown | tabs | toggle` range applies only to **page lists** / + `interfaceConfig.userFilters` (the block above). - **Omit `userFilters` when unsure — omission means a clean toolbar.** Filter elements render only when explicitly configured; nothing is auto-derived. In data mode the saved-views switcher already covers the preset use case, @@ -585,7 +595,7 @@ Object list UI has **three run modes**, selected by the navigation item shape: | What renders | ALL list views as switcher tabs | The URL-defined slice, no saved-view tabs | One curated page referencing ONE view | | Anchored to | Saved views | **The URL itself** (`/:objectName/data?filter[...]`) | Page config | | User-created views | Allowed | "Save as view" exit only | Never | -| Quick filters | Auto-derived (or view `userFilters`) | Auto-derived + removable URL chips | Only what the author enabled | +| Quick filters | Auto-derived (or view `userFilters` — `dropdown` only) | Auto-derived + removable URL chips | Only what the author enabled | | Visualization | Switchable (whitelist) | Switchable (URL filter state survives) | Locked unless whitelisted | **Decision rule — default to data mode.** Generate ONLY objects + list views + From c3d31d4506e87088670fb429fea58d00d0966312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Wed, 8 Jul 2026 01:57:03 -0700 Subject: [PATCH 3/3] chore(spec): regenerate skill-docs + api-surface for ObjectUserFiltersSchema (#2679) --- content/docs/ai/skills-reference.mdx | 6 +++--- packages/spec/api-surface.json | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/content/docs/ai/skills-reference.mdx b/content/docs/ai/skills-reference.mdx index c1cef10f81..7cd5a23f9c 100644 --- a/content/docs/ai/skills-reference.mdx +++ b/content/docs/ai/skills-reference.mdx @@ -46,7 +46,7 @@ ObjectStack ships **9 domain-specific skills**. Each is self-contained — an AI | # | Skill | Domain | Path | What it covers | | :--- | :--- | :--- | :--- | :--- | | 1 | [Platform](#platform) | `platform` | `skills/objectstack-platform/` | Bootstrap, configure, extend, and operate ObjectStack runtimes. Covers project setup (`defineStack`, drivers, adapters, scaffolding), plugin and service development (PluginContext, DI, kernel hooks like `kernel:ready` and `data:*`), and operations (CLI commands, migrations, deployment, test harnesses via LiteKernel). | -| 2 | [Data](#data) | `data` | `skills/objectstack-data/` | Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security — and the seed definitions (`defineSeed()`) that load fixtures and reference data alongside them. | +| 2 | [Data](#data) | `data` | `skills/objectstack-data/` | Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security — and the seed datasets (`defineDataset()`) that load fixtures and reference data alongside them. | | 3 | [Query](#query) | `query` | `skills/objectstack-query/` | Construct ObjectQL queries — filters, sorting, pagination, aggregation, joins/expansion, window functions, and full-text search. | | 4 | [UI](#ui) | `ui` | `skills/objectstack-ui/` | Author ObjectStack UI metadata — Views (list/form/kanban/calendar/gantt), Apps (navigation), Pages (structured plus the HTML and React source-authoring tiers, ADR-0080/0081), Dashboards, Reports, Charts, Actions, and package Docs (`src/docs/*.md`). | | 5 | [Automation](#automation) | `automation` | `skills/objectstack-automation/` | Design ObjectStack automation — Flows (visual logic), Workflows (declarative rules), Triggers, Approvals, scheduled jobs, and webhooks. | @@ -75,7 +75,7 @@ Do not use for data schema design (see objectstack-data) or query patterns (see **Domain** `data` · **Path** `skills/objectstack-data/` -Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security — and the seed definitions (`defineSeed()`) that load fixtures and reference data alongside them. +Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security — and the seed datasets (`defineDataset()`) that load fixtures and reference data alongside them. Use when the user is creating or modifying `*.object.ts` / `*.seed.ts` files, picking field types, modelling relationships, writing `beforeInsert`/`afterUpdate` hooks, configuring per-object access control, or authoring bootstrap / demo data. Use for `visibleWhen` / `readonlyWhen` / `requiredWhen` rules that belong on fields. @@ -147,7 +147,7 @@ Do not use for general LLM prompting questions unrelated to ObjectStack metadata Design the server-side API surface that an ObjectStack runtime exposes — REST/GraphQL endpoints, auth providers, realtime channels, error envelopes, batch/versioning contracts. -Use when the user is adding `ApiEndpoint` entries to the `apis:` array in `defineStack()`, configuring auth providers, defining custom routes, or extending the REST/GraphQL generator. +Use when the user is adding `*.endpoint.ts`, configuring auth providers, defining custom routes, or extending the REST/GraphQL generator. Do not use for: consuming an ObjectStack API from a client (that is just standard HTTP — no skill needed); the auto-generated CRUD endpoints (those follow from objectstack-data); request-side query syntax (see objectstack-query). CEL expressions in route guards or auth predicates: load objectstack-formula alongside. diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index eec7dcad7b..0d738dff5e 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3251,6 +3251,7 @@ "ObjectListViewSchema (const)", "ObjectNavItem (type)", "ObjectNavItemSchema (const)", + "ObjectUserFiltersSchema (const)", "OfflineCacheConfig (type)", "OfflineCacheConfigSchema (const)", "OfflineConfig (type)",