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
27 changes: 27 additions & 0 deletions .changeset/adr-0053-list-view-mode-guardrail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@objectstack/spec": minor
"@objectstack/lint": minor
"@objectstack/cli": minor
---

feat(spec,lint): reject userFilters on object list views (ADR-0053 phase 4)

ADR-0053 reserves `userFilters`/`quickFilters` for page lists ("filters" mode);
on an object list view ("views" mode — where the `ViewTabBar` is the only nav
control) they are silently dropped. This lands the phase-4 guardrail as a
layered defence, so the wrong-context authoring mistake is caught without
breaking existing metadata:

- **Type-level (author time):** new `ObjectListViewSchema` = `ListViewSchema`
minus `userFilters`. Object built-in `listViews` and `defineView`
`list`/`listViews` now use it, so `userFilters` on an object list view is a
`tsc` error. The full `ListViewSchema` (page "filters" mode) is untouched.
- **Runtime (back-compat):** the field is STRIPPED at parse (default strip, no
throw), so existing metadata keeps loading — `ObjectSchema.parse` never fails
on a stray `userFilters`.
- **Author/CI (actionable):** new `@objectstack/lint` rule
`validateListViewMode`, wired into `os validate`, reports the wrong-context
field PRE-parse (before the schema strips it) with a fix hint.

Closes the schema half of objectui #2219; supersedes the interim runtime warn in
objectui #2220.
30 changes: 30 additions & 0 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ZodError } from 'zod';
import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec';
import { loadConfig } from '../utils/config.js';
import { validateStackExpressions } from '@objectstack/lint';
import { validateListViewMode } from '@objectstack/lint';
import { validateWidgetBindings } from '@objectstack/lint';
import { validateResponsiveStyles } from '@objectstack/lint';
import { validateJsxPages, validateReactPages, validateReactPageProps, validatePageSourceStyling } from '@objectstack/lint';
Expand Down Expand Up @@ -108,6 +109,35 @@ export default class Validate extends Command {
this.exit(1);
}

// 2c. ADR-0053 list-view navigation modes — `userFilters`/`quickFilters`
// on an object list view ("views" mode) are silently dropped: the
// object-list schema (ObjectListViewSchema) OMITS them, so this is
// checked on `normalized` (PRE-parse) — `result.data` has already had
// the field stripped. They belong to a page list ("filters" mode).
// See objectui #2219 and ADR-0053 phase 4.
if (!flags.json) printStep('Checking list-view navigation modes (ADR-0053)...');
const listViewFindings = validateListViewMode(normalized as Record<string, unknown>);
const listViewErrors = listViewFindings.filter((f) => f.severity === 'error');

if (listViewErrors.length > 0) {
if (flags.json) {
console.log(JSON.stringify({
valid: false,
errors: listViewErrors,
duration: timer.elapsed(),
}, null, 2));
this.exit(1);
}
console.log('');
printError(`List-view mode check failed (${listViewErrors.length} issue${listViewErrors.length > 1 ? 's' : ''})`);
for (const f of listViewErrors.slice(0, 50)) {
console.log(` • ${f.where}: ${f.message}`);
console.log(chalk.dim(` ${f.hint}`));
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
}
this.exit(1);
}

// 3. Dashboard widget reference integrity (issue #1721) — a semantic
// cross-reference pass the protocol schema cannot express: every
// widget's `dataset`/`dimensions`/`values` and chartConfig
Expand Down
3 changes: 3 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export type { WidgetBindingFinding, WidgetBindingSeverity } from './validate-wid
export { validateStackExpressions } from './validate-expressions.js';
export type { ExprIssue } from './validate-expressions.js';

export { validateListViewMode, LIST_VIEW_FILTERS_IN_VIEWS_MODE } from './validate-list-view-mode.js';
export type { ListViewModeFinding, ListViewModeSeverity } from './validate-list-view-mode.js';

export {
validateResponsiveStyles,
STYLE_NODE_MISSING_ID,
Expand Down
68 changes: 68 additions & 0 deletions packages/lint/src/validate-list-view-mode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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)', () => {
it('passes a clean stack — object listViews without page-only filters', () => {
const findings = validateListViewMode({
objects: [
{ name: 'task', listViews: { my_pending: { label: 'My Pending', filter: [] } } },
],
});
expect(findings).toHaveLength(0);
});

it('flags userFilters on an object built-in list view, with location + hint', () => {
const findings = validateListViewMode({
objects: [
{ name: 'task', listViews: { tabular: { label: 'Tabular', userFilters: { element: 'dropdown' } } } },
],
});
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
severity: 'error',
rule: LIST_VIEW_FILTERS_IN_VIEWS_MODE,
path: 'objects[0].listViews.tabular.userFilters',
});
expect(findings[0].where).toContain('task');
expect(findings[0].message).toContain('views');
expect(findings[0].hint).toContain('filters');
});

it('flags quickFilters too', () => {
const findings = validateListViewMode({
objects: [
{ name: 'task', listViews: { all: { label: 'All', quickFilters: [{ field: 'status' }] } } },
],
});
expect(findings).toHaveLength(1);
expect(findings[0].path).toBe('objects[0].listViews.all.quickFilters');
});

it('flags userFilters on a defineView default list AND named listViews', () => {
const findings = validateListViewMode({
views: [
{
objectName: 'task',
list: { userFilters: { element: 'tabs' } },
listViews: { mine: { label: 'Mine', userFilters: { element: 'dropdown' } } },
},
],
});
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);
});

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

it('stays silent on an empty stack', () => {
expect(validateListViewMode({})).toHaveLength(0);
});
});
128 changes: 128 additions & 0 deletions packages/lint/src/validate-list-view-mode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// Build-time guardrail for ADR-0053 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.
//
// 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.

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

