Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions docs/adr/0053-list-view-navigation-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<typeof vi.spyOn>;

beforeEach(() => {
Expand All @@ -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', () => {
Expand All @@ -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);
});

Expand Down
41 changes: 27 additions & 14 deletions packages/app-shell/src/utils/warnSuppressedListNav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,39 +7,52 @@
*/

/**
* 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<string>();

/** 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<string, unknown> | null | undefined,
listSchema: Record<string, unknown> | 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)) {
warned.add(key);
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;
Expand Down
17 changes: 12 additions & 5 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading