From 16f15db6a784ab9e7912fde7b7ba2e0f4d6a46a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:21:43 +0000 Subject: [PATCH 1/6] feat(types,plugin-view,app-shell): mirror ObjectViewSchema.listViews by reference; fold stored options at ViewPreview `ObjectViewSchema.listViews` is now the spec's own `ViewSchema.shape.listViews` (a record of the strict `ObjectListViewSchema`) on the zod face, and `Record>` on the TS face. A named view authoring `options` is refused by name. `ViewPreview` folds a stored body's `options.KIND` onto the top-level `KIND` block (top-level wins per key) before building the node, and plugin-view's `ObjectView` no longer reads `options` off a named view. The host `views` entry keeps its rung. `NamedListView` is unchanged. The #8355 calendar-alias check keeps its by-name pointer by reading the protocol's refusal (`superRefine` with `when`), since the strict record drops the alias from the parse output and aborts a default refinement. Pins that asserted the unmirrored state are inverted, none deleted; the `listViews` ledger entry is removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014fWVhLzhxR8qrFsJ5o8TYW --- examples/byo-backend-console/src/App.tsx | 13 +- .../ViewPreview.optionsFold-7928.test.tsx | 203 ++++++++++++++++++ .../metadata-admin/previews/ViewPreview.tsx | 54 ++++- packages/plugin-view/src/ObjectView.tsx | 53 +++-- ...ectView.calendarAliasRefused-8355.test.tsx | 103 ++++++--- ...ctView.namedViewProtocolKeys-8980.test.tsx | 45 ++-- .../calendar-date-alias-refusal-8355.test.ts | 14 +- .../__tests__/imported-defaults-8317.test.ts | 12 +- ...t-view-listviews-by-reference-7928.test.ts | 81 +++++++ .../__tests__/object-view-spec-parity.test.ts | 22 +- .../object-view-unmirrored-keys-7779.test.ts | 171 +++++++++++---- .../src/__tests__/zod-mirror-parity.test.ts | 82 ++----- packages/types/src/objectql.ts | 21 +- packages/types/src/zod/imported-defaults.ts | 4 + packages/types/src/zod/objectql.zod.ts | 171 +++++++-------- 15 files changed, 769 insertions(+), 280 deletions(-) create mode 100644 packages/app-shell/src/views/metadata-admin/previews/ViewPreview.optionsFold-7928.test.tsx create mode 100644 packages/types/src/__tests__/object-view-listviews-by-reference-7928.test.ts diff --git a/examples/byo-backend-console/src/App.tsx b/examples/byo-backend-console/src/App.tsx index 4f0c4c4833..4f72ed81b4 100644 --- a/examples/byo-backend-console/src/App.tsx +++ b/examples/byo-backend-console/src/App.tsx @@ -133,6 +133,17 @@ function Home() { ); } +/** + * The grid columns of each object's named view. A named view on an + * `object-view` node is the protocol's `ObjectListViewSchema`, which requires + * `columns` (objectui#7928), so each object lists the fields it declares in + * `mockDataSource.ts`. + */ +const LIST_COLUMNS: Record = { + contact: ['name', 'email', 'phone', 'notes'], + account: ['name', 'industry', 'website'], +}; + function ObjectPage() { const { objectName } = useParams<{ objectName: string }>(); @@ -143,7 +154,7 @@ function ObjectPage() { type: 'object-view', objectName: objectName || '', listViews: { - default: { label: 'All', type: 'grid' }, + default: { label: 'All', type: 'grid', columns: LIST_COLUMNS[objectName ?? ''] ?? ['name'] }, }, }} dataSource={mockDataSource} diff --git a/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.optionsFold-7928.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.optionsFold-7928.test.tsx new file mode 100644 index 0000000000..b2f8ddccce --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.optionsFold-7928.test.tsx @@ -0,0 +1,203 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#7928 — the stored `options.KIND` bag is folded at `ViewPreview`, + * the one door that relays a STORED list body into `object-view`'s `listViews`. + * + * Director ruling on objectui#7928 (comment 5856694523, Q1 A): the named-view + * record is the protocol's strict `ObjectListViewSchema` by reference, which + * refuses `options`, and `plugin-view` no longer reads `options` off a named + * view. A stored list overlay may still carry the bag — the protocol declares it + * on that wire (objectstack#20051) and objectui#10380 ruling A keeps it legal + * there — so `ViewPreview` folds each `options.KIND` onto the top-level `KIND` + * block before it builds the node, the TOP-LEVEL value winning per key. + * + * The lane cases below are the inverted objectui#8980 legacy-nesting pins + * (`ObjectView.namedViewProtocolKeys-8980.test.tsx`) pointed at the fold: the + * same stored body, captured as the node `ViewPreview` hands to + * `SchemaRenderer`, then rendered through the REAL `plugin-view` `ObjectView`. + * Before objectui#7928 that renderer read the bag itself; these assert it + * resolves the same lane now that the fold does the work. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import { safeValidateSchema } from '@object-ui/types/zod'; +import { ObjectView } from '@object-ui/plugin-view'; +import { ViewPreview, foldStoredListOptions } from './ViewPreview'; + +/** Every schema handed to `SchemaRenderer`, in order. */ +const rendered: any[] = []; + +vi.mock('@object-ui/react', async (importOriginal) => { + const ReactMod = await import('react'); + return { + ...(await importOriginal>()), + SchemaRenderer: ({ schema }: any) => { + rendered.push(schema); + return
{schema?.type}
; + }, + SchemaRendererContext: ReactMod.createContext(null), + subscribeDataChanges: () => () => {}, + notifyDataChanged: () => {}, + }; +}); + +const dataSource = (): any => ({ + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ name: 'task', fields: {} }), +}); + +/** + * ⚠️ `cleanup()` is load-bearing: without it a previously mounted view keeps + * pushing into `rendered`, and the last entry answers the previous fixture + * (the objectui#9242 trap, recorded in the objectui#8980 pin file too). + */ +function reset() { + cleanup(); + rendered.length = 0; +} + +/** The `object-view` node `ViewPreview` builds for a stored list body. */ +function previewNode(config: Record): any { + reset(); + render(); + const node = rendered.find((s) => s?.type === 'object-view'); + expect(node, 'ViewPreview did not hand an object-view node to SchemaRenderer').toBeDefined(); + return node; +} + +/** The node the REAL `plugin-view` `ObjectView` generates for that preview node. */ +async function renderedKindNode(node: any): Promise { + reset(); + render(); + await waitFor(() => expect(rendered.length).toBeGreaterThan(0)); + return rendered[rendered.length - 1]; +} + +beforeEach(reset); + +describe('objectui#7928 — `foldStoredListOptions`, the wire → named-view translation', () => { + it('a body with no `options` comes back as the SAME object — nothing moves for the population that never carried the bag', () => { + const body = { type: 'kanban', columns: ['name'], kanban: { groupByField: 'stage', columns: ['name'] } }; + expect(foldStoredListOptions(body)).toBe(body); + }); + + it('each `options.KIND` becomes the top-level `KIND` block, and `options` is not relayed', () => { + const folded = foldStoredListOptions({ type: 'kanban', columns: ['name'], options: { kanban: { groupByField: 'legacy_lane' } } }); + expect(folded.kanban).toEqual({ groupByField: 'legacy_lane' }); + expect(folded).not.toHaveProperty('options'); + }); + + it('TOP-LEVEL wins per key, and a key only the bag spells survives — the merge is per key, not wholesale', () => { + const folded = foldStoredListOptions({ + type: 'kanban', + columns: ['name'], + kanban: { groupByField: 'canonical_lane' }, + options: { kanban: { groupByField: 'legacy_lane', titleField: 'subject' } }, + }); + expect(folded.kanban).toEqual({ groupByField: 'canonical_lane', titleField: 'subject' }); + }); + + it('every view kind the protocol carries at the top level is folded', () => { + const kinds = ['kanban', 'calendar', 'gallery', 'timeline', 'gantt', 'map', 'chart', 'tree']; + const options = Object.fromEntries(kinds.map((k) => [k, { marker: k }])); + const folded = foldStoredListOptions({ columns: ['name'], options }); + for (const k of kinds) expect(folded[k], k).toEqual({ marker: k }); + }); + + it('a key under `options` that is not a view kind is not lifted to the top level', () => { + // The protocol's stored bag carries per-kind blocks and nothing else + // (objectstack#20051), and the renderer read nothing else from it. + const folded = foldStoredListOptions({ columns: ['name'], options: { grid: { x: 1 }, zzqxNotAKind: true } }); + expect(folded).not.toHaveProperty('grid'); + expect(folded).not.toHaveProperty('zzqxNotAKind'); + expect(folded).not.toHaveProperty('options'); + }); + + it('the input body is not mutated', () => { + const body = { columns: ['name'], kanban: { groupByField: 'a' }, options: { kanban: { titleField: 'b' } } }; + const snapshot = JSON.parse(JSON.stringify(body)); + foldStoredListOptions(body); + expect(body).toEqual(snapshot); + }); +}); + +describe('objectui#7928 — the inverted objectui#8980 legacy-nesting pins, pointed at the fold', () => { + it('the legacy nesting alone resolves the lane — through the fold, on the real renderer', async () => { + const node = previewNode({ type: 'kanban', columns: ['name'], options: { kanban: { groupByField: 'legacy_lane' } } }); + expect(node.listViews.board).not.toHaveProperty('options'); + expect(node.listViews.board.kanban).toEqual({ groupByField: 'legacy_lane' }); + const kanban = await renderedKindNode(node); + expect(kanban.type).toBe('object-kanban'); + expect(kanban.groupBy).toBe('legacy_lane'); + }); + + it('the canonical block WINS for a key both spell', async () => { + const node = previewNode({ + type: 'kanban', + columns: ['name'], + kanban: { groupByField: 'canonical_lane' }, + options: { kanban: { groupByField: 'legacy_lane' } }, + }); + const kanban = await renderedKindNode(node); + expect(kanban.groupBy).toBe('canonical_lane'); + expect(kanban.groupBy).not.toBe('legacy_lane'); + }); + + it('a key the canonical block does NOT restate survives from the legacy nesting — the merge is per-key, not wholesale', async () => { + const node = previewNode({ + type: 'kanban', + columns: ['name'], + kanban: { groupByField: 'canonical_lane' }, + options: { kanban: { titleField: 'subject' } }, + }); + const kanban = await renderedKindNode(node); + expect(kanban.groupBy).toBe('canonical_lane'); + expect(kanban.titleField).toBe('subject'); + }); + + it('CONTROL: without the fold, the same body on the same renderer does NOT resolve the lane', async () => { + // The firing control for the three cases above: the stored body handed to + // the renderer as-is, which is what `ViewPreview` did before the fold. The + // renderer stopped reading `options` off a named view at objectui#7928, so + // only the fold makes the cases above pass. + const kanban = await renderedKindNode({ + type: 'object-view', + objectName: 'task', + listViews: { board: { type: 'kanban', columns: ['name'], options: { kanban: { groupByField: 'legacy_lane' } } } }, + defaultListView: 'board', + }); + expect(kanban.groupBy).not.toBe('legacy_lane'); + }); +}); + +describe('objectui#7928 — the node `ViewPreview` builds is one the contract accepts', () => { + it('a stored body carrying the legacy bag becomes a named view `ObjectViewSchema` parses green', () => { + const node = previewNode({ + type: 'kanban', + columns: ['name'], + options: { kanban: { groupByField: 'stage', columns: ['name'] } }, + }); + const r = safeValidateSchema(node); + expect(r.success, r.success ? '' : JSON.stringify(r.error.issues)).toBe(true); + }); + + it('CONTROL: the same body relayed unfolded is refused at `options`, by name', () => { + const r = safeValidateSchema({ + type: 'object-view', + objectName: 'task', + listViews: { board: { label: 'Board', type: 'kanban', columns: ['name'], options: { kanban: { groupByField: 'stage', columns: ['name'] } } } }, + }); + expect(r.success).toBe(false); + const issues = r.success ? [] : r.error.issues; + const refusal = issues.find((i) => i.code === 'unrecognized_keys' && i.path.join('.') === 'listViews.board'); + expect(refusal, JSON.stringify(issues)).toBeDefined(); + expect((refusal as { keys?: string[] }).keys).toEqual(['options']); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.tsx index a0c1122ef3..640f50c21b 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.tsx @@ -47,6 +47,54 @@ function resolveObjectName( return undefined; } +/** + * The protocol's per-kind config blocks on a list view — the keys + * `ObjectListViewSchema` carries at the top level, and the ones `plugin-view`'s + * `ObjectView` reads off a named view (its `canonicalViewKindBlocks`). + */ +const VIEW_KIND_BLOCKS = ['kanban', 'calendar', 'gallery', 'timeline', 'gantt', 'map', 'chart', 'tree'] as const; + +function isPlainObject(v: unknown): v is Record { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +/** + * objectui#7928 — translate a STORED list body into the named-view shape, at + * the one door that relays a stored body into `object-view`'s `listViews`. + * + * A stored list overlay may carry the legacy per-kind bag `options.KIND`: the + * protocol declares it on that wire and nowhere else (objectstack#20051), and + * objectui#10380 ruling A keeps it legal there. The named-view record this body + * is injected into is the protocol's strict `ObjectListViewSchema`, which + * refuses `options`, and `plugin-view` no longer reads it off a named view. So + * the bag is folded here, before the node is built: each `options.KIND` block + * becomes the top-level `KIND` block, and where both spell a key the TOP-LEVEL + * value wins — the same per-key merge the #20051 door describes and the one + * `ObjectView` applied while it still read the bag. `options` itself is not + * relayed; the renderer read nothing from it but these blocks. + * + * Director ruling on objectui#7928 (comment 5856694523, Q1 A). ⛔ This is the + * wire → authoring translation for a STORED body only; it is not a tolerance + * for authored `options`, which the contract refuses by name. + * + * A body without `options` is returned as the same object, so nothing changes + * for the population that never carried the bag. + */ +export function foldStoredListOptions(body: Record): Record { + if (!('options' in body)) return body; + const { options, ...rest } = body; + if (!isPlainObject(options)) return rest; + const folded: Record = { ...rest }; + for (const kind of VIEW_KIND_BLOCKS) { + const legacy = options[kind]; + if (!isPlainObject(legacy)) continue; + const canonical = rest[kind]; + if (canonical === undefined) folded[kind] = { ...legacy }; + else if (isPlainObject(canonical)) folded[kind] = { ...legacy, ...canonical }; + // A top-level value that is not a block wins as written; the record judges it. + } + return folded; +} // A ViewItem form section uses the spec's `{ field, readonly, … }` shape, but // `object-form` selects fields by `name` and reads `readOnly`. Normalize so the @@ -102,7 +150,9 @@ export function ViewPreview({ name, draft, editing }: MetadataPreviewProps) { const designMode = !!editing; // Surface the draft body as a named listView so the preview renders THIS - // view (with unsaved edits) rather than the object's saved default. + // view (with unsaved edits) rather than the object's saved default. The body + // is a STORED ViewItem config, so a legacy `options.KIND` bag is folded onto + // the top-level blocks first (`foldStoredListOptions`, objectui#7928). const { listViews, defaultViewId, defaultViewType } = React.useMemo(() => { if (!body) { return { listViews: {}, defaultViewId: undefined, defaultViewType: 'grid' }; @@ -111,7 +161,7 @@ export function ViewPreview({ name, draft, editing }: MetadataPreviewProps) { return { listViews: { [id]: { - ...body, + ...foldStoredListOptions(body), label: (body as any).label ?? (draft as any).label ?? name, }, }, diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index a5da47e324..a7b7acf9c3 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -240,6 +240,14 @@ function viewColumnFieldNames(columns: unknown): string[] | undefined { return columns.map(columnIdentity).filter((n): n is string => !!n); } +/** + * One entry of `ObjectViewSchema.listViews` — the protocol's + * `ObjectListViewSchema`, by reference (objectui#7928). Derived from the member + * rather than named on its own, so this component reads exactly the type the + * contract declares. + */ +type NamedViewConfig = NonNullable[string]; + /** * The three authored filter segments this component chains, in precedence * order: the active view's own filter (a named `listViews` entry, else the @@ -848,8 +856,8 @@ export const OBJECT_VIEW_HOST_COMPOSITION_VIEW_TYPES = [ * objectName: 'contacts', * listViews: { * all: { label: 'All Contacts', type: 'grid', columns: ['name', 'email', 'phone'] }, - * board: { label: 'By Status', type: 'kanban', options: { kanban: { groupField: 'status' } } }, - * calendar: { label: 'Meetings', type: 'calendar', options: { calendar: { startDateField: 'meeting_date' } } }, + * board: { label: 'By Status', type: 'kanban', columns: ['name'], kanban: { groupByField: 'status', columns: ['name', 'email'] } }, + * calendar: { label: 'Meetings', type: 'calendar', columns: ['name'], calendar: { startDateField: 'meeting_date' } }, * }, * defaultListView: 'all', * }} @@ -993,8 +1001,9 @@ export const ObjectView: React.FC = ({ return ''; }); - // Get current named view config - const currentNamedViewConfig: NamedListView | null = useMemo(() => { + // Get current named view config. Typed off `ObjectViewSchema.listViews`, the + // protocol's `ObjectListViewSchema` by reference (objectui#7928). + const currentNamedViewConfig: NamedViewConfig | null = useMemo(() => { if (!hasNamedViews || !activeNamedView) return null; return namedListViews![activeNamedView] || null; }, [hasNamedViews, activeNamedView, namedListViews]); @@ -1664,9 +1673,20 @@ export const ObjectView: React.FC = ({ color: activeView?.color, }; - // Resolve type-specific options from current named view or active view - // Per @objectstack/spec, type-specific config MUST be nested under the view type key - const legacyViewOptions: Record = currentNamedViewConfig?.options || activeView || {}; + // Resolve type-specific options from the host's active view (the `views` + // prop). Per @objectstack/spec, type-specific config MUST be nested under the + // view type key. + // + // objectui#7928 (director ruling, comment 5856694523, Q1 A) — a NAMED view's + // legacy `options` bag is no longer read here. The protocol refuses `options` + // on a named view (`ObjectViewSchema.listViews` is its strict + // `ObjectListViewSchema` by reference), so the canonical top-level blocks + // below are the named view's only source. A stored body that still carries + // `options.KIND` is folded onto `KIND` at the door that relays it into + // `listViews` — `@object-ui/app-shell`'s `ViewPreview` — so it arrives here + // already canonical. ⛔ Do not restore the read: that would re-open a + // dialect the contract refuses. The host `views` entry keeps this rung. + const legacyViewOptions: Record = activeView || {}; // ⭐ objectui#8980 — THE CANONICAL, PROTOCOL-DECLARED PLACE FOR THE EIGHT // VIEW-KIND BLOCKS, read here for the first time. @@ -1685,6 +1705,9 @@ export const ObjectView: React.FC = ({ // aliases `groupField` / `imageField` / `dateField` live), while a canonical // block wins key-by-key over the legacy one for the same kind. A partially // declared canonical block therefore does not blank its legacy neighbour. + // ⚠️ Since objectui#7928 the legacy side is the host `views` entry only; a + // named view's own `options` bag is folded at `ViewPreview` instead (see + // `legacyViewOptions` above). // // ⚠️ IDENTITY IS PRESERVED WHEN NOTHING CANONICAL IS DECLARED — the `else` // arm hands back the very object the line above produced. That is the whole @@ -1869,10 +1892,11 @@ export const ObjectView: React.FC = ({ // ⛔ STRIPPING IS ONLY THE QUIET HALF, and on its own it would make this // route fail CONSISTENTLY and still mutely, which is the shape the // ruling refuses. The loud half is the read door: - // `@object-ui/types`' `ObjectViewSchema` carries a `.check()` that - // refuses both spellings BY NAME under `listViews[*].calendar` and - // `listViews[*].options.calendar`, naming `startDateField` / - // `endDateField`. ⛔ Never land one half without the other. + // `@object-ui/types`' `ObjectViewSchema` refuses both spellings under + // `listViews[*].calendar` (the protocol's strict calendar block, since + // objectui#7928), and its check names `startDateField` / `endDateField` + // beside that refusal. A named view's `options` bag is refused whole. + // ⛔ Never land one half without the other. // // ⛔ Deliberately NOT folded onto the canonical keys: option A was put to // the director seat and refused as the end state on the first route, and @@ -1973,7 +1997,8 @@ export const ObjectView: React.FC = ({ case 'map': // Whitelisted flatten (objectui#5177) — see `FLAT_MAP_CONFIG_SPELLING`, // which also carries `style` out as `mapStyle` (objectui#9950). - // `viewOptions.map` is an untyped bag (`NamedListView.options`); a raw + // `viewOptions.map` is an untyped bag (the host `views` entry, merged + // with the named view's canonical `map` block); a raw // spread here forwarded every key the author wrote, including `style`, // which `ObjectMap`'s `FlatMapConfigKeys` declares OUT of this flat form. // @@ -2406,7 +2431,9 @@ export const ObjectView: React.FC = ({ // no rung here at all. Canonical source first, host `views` entry // second — the precedence every other pair on this branch uses. grouping: currentNamedViewConfig?.grouping ?? activeView?.grouping, - options: currentNamedViewConfig?.options || activeView, + // objectui#7928 — the host `views` entry only; a named view's legacy + // `options` bag is no longer read (see `legacyViewOptions` above). + options: activeView, // Toolbar policy — one vocabulary (#2890). The host node and the // active view may still carry the legacy bare `show*` flags, so both // go through `normalizeListViewSchema` (the single fold) and merge, diff --git a/packages/plugin-view/src/__tests__/ObjectView.calendarAliasRefused-8355.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.calendarAliasRefused-8355.test.tsx index ebc41191d3..7833ed7a0f 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.calendarAliasRefused-8355.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.calendarAliasRefused-8355.test.tsx @@ -50,16 +50,17 @@ * * ## THE REFUSAL'S SHAPE HERE, and why it is not a mirror * - * `listViews` STAYS UNMIRRORED. The ruling that keeps it so (recorded on - * `ObjectViewSchema` in `@object-ui/types`) turns on its VALUE TYPE — neither - * the spec's strict `ObjectListViewSchema` nor the local `NamedListView` can be - * the declared value without losing documented behaviour or enforcing 43 unread - * members, so the key waits for the maintainer to decide that type, and ⛔ not - * `z.any()`. A `.check()` on the `object-view` object decides none of that: it - * declares no value, puts no key in `.shape`, enforces none of the 43, and - * judges exactly the two spellings this card retires. The in-module precedent - * is `ListViewSchema.options`, an untyped bag whose own `.check()` refuses - * `kanban.groupBy` and this card's two calendar spellings by name. + * `listViews` stayed unmirrored when this file was written, waiting on its + * VALUE TYPE. objectui#7928 answered that (ruling A): the key is the protocol's + * strict `ObjectListViewSchema` record by reference now, so the protocol refuses + * both spellings under `listViews.KEY.calendar` itself. The `.check()` this + * card added stays beside that member, still not the mirror: it reads the + * protocol's refusal and adds the by-name pointer at the key (the protocol's own + * hint answers `dateField` → `endDateField`). The legacy `options.calendar` + * nesting is refused WHOLE now (`options` is not a member) and is no longer read + * off a named view; a stored body's bag is folded at `ViewPreview` + * (objectui#7928 director ruling, Q1 A). The scope controls below were + * inverted to that tree, ⛔ none deleted. */ import React from 'react'; @@ -196,13 +197,17 @@ describe('objectui#8355 · second route, half 1 — the emitted node carries can expect(node.allDayField).toBe('is_all_day'); }); - it('CONTROL: the LEGACY `options.calendar` nesting is stripped on the same path', async () => { - // `viewOptions` merges the canonical block over the legacy `options` bag, - // and the file's own note says that bag "is where the legacy field aliases - // … `dateField` live" — so it is the nesting stored views actually carry. + it('CONTROL: the LEGACY `options.calendar` nesting is stripped on the same path — and since objectui#7928 not read at all', async () => { + // `viewOptions` merged the canonical block over the legacy `options` bag + // until objectui#7928, and that bag "is where the legacy field aliases … + // `dateField` live". The two assertions that follow still hold; the third is + // the inversion: the bag is no longer READ off a named view (a stored body's + // bag is folded at `ViewPreview`, where it becomes `calendar` and meets the + // strip above), so its `titleField` does not reach the node either. const node = await generatedCalendarNode({ options: { calendar: { dateField: 'kickoff', titleField: 'name' } } }); expect(Object.keys(node)).not.toContain('dateField'); expect(node.startDateField).toBeUndefined(); + expect(node.titleField).toBeUndefined(); }); }); @@ -220,36 +225,68 @@ describe('objectui#8355 · second route, half 2 — the document is REFUSED at t expect(issues[0].message).toContain('Did you mean `endField` → `endDateField`?'); }); - it('the LEGACY `options.calendar` nesting is refused too, with the same message', () => { - const issues = issuesEndingIn(objectViewDoc({ options: { calendar: { dateField: 'kickoff' } } }), 'options.calendar.dateField'); - expect(issues).toHaveLength(1); - expect(issues[0].message).toContain('Did you mean `dateField` → `startDateField`?'); + it('INVERTED: the LEGACY `options.calendar` nesting is refused WHOLE — `options` by name — and no longer judged inside (objectui#7928)', () => { + // Was "refused too, with the same message" at `options.calendar.dateField`. + // The named view is the protocol's strict record now, which refuses the + // `options` key itself; the check no longer reads inside a bag the contract + // refuses and the renderer does not read. A stored body's bag is folded onto + // `calendar` at `ViewPreview`, where the arms above judge it. + const doc = objectViewDoc({ options: { calendar: { dateField: 'kickoff' } } }); + const issues = issuesEndingIn(doc, 'options.calendar.dateField'); + expect(issues).toHaveLength(0); + expect(issues.some((i) => i.message.includes('Did you mean `dateField` → `startDateField`?'))).toBe(false); + const whole = issuesEndingIn(doc, 'listViews.v1').filter((i) => i.code === 'unrecognized_keys'); + expect(whole).toHaveLength(1); + expect((whole[0] as { keys?: string[] }).keys).toEqual(['options']); }); it('DARK CONTROL: the canonical named view parses GREEN through the same door', () => { - const r = safeValidateSchema(objectViewDoc({ calendar: { startDateField: 'kickoff', endDateField: 'wrapup' } })); + // `columns` since objectui#7928: the protocol's named view requires it, so a + // canonical view carries it. + const r = safeValidateSchema(objectViewDoc({ columns: ['name'], calendar: { startDateField: 'kickoff', endDateField: 'wrapup' } })); expect(r.success, r.success ? '' : JSON.stringify(r.error.issues)).toBe(true); }); - it('SCOPE CONTROL: `listViews` is still UNMIRRORED — nothing else about a named view is judged', () => { - // ⭐ THE ARM THAT PROVES THIS IS NOT A MIRROR. The unmirrored ruling waits - // on the key's VALUE TYPE; a check that declared one would refuse an - // undeclared member, require `columns`, or reject the legacy `options` bag. - // None of that happens: only the two retired spellings are judged. - const undeclared = safeValidateSchema(objectViewDoc({ calendar: { zzqxNoSuchField: 'x' } })); - expect(undeclared.success, 'an undeclared key inside a named view is now refused — that is a mirror, not a check').toBe(true); + it('INVERTED SCOPE CONTROL: `listViews` IS the protocol\'s record since objectui#7928 — the MIRROR judges a named view now, not this check', () => { + // Was "`listViews` is still UNMIRRORED — nothing else about a named view is + // judged": the unmirrored ruling waited on the key's VALUE TYPE, and + // objectui#7928 chose the protocol's. Each of the three documents that + // parsed green then is refused now, by the mirror and at the key. + const undeclared = safeValidateSchema(objectViewDoc({ columns: ['name'], calendar: { startDateField: 'kickoff', zzqxNoSuchField: 'x' } })); + expect(undeclared.success, 'an undeclared key inside a named view is accepted — the mirror is not the protocol\'s strict record').toBe(false); + expect(issuesEndingIn(objectViewDoc({ columns: ['name'], calendar: { startDateField: 'kickoff', zzqxNoSuchField: 'x' } }), 'listViews.v1.calendar') + .some((i) => i.code === 'unrecognized_keys')).toBe(true); const noColumns = safeValidateSchema(objectViewDoc({ calendar: { startDateField: 'kickoff' } })); - expect(noColumns.success, 'a named view without `columns` is now refused — the spec value type leaked in').toBe(true); - const legacyBag = safeValidateSchema(objectViewDoc({ options: { calendar: { startDateField: 'kickoff' } } })); - expect(legacyBag.success, 'the legacy `options` bag is now refused — documented behaviour was lost').toBe(true); + expect(noColumns.success, 'a named view without `columns` is accepted — the spec value type is not the mirror').toBe(false); + const legacyBag = safeValidateSchema(objectViewDoc({ columns: ['name'], options: { calendar: { startDateField: 'kickoff' } } })); + expect(legacyBag.success, 'the legacy `options` bag is accepted on a named view — the contract refuses it (objectui#7928)').toBe(false); + // …and the check still judges only the two calendar spellings: none of the + // three draws a `custom` issue. + for (const doc of [ + objectViewDoc({ columns: ['name'], calendar: { startDateField: 'kickoff', zzqxNoSuchField: 'x' } }), + objectViewDoc({ calendar: { startDateField: 'kickoff' } }), + objectViewDoc({ columns: ['name'], options: { calendar: { startDateField: 'kickoff' } } }), + ]) { + const r = safeValidateSchema(doc); + const custom = r.success ? [] : r.error.issues.filter((i) => i.code === 'custom'); + expect(custom).toEqual([]); + } }); - it('SCOPE CONTROL: the TIMELINE alias is untouched on this route too', () => { - const r = safeValidateSchema({ + it('SCOPE CONTROL: the TIMELINE alias is untouched BY THIS CHECK — the protocol\'s strict record refuses it since objectui#7928', () => { + const doc = { type: 'object-view', objectName: 'task', listViews: { v1: { type: 'timeline', timeline: { dateField: 'kickoff' } } }, - }); - expect(r.success, r.success ? '' : JSON.stringify(r.error.issues)).toBe(true); + }; + const r = safeValidateSchema(doc); + // INVERTED from `success === true`: the protocol's timeline block declares + // no `dateField` (and requires `startDateField` / `titleField`), and the + // named view is that block by reference now. + expect(r.success).toBe(false); + expect(issuesEndingIn(doc, 'listViews.v1.timeline').some((i) => i.code === 'unrecognized_keys')).toBe(true); + // The scope this control exists for is unchanged: the calendar check adds + // nothing under `timeline`. + expect(issuesEndingIn(doc, 'timeline.dateField').filter((i) => i.code === 'custom')).toEqual([]); }); }); diff --git a/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx index 920f80f8b0..50ec2afec6 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx @@ -239,7 +239,8 @@ describe('objectui#8980 — a canonical top-level view-kind block reaches the re // refuses the fixture. That is the protocol narrowing a legacy escape // hatch, ⛔ not a defect in this declaration, and ⛔ not licence to widen // the type locally: the legacy shape still reaches the branch through the - // untyped `options.chart` bag. + // host `views` entry (a named view's own `options.chart` bag is folded at + // `ViewPreview` since objectui#7928, onto this same top-level block). const chartNode = await generatedNodeFor( { label: 'Agg', chart: { dataset: 'deals_by_stage', dimensions: ['stage'], values: ['amount'], chartType: 'line' } }, [{ id: 'c', label: 'Agg', type: 'chart' }], @@ -258,16 +259,30 @@ describe('objectui#8980 — a canonical top-level view-kind block reaches the re }); }); -describe('objectui#8980 — the legacy `options.` nesting keeps working, and the canonical block wins key-by-key', () => { - it('CONTROL: the legacy nesting alone still resolves the lane — untouched by this card', async () => { - // The firing control for every canonical case above: the same query through - // the path that already worked. Without it, a canonical case passing would - // not tell us the merge left the legacy route intact. +/* + * ⭐ objectui#7928 INVERTED this block (director ruling, comment 5856694523, + * Q1 A; ⛔ no assertion deleted). A named view's legacy `options.KIND` bag is + * refused by the contract (`ObjectViewSchema.listViews` is the protocol's strict + * record by reference) and is NO LONGER READ here. The per-key merge these + * cases pinned moved to the one door that relays a STORED body into a named + * view, `@object-ui/app-shell`'s `ViewPreview` (`foldStoredListOptions`), and + * is pinned there, through this same renderer, in + * `ViewPreview.optionsFold-7928.test.tsx`. Each case below asserts what the + * renderer does with the bag now; the fold file asserts the lane it used to + * resolve still resolves. + */ +describe('objectui#8980 → objectui#7928 — the legacy `options.` nesting is no longer read off a named view; the canonical block is the only source', () => { + it('INVERTED: the legacy nesting alone no longer resolves the lane — the fold at `ViewPreview` does (objectui#7928)', async () => { + // Was the firing control for every canonical case above ("the legacy + // nesting alone still resolves the lane"). The canonical cases keep their + // own control: the `kanban` case at the top of this file. const node = await generatedNodeFor({ label: 'Board', type: 'kanban', options: { kanban: { groupByField: 'legacy_lane' } } } as any); - expect(node.groupBy).toBe('legacy_lane'); + expect(node.groupBy).not.toBe('legacy_lane'); + // The branch's own floor, which is what a view with no lane declared gets. + expect(node.groupBy).toBe('status'); }); - it('the canonical block WINS for a key both spell', async () => { + it('the canonical block WINS for a key both spell — here because the bag is not read; the per-key rule itself is the fold\'s', async () => { const node = await generatedNodeFor({ label: 'Board', type: 'kanban', @@ -278,10 +293,12 @@ describe('objectui#8980 — the legacy `options.` nesting keeps working, a expect(node.groupBy).not.toBe('legacy_lane'); }); - it('a key the canonical block does NOT restate survives from the legacy nesting — the merge is per-key, not wholesale', async () => { - // The arm that tells a MERGE apart from a REPLACE. A wholesale swap would - // blank `titleField` here, silently, on every stored view that mixes the two - // spellings — which is exactly the population this change has to protect. + it('INVERTED: a key only the legacy nesting spells does NOT survive off a named view — the per-key merge is the fold\'s now (objectui#7928)', async () => { + // Was "survives from the legacy nesting — the merge is per-key, not + // wholesale". The stored population that mixes the two spellings is still + // protected: `foldStoredListOptions` merges per key before the body becomes + // a named view, and `ViewPreview.optionsFold-7928.test.tsx` asserts this + // exact `titleField` through this renderer. const node = await generatedNodeFor({ label: 'Board', type: 'kanban', @@ -289,7 +306,9 @@ describe('objectui#8980 — the legacy `options.` nesting keeps working, a options: { kanban: { titleField: 'subject' } }, } as any); expect(node.groupBy).toBe('canonical_lane'); - expect(node.titleField).toBe('subject'); + expect(node.titleField).not.toBe('subject'); + // The branch's own default, which is what an undeclared title gets. + expect(node.titleField).toBe('name'); }); }); diff --git a/packages/types/src/__tests__/calendar-date-alias-refusal-8355.test.ts b/packages/types/src/__tests__/calendar-date-alias-refusal-8355.test.ts index a189af2a60..ff67af410a 100644 --- a/packages/types/src/__tests__/calendar-date-alias-refusal-8355.test.ts +++ b/packages/types/src/__tests__/calendar-date-alias-refusal-8355.test.ts @@ -299,14 +299,14 @@ describe('objectui#8355 · surfaces 3 and 4 — the `object-calendar` node', () }); describe('objectui#8355 · surface 5 — a NAMED VIEW, and the ledger it must not disturb', () => { - it('⭐ `listViews` is STILL ABSENT from `ObjectViewSchema.shape` — the check is not a mirror', () => { - // THE STRUCTURAL GUARD for the unmirrored ruling. That ruling waits on the - // key's VALUE TYPE; the round-3 refusal is a `.check()` on the object, so it - // declares nothing and the key never enters the shape. If a later edit turns - // it into a member — of any type — this row reddens before the parity - // ledger has to notice. + it('⭐ `listViews` is a MEMBER of `ObjectViewSchema.shape` since objectui#7928 — the value type was ruled, and the check is still not the mirror', () => { + // THE STRUCTURAL GUARD for the unmirrored ruling, inverted when that ruling + // was answered. It waited on the key's VALUE TYPE; objectui#7928 (ruling A) + // chose the protocol's `ObjectListViewSchema` by reference, so the key is a + // member now. The round-3 refusal is still a check beside it, not the + // member: it adds the by-name pointer to the protocol's own refusal. const keys = Object.keys(ObjectViewSchema.shape); - expect(keys).not.toContain('listViews'); + expect(keys).toContain('listViews'); expect(keys, 'the shape is unreadable — the row above would pass vacuously').toContain('objectName'); }); diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index 09c2f22f5f..f69b671098 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -59,6 +59,7 @@ import { PageTypeSchema as SpecPageTypeSchema, PageVariableSchema as SpecPageVariableSchema, ListViewSchema as SpecListViewSchema, + ViewSchema as SpecViewSchema, KanbanConfigSchema as SpecKanbanConfigSchema, GanttConfigSchema as SpecGanttConfigSchema, CalendarConfigSchema as SpecCalendarConfigSchema, @@ -200,6 +201,10 @@ const IMPORTED: Array = [ // cross the same boundary with the same symbol, so this one entry measures // all four crossings. ['ChartAxisSchema', SpecChartAxisSchema], + // objectui#7928: `ObjectViewSchema.listViews` is the view container's own + // `listViews` record, crossed through this boundary, so the container is + // measured here like every other crossing. + ['ViewSchema', SpecViewSchema], ] as const; /** The subset that actually carries an imported default — where the strip does work. */ @@ -258,12 +263,13 @@ describe('the import boundary strips every imported default (objectui#8317)', () it('the walker docblock\'s `lazy` count is re-derived, not quoted', () => { // The `lazy` arm is the one place the identity property cannot hold: it // must rebuild without forcing the getter, so a clean subtree behind a - // `z.lazy` is rebuilt anyway. The module's docblock names THREE such - // nodes and says the exception costs nothing today because each sits + // `z.lazy` is rebuilt anyway. The module's docblock names FOUR such + // nodes (the fourth, `ViewSchema`'s form-field group, since objectui#7928) + // and says the exception costs nothing today because each sits // inside a schema that is being rebuilt regardless. Both halves are // measured here, so a spec bump that moves either one is red rather than // quietly making the docblock false. - expect(walk(IMPORTED.map(([, s]) => s)).lazies).toBe(3); + expect(walk(IMPORTED.map(([, s]) => s)).lazies).toBe(4); const lazyOwners = IMPORTED.filter(([, s]) => walk([s]).lazies > 0); expect(lazyOwners.length, 'no schema owns a lazy — the count above found them elsewhere').toBeGreaterThan(0); for (const [name, schema] of lazyOwners) { diff --git a/packages/types/src/__tests__/object-view-listviews-by-reference-7928.test.ts b/packages/types/src/__tests__/object-view-listviews-by-reference-7928.test.ts new file mode 100644 index 0000000000..92d55a44c4 --- /dev/null +++ b/packages/types/src/__tests__/object-view-listviews-by-reference-7928.test.ts @@ -0,0 +1,81 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * objectui#7928 — `ObjectViewSchema.listViews` is the protocol's named-view + * record BY REFERENCE (maintainer ruling A, comment 5565628927; director ruling + * on `options`, comment 5856694523). + * + * The measurement that made this the natural pin is objectui#7928 comment + * 5708219442: every alias refusal the list-view vocabulary declares stopped at + * `listViews`, because the key was unmirrored and the document rode + * `.passthrough()`. The objectui#8365 stray `kanban.groupBy` was REFUSED on a + * `list-view` document and ACCEPTED inside a named view. With the record mirrored + * it is refused in both places, by name, and a spec-shaped control beside it + * parses green, so the refusal is a reading of that key and not of a closed door. + */ +import { describe, it, expect } from 'vitest'; +import { ObjectViewSchema } from '../zod/objectql.zod'; +import { safeValidateSchema } from '../zod/index.zod'; + +/** A spec-shaped kanban named view: `columns` at the top and inside the block. */ +const KANBAN_VIEW = { + label: 'Board', + type: 'kanban', + columns: ['name', 'stage'], + kanban: { groupByField: 'stage', columns: ['name'] }, +} as const; + +const doc = (view: Record) => ({ type: 'object-view', objectName: 'deal', listViews: { v1: view } }); + +type Issue = { code?: string; path?: readonly PropertyKey[]; keys?: readonly string[] }; +const issuesOf = (r: { success: boolean; error?: { issues: readonly unknown[] } }): Issue[] => + (r.success ? [] : (r.error!.issues as Issue[])); +const at = (issues: Issue[], path: string) => issues.filter((i) => (i.path ?? []).join('.') === path); + +describe('objectui#7928 — `listViews.v1.kanban.groupBy` is refused BY NAME, beside an accepted spec-shaped control', () => { + it('CONTROL: the spec-shaped kanban named view parses green, directly and through the union door', () => { + const direct = ObjectViewSchema.safeParse(doc(KANBAN_VIEW)); + expect(direct.success, JSON.stringify(issuesOf(direct))).toBe(true); + const union = safeValidateSchema(doc(KANBAN_VIEW)); + expect(union.success, JSON.stringify(issuesOf(union))).toBe(true); + }); + + it('the stray `groupBy` inside that same block is refused `unrecognized_keys` AT `listViews.v1.kanban`, naming the key', () => { + const view = { ...KANBAN_VIEW, kanban: { ...KANBAN_VIEW.kanban, groupBy: 'stage' } }; + for (const r of [ObjectViewSchema.safeParse(doc(view)), safeValidateSchema(doc(view))]) { + expect(r.success).toBe(false); + const refusals = at(issuesOf(r), 'listViews.v1.kanban').filter((i) => i.code === 'unrecognized_keys'); + expect(refusals).toHaveLength(1); + expect(refusals[0].keys).toEqual(['groupBy']); + } + }); + + it('the legacy `options` bag is refused `unrecognized_keys` AT the named view, naming `options` (director ruling, Q1 A)', () => { + const view = { ...KANBAN_VIEW, options: { kanban: { groupByField: 'stage' } } }; + for (const r of [ObjectViewSchema.safeParse(doc(view)), safeValidateSchema(doc(view))]) { + expect(r.success).toBe(false); + const refusals = at(issuesOf(r), 'listViews.v1').filter((i) => i.code === 'unrecognized_keys'); + expect(refusals).toHaveLength(1); + expect(refusals[0].keys).toEqual(['options']); + } + }); + + it('a named view without `columns` is refused AT `listViews.v1.columns` — the protocol requires it', () => { + const { columns: _columns, ...noColumns } = KANBAN_VIEW; + const r = ObjectViewSchema.safeParse(doc(noColumns)); + expect(r.success).toBe(false); + expect(at(issuesOf(r), 'listViews.v1.columns').length).toBeGreaterThan(0); + }); + + it('no default is written into the parsed named view — the import boundary strips the protocol\'s (objectui#8317)', () => { + // `ObjectListViewSchema` defaults `type` to `grid`; the mirror authors no + // default, so a view that omits `type` comes back without one. + const r = ObjectViewSchema.safeParse(doc({ label: 'All', columns: ['name'] })); + expect(r.success).toBe(true); + if (r.success) expect(r.data.listViews?.v1).toEqual({ label: 'All', columns: ['name'] }); + }); +}); diff --git a/packages/types/src/__tests__/object-view-spec-parity.test.ts b/packages/types/src/__tests__/object-view-spec-parity.test.ts index 070e823ec3..7ec38a1718 100644 --- a/packages/types/src/__tests__/object-view-spec-parity.test.ts +++ b/packages/types/src/__tests__/object-view-spec-parity.test.ts @@ -244,14 +244,13 @@ const TS_ONLY_BACKLOG = new Set([ // the sibling `ViewSwitcherSchema` slots by reference, `defaultViewType` / // `defaultListView` / `showViewSwitcher` local literals after a reader census, // and `viewTabBar` is a tombstone on BOTH faces (declared, refused by name) — - // so none of the nine is TS-only any more. The one that stays: + // so none of the nine is TS-only any more. // - // `listViews` — its VALUE type is undecided (the declaration's `NamedListView` - // vs the spec's strict `ObjectListViewSchema`, which refuses the named views - // this package's docs teach); it stays in the parity ledger with that - // measurement (`zod-mirror-parity.test.ts`, `UnmirroredDeclared`) until the - // maintainer decides. ⛔ Not closed with `z.any()`. - 'listViews', + // ⭐ `listViews`, the tenth, LEFT this backlog with objectui#7928. Its value + // type was the open question (the declaration's `NamedListView` vs the spec's + // strict `ObjectListViewSchema`); ruling A chose the spec's, and both faces + // now carry `ViewSchema.listViews` by reference. The set is empty, and a name + // appearing in it again is a key added to the interface only. // ⭐ `onNavigate` LEFT this backlog with objectui#7804's `objectql.ts` slice, // and the sentence it used to carry here was the thing that needed correcting // — not the entry. It read "a function, so it CANNOT be declared in a JSON @@ -419,8 +418,13 @@ describe('ObjectViewSchema declared-surface consistency (#2890 scope B)', () => // 21 non-envelope keys, 21 + 2 = 23 declared — and the TS figure below again // does not move, because the key was already declared there. That is the // whole shape of this slice: it closes gaps on the ZOD side only. - expect(ouiZodKeys.filter((k) => !ENVELOPE.has(k))).toHaveLength(21); - expect(ouiDeclaredKeys).toHaveLength(23); + // + // objectui#7928 added the TWENTY-SECOND: `listViews`, the spec's record by + // reference — the last TS-only key closed on the zod side. So 22 + // non-envelope keys, 22 + 2 = 24 declared, and the TS figure again does not + // move. + expect(ouiZodKeys.filter((k) => !ENVELOPE.has(k))).toHaveLength(22); + expect(ouiDeclaredKeys).toHaveLength(24); // The interface's own surface beyond the envelope. The audit counted 25 // declared fields including the 3 whose names the envelope also owns // (`type`, `description`, `className`); 22 is that figure with those three diff --git a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts index a0b1182232..659e91fed7 100644 --- a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts +++ b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts @@ -41,12 +41,23 @@ * `retirementTombstone()` on the mirror, BOTH halves, so the retired spelling is * refused BY NAME on each face rather than deleted into the index signature. * - * ## The one that stayed, and why it is pinned too + * ## The one that stayed, and how it was closed (objectui#7928) * - * `listViews` is NOT mirrored. The ruling's own fallback clause fires on the - * measurement below: the declaration's value is the local `NamedListView` — 64 - * declared top-level members, of which the renderer reads 21, leaving 43 that a - * key-for-key local mirror would enforce unread. + * `listViews` was NOT mirrored by this card. The ruling's own fallback clause + * fired on the measurement below: the declaration's value was the local + * `NamedListView` — 64 declared top-level members, of which the renderer read + * 21, leaving 43 that a key-for-key local mirror would enforce unread. + * + * ⭐ objectui#7928 closed it on the OTHER value type: ruling A (maintainer + * 「同意」, comment 5565628927) mirrors it BY REFERENCE to the spec's + * `ViewSchema.shape.listViews`, staged behind objectui#8254 (the renderer + * honours a spec-shaped named view) and objectui#8255 (the docs teach one). The + * one key the renderer still read that the protocol refuses, `options`, was + * disposed by the director ruling on that card (comment 5856694523): refused on + * the authoring face, no longer read off a named view, and folded at + * `@object-ui/app-shell`'s `ViewPreview` for a stored body. The census below is + * RE-TAKEN on that tree — 20 read / 44 unread — and the pins that said "still + * unmirrored" are inverted rather than deleted. * * ⚠️ BOTH FIGURES WERE RE-TAKEN at objectui#8980 and are NOT the ones this file * was written with. It measured 47 declared / 6 read / 41 unread, plus a @@ -102,6 +113,7 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import ts from 'typescript'; +import type { z } from 'zod'; import { ListViewSchema as SpecListViewSchema, ObjectListViewSchema as SpecObjectListViewSchema, @@ -133,6 +145,7 @@ const LOCAL_LITERALS = ['defaultViewType', 'defaultListView', 'showViewSwitcher' const MIRRORED = [...SPEC_REFERENCED, ...SIBLING_REFERENCED, ...LOCAL_LITERALS] as const; type Mirrored = (typeof MIRRORED)[number]; const RETIRED = 'viewTabBar'; +/** Ledgered until objectui#7928; the spec's own record, by reference, since. */ const LEDGERED = 'listViews'; /** @@ -173,13 +186,15 @@ const READ_CONTROL_KEY = 'objectName'; const CONTROL_KEY = 'viewSwitcherPosition'; /** - * The `NamedListView` members the renderer reads off a named view. Twenty-one - * since objectui#8980 wired a read point for each of the seventeen protocol - * members it declares; seven before it, of which `data` was the undeclared cast. + * The `NamedListView` members the renderer reads off a named view. Twenty since + * objectui#7928 stopped reading the legacy `options` bag off a named view (it is + * folded at `ViewPreview` instead); twenty-one from objectui#8980, which wired a + * read point for each of the seventeen protocol members it declares; seven + * before that, of which `data` was the undeclared cast. */ const NAMED_VIEW_READS = [ 'appearance', 'calendar', 'chart', 'columns', 'data', 'fieldOrder', 'filter', 'gallery', - 'gantt', 'grouping', 'kanban', 'label', 'map', 'name', 'options', 'rowColor', 'sort', + 'gantt', 'grouping', 'kanban', 'label', 'map', 'name', 'rowColor', 'sort', 'timeline', 'tree', 'type', 'userActions', ] as const; @@ -204,13 +219,14 @@ const NAMED_LIST_VIEW_DECLARED = [ ] as const; /** - * The DECLARED members the renderer reads off a named view. All 21 of them - * since objectui#8980 — the read set and the declared set no longer disagree on - * a single name, which is what closed {@link NAMED_VIEW_READ_UNDECLARED}. + * The DECLARED members the renderer reads off a named view. All 20 of them + * (21 until objectui#7928 dropped `options`) — the read set and the declared + * set disagree on no name since objectui#8980, which is what closed + * {@link NAMED_VIEW_READ_UNDECLARED}. */ const NAMED_VIEW_READ_DECLARED = [ 'appearance', 'calendar', 'chart', 'columns', 'data', 'fieldOrder', 'filter', 'gallery', - 'gantt', 'grouping', 'kanban', 'label', 'map', 'name', 'options', 'rowColor', 'sort', + 'gantt', 'grouping', 'kanban', 'label', 'map', 'name', 'rowColor', 'sort', 'timeline', 'tree', 'type', 'userActions', ] as const; @@ -231,7 +247,8 @@ const NAMED_VIEW_READ_DECLARED = [ const NAMED_VIEW_READ_UNDECLARED = [] as const; /** - * The census result: declared, and NOT read off a named view. 43 today. A + * The census result: declared, and NOT read off a named view. 44 today — 43 + * plus `options`, which objectui#7928 stopped reading off a named view. A * document authoring any of these validates green (`BaseSchema` is * `.passthrough()`) and changes nothing — the finding objectui#7924 records. * @@ -250,7 +267,7 @@ const NAMED_LIST_VIEW_UNREAD = [ 'aria', 'bulkActionDefs', 'bulkActions', 'clickIntoRecordDetails', 'collapseAllByDefault', 'color', 'compactToolbar', 'conditionalFormatting', 'densityMode', 'description', 'emptyState', 'exportOptions', 'fieldTextColor', 'filterableFields', 'hiddenFields', 'inlineEdit', - 'navigation', 'pageName', 'pagination', 'prefixField', 'resizable', 'rowActions', 'rowHeight', + 'navigation', 'options', 'pageName', 'pagination', 'prefixField', 'resizable', 'rowActions', 'rowHeight', 'searchableFields', 'selection', 'sharing', 'showColor', 'showDensity', 'showDescription', 'showFilters', 'showGroup', 'showHideFields', 'showRecordCount', 'showSearch', 'showSort', 'tabs', 'userFilters', 'wrapHeaders', @@ -364,7 +381,7 @@ const BUCKET_LEGACY_SHOW_SPELLINGS = [ */ const BUCKET_LOCAL_ONLY_UNREAD = [ 'addDeleteRecordsInline', 'addRecordViaForm', 'allowExport', 'clickIntoRecordDetails', - 'collapseAllByDefault', 'color', 'densityMode', 'fieldTextColor', 'prefixField', 'wrapHeaders', + 'collapseAllByDefault', 'color', 'densityMode', 'fieldTextColor', 'options', 'prefixField', 'wrapHeaders', ] as const; /** @@ -393,12 +410,22 @@ const NAMED_LIST_VIEW_TOMBSTONES = [ const BUCKET_DECLARED_INERT = ['pageName', 'tabs'] as const; /** - * The one objectui-only member that IS read off a named view. It is the whole - * cost of a by-reference mirror in one name: the strict protocol value refuses - * it, and the renderer reads it. + * The one objectui-only member that WAS read off a named view, until + * objectui#7928. It was the whole cost of a by-reference mirror in one name: the + * strict protocol value refuses it, and the renderer read it. The director + * ruling on that card (comment 5856694523) removed the read and folds a stored + * body's bag at `ViewPreview`, so it joined bucket ③. It is NOT tombstoned: + * Q2 (i) of the same ruling keeps `NamedListView` exported and unchanged until + * objectui#7924 disposes it ({@link BUCKET_LOCAL_ONLY_HELD}). */ const LOCAL_ONLY_READ = 'options'; +/** + * Bucket-③ members kept declared and NOT tombstoned by a ruling rather than by + * a read: `options`, under objectui#7928's Q2 (i). + */ +const BUCKET_LOCAL_ONLY_HELD = [LOCAL_ONLY_READ] as const; + /** The `SHOW_FLAG_TO_USER_ACTION` fold lives here, and is read off disk. */ const FOLD = 'packages/core/src/utils/normalize-list-view.ts'; @@ -455,8 +482,10 @@ export type _DefaultViewTypeIsNotAny = Expect>; export type _ViewTabBarIsNotAny = Expect, false>>; -// `listViews`: STILL the declaration's local value — this card moved neither face. -export type _ListViewsIsTheLocalRecord = Expect | undefined>>; +// `listViews`: the protocol's record since objectui#7928 — inverted from "STILL the +// declaration's local value", which this card (objectui#7779) pinned. +export type _ListViewsIsNoLongerTheLocalRecord = Expect | undefined>, false>>; +export type _ListViewsIsTheSpecRecord = Expect> | undefined>>; // The control key is NOT declared: it resolves to `any` through the index // signature, exactly as the ten did on the zod side before this card. export type _ControlKeyFallsThroughToIndexSignature = Expect>; @@ -555,6 +584,14 @@ function isObjectStatic(node: ts.Node, method: string): node is ts.CallExpressio const DEP_ARRAY_HOOKS = new Set(['useMemo', 'useCallback', 'useEffect', 'useLayoutEffect']); +/** + * The type names that mark a binding as ONE named view. `NamedViewConfig` is the + * renderer's local alias for an entry of the mirrored `ObjectViewSchema['listViews']` + * (objectui#7928); the pin below re-reads its definition, so the alias cannot + * drift onto something else while this census keeps trusting the name. + */ +const NAMED_VIEW_TYPE_NAMES = new Set(['NamedListView', 'NamedViewConfig']); + interface NamedListViewCensus { /** Declared member names, in declaration order. */ names: string[]; @@ -614,7 +651,10 @@ interface NamedViewReadDerivation { * * Every route from the record to a named view is derived, not listed: * RECORD — a variable initialised from `schema.listViews`. - * VALUE — a binding annotated `NamedListView`; the value binding of + * VALUE — a binding annotated `NamedListView`, or `NamedViewConfig` (the + * entry type of the mirrored `ObjectViewSchema['listViews']`, + * which types `currentNamedViewConfig` since objectui#7928); the + * value binding of * `Object.entries(RECORD).map(([key, view]) => …)` (through an * intermediate `const entries = …` too); or an element access * straight off the RECORD. @@ -645,7 +685,7 @@ function deriveNamedViewReads(): NamedViewReadDerivation { if (!ts.isVariableDeclaration(n) || !ts.isIdentifier(n.name) || !n.type) continue; let mentions = false; (function walk(t: ts.Node) { - if (ts.isTypeReferenceNode(t) && ts.isIdentifier(t.typeName) && t.typeName.text === 'NamedListView') mentions = true; + if (ts.isTypeReferenceNode(t) && ts.isIdentifier(t.typeName) && NAMED_VIEW_TYPE_NAMES.has(t.typeName.text)) mentions = true; ts.forEachChild(t, walk); })(n.type); if (mentions) valueBindings.add(n.name.text); @@ -819,13 +859,22 @@ describe('objectui#7779 — the renderer reads the eight mirrored keys, which is /* ── The zod mirror: membership ───────────────────────────────────────────── */ -describe('objectui#7779 — the zod mirror declares the eight keys and the tombstone, and NOT `listViews`', () => { +describe('objectui#7779 — the zod mirror declares the eight keys and the tombstone; `listViews` joined them at objectui#7928', () => { it.each([...MIRRORED, RETIRED])('`%s` is a member of the mirror shape (membership cannot be read off acceptance under passthrough)', (key) => { expect(shapeKeys(ObjectViewSchema)).toContain(key); }); - it('`listViews` is STILL not a member — the ruling\'s fallback clause, pinned so the ledger entry cannot go stale unnoticed', () => { - expect(shapeKeys(ObjectViewSchema)).not.toContain(LEDGERED); + it('`listViews` IS a member since objectui#7928 — the ledger entry the fallback clause kept is gone, and this pin moved with it', () => { + // Inverted from "STILL not a member" (the objectui#7779 fallback clause). + expect(shapeKeys(ObjectViewSchema)).toContain(LEDGERED); + }); + + it('`listViews` IS the spec\'s `ViewSchema.shape.listViews`, crossed through the import boundary — the record, by reference', () => { + expect(shapeMember(ObjectViewSchema, LEDGERED)).toBe(shapeMember(stripImportedDefaults(SpecViewSchema), LEDGERED)); + // …whose value is the protocol's strict named-view schema (defaults stripped). + const slot = shapeMember(ObjectViewSchema, LEDGERED) as { unwrap(): { def?: { valueType?: unknown }; _def?: { valueType?: unknown } } }; + const inner = slot.unwrap(); + expect((inner.def ?? inner._def)?.valueType).toBe(stripImportedDefaults(SpecObjectListViewSchema)); }); it('the control key is undeclared on the mirror too', () => { @@ -1093,7 +1142,7 @@ function docNamedViews(rel: string): Array<{ where: string; view: unknown }> { return out; } -describe('objectui#7779 — `listViews` stays unmirrored on the ruling\'s fallback clause; the measurement is pinned against the SPEC', () => { +describe('objectui#7779 → objectui#7928 — `listViews` was ledgered on the fallback clause and is mirrored by reference now; the measurement stays pinned against the SPEC', () => { it('the spec slot `ViewSchema.listViews` is a record whose value is the strict `ObjectListViewSchema`', () => { const slot = shapeMember(SpecViewSchema, 'listViews') as { unwrap(): { def?: { type?: string; valueType?: unknown }; _def?: { type?: string; valueType?: unknown } } }; const inner = slot.unwrap(); @@ -1161,8 +1210,8 @@ describe('objectui#7779 — `listViews` stays unmirrored on the ruling\'s fallba ])).toEqual(['label-only', 'tuple']); }); - it('the renderer reads twenty-one keys off a named view — every one of them a declared `NamedListView` member since objectui#8980 — of a declaration with 64, the reason a local key-for-key mirror is still not the answer', () => { - // ⚠️ The REGEX instrument sees twenty, not twenty-one: `name` is read only + it('the renderer reads twenty keys off a named view — every one of them a declared `NamedListView` member — of a declaration with 64, the reason a local key-for-key mirror was never the answer', () => { + // ⚠️ The REGEX instrument sees nineteen, not twenty: `name` is read only // at the tab strip (`view.name`), which `currentNamedViewConfig?.KEY` cannot // see. The AST derivation below finds it; the gap between the two is // asserted by name there rather than papered over here. @@ -1170,7 +1219,7 @@ describe('objectui#7779 — `listViews` stays unmirrored on the ruling\'s fallba // The tab strip reads `label` off the entries too — same member, second // site — and since objectui#8980 `name` between it and the record key. expect(readRepo(READER)).toContain('{view.label || view.name || key}'); - // ALL twenty-one are declared `NamedListView` members since objectui#8980: + // ALL twenty are declared `NamedListView` members since objectui#8980: // the seventeen the protocol declares on this surface landed with a read // point each, and `data` — which used to reach the renderer through an // `as any` cast on the named-view config — is one of them. So the "unread" @@ -1201,8 +1250,13 @@ describe('objectui#7779 — `listViews` stays unmirrored on the ruling\'s fallba expect(declared).toBeGreaterThan(NAMED_VIEW_READS.length); }); - it('the TS face still declares `listViews` as the local record (neither face moved)', () => { - expect(readRepo(DECLARATION)).toContain('listViews?: Record;'); + it('the TS face declares `listViews` as the SPEC record since objectui#7928 — no longer the local one', () => { + // Inverted from "still declares the local record (neither face moved)". + expect(readRepo(DECLARATION)).not.toContain('listViews?: Record;'); + expect(readRepo(DECLARATION)).toContain('listViews?: Record>;'); + // The renderer types its named-view config off that member, not off `NamedListView`. + expect(readRepo(READER)).toContain("type NamedViewConfig = NonNullable[string];"); + expect(readRepo(READER)).toContain('const currentNamedViewConfig: NamedViewConfig | null = useMemo(() => {'); }); }); @@ -1267,10 +1321,10 @@ describe('objectui#7924 — the per-member liveness census on `NamedListView`, r } }); - it('the renderer reads exactly twenty-one names off a named view — every one of them declared', () => { + it('the renderer reads exactly twenty names off a named view — every one of them declared', () => { const d = deriveNamedViewReads(); expect(d.reads).toEqual([...NAMED_VIEW_READS]); - expect(d.reads).toHaveLength(21); + expect(d.reads).toHaveLength(20); // ⭐ THE INSTRUMENTS NOW DISAGREE BY ONE NAME, and the difference is pinned // rather than smoothed over. The AST finds STRICTLY more than the // `currentNamedViewConfig?.KEY` regex it replaced — that was already true @@ -1289,15 +1343,15 @@ describe('objectui#7924 — the per-member liveness census on `NamedListView`, r expect(d.readSites.name.length, '`name` is read at exactly one site: the tab strip\'s display fallback').toBe(1); }); - it('the census partitions the declaration exactly: 21 read + 43 unread = 64, disjoint and exhaustive', () => { + it('the census partitions the declaration exactly: 20 read + 44 unread = 64, disjoint and exhaustive', () => { const declared = new Set(namedListViewMembers().names); const reads = new Set(deriveNamedViewReads().reads); const read = [...declared].filter((m) => reads.has(m)).sort(); const unread = [...declared].filter((m) => !reads.has(m)).sort(); expect(read).toEqual([...NAMED_VIEW_READ_DECLARED]); expect(unread).toEqual([...NAMED_LIST_VIEW_UNREAD]); - expect(read).toHaveLength(21); - expect(unread).toHaveLength(43); + expect(read).toHaveLength(20); + expect(unread).toHaveLength(44); expect(read.length + unread.length).toBe(declared.size); expect(read.filter((m) => unread.includes(m))).toEqual([]); }); @@ -1444,7 +1498,7 @@ function foldFlagMap(): Record { return out; } -describe('objectui#7924 — the 43 unread members are FOUR populations, derived against the installed protocol', () => { +describe('objectui#7924 — the 44 unread members are FOUR populations, derived against the installed protocol', () => { it('the protocol declares 50 keys for a named list view, and the instrument that reads them fires', () => { const keys = protocolListViewKeys(); expect(keys).toEqual([...PROTOCOL_LIST_VIEW_KEYS]); @@ -1492,16 +1546,24 @@ describe('objectui#7924 — the 43 unread members are FOUR populations, derived // sets is non-empty in the other direction, so the emptiness is a reading. const localOnly = [...declared].filter((k) => !protocolListViewKeys().includes(k)).sort(); expect(localOnly).toHaveLength(19); + // `options` is counted once: it sits in bucket ③ since objectui#7928. expect(localOnly).toEqual([ - ...BUCKET_LEGACY_SHOW_SPELLINGS, ...BUCKET_LOCAL_ONLY_UNREAD, LOCAL_ONLY_READ, + ...BUCKET_LEGACY_SHOW_SPELLINGS, ...BUCKET_LOCAL_ONLY_UNREAD, ].sort()); + expect(BUCKET_LOCAL_ONLY_UNREAD as readonly string[]).toContain(LOCAL_ONLY_READ); }); - it('exactly one objectui-only member is READ off a named view — the whole cost of a by-reference mirror, in one name', () => { + it('NO objectui-only member is read off a named view since objectui#7928 — the one that was, `options`, is folded at `ViewPreview`', () => { const reads = deriveNamedViewReads().reads; const protocolKeys = protocolListViewKeys(); const localOnlyRead = reads.filter((r) => !protocolKeys.includes(r)); - expect(localOnlyRead).toEqual([LOCAL_ONLY_READ]); + // Inverted from `[LOCAL_ONLY_READ]`: the renderer read `options` off a named + // view until the director ruling on objectui#7928 removed both reads. + expect(localOnlyRead).toEqual([]); + expect(localOnlyRead).not.toContain(LOCAL_ONLY_READ); + expect(readRepo(READER)).not.toContain(`currentNamedViewConfig?.${LOCAL_ONLY_READ}`); + // Control on the same query: a canonical block IS read off the same binding. + expect(readRepo(READER)).toContain('currentNamedViewConfig?.kanban'); // And the strict protocol value refuses it BY NAME, which is why the mirror // is still not a drop-in — the same fact objectui#7928 weighs. const r = SpecObjectListViewSchema.safeParse({ label: 'x', columns: ['a'], [LOCAL_ONLY_READ]: {} }); @@ -1527,7 +1589,7 @@ describe('objectui#7924 — the 43 unread members are FOUR populations, derived } }); - it('the 43 unread members partition FOUR ways — 23 + 8 + 10 + 2 — disjoint and exhaustive', () => { + it('the 44 unread members partition FOUR ways — 23 + 8 + 11 + 2 — disjoint and exhaustive', () => { const unread = new Set(NAMED_LIST_VIEW_UNREAD as readonly string[]); const protocolKeys = new Set(protocolListViewKeys()); const ruledInert = new Set(BUCKET_DECLARED_INERT); @@ -1544,14 +1606,14 @@ describe('objectui#7924 — the 43 unread members are FOUR populations, derived expect([...ruledInert].sort()).toEqual([...BUCKET_DECLARED_INERT]); expect(protocolDeclared).toHaveLength(23); expect(legacy).toHaveLength(8); - expect(inventedOnly).toHaveLength(10); + expect(inventedOnly).toHaveLength(11); expect(BUCKET_DECLARED_INERT).toHaveLength(2); // Exhaustive and disjoint against the census's own unread set: a member that // lands in no bucket, or in two, fails here — which is the event that makes // the 2026-09-16 disposition ruling stale. const union = [...protocolDeclared, ...legacy, ...inventedOnly, ...BUCKET_DECLARED_INERT]; - expect(union).toHaveLength(43); - expect(new Set(union).size).toBe(43); + expect(union).toHaveLength(44); + expect(new Set(union).size).toBe(44); expect([...union].sort()).toEqual([...unread].sort()); }); @@ -1695,13 +1757,14 @@ export const _acceptedCanonical: NamedListView = { export const _acceptedRetained: NamedListView = { ...BASE_VIEW, allowExport: false, densityMode: 'compact' }; describe('objectui#7924 — the retirement: sixteen `?: never` tombstones, two retained reads, the canonical blocks', () => { - it('the tombstone set is EXACTLY bucket ② plus bucket ③ minus the two retained reads, derived off the AST', () => { + it('the tombstone set is EXACTLY bucket ② plus bucket ③ minus the two retained reads and the one held member, derived off the AST', () => { const { tombstones, names } = namedListViewMembers(); expect([...tombstones].sort()).toEqual([...NAMED_LIST_VIEW_TOMBSTONES]); expect(tombstones).toHaveLength(16); const expected = [ ...BUCKET_LEGACY_SHOW_SPELLINGS, - ...BUCKET_LOCAL_ONLY_UNREAD.filter((m) => !(BUCKET_LOCAL_ONLY_RETAINED as readonly string[]).includes(m)), + ...BUCKET_LOCAL_ONLY_UNREAD.filter((m) => !(BUCKET_LOCAL_ONLY_RETAINED as readonly string[]).includes(m) + && !(BUCKET_LOCAL_ONLY_HELD as readonly string[]).includes(m)), ].sort(); expect([...tombstones].sort()).toEqual(expected); // A tombstone is still a declared member — that is what makes the refusal @@ -1757,6 +1820,20 @@ describe('objectui#7924 — the retirement: sixteen `?: never` tombstones, two r expect(fold).toContain('delete next.densityMode;'); }); + it('`options` is HELD, not tombstoned: unread off a named view since objectui#7928, and `NamedListView` stays unchanged by that ruling\'s Q2 (i)', () => { + const { tombstones, names } = namedListViewMembers(); + expect(names).toContain(LOCAL_ONLY_READ); + expect(tombstones).not.toContain(LOCAL_ONLY_READ); + expect(readRepo(DECLARATION)).toContain('options?: Record;'); + // …while the named-view record refuses it: `ObjectViewSchema.listViews` is + // the protocol's strict value by reference. + const r = ObjectViewSchema.safeParse({ ...NODE, listViews: { v1: { label: 'x', columns: ['a'], [LOCAL_ONLY_READ]: {} } } }); + expect(r.success).toBe(false); + if (!r.success) { + expect((r.error.issues as readonly Issue[]).some((i) => i.code === 'unrecognized_keys' && (i.path ?? []).join('.') === 'listViews.v1')).toBe(true); + } + }); + it('the retained reads have a firing control: a tombstoned relay key that ListView never reads, on the same query', () => { // app-shell relays `prefixField` exactly as it relays `allowExport`… expect(readRepo(APP_SHELL_OBJECT_VIEW)).toContain('prefixField: viewDef.prefixField ?? listSchema.prefixField,'); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 10d3b9c82a..b0bf1ef960 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -333,8 +333,12 @@ * and 37 / 53 until objectui#7344 swept the string / `z.any()` handler mirrors: * `DetailSchema` and `DetailViewSchema` entered (one `onBack` each) and * `CalendarViewSchema` grew by `onEventClick`. - * - **13 entries** in `UnmirroredDeclared`, **85 keys** across them — 14 / 86 until - * objectui#8072 MIRRORED `InputSchema.wrapperClass`, the LAST `schema.wrapperClass` + * - **12 entries** in `UnmirroredDeclared`, **84 keys** across them — 13 / 85 until + * objectui#7928 MIRRORED `ObjectViewSchema.listViews` BY REFERENCE to the spec's + * `ViewSchema.shape.listViews` (maintainer ruling A, staged behind objectui#8254 and + * objectui#8255): the key objectui#7779 left on its fallback clause, and that entry's + * ONLY remaining key, so the entry emptied and BOTH figures moved together; 14 / 86 + * until objectui#8072 MIRRORED `InputSchema.wrapperClass`, the LAST `schema.wrapperClass` * reader whose value the mirror admitted unexamined; it was that entry's ONLY key, * so the entry emptied and BOTH figures moved together — read it against the next * clause, where one key off a fifteen-key entry moved only the key total: 14 / 87 @@ -420,8 +424,8 @@ * two. ⭐ The direction is the one this ledger is meant to drain in: a * runtime-only key leaves by being declared on the mirror, never by being * quietly refiled. - * **3 of the 3** are a subset of the **13** pairs above, so - * the union of the two unmirrored ledgers is **13** pairs — `UnmirroredDeclared` + * **3 of the 3** are a subset of the **12** pairs above, so + * the union of the two unmirrored ledgers is **12** pairs — `UnmirroredDeclared` * itself, exactly. ⭐ This sentence read `3 of the 4` / `15 pairs and not 14` * until objectui#7804's `TreeViewSchema` slice, and the pair it named as the * exception was the whole content of the difference: objectui#6150 had declared @@ -2367,7 +2371,7 @@ interface KnownDrift { * * objectui#6058 seeded this ledger at **121 keys**, and — on ONE line, because the * pin below reads this sentence off disk — - * **85 keys** is what this ledger records today. + * **84 keys** is what this ledger records today. * The movements between the two are different facts. objectui#6152 measured the 23 * callback-shaped (`on*`) keys and ruled that mirroring is the wrong remedy for every * one of them; @@ -2471,7 +2475,12 @@ interface KnownDrift { * explicit that forcing the 121 per-key decisions now would be wrong. Two splits * are recorded here so whoever works them off does not re-derive them: * - * - **SPEC-DERIVED (3 entries, 17 keys)** — it was 2 / 3 until objectui#7762 MIRRORED + * - **SPEC-DERIVED (2 entries, 16 keys)** — it was 3 / 17 until objectui#7928 MIRRORED + * `ObjectViewSchema.listViews` by reference to the spec's own record + * (`ViewSchema.shape.listViews`): the value-type question below was answered with + * ruling A, the entry's one remaining key closed, and the entry left the ledger — + * the half lost an entry and a key, and the LOCAL half did not move. It was 2 / 3 + * until objectui#7762 MIRRORED * `ObjectGridSchema.exportOptions` as the spec's object arm BY REFERENCE, which * re-derived that entry (its fourteen remaining keys) into this half: the #7779 * membership mechanism a second time, and the first entry to arrive here by a single @@ -2576,10 +2585,12 @@ interface KnownDrift { * moved. objectui#8072 then MIRRORED `InputSchema.wrapperClass` — the opposite shape on * every axis: one key that was its entry's WHOLE key set, on a pair no spec reference * touches, so the entry emptied, the LOCAL half lost an entry and a key, and the - * SPEC-DERIVED half did not move. The seeded pair is no longer + * SPEC-DERIVED half did not move. objectui#7928 then MIRRORED + * `ObjectViewSchema.listViews`, that entry's last key, by reference: the SPEC-DERIVED + * half lost the entry, and the LOCAL half did not move. The seeded pair is no longer * among them, and the ledger now totals — on ONE line, because the pin below reads * this sentence off disk — - * **13 entries / 85 keys** — 3 / 17 spec-derived, 10 / 68 local. + * **12 entries / 84 keys** — 2 / 16 spec-derived, 10 / 68 local. * * ⛔ The four split figures above and this totals line are PINNED: 'objectui#7279' * at the bottom of this file derives every one of them from the `UnmirroredDeclared` @@ -2731,54 +2742,6 @@ interface UnmirroredDeclared { | 'emptyState' | 'grouping' | 'navigation' | 'operations' | 'reorderableColumns' | 'resizableColumns' | 'rowColor' | 'rowHeight' | 'rowSpecActions' | 'singleClickEdit'; - /** - * SPEC-DERIVED by MEMBERSHIP since objectui#7779 (the mirror references - * `SpecListViewSchema.shape.*` in code, so `SPEC_DERIVED_PAIRS` re-derives it); - * LOCAL between objectui#7279 and then; recorded as SPEC-DERIVED → objectui#2231 - * from objectui#6058 until #7279, on the scanner false positive objectui#6705 - * exposed. ⭐ This pair had NO entry in EITHER ledger before objectui#6058 — - * eleven declared keys the published validator had never heard of, and the guard - * reported the pair clean. It is the clearest single instance of the blind spot - * this ledger exists to make visible. It was eleven: `onNavigate` is in - * `RuntimeOnlyDeclared` below (objectui#6152). It was TEN until objectui#7779 - * (maintainer ruling B, 2026-09-06 — liveness first, then mirror-or-retire per - * key) closed nine, each with its census on the `object-view` node renderer - * (`packages/plugin-view/src/ObjectView.tsx`; `schema.objectName` / `schema.layout` - * the positive controls of the same `schema.KEY` query; pinned in - * `object-view-unmirrored-keys-7779.test.ts`): - * - MIRRORED by reference to the spec slot (`SpecListViewSchema.shape.*`): - * `navigation`, `searchableFields`, `filterableFields` — identity-pinned, so a - * spec-side change moves them; - * - MIRRORED by reference to the sibling `ViewSwitcherSchema` slots the renderer - * forwards them into verbatim: `allowCreateView`, `viewActions`; - * - MIRRORED as local literals matching the declaration: `defaultViewType` (read - * `schema.defaultViewType || 'grid'`), `defaultListView` (read - * `namedListViews?.[schema.defaultListView]`), `showViewSwitcher` (read - * `schema.showViewSwitcher === true`); - * - RETIRED (`?: never` + `retirementTombstone()`, the objectui#7129 route): - * `viewTabBar` — zero reads; the tab-bar config is `ViewTabBar`'s `config` PROP - * from the host, never a node key. - * ⚠️ `listViews` STAYS, on the ruling's own fallback clause, with the measurement - * that triggered it — RE-TAKEN at objectui#8980, which moved both halves of it. - * The declaration's value is the local `NamedListView`, now 64 declared - * top-level members (47 plus the seventeen the protocol declares on this - * surface and objectui did not), 21 of which the renderer reads off a named - * view. `data` is one of the 21: it used to arrive through an `as any` cast on - * the named-view config in `plugin-view/src/ObjectView.tsx` and be outside the - * count, and objectui#8980's ruling declared it, which is objectui#7928's open - * half answered. The spec slot `ViewSchema.listViews` is - * a record of the STRICT `ObjectListViewSchema`, which requires `columns` and - * refuses `options`, ObjectQL tuple filters and `default` — the named views - * `plugin-view`'s README and `content/docs/api/schema-reference.md` teach fail it - * at `columns` / `filter.0` / unrecognized_keys. Mirroring the spec value loses - * documented behaviour; mirroring the local value enforces 43 unread members - * (64 declared, minus the 21 that are both declared and read) into the contract - * (the reason ruling B - * refused option A for the six local keys). Neither is a mirror edit this ledger can - * authorise; ⛔ `z.any()` was ruled out by name. The value type is the maintainer's - * decision, recorded on objectui#7779's report. - */ - 'objectql.zod.ts#ObjectViewSchema': 'listViews'; /** LOCAL. */ 'reports.zod.ts#ReportComponentSchema': 'chartConfig' | 'conditionalFormatting' | 'reportType'; /** @@ -2801,7 +2764,7 @@ interface UnmirroredDeclared { * * `UnmirroredDeclared` above was seeded at **121 keys** by objectui#6058, and — on * ONE line, because the pin below reads this sentence off disk — - * `UnmirroredDeclared` records **85 keys** today. + * `UnmirroredDeclared` records **84 keys** today. * These 23 moved here whole. Keys have since left that ledger by MIRRORING and by * RETIREMENT, but the move recorded HERE is neither and repaired nothing. ⛔ Nothing * was mirrored by it, no declaration was removed, no defect was repaired and nothing was @@ -4211,8 +4174,9 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [ // objectui#7779: BACK, by a real code reference this time — `navigation`, // `searchableFields` and `filterableFields` are `SpecListViewSchema.shape.*` // by reference (identity-pinned in `object-view-unmirrored-keys-7779.test.ts`). - // Membership here is what re-derives the pair's one remaining - // `UnmirroredDeclared` key (`listViews`) into the split's SPEC-DERIVED half. + // Membership here is what re-derived the pair's last `UnmirroredDeclared` key + // (`listViews`) into the split's SPEC-DERIVED half, until objectui#7928 mirrored + // it by reference to `ViewSchema.shape.listViews` and the entry left the ledger. 'objectql.zod.ts#ObjectViewSchema', // objectui#10296: `options` is the spec's authoring `SelectOptionSchema` by // reference (ruling F1 on objectui#7759). diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index b511ab98fc..ce95f7ff94 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -31,6 +31,12 @@ import type { ObjectDataTableDrillDownConfig } from './data-display.js'; // this adds no cycle in either direction. import type { QueryParams } from './data.js'; import type { BulkActionOperation } from '@objectstack/spec/ui'; +// objectui#7928 — `ObjectViewSchema.listViews` is the protocol's record of this +// schema, BY REFERENCE. The spec publishes a TS type for `ListView` and none for +// the object-scoped `ObjectListView`, so the member reads it as `z.input` (the +// authoring face: the mirror strips every imported default). Type-only. +import type { ObjectListViewSchema as SpecObjectListViewSchema } from '@objectstack/spec/ui'; +import type { z } from 'zod'; import type { FormField } from './form.js'; // ListView type is now derived from the zod schema (issue #2231) — see ListViewSchema below. import type { ListViewInferred, ObjectCalendarBlockConfig } from './zod/objectql.zod.js'; @@ -2031,9 +2037,18 @@ export interface ObjectViewSchema extends BaseSchema { /** * Named list views (e.g., "All Records", "My Records", "Active"). - * Aligned with @objectstack/spec View.listViews. - */ - listViews?: Record; + * + * The protocol's own slot BY REFERENCE (objectui#7928, ruling A): the value is + * `@objectstack/spec/ui`'s `ObjectListViewSchema`, the value type of + * `ViewSchema.listViews`. A named view therefore needs `columns`, takes + * `filter` as `{ field, operator, value }` rules and declares each view kind's + * config in the top-level block of that kind (`kanban`, `calendar`, …). + * ⛔ The legacy `options` bag is not a member: a stored body that carries one + * is folded onto the top-level blocks by `@object-ui/app-shell`'s + * `ViewPreview` before it reaches this record. `NamedListView` is no longer + * this member's type, and its retirement follows objectui#7924. + */ + listViews?: Record>; /** * Default named list view to display diff --git a/packages/types/src/zod/imported-defaults.ts b/packages/types/src/zod/imported-defaults.ts index 2aab2660ab..b9c3e97a59 100644 --- a/packages/types/src/zod/imported-defaults.ts +++ b/packages/types/src/zod/imported-defaults.ts @@ -131,6 +131,10 @@ const walk = (schema: z.ZodType): z.ZodType => { // the same recursive nav item; the recursive filter clause under // `DashboardWidgetSchema.filter` / `GlobalFilterSchema.optionsFrom.filter` / // `PageSchema.slots.header…dataSource.filter`; and `PageSchema.regions[].components[]`). + // objectui#7928 added a FOURTH by importing the view container `ViewSchema` + // for its `listViews` slot: the recursive form-field group under + // `ViewSchema.formViews[…].groups[].fields[]`, walked because the whole + // container crosses the boundary, not only the slot. // Each sits inside a schema that carries a default anyway, so today the // exception costs no extra rebuild — the pin file re-derives that count and // goes red if it moves, rather than trusting this sentence. diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 8ac7380af6..605a5042c7 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -40,6 +40,9 @@ import { DashboardWidgetSchema as SpecDashboardWidgetSchema, ChartAxisSchema as SpecChartAxisSchema, UserFilterFieldSchema as SpecUserFilterFieldSchema, + // objectui#7928 — the spec's view CONTAINER, read for ONE slot: its + // `listViews` record (`ObjectViewSchema.listViews` below, by reference). + ViewSchema as SpecViewSchema, checkListViewCalendarVisualization, } from '@objectstack/spec/ui'; import { BaseSchema, specFieldsExcept } from './base.zod.js'; @@ -510,25 +513,27 @@ export const ObjectFormSchema = BaseSchema.extend({ * the host owns the switcher). The 2026-07 audit * (`docs/audits/2026-07-objectview-detailview-schema.md`) had already * measured it dead since introduction. - * - `listViews` — STILL UNMIRRORED, on the ruling's own fallback clause and - * by measurement. ⚠️ BOTH HALVES OF THAT MEASUREMENT MOVED at objectui#8980 - * and the sentence is re-taken, not adjusted: the declaration's value is the - * local `NamedListView`, now 64 declared top-level members — 47, plus the - * seventeen the protocol declares on this surface and objectui did not - * (director-seat ruling of 2026-09-13) — of which the renderer reads 21 off - * a named view. `data` is one of the 21 now: it used to be read through an - * `as any` cast on the named-view config and declared nowhere, and that - * ruling declared it by name, which answers objectui#7928's open half. - * Meanwhile the spec slot (`ViewSchema.listViews`) is a record of the - * STRICT `ObjectListViewSchema`, - * which requires `columns` and refuses `options`, ObjectQL tuple filters and - * `default` — i.e. it refuses the named views this package's own README and - * `content/docs/api/schema-reference.md` teach. Neither value type can be - * mirrored without either losing documented behaviour (spec) or enforcing - * 43 unread members (64 declared, minus the 21 that are both declared and - * read) into the contract (local), so the key stays in the - * parity ledger with that measurement until the maintainer decides its - * value type. ⛔ Not `z.any()`: that was ruled out by name. + * - `listViews` — MIRRORED BY REFERENCE (objectui#7928): the spec's own + * `ViewSchema.shape.listViews`, a record of the STRICT + * `ObjectListViewSchema`. It was the tenth key, left in the parity ledger + * on ruling B's fallback clause because neither value type could be + * mirrored without a loss; the maintainer's ruling A (objectui#7928, + * comment 5565628927, 「同意」) chose the spec value, staged behind two + * cards that removed both losses first: objectui#8254 made the renderer + * honour a spec-shaped named view, and objectui#8255 made every document + * teach one. ⇒ a named view now needs `columns`, takes `filter` as + * `{ field, operator, value }` rules, and is refused `unrecognized_keys` + * for any key the protocol does not declare there. + * + * ⚠️ `options` — the one key the renderer still read that the protocol + * does not declare on this shape — is REFUSED with the rest (director + * ruling, objectui#7928 comment 5856694523, Q1 A). The legacy per-kind bag + * stays legal where the protocol declares it, a STORED list overlay + * (objectstack#20051), and is folded onto the top-level kind block at the + * one door that relays a stored body into this record, + * `@object-ui/app-shell`'s `ViewPreview`. `plugin-view` no longer reads + * `options` off a named view. ⛔ Never re-admit it here: the canonical + * `KIND` block is the only spelling this surface teaches. */ export const ObjectViewSchema = BaseSchema.extend({ type: z.literal('object-view'), @@ -538,6 +543,10 @@ export const ObjectViewSchema = BaseSchema.extend({ layout: z.enum(['drawer', 'modal', 'page']).optional().describe('Layout mode'), defaultViewType: z.enum(['grid', 'kanban', 'gallery', 'calendar', 'timeline', 'gantt', 'map']).optional().describe('Default list view type (grid unless a named view sets its own type)'), defaultListView: z.string().optional().describe('Key of the listViews entry shown first'), + // Spec slot by reference (objectui#7928) — `z.record(z.string(), + // ObjectListViewSchema).optional()`, the container's own named-view record. + // The value type, and why `options` is refused, are in the docblock above. + listViews: stripImportedDefaults(SpecViewSchema).shape.listViews, // Spec slot by reference (objectui#7779) — `NavigationConfigSchema.optional()`, // the same object `ListViewSchema` derives its `navigation` from. navigation: stripImportedDefaults(SpecListViewSchema).shape.navigation, @@ -589,12 +598,18 @@ export const ObjectViewSchema = BaseSchema.extend({ // and a different supplier. Judged separately for that reason. onNavigate: handlerKeyRefusal('onNavigate', 'runtime-slot', 'Record navigation handler'), }) - // ⭐ objectui#8355 — the ONE thing this object judges about a named view, and - // ⛔ NOT a mirror of `listViews`. Declared and explained at + // ⭐ objectui#8355 — the by-name pointer for the two retired calendar + // spellings on a named view. Declared and explained at // `checkNamedViewCalendarAliases` below (a hoisted function declaration, so // the forward reference resolves at module init and the body runs at parse // time, long after the refusal arms it reads are built). - .check(checkNamedViewCalendarAliases); + // + // ⚠️ `when` is load-bearing since objectui#7928. The named view is now the + // protocol's strict record, which refuses an alias itself, and a refinement + // zod runs by default is SKIPPED once any earlier issue aborts the parse. So + // without `when` this check never runs on exactly the documents it exists + // for. Measured on the flip before this line was added. + .superRefine((_value, ctx) => checkNamedViewCalendarAliases(ctx), { when: () => true }); /** * User Filters — field-level filter option @@ -1093,83 +1108,59 @@ const CalendarNodeDateAliasRefusals = { * the loud half, without which the strip would only make the failure CONSISTENT * and still mute, which is the shape the ruling refuses. * - * ## ⛔ WHY THIS IS NOT A MIRROR OF `listViews`, AND DOES NOT TOUCH THAT RULING - * - * `listViews` is unmirrored on purpose (the reading is on `ObjectViewSchema` - * above), and that ruling turns on the key's VALUE TYPE: neither the spec's - * strict `ObjectListViewSchema` nor the local `NamedListView` can be the - * declared value without losing documented behaviour or enforcing 43 unread - * members, so the key waits for the maintainer to decide that type — and ⛔ not - * `z.any()`. - * - * This check decides none of that. It declares no value type, puts no key in - * `.shape` (so the parity ledger's unmirrored reading is untouched), enforces - * none of the 43 unread members, requires no `columns`, and refuses no - * undeclared key or legacy `options` bag. It judges exactly the two spellings - * this card retires, at the two nestings the producer actually merges. Three - * SCOPE CONTROLS in `plugin-view`'s - * `ObjectView.calendarAliasRefused-8355.test.tsx` assert each of those - * non-effects, so a later edit that quietly grew this into a mirror goes red. + * ## What it reads since `listViews` became a mirror (objectui#7928) + * + * `listViews` is the protocol's strict record by reference now (the reading is + * on `ObjectViewSchema` above). The protocol's calendar block refuses both + * spellings itself — `unrecognized_keys` at `listViews.KEY.calendar`, naming + * the key — and the parse output DROPS the refused key. So the value this check + * used to read never carries the alias any more, and the check reads the + * protocol's refusal instead: for each alias that refusal names, it adds the + * objectui pointer at the key itself. The protocol's own hint is a near-miss + * suggester that answers `dateField` → `endDateField` (see the reading above + * {@link CALENDAR_DATE_ALIAS_STEM}), so without this pointer an author would be + * taught to bind the END of an event to the date they meant as its START. + * + * ⚠️ It adds a message to a document that is already refused. It refuses + * nothing on its own and accepts nothing the protocol refuses. `superRefine`'s + * `when` on `ObjectViewSchema` is what lets it run after that refusal. + * + * ⛔ The legacy `options.calendar` nesting is NOT read any more. The protocol + * refuses `options` whole on a named view (`unrecognized_keys` naming + * `options`), `plugin-view` no longer merges it, and a stored body's + * `options.calendar` is folded onto `calendar` by `@object-ui/app-shell`'s + * `ViewPreview` before it reaches this record, where the arm below judges it + * (director ruling on objectui#7928, comment 5856694523). * * The in-module precedent is `ListViewSchema.options`: an untyped bag that * declares no member and still carries a `.check()` refusing `kanban.groupBy` * (objectui#8365) and this card's two calendar spellings by name. * - * ⚠️ A PREMISE THAT DIED ON CONTACT, recorded because it changes what this - * check is for. The gap here is NOT specific to the calendar and was NOT opened - * by this card: measured on the same instrument, a named view carrying the - * objectui#8365 stray `kanban.groupBy` is ACCEPTED, while the identical key on a - * `list-view` document is refused. Until this check, no alias refusal this - * module declares reached inside `listViews`. This check is the only door in, - * and it judges the two calendar spellings only, at both nestings (so - * `listViews.KEY.calendar.dateField` IS refused); a search for `listViews` in - * this file re-derives that. What this card regressed on the object-view route - * is the BEHAVIOUR — a document that drew at the merge-base goes mute once the - * ladder is gone — and this check is what makes that failure loud. ⛔ It is not - * a general repair of the unmirrored key, and the kanban twin is still silent - * here (no pin re-derives that silence); that belongs to objectui#8365's own - * text, not to this card. - * - * ⛔ Scoped to the TWO keys under `calendar`: `timeline.dateField` on a named - * view stays accepted (the timeline alias is live by ruling), and nothing else - * about a named view is judged. + * ⛔ Scoped to the TWO keys under `calendar`. `timeline.dateField` on a named + * view draws only the protocol's own refusal, and nothing else about a named + * view is judged here. */ function checkNamedViewCalendarAliases(ctx: { value: unknown; issues: unknown[] }): void { - const doc = ctx.value as { listViews?: Record } | undefined; - const named = doc?.listViews; - if (!named || typeof named !== 'object' || Array.isArray(named)) return; + type RawIssue = { code?: string; path?: PropertyKey[]; keys?: string[]; input?: unknown }; const isBlock = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); - for (const [viewKey, view] of Object.entries(named)) { - if (!isBlock(view)) continue; - // The two nestings `generateViewSchema` merges, in its own order: the - // canonical block wins key-by-key over the legacy `options` bag, and the - // file's own note says that bag "is where the legacy field aliases … - // `dateField` live". Refusing only one of them would leave exactly the - // stored population silent. - const nestings: Array<[Record | undefined, string[]]> = [ - [isBlock(view.calendar) ? view.calendar : undefined, ['listViews', viewKey, 'calendar']], - [ - isBlock(view.options) && isBlock((view.options as Record).calendar) - ? ((view.options as Record).calendar as Record) - : undefined, - ['listViews', viewKey, 'options', 'calendar'], - ], - ]; - for (const [block, path] of nestings) { - if (!block) continue; - for (const alias of ['dateField', 'endField'] as const) { - const written = block[alias]; - if (written === undefined) continue; - // ⛔ One string, read off the arm's own `.description`, so this door and - // the four declared ones cannot answer an author differently. - ctx.issues.push({ - code: 'custom', - message: CalendarBlockDateAliasRefusals[alias].description as string, - input: written, - path: [...path, alias], - }); - } + // A copy, because the loop below pushes onto the same array. + const refusals = (ctx.issues as RawIssue[]).filter((issue) => + issue.code === 'unrecognized_keys' + && issue.path?.length === 3 + && issue.path[0] === 'listViews' + && issue.path[2] === 'calendar'); + for (const refusal of refusals) { + for (const alias of ['dateField', 'endField'] as const) { + if (!refusal.keys?.includes(alias)) continue; + // ⛔ One string, read off the arm's own `.description`, so this door and + // the four declared ones cannot answer an author differently. + ctx.issues.push({ + code: 'custom', + message: CalendarBlockDateAliasRefusals[alias].description as string, + input: isBlock(refusal.input) ? refusal.input[alias] : undefined, + path: [...(refusal.path as PropertyKey[]), alias], + }); } } } From e3d45f8b7bc1affa762a6e526d11201c6b263b97 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:35:11 +0000 Subject: [PATCH 2/6] fix(plugin-view): resolve a named view's I18nLabel on the tab strip; type the named-view sort reads `ObjectViewSchema.listViews` is the protocol's `ObjectListViewSchema` record, whose `label` is an `I18nLabel` and whose `sort` still admits the bare string clause objectui retired. The tab strip now resolves the label the way `ListView` does (a locale map rendered raw threw). A string `sort` still reaches the sort sinks unchanged, which refuse it out loud. Adds the `@object-ui/types` / plugin-view / app-shell changeset. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014fWVhLzhxR8qrFsJ5o8TYW --- .../7928-listviews-by-reference-fold.md | 47 +++++++ packages/plugin-view/src/ObjectView.tsx | 30 ++++- ...jectView.namedViewSpecRecord-7928.test.tsx | 125 ++++++++++++++++++ .../object-view-unmirrored-keys-7779.test.ts | 4 +- 4 files changed, 201 insertions(+), 5 deletions(-) create mode 100644 .changeset/7928-listviews-by-reference-fold.md create mode 100644 packages/plugin-view/src/__tests__/ObjectView.namedViewSpecRecord-7928.test.tsx diff --git a/.changeset/7928-listviews-by-reference-fold.md b/.changeset/7928-listviews-by-reference-fold.md new file mode 100644 index 0000000000..4c3b6e480e --- /dev/null +++ b/.changeset/7928-listviews-by-reference-fold.md @@ -0,0 +1,47 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-view': minor +'@object-ui/app-shell': minor +--- + +**`ObjectViewSchema.listViews` is the protocol's named-view record, by reference** +(objectui#7928: maintainer ruling A, then the director ruling on `options`). + +⚠️ **Breaking for authored metadata** (labelled `minor` under this repo's +version-alignment policy). A named view on an `object-view` node is now judged by +`@objectstack/spec`'s strict `ObjectListViewSchema`: + +- the zod mirror declares `listViews` as the spec's own `ViewSchema.shape.listViews`, + crossed through the import boundary so no protocol default is written into the + parsed document; +- the TypeScript face is `Record>`. + +So a named view needs `columns`, takes `filter` as `{ field, operator, value }` +rules, and puts each view kind's config in the top-level block of that kind +(`kanban`, `calendar`, …). A key the protocol does not declare there is refused +`unrecognized_keys` by name. That includes the objectui#8365 stray +`kanban.groupBy`, which a named view used to accept while a `list-view` document +refused it. The docs have taught this shape since objectui#8255, and `ObjectView` +has honoured it since objectui#8254. + +**`options` on a named view is refused, and no longer read.** The legacy per-kind +bag (`options.kanban`, `options.calendar`, …) is not a member of the protocol's +authoring shape. `plugin-view`'s `ObjectView` stopped reading it off a named view. +The host `views` entry keeps its own rung, so nothing changes there. A STORED list +body may still carry the bag, because the protocol declares it on that wire. The +one door that relays a stored body into `listViews`, `app-shell`'s `ViewPreview`, +now folds each `options.KIND` onto the top-level `KIND` block before it builds the +node. Where both spell a key, the top-level value wins, the same per-key merge +`ObjectView` used to apply. A saved view that carried the bag therefore previews +exactly as before. Migrate an authored `options: { kanban: { groupByField } }` to +`kanban: { groupByField, columns }`. + +The calendar-alias pointer from objectui#8355 still reaches a named view. The +protocol now refuses `calendar.dateField` / `calendar.endField` itself, and the +check adds `Did you mean dateField → startDateField?` (or `endField → +endDateField`) at the key. Without it, only the protocol's near-miss hint would +answer, and that hint says `dateField → endDateField`. + +`NamedListView` is exported and unchanged. It no longer types +`ObjectViewSchema.listViews` or `ObjectView`'s named-view config, and +objectui#7924 decides whether it is retired. diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index a7b7acf9c3..4d1ee0152c 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -66,7 +66,11 @@ import { toast, } from '@object-ui/components'; import { Plus } from 'lucide-react'; -import { useObjectTranslation, createSafeTranslation } from '@object-ui/i18n'; +import { useObjectTranslation, createSafeTranslation, useDisplayLocale } from '@object-ui/i18n'; +// objectui#7928 — a named view's `label` is the protocol's `I18nLabel` (a plain +// string or an inline locale map) since `ObjectViewSchema.listViews` became its +// record by reference. Resolved the way `ListView` resolves its own `label`. +import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; import { buildExpandFields, normalizeListViewSchema, @@ -898,6 +902,9 @@ export const ObjectView: React.FC = ({ // Declared with the other top-level hooks so it stays above every conditional // return — rules-of-hooks. const { t: tView } = useObjectViewTranslation(); + // The locale a named view's `I18nLabel` resolves in on the tab strip + // (objectui#7928). A top-level hook for the same rules-of-hooks reason. + const displayLocale = useDisplayLocale(); // The object-schema read and the fact that it has SETTLED are ONE piece of // state, keyed by the object it belongs to (objectui#6419). This replaces a // `useState` + a render-body `objectSchemaRef.current = objectSchema` write, @@ -1184,7 +1191,14 @@ export const ObjectView: React.FC = ({ // path honours the key while another ignores it. A view's sort still // outranks `table.sort`, the same order the grid path and `mergedSort` // express. - const sort = currentNamedViewConfig?.sort || activeViewQueryInputs?.sort + // + // objectui#7928: a named view's `sort` is the protocol's by reference, + // which still admits the bare string clause objectui retired + // (objectui#8221). The value is handed on UNCHANGED and + // `convertSortToQueryParams` refuses a string out loud; the cast only + // restates the sink's declared input. ⛔ Never narrow, drop or lower it + // here: that would turn the loud refusal into silence. + const sort = (currentNamedViewConfig?.sort as ObjectGridSchema['sort']) || activeViewQueryInputs?.sort || schema.table?.sort; // Auto-inject $expand for lookup/master_detail fields. Reached only @@ -2153,7 +2167,9 @@ export const ObjectView: React.FC = ({ // `authoredFilters`), and held while their inputs are unchanged — ObjectGrid // keys its fetch on `schema.filter`'s identity. const viewFilter = authoredFilters.view; - const viewSort = currentNamedViewConfig?.sort || activeView?.sort; + // objectui#7928: see the non-grid fetch above. A retired string `sort` on a + // named view reaches `ObjectGrid` unchanged, which refuses it out loud. + const viewSort = (currentNamedViewConfig?.sort as ObjectGridSchema['sort']) || activeView?.sort; return { type: 'object-grid', @@ -2602,7 +2618,13 @@ export const ObjectView: React.FC = ({ * same string there and no existing tab label moves. It changes * only for an authored view whose `name` differs from its key. */} - {view.label || view.name || key} + {/* + * objectui#7928 — `label` is the protocol's `I18nLabel`, so a + * locale map is RESOLVED here; rendered raw it threw "Objects are + * not valid as a React child". A map with no usable entry resolves + * to nothing and falls through to `name`, then the key. + */} + {resolveInlineI18nLabel(view.label, displayLocale) || view.name || key} ))} diff --git a/packages/plugin-view/src/__tests__/ObjectView.namedViewSpecRecord-7928.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.namedViewSpecRecord-7928.test.tsx new file mode 100644 index 0000000000..34deb16b36 --- /dev/null +++ b/packages/plugin-view/src/__tests__/ObjectView.namedViewSpecRecord-7928.test.tsx @@ -0,0 +1,125 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#7928 — what the renderer does with the two named-view members whose + * PROTOCOL type is wider than the one `NamedListView` declared. + * + * `ObjectViewSchema.listViews` is the protocol's `ObjectListViewSchema` record + * by reference, so the contract now accepts what that schema accepts. Two + * members of it are wider than the local shape `ObjectView` was typed off, and + * the by-reference type surfaced both: + * + * - `label` is the protocol's `I18nLabel`, a plain string OR an inline locale + * map. The tab strip rendered it raw, so a map threw "Objects are not valid + * as a React child". Measured on the flipped tree before the fix. A shape the + * contract declares has to render, so the tab strip resolves it now, the way + * `ListView` resolves its own `label`. + * - `sort` still admits the bare string clause objectui retired + * (objectui#8221). That is the protocol's own divergence, and the renderer's + * answer to it is unchanged: the value reaches the sort sink as written and + * is refused out loud, with no `$orderby`. It is pinned here so that answer + * is a reading and not an assumption. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import { ObjectView } from '../ObjectView'; +import type { ObjectViewSchema } from '@object-ui/types'; + +vi.mock('@object-ui/react', async (importOriginal) => { + const ReactMod = await import('react'); + return { + ...(await importOriginal>()), + SchemaRenderer: ({ schema }: any) =>
{schema?.type}
, + SchemaRendererContext: ReactMod.createContext(null), + subscribeDataChanges: () => () => {}, + notifyDataChanged: () => {}, + }; +}); +vi.mock('@object-ui/plugin-grid', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectGrid: () =>
, +})); + +const dataSource = (): any => ({ + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ name: 'task', fields: {} }), +}); + +const node = (listViews: Record) => + ({ type: 'object-view', objectName: 'task', listViews } as unknown as ObjectViewSchema); + +beforeEach(() => cleanup()); +afterEach(() => vi.restoreAllMocks()); + +describe('objectui#7928 — a named view\'s `I18nLabel` renders on the tab strip', () => { + it('a locale-map label renders as one of its entries, and does not throw', async () => { + const { findAllByRole } = render( + , + ); + const tabs = (await findAllByRole('tab')).map((t) => t.textContent); + expect(tabs).toContain('Other'); + const first = tabs.find((t) => t !== 'Other'); + expect(['All deals', 'ZH deals']).toContain(first); + }); + + it('CONTROL: a plain-string label renders unchanged, and a map with no usable entry falls through to `name`, then the key', async () => { + const { findAllByRole } = render( + , + ); + const tabs = (await findAllByRole('tab')).map((t) => t.textContent); + expect(tabs).toEqual(['My Deals', 'named_view', 'v3']); + }); +}); + +describe('objectui#7928 — a named view\'s retired string `sort` is refused out loud, not lowered', () => { + it('the non-grid fetch carries no `$orderby`, and the sink reports the refusal', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + const ds = dataSource(); + render( + , + ); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + const params = ds.find.mock.calls[0][1] ?? {}; + expect(params.$orderby).toBeUndefined(); + expect(errors.mock.calls.some((c) => String(c[0]).includes('zzq_created desc'))).toBe(true); + }); + + it('CONTROL: the array form orders the same fetch', async () => { + const ds = dataSource(); + render( + , + ); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + expect(ds.find.mock.calls[0][1]?.$orderby).toEqual({ zzq_created: 'desc' }); + }); +}); diff --git a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts index 659e91fed7..fa1f6570a7 100644 --- a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts +++ b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts @@ -1218,7 +1218,9 @@ describe('objectui#7779 → objectui#7928 — `listViews` was ledgered on the fa expect(namedViewReads()).toEqual([...NAMED_VIEW_READS].filter((k) => k !== 'name')); // The tab strip reads `label` off the entries too — same member, second // site — and since objectui#8980 `name` between it and the record key. - expect(readRepo(READER)).toContain('{view.label || view.name || key}'); + // objectui#7928 resolves it as the protocol's `I18nLabel` first; `view.label` + // is still the read. + expect(readRepo(READER)).toContain('{resolveInlineI18nLabel(view.label, displayLocale) || view.name || key}'); // ALL twenty are declared `NamedListView` members since objectui#8980: // the seventeen the protocol declares on this surface landed with a read // point each, and `data` — which used to reach the renderer through an From 50fd6c08890c08bcc8671723bdd443249ec6feeb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:35:48 +0000 Subject: [PATCH 3/6] test(plugin-view): split the tab-strip label control from the empty-map fallthrough case Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014fWVhLzhxR8qrFsJ5o8TYW --- ...jectView.namedViewSpecRecord-7928.test.tsx | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/plugin-view/src/__tests__/ObjectView.namedViewSpecRecord-7928.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.namedViewSpecRecord-7928.test.tsx index 34deb16b36..52abfd49c3 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.namedViewSpecRecord-7928.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.namedViewSpecRecord-7928.test.tsx @@ -79,19 +79,32 @@ describe('objectui#7928 — a named view\'s `I18nLabel` renders on the tab strip expect(['All deals', 'ZH deals']).toContain(first); }); - it('CONTROL: a plain-string label renders unchanged, and a map with no usable entry falls through to `name`, then the key', async () => { + it('CONTROL: plain-string labels render unchanged — the rung the fix did not touch', async () => { const { findAllByRole } = render( , + ); + const tabs = (await findAllByRole('tab')).map((t) => t.textContent); + expect(tabs).toEqual(['My Deals', 'Other']); + }); + + it('a map with no usable entry resolves to nothing and falls through to `name`, then the record key', async () => { + const { findAllByRole } = render( + , ); const tabs = (await findAllByRole('tab')).map((t) => t.textContent); - expect(tabs).toEqual(['My Deals', 'named_view', 'v3']); + expect(tabs).toEqual(['named_view', 'v2']); }); }); From bbbb2f2d6c38dda5a864a9176c709948afc611e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:42:58 +0000 Subject: [PATCH 4/6] test(plugin-view): named-view fixtures carry `columns` and spec filter rules The by-reference `listViews` type requires `columns` and types `filter` as `{ field, operator, value }` rules, so three fixture files typed as `ObjectViewSchema` are respelled. Their assertions are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014fWVhLzhxR8qrFsJ5o8TYW --- .../ObjectView.namedViewContextTokens-10506.test.tsx | 3 ++- .../plugin-view/src/__tests__/ObjectView.test.tsx | 12 +++++++----- .../__tests__/ObjectView.viewIdentityDeps.test.tsx | 5 ++++- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/plugin-view/src/__tests__/ObjectView.namedViewContextTokens-10506.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.namedViewContextTokens-10506.test.tsx index 9d22d67b9c..05d75f538d 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.namedViewContextTokens-10506.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.namedViewContextTokens-10506.test.tsx @@ -81,7 +81,8 @@ function namedView(type: 'calendar' | 'grid', filter: any[]): ObjectViewSchema { type: 'object-view', objectName: 'task', defaultListView: 'mine', - listViews: { mine: { label: 'Mine', type, filter } }, + // `columns` since objectui#7928: the named view is the protocol's record. + listViews: { mine: { label: 'Mine', type, columns: ['name'], filter } }, } as ObjectViewSchema; } diff --git a/packages/plugin-view/src/__tests__/ObjectView.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.test.tsx index 0b5750154e..42de379f52 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.test.tsx @@ -175,8 +175,10 @@ describe('ObjectView', () => { type: 'object-view', objectName: 'contacts', listViews: { - all: { label: 'All Contacts', type: 'grid' }, - active: { label: 'Active', type: 'grid', filter: [['status', '=', 'active']] }, + // objectui#7928: a named view is the protocol's `ObjectListViewSchema`, + // so it carries `columns` and spells `filter` as rule objects. + all: { label: 'All Contacts', type: 'grid', columns: ['name'] }, + active: { label: 'Active', type: 'grid', columns: ['name'], filter: [{ field: 'status', operator: 'equals', value: 'active' }] }, }, defaultListView: 'all', }; @@ -192,7 +194,7 @@ describe('ObjectView', () => { type: 'object-view', objectName: 'contacts', listViews: { - all: { label: 'All Contacts', type: 'grid' }, + all: { label: 'All Contacts', type: 'grid', columns: ['name'] }, }, }; @@ -207,8 +209,8 @@ describe('ObjectView', () => { type: 'object-view', objectName: 'contacts', listViews: { - all: { label: 'All Contacts', type: 'grid' }, - active: { label: 'Active', type: 'grid' }, + all: { label: 'All Contacts', type: 'grid', columns: ['name'] }, + active: { label: 'Active', type: 'grid', columns: ['name'] }, }, }; diff --git a/packages/plugin-view/src/__tests__/ObjectView.viewIdentityDeps.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.viewIdentityDeps.test.tsx index e5b427f221..a2f3725660 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.viewIdentityDeps.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.viewIdentityDeps.test.tsx @@ -255,10 +255,13 @@ describe('ObjectView non-grid fetch does not churn on an inline `views` array (o schema={{ ...SCHEMA, listViews: { + // objectui#7928: the protocol's named view — `columns`, and + // `filter` as rule objects rather than the ObjectQL tuple dialect. named: { label: 'Named', type: 'calendar', - filter: [['status', '=', 'named-wins']], + columns: ['name'], + filter: [{ field: 'status', operator: 'equals', value: 'named-wins' }], sort: [{ field: 'created', order: 'desc' }], }, }, From f47d93cc178e03cca105a82cbc04228070d4f895 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:58:06 +0000 Subject: [PATCH 5/6] docs: a listViews entry is the protocol's ObjectListViewSchema, not NamedListView Three reference rows still said `listViews` is `Record`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014fWVhLzhxR8qrFsJ5o8TYW --- content/docs/api/schema-reference.md | 2 +- content/docs/plugins/plugin-view.mdx | 2 +- packages/plugin-view/README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 4e879fc188..771901f28e 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -914,7 +914,7 @@ A complete object management interface combining grid, form, search, filters, an | `objectName` | `string` | **Required.** ObjectQL object API name. | | `title` | `string` | View title. | | `defaultViewType` | `string` | Initial view: `"grid"`, `"kanban"`, `"gallery"`, `"calendar"`, `"timeline"`, `"gantt"`, `"map"`. | -| `listViews` | `Record` | Named list views with filters and sort. | +| `listViews` | `Record` | Named list views with filters and sort. Each entry is `@objectstack/spec`'s `ObjectListViewSchema`: it needs `columns`, takes `filter` as `{ field, operator, value }` rules, and puts view-kind config in the top-level block of that kind (`kanban`, `calendar`, …). A legacy `options` bag is refused. | | `defaultListView` | `string` | Key of the default list view. | | `table` | `Partial` | Grid configuration overrides. | | `form` | `Partial` | Form configuration overrides. | diff --git a/content/docs/plugins/plugin-view.mdx b/content/docs/plugins/plugin-view.mdx index 694138e4f8..1016e29c54 100644 --- a/content/docs/plugins/plugin-view.mdx +++ b/content/docs/plugins/plugin-view.mdx @@ -603,7 +603,7 @@ under your own key" — it ships **no schema types**. The authored | Import from `@object-ui/types` | What it types | | --- | --- | | `ObjectViewSchema` | the whole `type: 'object-view'` node — `objectName` (required), `title`, `description`, `layout`, `defaultViewType`, `listViews`, `defaultListView`, `navigation`, `table`, `form`, `searchableFields`, `filterableFields`, the `show*` flags, `operations`, `onNavigate`, `allowCreateView`, `viewActions` (`viewTabBar` is retired — objectui#7779 — and refused by name) | -| `NamedListView` | one entry of `listViews` — toolbar toggles go under `userActions` (`search`, `sort`, `filter`, `group`, `hideFields`, `rowHeight`, `rowColor`) and `showDescription` under `appearance`; the legacy bare `show*` spellings are `never` on this type (objectui#7924), though stored views carrying them still load | +| `NamedListView` | the former type of a `listViews` entry, still exported. Since objectui#7928 an entry is `@objectstack/spec`'s `ObjectListViewSchema` (`NonNullable[string]`): toolbar toggles go under `userActions` (`search`, `sort`, `filter`, `group`, `hideFields`, `rowHeight`, `rowColor`), `showDescription` under `appearance`, and view-kind config in the top-level `kanban` / `calendar` / … block, never a legacy `options` bag | | `ViewNavigationConfig` | `navigation` — row/item click behaviour | | `ViewTabBarConfig` | the `config` prop of `ViewTabBar` — tab-bar UX (inline add, overflow, indicators), composed by the host; not an `object-view` node key | diff --git a/packages/plugin-view/README.md b/packages/plugin-view/README.md index 4037d60309..60bd025e59 100644 --- a/packages/plugin-view/README.md +++ b/packages/plugin-view/README.md @@ -670,7 +670,7 @@ re-exporting, so import it from there: | Import from `@object-ui/types` | What it types | | --- | --- | | `ObjectViewSchema` | the whole `type: 'object-view'` node — `objectName` (required), `title`, `description`, `layout`, `defaultViewType`, `listViews`, `defaultListView`, `navigation`, `table`, `form`, `searchableFields`, `filterableFields`, `show*`, `operations`, `onNavigate`, `allowCreateView`, `viewActions` (`viewTabBar` is retired — objectui#7779 — and refused by name) | -| `NamedListView` | one entry of `listViews` | +| `NamedListView` | the former type of a `listViews` entry, still exported. Since objectui#7928 an entry is `@objectstack/spec`'s `ObjectListViewSchema` — `NonNullable[string]` | | `ViewNavigationConfig` | `navigation` — row/item click behaviour | | `ViewTabBarConfig` | the `config` prop of `ViewTabBar` — tab-bar UX (inline add, overflow, indicators), composed by the host; not an `object-view` node key | From 395371dad2c551b85acbacd9b67ada2ac0b88dd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 00:45:45 +0000 Subject: [PATCH 6/6] docs(changeset): the objectui#7928 entry no longer waits on objectui#7924 objectui#7924's ruling A' landed in this release through the base merge (PR objectui#10793). The entry said NamedListView is unchanged and that objectui#7924 decides its retirement; it now says this entry leaves it unchanged, names the densityMode retirement as that card's own entry, and states the ruling's actual wording: retirement or narrowing follows ruling A' and the allowExport ruling. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014fWVhLzhxR8qrFsJ5o8TYW --- .changeset/7928-listviews-by-reference-fold.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.changeset/7928-listviews-by-reference-fold.md b/.changeset/7928-listviews-by-reference-fold.md index 6bdba7548e..8e70a7bedd 100644 --- a/.changeset/7928-listviews-by-reference-fold.md +++ b/.changeset/7928-listviews-by-reference-fold.md @@ -62,6 +62,8 @@ unmirrored, and the objectui#8355, #8365, #9242 and #10321 entries say the named-view doors judge the `options` nesting. This entry supersedes both: the key is mirrored by reference, and the `options` bag is refused whole. -`NamedListView` is exported and unchanged. It no longer types -`ObjectViewSchema.listViews` or `ObjectView`'s named-view config, and -objectui#7924 decides whether it is retired. +`NamedListView` is exported, and this entry does not change it (objectui#7924's +own entry in this release retires its `densityMode`). It no longer types +`ObjectViewSchema.listViews` or `ObjectView`'s named-view config. Its retirement +or narrowing follows objectui#7924's ruling A′ and the ruling on `allowExport`, +not this entry.