Skip to content

Commit 0a42126

Browse files
authored
feat(spec,lint): allow dropdown userFilters on object list views (#2679) (#2683)
* feat(spec,lint): allow dropdown userFilters on object list views (#2679) 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. * docs(skills): note object list views allow dropdown userFilters, tabs page-only (#2679) * chore(spec): regenerate skill-docs + api-surface for ObjectUserFiltersSchema (#2679)
1 parent 21420d9 commit 0a42126

11 files changed

Lines changed: 206 additions & 73 deletions

File tree

content/docs/ai/skills-reference.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ ObjectStack ships **9 domain-specific skills**. Each is self-contained — an AI
4646
| # | Skill | Domain | Path | What it covers |
4747
| :--- | :--- | :--- | :--- | :--- |
4848
| 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). |
49-
| 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. |
49+
| 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. |
5050
| 3 | [Query](#query) | `query` | `skills/objectstack-query/` | Construct ObjectQL queries — filters, sorting, pagination, aggregation, joins/expansion, window functions, and full-text search. |
5151
| 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`). |
5252
| 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
7575

7676
**Domain** `data` · **Path** `skills/objectstack-data/`
7777

78-
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.
78+
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.
7979

8080
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.
8181

@@ -147,7 +147,7 @@ Do not use for general LLM prompting questions unrelated to ObjectStack metadata
147147

148148
Design the server-side API surface that an ObjectStack runtime exposes — REST/GraphQL endpoints, auth providers, realtime channels, error envelopes, batch/versioning contracts.
149149

150-
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.
150+
Use when the user is adding `*.endpoint.ts`, configuring auth providers, defining custom routes, or extending the REST/GraphQL generator.
151151

152152
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.
153153

content/docs/references/ui/view.mdx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ Migrated to shared/http.zod.ts. Re-exported here for backward compatibility.
1616
## TypeScript Usage
1717

1818
```typescript
19-
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';
20-
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';
19+
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';
20+
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';
2121

2222
// Validate data
2323
const result = AddRecordConfig.parse(data);
@@ -255,6 +255,18 @@ List chart view configuration
255255
* `none`
256256

257257

258+
---
259+
260+
## ObjectUserFilters
261+
262+
### Properties
263+
264+
| Property | Type | Required | Description |
265+
| :--- | :--- | :--- | :--- |
266+
| **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. |
267+
| **fields** | `Object[]` | optional | Fields exposed as quick filters (dropdown/toggle elements) |
268+
269+
258270
---
259271

260272
## PaginationConfig

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,20 @@
1010
> and the page editor edits them in place. `sourceView` is deprecated and kept
1111
> only as a runtime back-compat fallback. TL;DR #2 below no longer holds; the
1212
> rest of the ADR (two run modes, userFilters, visualization whitelist) stands.
13+
>
14+
> **Amendment (2026-07-07) — `dropdown` user filters are allowed on object list
15+
> views again (framework #2679 / objectui #2338).** The phase-4 rollout had
16+
> blanket-suppressed *all* `userFilters` on object list views ("views" mode):
17+
> `ObjectListViewSchema` omitted the field, the `validate` list-view-mode rule
18+
> errored on it, and objectui's `ObjectView` forced `userFilters: undefined` at
19+
> render. That over-corrected against TL;DR #5 (data mode is *supposed* to expose
20+
> quick filters). The narrowed rule: an object list view MAY carry a `dropdown`
21+
> (per-field value-chip) `userFilters`; only the `tabs` preset style stays
22+
> page-only, because an object view's saved-view `ViewTabBar` already owns the
23+
> tab-bar role and a `tabs` user-filter would render a second, conflicting bar
24+
> (need named presets on an object → use `listViews`). Enforced by a new
25+
> `ObjectUserFiltersSchema` (element narrowed to `dropdown`/`toggle`, `tabs`/
26+
> `showAllRecords` omitted), which `ObjectListViewSchema` now `.extend`s back in.
1327
**Deciders**: ObjectStack Protocol Architects
1428
**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**)
1529
**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

examples/app-showcase/src/ui/views/project.view.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@ export const ProjectViews = defineView({
1010
label: 'All Projects',
1111
type: 'grid',
1212
data,
13+
// Airtable-style quick-filter chips on the DEFAULT object list view
14+
// (ADR-0047 amendment, framework #2679 / objectui #2338). `dropdown` value
15+
// chips are allowed on object views; `tabs` presets stay page-only (they'd
16+
// collide with the saved-view ViewTabBar — use `listViews` for those).
17+
userFilters: {
18+
element: 'dropdown',
19+
fields: [
20+
{ field: 'status' },
21+
{ field: 'health', showCount: true },
22+
],
23+
},
1324
columns: [
1425
{ field: 'name' },
1526
{ field: 'account' },

packages/lint/src/validate-list-view-mode.test.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it, expect } from 'vitest';
22
import { validateListViewMode, LIST_VIEW_FILTERS_IN_VIEWS_MODE } from './validate-list-view-mode.js';
33

4-
describe('validateListViewMode (ADR-0053 views-mode guardrail)', () => {
4+
describe('validateListViewMode (ADR-0047 views-mode guardrail)', () => {
55
it('passes a clean stack — object listViews without page-only filters', () => {
66
const findings = validateListViewMode({
77
objects: [
@@ -11,10 +11,19 @@ describe('validateListViewMode (ADR-0053 views-mode guardrail)', () => {
1111
expect(findings).toHaveLength(0);
1212
});
1313

14-
it('flags userFilters on an object built-in list view, with location + hint', () => {
14+
it('allows a dropdown userFilters on an object built-in list view', () => {
1515
const findings = validateListViewMode({
1616
objects: [
17-
{ name: 'task', listViews: { tabular: { label: 'Tabular', userFilters: { element: 'dropdown' } } } },
17+
{ name: 'task', listViews: { tabular: { label: 'Tabular', userFilters: { element: 'dropdown', fields: [{ field: 'status' }] } } } },
18+
],
19+
});
20+
expect(findings).toHaveLength(0);
21+
});
22+
23+
it('flags a tabs userFilters on an object built-in list view, with location + hint', () => {
24+
const findings = validateListViewMode({
25+
objects: [
26+
{ name: 'task', listViews: { tabular: { label: 'Tabular', userFilters: { element: 'tabs' } } } },
1827
],
1928
});
2029
expect(findings).toHaveLength(1);
@@ -25,7 +34,17 @@ describe('validateListViewMode (ADR-0053 views-mode guardrail)', () => {
2534
});
2635
expect(findings[0].where).toContain('task');
2736
expect(findings[0].message).toContain('views');
28-
expect(findings[0].hint).toContain('filters');
37+
expect(findings[0].hint).toContain('listViews');
38+
});
39+
40+
it('flags a userFilters that carries a tabs array even without element: "tabs"', () => {
41+
const findings = validateListViewMode({
42+
objects: [
43+
{ name: 'task', listViews: { t: { userFilters: { tabs: [{ label: 'Mine', filter: [] }] } } } },
44+
],
45+
});
46+
expect(findings).toHaveLength(1);
47+
expect(findings[0].path).toBe('objects[0].listViews.t.userFilters');
2948
});
3049

3150
it('flags quickFilters too', () => {
@@ -38,7 +57,7 @@ describe('validateListViewMode (ADR-0053 views-mode guardrail)', () => {
3857
expect(findings[0].path).toBe('objects[0].listViews.all.quickFilters');
3958
});
4059

41-
it('flags userFilters on a defineView default list AND named listViews', () => {
60+
it('flags a tabs userFilters on a defineView default list but allows a dropdown named listView', () => {
4261
const findings = validateListViewMode({
4362
views: [
4463
{
@@ -48,15 +67,14 @@ describe('validateListViewMode (ADR-0053 views-mode guardrail)', () => {
4867
},
4968
],
5069
});
51-
expect(findings).toHaveLength(2);
52-
const paths = findings.map((f) => f.path).sort();
53-
expect(paths).toEqual(['views[0].list.userFilters', 'views[0].listViews.mine.userFilters']);
54-
expect(findings.every((f) => f.where.includes('task'))).toBe(true);
70+
expect(findings).toHaveLength(1);
71+
expect(findings[0].path).toBe('views[0].list.userFilters');
72+
expect(findings[0].where).toContain('task');
5573
});
5674

5775
it('handles the name-keyed map form of objects', () => {
5876
const findings = validateListViewMode({
59-
objects: { task: { listViews: { t: { userFilters: { element: 'dropdown' } } } } },
77+
objects: { task: { listViews: { t: { userFilters: { element: 'tabs' } } } } },
6078
});
6179
expect(findings).toHaveLength(1);
6280
expect(findings[0].where).toContain('task');

packages/lint/src/validate-list-view-mode.ts

Lines changed: 50 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22
//
3-
// Build-time guardrail for ADR-0053 list-view navigation modes.
3+
// Build-time guardrail for ADR-0047 list-view navigation modes.
44
//
55
// A pure `(stack) => Finding[]` rule (ADR-0019), run from `os validate` and
66
// reusable by AI authoring. It catches the "wrong context" authoring mistake
7-
// the type system alone cannot surface at author time: `userFilters` /
8-
// `quickFilters` placed on an object list view ("views" mode — where the
9-
// ViewTabBar is the only nav control), where they are silently dropped. Those
10-
// controls belong to a page list (InterfaceListPage, "filters" mode) only.
7+
// the type system alone cannot surface at author time on an object list view
8+
// ("views" mode — where the ViewTabBar owns the tab-bar role):
9+
// - `quickFilters` — never valid on an object list view;
10+
// - `userFilters` with `element: 'tabs'` (or carrying `tabs`) — the tab-bar
11+
// preset style is page-only; it would collide with the ViewTabBar.
12+
// A `dropdown` (value-chip) `userFilters` IS allowed on object views since the
13+
// ADR-0047 amendment (framework #2679 / objectui #2338) and is NOT flagged.
1114
//
1215
// Runs PRE-parse (on the normalizeStackInput output, before the
1316
// ObjectStackDefinition parse): the object-list schema (ObjectListViewSchema)
14-
// OMITS `userFilters`, so a post-parse stack has already had the field
15-
// stripped and this rule would never see it. The layering is deliberate —
16-
// tsc rejects it at author time, the schema strips it at runtime (no throw,
17-
// back-compat), and this rule reports it at `os validate` with a fix hint.
18-
// See objectui #2219 / #2220 and ADR-0053 phase 4.
17+
// narrows `userFilters` to ObjectUserFiltersSchema (dropdown/toggle only), so a
18+
// post-parse stack has already had a `tabs` user-filter stripped and this rule
19+
// would never see it. The layering is deliberate — tsc rejects it at author
20+
// time, the schema strips it at runtime (no throw, back-compat), and this rule
21+
// reports it at `os validate` with a fix hint. See objectui #2338 and ADR-0047.
1922

2023
export type ListViewModeSeverity = 'error' | 'warning';
2124

@@ -35,9 +38,6 @@ export const LIST_VIEW_FILTERS_IN_VIEWS_MODE = 'list-view-filters-in-views-mode'
3538

3639
type AnyRec = Record<string, unknown>;
3740

38-
/** Page filters-mode controls that must not appear on an object list view. */
39-
const FORBIDDEN_FIELDS = ['userFilters', 'quickFilters'] as const;
40-
4141
/** Coerce an array-or-name-keyed-map collection to an array (name injected). */
4242
function asArray(v: unknown): AnyRec[] {
4343
if (Array.isArray(v)) return v as AnyRec[];
@@ -50,7 +50,7 @@ function asArray(v: unknown): AnyRec[] {
5050
return [];
5151
}
5252

53-
/** Emit a finding for each forbidden field present on a single list-view def. */
53+
/** Emit a finding for each wrong-context filter control on a single list-view def. */
5454
function scanView(
5555
view: unknown,
5656
where: string,
@@ -59,21 +59,45 @@ function scanView(
5959
): void {
6060
if (!view || typeof view !== 'object') return;
6161
const rec = view as AnyRec;
62-
for (const field of FORBIDDEN_FIELDS) {
63-
if (rec[field] == null) continue;
62+
63+
// `quickFilters` is never valid on an object list view.
64+
if (rec.quickFilters != null) {
6465
out.push({
6566
severity: 'error',
6667
rule: LIST_VIEW_FILTERS_IN_VIEWS_MODE,
6768
where,
68-
path: `${path}.${field}`,
69+
path: `${path}.quickFilters`,
6970
message:
70-
`\`${field}\` is a page filters-mode control and is ignored on an object ` +
71-
`list view ("views" mode) — the ViewTabBar is the only nav control here.`,
71+
'`quickFilters` is a page filters-mode control and is ignored on an object ' +
72+
'list view ("views" mode) — the ViewTabBar owns nav here.',
7273
hint:
73-
`Move \`${field}\` to a page list (InterfaceListPage, "filters" mode), or ` +
74-
`remove it. See ADR-0053.`,
74+
'Move `quickFilters` to a page list (InterfaceListPage, "filters" mode), or ' +
75+
'remove it. See ADR-0047.',
7576
});
7677
}
78+
79+
// `userFilters` is allowed on object views ONLY as `dropdown` (value chips).
80+
// The `tabs` preset style — or any `userFilters` carrying `tabs` — collides
81+
// with the ViewTabBar and stays page-only.
82+
const uf = rec.userFilters;
83+
if (uf && typeof uf === 'object') {
84+
const ufRec = uf as AnyRec;
85+
if (ufRec.element === 'tabs' || ufRec.tabs != null) {
86+
out.push({
87+
severity: 'error',
88+
rule: LIST_VIEW_FILTERS_IN_VIEWS_MODE,
89+
where,
90+
path: `${path}.userFilters`,
91+
message:
92+
'`userFilters` with `element: "tabs"` is page-only and is ignored on an ' +
93+
'object list view ("views" mode) — it would collide with the ViewTabBar.',
94+
hint:
95+
'Use `listViews` for named presets on an object (each becomes a segmented ' +
96+
'tab), switch to `element: "dropdown"` for value chips, or move the `tabs` ' +
97+
'filter to a page list (InterfaceListPage, "filters" mode). See ADR-0047.',
98+
});
99+
}
100+
}
77101
}
78102

79103
/** Scan a `listViews` record (name → list-view def). */
@@ -95,10 +119,11 @@ function scanListViews(
95119
}
96120

97121
/**
98-
* Flag ADR-0053 "views" mode violations: `userFilters` / `quickFilters` on an
99-
* object's built-in named views or a `defineView` default `list` / named
100-
* `listViews`. Returns the list of findings (empty = clean). Caller decides how
101-
* to surface / whether to fail the build.
122+
* Flag ADR-0047 "views" mode violations on an object's built-in named views or a
123+
* `defineView` default `list` / named `listViews`: `quickFilters`, or a `tabs`
124+
* `userFilters`. A `dropdown` `userFilters` is allowed and not flagged. Returns
125+
* the list of findings (empty = clean). Caller decides how to surface / whether
126+
* to fail the build.
102127
*
103128
* Feed the PRE-parse stack (normalizeStackInput output) — see file header.
104129
*/

packages/spec/api-surface.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3251,6 +3251,7 @@
32513251
"ObjectListViewSchema (const)",
32523252
"ObjectNavItem (type)",
32533253
"ObjectNavItemSchema (const)",
3254+
"ObjectUserFiltersSchema (const)",
32543255
"OfflineCacheConfig (type)",
32553256
"OfflineCacheConfigSchema (const)",
32563257
"OfflineConfig (type)",

0 commit comments

Comments
 (0)