export interface ListViewModeFinding {
severity: ListViewModeSeverity;
rule: string;
/** Human-readable location, e.g. `object "task" › listViews.my_pending`. */
where: string;
/** Config path, e.g. `objects[0].listViews.my_pending.userFilters`. */
path: string;
message: string;
hint: string;
}

// Rule id (registry entry).
export const LIST_VIEW_FILTERS_IN_VIEWS_MODE = 'list-view-filters-in-views-mode';

type AnyRec = Record<string, unknown>;

/** 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[];
if (v && typeof v === 'object') {
return Object.entries(v as AnyRec).map(([name, def]) => ({
name,
...(def as AnyRec),
}));
}
return [];
}

/** Emit a finding for each forbidden field present on a single list-view def. */
function scanView(
view: unknown,
where: string,
path: string,
out: ListViewModeFinding[],
): void {
if (!view || typeof view !== 'object') return;
const rec = view as AnyRec;
for (const field of FORBIDDEN_FIELDS) {
if (rec[field] == null) continue;
out.push({
severity: 'error',
rule: LIST_VIEW_FILTERS_IN_VIEWS_MODE,
where,
path: `${path}.${field}`,
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.`,
hint:
`Move \`${field}\` to a page list (InterfaceListPage, "filters" mode), or ` +
`remove it. See ADR-0053.`,
});
}
}

/** Scan a `listViews` record (name → list-view def). */
function scanListViews(
listViews: unknown,
wherePrefix: string,
pathPrefix: string,
out: ListViewModeFinding[],
): void {
if (!listViews || typeof listViews !== 'object') return;
for (const [name, view] of Object.entries(listViews as AnyRec)) {
scanView(
view,
`${wherePrefix} › listViews.${name}`,
`${pathPrefix}.listViews.${name}`,
out,
);
}
}

/**
* 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.
*
* Feed the PRE-parse stack (normalizeStackInput output) — see file header.
*/
export function validateListViewMode(stack: AnyRec): ListViewModeFinding[] {
const out: ListViewModeFinding[] = [];

// Object built-in named views (object.zod.ts `listViews`).
asArray(stack.objects).forEach((obj, i) => {
const label = typeof obj.name === 'string' ? `object "${obj.name}"` : `objects[${i}]`;
scanListViews(obj.listViews, label, `objects[${i}]`, out);
});

// `defineView` aggregates (stack `views`: default `list` + named `listViews`).
asArray(stack.views).forEach((view, i) => {
const named =
typeof view.objectName === 'string'
? view.objectName
: typeof view.name === 'string'
? view.name
: undefined;
const label = named ? `view "${named}"` : `views[${i}]`;
scanView(view.list, `${label} › list`, `views[${i}].list`, out);
scanListViews(view.listViews, label, `views[${i}]`, out);
});

return out;
}
1 change: 1 addition & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -3232,6 +3232,7 @@
"NotificationTypeSchema (const)",
"NumberFormat (type)",
"NumberFormatSchema (const)",
"ObjectListViewSchema (const)",
"ObjectNavItem (type)",
"ObjectNavItemSchema (const)",
"OfflineCacheConfig (type)",
Expand Down
10 changes: 6 additions & 4 deletions packages/spec/src/data/object.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { z } from 'zod';
import { FieldSchema } from './field.zod';
import { ValidationRuleSchema } from './validation.zod';
import { ActionSchema } from '../ui/action.zod';
import { ListViewSchema } from '../ui/view.zod';
import { ObjectListViewSchema } from '../ui/view.zod';

/**
* API Operations Enum
Expand Down Expand Up @@ -629,8 +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 a `ListViewSchema` (see `@objectstack/spec/ui`) so authors
* get the full tab/filter/sort/grouping vocabulary.
* 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.
*
* @example
* ```ts
Expand All @@ -644,7 +646,7 @@ const ObjectSchemaBase = z.object({
* }
* ```
*/
listViews: z.record(z.string(), ListViewSchema).optional().describe('Built-in named list views (segmented tabs) shipped with the object schema'),
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)'),

/**
* Search Engine Config
Expand Down
33 changes: 33 additions & 0 deletions packages/spec/src/ui/object-list-view.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { ObjectListViewSchema, 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.
*/
describe('ObjectListViewSchema (ADR-0053 "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<string, unknown> }).shape).toBe(false);
});

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('accepts a clean object list view unchanged', () => {
const parsed = ObjectListViewSchema.parse({ ...base, label: 'All' } as never);
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' });
});
});
17 changes: 15 additions & 2 deletions packages/spec/src/ui/view.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,19 @@ export const FormViewSchema = lazySchema(() => z.object({
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes for the form view'),
}));

/**
* ADR-0053 "views" mode — an object's default list + named list views.
*
* 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.
*/
export const ObjectListViewSchema = lazySchema(() => ListViewSchema.omit({ userFilters: true }));

/**
* Master View Schema
* Can define multiple named views.
Expand All @@ -909,9 +922,9 @@ export const FormViewSchema = lazySchema(() => z.object({
* }
*/
export const ViewSchema = lazySchema(() => z.object({
list: ListViewSchema.optional(), // Default list view
list: ObjectListViewSchema.optional(), // Default list view (views mode — no userFilters, ADR-0053)
form: FormViewSchema.optional(), // Default form view
listViews: z.record(z.string(), ListViewSchema).optional().describe('Additional named list views'),
listViews: z.record(z.string(), ObjectListViewSchema).optional().describe('Additional named list views (views mode — no userFilters, ADR-0053)'),
formViews: z.record(z.string(), FormViewSchema).optional().describe('Additional named form views'),
/**
* ADR-0010 §3.7 — Package-level protection envelope. Package
Expand Down