From 7ac5efbb8691de124d8da339c7c283499236c897 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:24 -0700 Subject: [PATCH 1/2] feat(app-shell): honor dropdown userFilters on object list views (#2338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The object default list view ("views" mode) hard-coded `userFilters: undefined` in `ObjectView.renderListView`, dropping the Airtable-style quick-filter chips even when the backend shipped them. `` / `` already render dropdown chips — only this app-shell layer blocked them. - ObjectView: pass `viewDef.userFilters ?? listSchema.userFilters` through when `element !== 'tabs'` and no `tabs` array; `tabs` stays suppressed (it would collide with the ViewTabBar) and `quickFilters` stays suppressed entirely. - warnSuppressedListNav: warn only for `quickFilters` and `tabs`-mode userFilters; a `dropdown` userFilters is no longer "suppressed". Companion framework spec change (opens the schema): objectstack-ai/framework#2679. Verified end-to-end on the app-showcase Projects default list view. --- .../__tests__/warnSuppressedListNav.test.ts | 33 +++++++++------ .../src/utils/warnSuppressedListNav.ts | 41 ++++++++++++------- packages/app-shell/src/views/ObjectView.tsx | 17 +++++--- 3 files changed, 60 insertions(+), 31 deletions(-) diff --git a/packages/app-shell/src/utils/__tests__/warnSuppressedListNav.test.ts b/packages/app-shell/src/utils/__tests__/warnSuppressedListNav.test.ts index 3bd9fe75a..5ed0915df 100644 --- a/packages/app-shell/src/utils/__tests__/warnSuppressedListNav.test.ts +++ b/packages/app-shell/src/utils/__tests__/warnSuppressedListNav.test.ts @@ -7,10 +7,11 @@ */ /** - * ADR-0053 wrong-context authoring surfaced instead of silently dropped - * (#2219): `userFilters` / `quickFilters` on an object list view are - * suppressed by ObjectView — the author must get a console warning, once - * per object/view. + * ADR-0047 (amended, #2338): a `dropdown` `userFilters` is honored on an object + * list view (Airtable quick-filter pills). Only `quickFilters` and a `tabs` + * `userFilters` stay page-only and are suppressed by ObjectView — the author + * must get a console warning for those, once per object/view. A `dropdown` + * userFilters must NOT warn. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -19,9 +20,10 @@ import { resetSuppressedListNavWarnings, } from '../warnSuppressedListNav'; -const USER_FILTERS = { element: 'dropdown', fields: [{ field: 'status' }] }; +const DROPDOWN_FILTERS = { element: 'dropdown', fields: [{ field: 'status' }] }; +const TABS_FILTERS = { element: 'tabs', tabs: [{ label: 'Mine', filter: [] }] }; -describe('warnSuppressedListNav (#2219)', () => { +describe('warnSuppressedListNav (#2338)', () => { let warn: ReturnType; beforeEach(() => { @@ -33,15 +35,22 @@ describe('warnSuppressedListNav (#2219)', () => { warn.mockRestore(); }); - it('warns when the view def carries userFilters', () => { + it('does NOT warn for a dropdown userFilters (honored on object views)', () => { const hit = warnSuppressedListNav('showcase_task', 'showcase_task.tabular', - { userFilters: USER_FILTERS }, {}); + { userFilters: DROPDOWN_FILTERS }, {}); + expect(hit).toBe(false); + expect(warn).not.toHaveBeenCalled(); + }); + + it('warns when the view def carries a tabs userFilters', () => { + const hit = warnSuppressedListNav('showcase_task', 'showcase_task.tabular', + { userFilters: TABS_FILTERS }, {}); expect(hit).toBe(true); expect(warn).toHaveBeenCalledTimes(1); const msg = warn.mock.calls[0][0] as string; expect(msg).toContain('showcase_task.tabular'); expect(msg).toContain('userFilters'); - expect(msg).toContain('ADR-0053'); + expect(msg).toContain('ADR-0047'); }); it('warns when only the base list schema carries quickFilters', () => { @@ -53,11 +62,11 @@ describe('warnSuppressedListNav (#2219)', () => { }); it('warns only once per object/view across re-renders', () => { - warnSuppressedListNav('showcase_task', 'tabular', { userFilters: USER_FILTERS }, {}); - warnSuppressedListNav('showcase_task', 'tabular', { userFilters: USER_FILTERS }, {}); + warnSuppressedListNav('showcase_task', 'tabular', { userFilters: TABS_FILTERS }, {}); + warnSuppressedListNav('showcase_task', 'tabular', { userFilters: TABS_FILTERS }, {}); expect(warn).toHaveBeenCalledTimes(1); // A different view still gets its own warning. - warnSuppressedListNav('showcase_task', 'board', { userFilters: USER_FILTERS }, {}); + warnSuppressedListNav('showcase_task', 'board', { userFilters: TABS_FILTERS }, {}); expect(warn).toHaveBeenCalledTimes(2); }); diff --git a/packages/app-shell/src/utils/warnSuppressedListNav.ts b/packages/app-shell/src/utils/warnSuppressedListNav.ts index 161064a91..295854de3 100644 --- a/packages/app-shell/src/utils/warnSuppressedListNav.ts +++ b/packages/app-shell/src/utils/warnSuppressedListNav.ts @@ -7,30 +7,42 @@ */ /** - * ADR-0053: on an object's default list ("views" mode) the ViewTabBar is the - * only navigation control — `userFilters` / `quickFilters` belong to page - * lists (InterfaceListPage, "filters" mode) and are suppressed by - * `ObjectView.renderListView`. + * ADR-0047 (amended, objectui #2338): on an object's default list ("views" + * mode) a `dropdown` (value-chip) `userFilters` IS honored — those are the + * Airtable quick-filter pills. What stays suppressed by + * `ObjectView.renderListView` is only the page-only navigation: + * - `quickFilters` (never valid on an object view), and + * - a `tabs`-style `userFilters` (or one carrying `tabs`), which would + * collide with the ViewTabBar that already owns the tab-bar role. * - * The suppression is correct, but until the phase-4 guardrail (zod `refine` - * + `check` rule) lands, an author who puts `userFilters` on an object list - * view gets a valid schema and a page that renders nothing where they expect - * filter controls — zero signal at any layer (#2219). Surface the drop with - * a one-shot console warning per object/view. + * An author who reaches for those on an object list view gets a page that + * renders nothing where they expect the control — surface the drop with a + * one-shot console warning per object/view. A `dropdown` userFilters is NOT + * a drop and is never warned. * * Returns whether the view carried a suppressed field (mainly for tests). */ const warned = new Set(); +/** True when a `userFilters` value is the page-only `tabs` preset style. */ +function isTabsUserFilters(uf: unknown): boolean { + if (!uf || typeof uf !== 'object') return false; + const rec = uf as { element?: unknown; tabs?: unknown }; + return rec.element === 'tabs' || rec.tabs != null; +} + export function warnSuppressedListNav( objectName: string, viewId: string, viewDef: Record | null | undefined, listSchema: Record | null | undefined, ): boolean { - const offending = (['userFilters', 'quickFilters'] as const).filter( - (k) => (viewDef?.[k] ?? listSchema?.[k]) != null, - ); + const offending: string[] = []; + if ((viewDef?.quickFilters ?? listSchema?.quickFilters) != null) { + offending.push('quickFilters'); + } + const uf = viewDef?.userFilters ?? listSchema?.userFilters; + if (isTabsUserFilters(uf)) offending.push('userFilters (element: "tabs")'); if (offending.length === 0) return false; const key = `${objectName}.${viewId}`; if (!warned.has(key)) { @@ -38,8 +50,9 @@ export function warnSuppressedListNav( console.warn( `[ObjectView] View "${viewId}" on object "${objectName}" defines ` + `${offending.join(' and ')}, which are ignored on an object list view ` + - `(ADR-0053 "views" mode — the view switcher is the only nav control here). ` + - `Move them to a page list (InterfaceListPage "filters" mode).`, + `(ADR-0047 "views" mode — the view switcher owns the tab bar here). ` + + `Use \`listViews\` for named presets, \`element: "dropdown"\` for value ` + + `chips, or move them to a page list (InterfaceListPage "filters" mode).`, ); } return true; diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index bc9456eae..8fd7e2583 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -1332,12 +1332,19 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an sharing: viewDef.sharing ?? listSchema.sharing, addRecord: viewDef.addRecord ?? listSchema.addRecord, conditionalFormatting: viewDef.conditionalFormatting ?? listSchema.conditionalFormatting, - // ADR-0053: this is the object default list = "views" mode; the - // ViewTabBar above is the only nav control. The in-list Airtable- - // style filter rows (quickFilters / userFilters / tabs) belong to - // page "filters" mode (InterfaceListPage), so suppress them here. + // ADR-0047 (amended, objectui #2338): this is the object default + // list = "views" mode. `dropdown` (value-chip) userFilters ARE + // allowed here — they're the Airtable quick-filter pills. Only the + // `tabs` preset style stays page-only (it would collide with the + // ViewTabBar above, which owns the tab-bar role — use `listViews` + // for named presets). `quickFilters` stays suppressed entirely. quickFilters: undefined, - userFilters: undefined, + userFilters: (() => { + const uf = (viewDef.userFilters ?? listSchema.userFilters) as + | { element?: string; tabs?: unknown } + | undefined; + return uf && uf.element !== 'tabs' && uf.tabs == null ? uf : undefined; + })(), showRecordCount: viewDef.showRecordCount ?? listSchema.showRecordCount, allowPrinting: viewDef.allowPrinting ?? listSchema.allowPrinting, virtualScroll: viewDef.virtualScroll ?? listSchema.virtualScroll, From bcee5afa516fdd45f4b4c913b323c7d5b0b7725e 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:12:18 -0700 Subject: [PATCH 2/2] =?UTF-8?q?docs(adr-0053):=20amend=20=E2=80=94=20dropd?= =?UTF-8?q?own=20userFilters=20allowed=20on=20object=20list=20views,=20tab?= =?UTF-8?q?s=20page-only=20(#2338)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/adr/0053-list-view-navigation-modes.md | 24 ++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/adr/0053-list-view-navigation-modes.md b/docs/adr/0053-list-view-navigation-modes.md index 82cb370a9..0ecb63569 100644 --- a/docs/adr/0053-list-view-navigation-modes.md +++ b/docs/adr/0053-list-view-navigation-modes.md @@ -8,6 +8,23 @@ --- +> **Amendment (2026-07-07, objectui #2338 / framework #2679).** The phase-4 +> blanket ban on `userFilters` in views mode was an over-correction. An object +> list view MAY now carry a **`dropdown`** (per-field value-chip) `userFilters` +> — the Airtable "quick filter" pills — because dropdown chips do not compete +> with the `ViewTabBar`; they sit alongside it. Only **`element: 'tabs'`** stays +> page-only: a named-preset tab bar would render a *second* tab row and collide +> with the saved-view switcher. So the two-context rule below now reads: +> object views allow `listViews` **+ dropdown `userFilters`**; page lists keep the +> full `dropdown | tabs` range. `ObjectView.renderListView` passes a non-`tabs` +> `userFilters` through; the framework spec narrows the object schema +> (`ObjectUserFiltersSchema`, dropdown/toggle only) and the `validate` +> list-view-mode lint flags `quickFilters` always but `userFilters` only when it +> carries `tabs`. Want status-style named presets on an object? Still use +> `listViews`. + +--- + ## TL;DR Architecture decision for a metadata-driven, **AI-authored** app platform. A list @@ -21,7 +38,7 @@ fields** so the conflicting state is literally untypable — rather than adding | Context | Mode | Single control | Config field | Owner | | --- | --- | --- | --- | --- | -| Object default list (`ObjectView`) | **views** | `ViewTabBar` switcher | `listViews` | per-user (seeded + user-created) | +| Object default list (`ObjectView`) | **views** | `ViewTabBar` switcher (+ `dropdown` `userFilters` chips, see Amendment) | `listViews` + `userFilters` (`dropdown` only) | per-user (seeded + user-created) | | List in a page (`InterfaceListPage`) | **filters** | `userFilters` (`dropdown`\|`tabs`) | `userFilters` | page author (fixed) | Sample fixtures are disposable, so we **clean-build** (no runtime back-compat @@ -73,8 +90,9 @@ list schema (`viewDef.* ?? listSchema.*`). Render paths are already split — presentation is unified. 6. **AI-authoring guardrails** (what field-removal can't cover — "wrong context"): - - `objectql.zod.ts` `refine`: error if `userFilters` appears on an object - data-mode view (it belongs to pages); error on any removed field. + - `objectql.zod.ts` `refine`: error on any removed field. **(Amended #2338: + a `dropdown` `userFilters` is now allowed on an object data-mode view; only a + `tabs` `userFilters` remains page-only — see the Amendment banner above.)** - wired into `check` / `doctor`; the AI authoring loop self-corrects on the error. - the two-mode rule lives in field `.describe()` / JSDoc the AI reads.