diff --git a/.changeset/7928-listviews-by-reference-fold.md b/.changeset/7928-listviews-by-reference-fold.md new file mode 100644 index 0000000000..8e70a7bedd --- /dev/null +++ b/.changeset/7928-listviews-by-reference-fold.md @@ -0,0 +1,69 @@ +--- +'@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 named-view alias pointers from objectui#8355 and objectui#10321 still reach +a named view. The protocol now refuses `calendar.dateField` / `calendar.endField` +and `kanban.groupBy` itself (`unrecognized_keys`, naming the key). The two checks +on the `object-view` door add this repository's pointer at the key: +`Did you mean dateField → startDateField?`, `endField → endDateField`, or +`groupBy → groupByField`. Without them, only the protocol's near-miss hint would +answer, and for `dateField` that hint says `endDateField`. The legacy +`options.calendar` / `options.kanban` nesting is refused whole (`options` by +name) rather than judged inside. + +**Keys the protocol's blocks do not declare are refused on a named view.** The +by-reference record judges each view-kind block with the protocol's strict block, +so a renderer-ahead knob or legacy alias inside one is an authoring error now. +Measured on 17.4.0: `kanban.titleField` / `groupField` / `swimlaneField` / +`conditionalFormatting`, `gallery.imageField`, `timeline.dateField` / `endField`, +`calendar.allDayField` / `defaultView` and `map.style`. `ObjectView` still reads +them off a named view that reaches it unvalidated, so a stored body keeps +rendering. Use the canonical key where one exists (`groupByField`, `coverField`, +`startDateField`, `grouping` for swimlanes). + +**Superseded statements in this release.** Pending entries for objectui#7779, +objectui#8355 and objectui#10321 say `ObjectViewSchema.listViews` stays +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 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. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 67378888de..2c9a0cdb28 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/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/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 | diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index ca75d82836..e1e9557d5d 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, @@ -240,6 +244,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 +860,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', * }} @@ -890,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, @@ -993,8 +1008,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]); @@ -1175,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 @@ -1664,9 +1687,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 +1719,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 @@ -1872,10 +1909,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 @@ -1976,7 +2014,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. // @@ -2131,7 +2170,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', @@ -2411,7 +2452,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, @@ -2579,7 +2622,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.calendarAliasRefused-8355.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.calendarAliasRefused-8355.test.tsx index bbc748dd84..542f5e475b 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.calendarAliasRefused-8355.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.calendarAliasRefused-8355.test.tsx @@ -52,16 +52,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'; @@ -198,13 +199,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(); }); }); @@ -222,37 +227,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 beyond the named alias refusals 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 named alias refusals are judged (here, the - // two retired calendar spellings). - 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; this door adds only the named alias pointers', () => { + // 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.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.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/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..52abfd49c3 --- /dev/null +++ b/packages/plugin-view/src/__tests__/ObjectView.namedViewSpecRecord-7928.test.tsx @@ -0,0 +1,138 @@ +/** + * 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: 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(['named_view', 'v2']); + }); +}); + +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/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' }], }, }, 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 5e8ead99e7..f5321e93bf 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -60,6 +60,7 @@ import { PageTypeSchema as SpecPageTypeSchema, PageVariableSchema as SpecPageVariableSchema, ListViewSchema as SpecListViewSchema, + ViewSchema as SpecViewSchema, KanbanConfigSchema as SpecKanbanConfigSchema, GanttConfigSchema as SpecGanttConfigSchema, CalendarConfigSchema as SpecCalendarConfigSchema, @@ -205,6 +206,10 @@ const IMPORTED: Array = [ // series schema, whose `yAxis` / `variant` defaults are exactly what this // boundary exists to keep out of a parse output. ['ChartSeriesSchema', SpecChartSeriesSchema], + // 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. */ @@ -263,12 +268,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__/named-view-kanban-stray-group-by-10321.test.ts b/packages/types/src/__tests__/named-view-kanban-stray-group-by-10321.test.ts index f4abd90c43..a49fe8478b 100644 --- a/packages/types/src/__tests__/named-view-kanban-stray-group-by-10321.test.ts +++ b/packages/types/src/__tests__/named-view-kanban-stray-group-by-10321.test.ts @@ -34,6 +34,19 @@ * - SIBLING CONTROL: the calendar check on the same door still fires, so an * ablation of the kanban check reddens the kanban pins alone. * + * ## ⭐ Inverted at objectui#7928, ⛔ no assertion deleted + * + * `listViews` is the protocol's strict `ObjectListViewSchema` record by + * reference now (ruling A; director ruling on `options`, comment 5856694523). + * So the protocol refuses the stray key itself (`unrecognized_keys` at + * `listViews.KEY.kanban`), and this door adds the pointer beside that refusal. + * The legacy `options.kanban` nesting is refused WHOLE (`options` by name) and + * no longer judged inside. The scope controls that asserted "not a mirror" now + * assert the mirror: an undeclared sibling, a block without `columns` and the + * legacy bag are each refused by the record, and the door still adds no issue + * of its own to any of them. Fixtures meant as canonical carry the protocol's + * required `columns`. + * * REVERSE VERIFICATION, direction predicted before running: unwire * `checkNamedViewKanbanStrayGroupBy` from `ObjectViewSchema` ⇒ every REFUSED arm * goes red (the document parses green), while the dark, scope and sibling @@ -74,68 +87,92 @@ function listViewRouteMessage(): string { return message; } -const NESTINGS = [ - { - name: 'declared `kanban` block', - view: { type: 'kanban', kanban: { groupByField: 'stage', groupBy: 'stage' } }, - path: 'listViews.v1.kanban.groupBy', - }, - { - name: 'legacy `options.kanban` bag', - view: { type: 'kanban', options: { kanban: { groupBy: 'stage' } } }, - path: 'listViews.v1.options.kanban.groupBy', - }, -] as const; +/** A canonical kanban named view: the protocol's required `columns`, at both levels. */ +const CANONICAL_KANBAN = { type: 'kanban', columns: ['name'], kanban: { groupByField: 'stage', columns: ['name'] } } as const; + +const customIssues = (result: ReturnType) => issuesOf(result).filter((i) => i.code === 'custom'); describe('objectui#10321 · a named view authoring the stray `kanban.groupBy` is REFUSED', () => { - for (const nesting of NESTINGS) { - it(`${nesting.name}: refused by name at its own path, with the list-view route's string`, () => { - const result = safeValidateSchema(objectView({ v1: nesting.view })); - expect(result.success, `${nesting.path} still parses green`).toBe(false); - const issue = issueAt(result, nesting.path); - expect(issue?.code).toBe('custom'); - expect(issue?.message).toBe(listViewRouteMessage()); - expect(issue?.message).toContain('Did you mean `groupBy` → `groupByField`?'); - expect(issuesOf(result), 'one key written, one issue').toHaveLength(1); - }); - } + it('declared `kanban` block: refused by name at its own path, with the list-view route\'s string', () => { + const view = { ...CANONICAL_KANBAN, kanban: { ...CANONICAL_KANBAN.kanban, groupBy: 'stage' } }; + const path = 'listViews.v1.kanban.groupBy'; + const result = safeValidateSchema(objectView({ v1: view })); + expect(result.success, `${path} still parses green`).toBe(false); + const issue = issueAt(result, path); + expect(issue?.code).toBe('custom'); + expect(issue?.message).toBe(listViewRouteMessage()); + expect(issue?.message).toContain('Did you mean `groupBy` → `groupByField`?'); + // Was "one key written, one issue". Since objectui#7928 the protocol's own + // refusal of the key sits beside this door's pointer: one key written, two + // issues, the refusal at the block and the pointer at the key. + expect(issuesOf(result), 'one key written: the protocol\'s refusal and this door\'s pointer').toHaveLength(2); + const refusal = issueAt(result, 'listViews.v1.kanban') as (Issue & { keys?: string[] }) | undefined; + expect(refusal?.code).toBe('unrecognized_keys'); + expect(refusal?.keys).toEqual(['groupBy']); + }); + + it('INVERTED — legacy `options.kanban` bag: refused WHOLE, `options` by name, and no longer judged inside (objectui#7928)', () => { + // Was "refused by name at its own path, with the list-view route's string" + // at `listViews.v1.options.kanban.groupBy`. The protocol refuses a named + // view's `options` bag outright, and a stored body's bag is folded onto + // `kanban` at `ViewPreview`, where the arm above judges it. + const path = 'listViews.v1.options.kanban.groupBy'; + const result = safeValidateSchema(objectView({ v1: { ...CANONICAL_KANBAN, options: { kanban: { groupBy: 'stage' } } } })); + expect(result.success, `${path} still parses green`).toBe(false); + const issue = issueAt(result, path); + expect(issue).toBeUndefined(); + expect(customIssues(result)).toEqual([]); + const whole = issueAt(result, 'listViews.v1') as (Issue & { keys?: string[] }) | undefined; + expect(whole?.code).toBe('unrecognized_keys'); + expect(whole?.keys).toEqual(['options']); + }); it('the key is judged in EVERY named view, and reported under that view\'s own key', () => { const result = safeValidateSchema(objectView({ - clean: { type: 'kanban', kanban: { groupByField: 'stage' } }, - stray: { type: 'kanban', kanban: { groupByField: 'stage', groupBy: 'owner' } }, + clean: CANONICAL_KANBAN, + stray: { ...CANONICAL_KANBAN, kanban: { ...CANONICAL_KANBAN.kanban, groupBy: 'owner' } }, })); - expect(issuesOf(result).map((i) => i.path.join('.'))).toEqual(['listViews.stray.kanban.groupBy']); + expect(customIssues(result).map((i) => i.path.join('.'))).toEqual(['listViews.stray.kanban.groupBy']); + // …and nothing at all is reported under the clean view. + expect(issuesOf(result).filter((i) => i.path.join('.').startsWith('listViews.clean'))).toEqual([]); }); }); describe('objectui#10321 · the controls, each able to fire on its own', () => { it('DARK CONTROL: the canonical named view parses GREEN through the same door', () => { - const result = safeValidateSchema(objectView({ - v1: { type: 'kanban', kanban: { groupByField: 'stage', columns: ['name'] } }, - })); + // `columns` at the top level too since objectui#7928: the protocol's named + // view requires it. + const result = safeValidateSchema(objectView({ v1: CANONICAL_KANBAN })); expect(result.success, JSON.stringify(issuesOf(result))).toBe(true); }); - it('SCOPE CONTROL: an undeclared sibling in the kanban block is accepted and KEPT', () => { + it('INVERTED SCOPE CONTROL: an undeclared sibling in the kanban block is REFUSED by the record, and this door adds nothing (objectui#7928)', () => { + // Was "accepted and KEPT". The protocol's kanban block declares + // `columns` / `groupByField` / `summarizeField` only, and the named view is + // that block by reference now. The door still judges `groupBy` alone. const result = safeValidateSchema(objectView({ - v1: { type: 'kanban', kanban: { groupByField: 'stage', swimlaneField: 'owner' } }, + v1: { ...CANONICAL_KANBAN, kanban: { ...CANONICAL_KANBAN.kanban, swimlaneField: 'owner' } }, })); - expect(result.success, JSON.stringify(issuesOf(result))).toBe(true); - const kept = result.success - ? ((result.data as { listViews?: Record }> }).listViews?.v1?.kanban) - : undefined; - expect(kept).toEqual({ groupByField: 'stage', swimlaneField: 'owner' }); + expect(result.success, JSON.stringify(issuesOf(result))).toBe(false); + const refusal = issueAt(result, 'listViews.v1.kanban') as (Issue & { keys?: string[] }) | undefined; + expect(refusal?.code).toBe('unrecognized_keys'); + expect(refusal?.keys).toEqual(['swimlaneField']); + expect(customIssues(result)).toEqual([]); }); - it('SCOPE CONTROL: no spec value type leaked in — no `columns` required, the legacy bag accepted', () => { - // `@objectstack/spec`'s `KanbanConfigSchema` requires `columns` and its - // `ObjectListViewSchema` refuses `options`; a door that adopted either would - // be a mirror of `listViews`, which waits on objectui#7928. + it('INVERTED SCOPE CONTROL: the spec value type IS the mirror — `columns` required, the legacy bag refused (objectui#7928)', () => { + // Was "no spec value type leaked in": a door that adopted either would be a + // mirror of `listViews`, and objectui#7928 made the mirror exactly that. const noColumns = safeValidateSchema(objectView({ v1: { type: 'kanban', kanban: { groupByField: 'stage' } } })); - expect(noColumns.success, JSON.stringify(issuesOf(noColumns))).toBe(true); - const legacyBag = safeValidateSchema(objectView({ v1: { type: 'kanban', options: { kanban: { groupByField: 'stage' } } } })); - expect(legacyBag.success, JSON.stringify(issuesOf(legacyBag))).toBe(true); + expect(noColumns.success, JSON.stringify(issuesOf(noColumns))).toBe(false); + expect(issueAt(noColumns, 'listViews.v1.columns')).toBeDefined(); + expect(issueAt(noColumns, 'listViews.v1.kanban.columns')).toBeDefined(); + const legacyBag = safeValidateSchema(objectView({ v1: { ...CANONICAL_KANBAN, options: { kanban: { groupByField: 'stage' } } } })); + expect(legacyBag.success, JSON.stringify(issuesOf(legacyBag))).toBe(false); + expect((issueAt(legacyBag, 'listViews.v1') as (Issue & { keys?: string[] }) | undefined)?.keys).toEqual(['options']); + // The door itself still adds no issue to either. + expect(customIssues(noColumns)).toEqual([]); + expect(customIssues(legacyBag)).toEqual([]); }); it('SIBLING CONTROL: the calendar arm on the same door still refuses its retired spelling', () => { 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 8c3dff8d14..95ad00abfe 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 @@ -103,6 +114,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, @@ -134,6 +146,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'; /** @@ -174,13 +187,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; @@ -205,13 +220,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; @@ -232,7 +248,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. * @@ -251,7 +268,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', @@ -351,8 +368,10 @@ const BUCKET_LEGACY_SHOW_SPELLINGS = [ ] as const; /** - * BUCKET ③ — ten members the protocol declares NOWHERE, outside the `show*` - * fold table, and read off no named view by the `object-view` renderer. + * BUCKET ③ — eleven members the protocol declares NOWHERE, outside the `show*` + * fold table, and read off no named view by the `object-view` renderer: ten at + * objectui#7924's retirement, plus `options` since objectui#7928 + * ({@link LOCAL_ONLY_READ}). * * ⚠️ CORRECTED at objectui#7924's retirement: this block used to add "no runtime * fold, and no reader on any path". Two of the ten falsified it — @@ -361,12 +380,12 @@ const BUCKET_LEGACY_SHOW_SPELLINGS = [ * the only host of a named view: `app-shell`'s object page holds the active * named view as a flat `NamedListView` and relays members off it into the * `list-view` node. The membership below is unchanged (it is derived by the - * protocol and the fold table, and both still place all ten here); only the + * protocol and the fold table, and both still place all eleven here); only the * "unread on any path" gloss was wrong. */ const BUCKET_LOCAL_ONLY_UNREAD = [ 'addDeleteRecordsInline', 'addRecordViaForm', 'allowExport', 'clickIntoRecordDetails', - 'collapseAllByDefault', 'color', 'densityMode', 'fieldTextColor', 'prefixField', 'wrapHeaders', + 'collapseAllByDefault', 'color', 'densityMode', 'fieldTextColor', 'options', 'prefixField', 'wrapHeaders', ] as const; /** @@ -392,7 +411,9 @@ const DENSITY_MODE_RETIRED = 'densityMode'; /** * Every `?: never` tombstone objectui#7924 put on `NamedListView`: the eight * bucket-② spellings plus the nine bucket-③ members nothing outside the fold - * reads (`densityMode` joined the eight at ruling A′). Derived off the AST (a + * reads (`densityMode` joined the eight at ruling A′). `options`, unread off a + * named view since objectui#7928, is the eleventh bucket-③ member and is held + * rather than tombstoned ({@link BUCKET_LOCAL_ONLY_HELD}). Derived off the AST (a * member whose type node is the `never` keyword). */ const NAMED_LIST_VIEW_TOMBSTONES = [ @@ -409,12 +430,24 @@ 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; its + * retirement or narrowing follows objectui#7924's ruling A′ (which retired + * `densityMode` and left `options` declared) and the `allowExport` ruling, not + * that card ({@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'; @@ -471,8 +504,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>; @@ -571,6 +606,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[]; @@ -630,7 +673,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. @@ -661,7 +707,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); @@ -835,13 +881,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', () => { @@ -1109,7 +1164,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(); @@ -1177,16 +1232,18 @@ 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. 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}'); - // ALL twenty-one are declared `NamedListView` members since objectui#8980: + // 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 // `as any` cast on the named-view config — is one of them. So the "unread" @@ -1217,8 +1274,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(() => {'); }); }); @@ -1283,10 +1345,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 @@ -1305,15 +1367,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([]); }); @@ -1460,7 +1522,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]); @@ -1508,16 +1570,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]: {} }); @@ -1543,7 +1613,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); @@ -1560,14 +1630,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()); }); @@ -1721,13 +1791,14 @@ export const _acceptedRetained: NamedListView = { ...BASE_VIEW, allowExport: fal export const _acceptedDensityCanonical: NamedListView = { ...BASE_VIEW, rowHeight: 'compact' }; describe('objectui#7924 — the retirement: seventeen `?: never` tombstones, one retained read, the canonical blocks', () => { - it('the tombstone set is EXACTLY bucket ② plus bucket ③ minus the retained read, derived off the AST', () => { + it('the tombstone set is EXACTLY bucket ② plus bucket ③ minus the retained read and the one held member, derived off the AST', () => { const { tombstones, names } = namedListViewMembers(); expect([...tombstones].sort()).toEqual([...NAMED_LIST_VIEW_TOMBSTONES]); expect(tombstones).toHaveLength(17); 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 @@ -1796,6 +1867,20 @@ describe('objectui#7924 — the retirement: seventeen `?: never` tombstones, one 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 ac15e30a46..d92ce8256d 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 @@ -2373,7 +2377,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; @@ -2477,7 +2481,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 @@ -2582,10 +2591,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` @@ -2737,54 +2748,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'; /** @@ -2807,7 +2770,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 @@ -4217,8 +4180,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 6daad064e5..dfe65a9051 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'; @@ -2036,9 +2042,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 5cf08feec7..9cb5bc1c83 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -42,6 +42,9 @@ import { ChartSeriesSchema as SpecChartSeriesSchema, ChartTypeSchema as SpecChartTypeSchema, 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'; @@ -512,25 +515,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'), @@ -540,6 +545,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, @@ -591,15 +600,23 @@ export const ObjectViewSchema = BaseSchema.extend({ // and a different supplier. Judged separately for that reason. onNavigate: handlerKeyRefusal('onNavigate', 'runtime-slot', 'Record navigation handler'), }) - // ⭐ objectui#8355 / objectui#10321 — the only things this object judges - // about a named view: the named alias refusals, two sibling checks on this - // one door (the calendar spellings, and the kanban twin's stray `groupBy`), - // and ⛔ NOT a mirror of `listViews`. Declared and explained at - // `checkNamedViewCalendarAliases` and `checkNamedViewKanbanStrayGroupBy` - // below (hoisted function declarations, so the forward references resolve at - // module init and the bodies run at parse time, long after the refusal arms - // they read are built). - .check(checkNamedViewCalendarAliases, checkNamedViewKanbanStrayGroupBy); + // ⭐ objectui#8355 / objectui#10321 — the by-name pointers for the named alias + // refusals on a named view: two sibling checks on this one door (the calendar + // spellings, and the kanban twin's stray `groupBy`). Declared and explained at + // `checkNamedViewCalendarAliases` and `checkNamedViewKanbanStrayGroupBy` below + // (hoisted function declarations, so the forward references resolve at module + // init and the bodies run at parse time, long after the refusal arms they read + // are built). + // + // ⚠️ `when` is load-bearing since objectui#7928. The named view is now the + // protocol's strict record, which refuses both kinds of key itself, and a + // refinement zod runs by default is SKIPPED once any earlier issue aborts the + // parse. So without `when` these checks never run on exactly the documents + // they exist for. Measured on the flip before this line was added. + .superRefine((_value, ctx) => { + checkNamedViewCalendarAliases(ctx); + checkNamedViewKanbanStrayGroupBy(ctx); + }, { when: () => true }); /** * User Filters — field-level filter option @@ -1103,33 +1120,38 @@ function isPlainBlock(v: unknown): v is Record { } /** - * Every place a named view on an `object-view` document can carry one view-kind - * block, with the path an issue is reported at: for each named view, the - * canonical `KIND` block and then the legacy `options.KIND` bag. Those are the - * two nestings `generateViewSchema` merges, in its own order — the canonical - * block wins key-by-key over the legacy bag, and the file's own note says that - * bag "is where the legacy field aliases … `dateField` live" (and the retired - * kanban producer, objectui#8213, wrote there too). Refusing only one of them - * would leave exactly the stored population silent. - * - * Shared by the named-view checks below so they walk `listViews` identically; - * it judges nothing itself. + * The protocol's refusals of undeclared keys inside ONE view-kind block of a + * named view on an `object-view` document, with the path each was reported at + * (`listViews.KEY.KIND`). + * + * Since objectui#7928 `listViews` is the protocol's strict record by reference, + * so a key its `KIND` block does not declare is refused there as + * `unrecognized_keys`, and the parse output DROPS it. The named-view checks + * below therefore read those refusals rather than the parsed value, which no + * longer carries the key. The legacy `options.KIND` nesting is not walked: the + * protocol refuses a named view's `options` bag whole, and a stored body's bag + * is folded onto `KIND` at `@object-ui/app-shell`'s `ViewPreview` before it + * reaches this record (director ruling on objectui#7928, comment 5856694523). + * + * Shared by the named-view checks below so they read the same refusals; it + * judges nothing itself. */ -function namedViewKindBlocks( - value: unknown, +function namedViewKindRefusals( + issues: readonly unknown[], kind: 'calendar' | 'kanban', -): Array<[Record, string[]]> { - const named = (value as { listViews?: unknown } | undefined)?.listViews; - if (!isPlainBlock(named)) return []; - const blocks: Array<[Record, string[]]> = []; - for (const [viewKey, view] of Object.entries(named)) { - if (!isPlainBlock(view)) continue; - const canonical = view[kind]; - if (isPlainBlock(canonical)) blocks.push([canonical, ['listViews', viewKey, kind]]); - const legacy = isPlainBlock(view.options) ? view.options[kind] : undefined; - if (isPlainBlock(legacy)) blocks.push([legacy, ['listViews', viewKey, 'options', kind]]); +): Array<{ keys: readonly string[]; input: Record | undefined; path: PropertyKey[] }> { + const out: Array<{ keys: readonly string[]; input: Record | undefined; path: PropertyKey[] }> = []; + for (const raw of issues) { + const issue = raw as { code?: string; path?: PropertyKey[]; keys?: string[]; input?: unknown }; + if (issue.code !== 'unrecognized_keys' || issue.path?.length !== 3) continue; + if (issue.path[0] !== 'listViews' || issue.path[2] !== kind) continue; + out.push({ + keys: issue.keys ?? [], + input: isPlainBlock(issue.input) ? issue.input : undefined, + path: [...issue.path], + }); } - return blocks; + return out; } /** @@ -1144,59 +1166,53 @@ function namedViewKindBlocks( * 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 was 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` was ACCEPTED, while the identical key - * on a `list-view` document was refused. Until this check, no alias refusal this - * module declares reached inside `listViews`. This check was the only door in - * until objectui#10321 added its kanban sibling below, 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 not judged here: that is - * `checkNamedViewKanbanStrayGroupBy`, below. - * - * ⛔ 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 here. + * Its kanban sibling, `checkNamedViewKanbanStrayGroupBy` below (objectui#10321), + * reads the same kind of refusal for the stray `groupBy`. + * + * ⛔ 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 { - for (const [block, path] of namedViewKindBlocks(ctx.value, 'calendar')) { + // Collected before the loop, which pushes onto the same array. + for (const refusal of namedViewKindRefusals(ctx.issues, 'calendar')) { for (const alias of ['dateField', 'endField'] as const) { - const written = block[alias]; - if (written === undefined) continue; + 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: written, - path: [...path, alias], + input: refusal.input?.[alias], + path: [...refusal.path, alias], }); } } @@ -1214,7 +1230,8 @@ function checkNamedViewCalendarAliases(ctx: { value: unknown; issues: unknown[] * `listViews.KEY.kanban.groupBy` is a stored view authoring the same key. So it * takes the same refusal, as a sibling of the calendar check above on the same * `ObjectViewSchema` door, with the ONE string `KanbanStrayGroupByRefusal` - * carries on the `list-view` route (`custom` here, at either nesting). + * carries on the `list-view` route (`custom` here, at `listViews.KEY.kanban.groupBy`; + * the `options.kanban` nesting it also judged until objectui#7928 is refused whole). * * The protocol agrees on this route too: `@objectstack/spec`'s `ViewSchema` * refuses `listViews.KEY.kanban.groupBy` as an unrecognized key exactly as its @@ -1226,23 +1243,32 @@ function checkNamedViewCalendarAliases(ctx: { value: unknown; issues: unknown[] * * ⛔ A sibling, not a widening of the calendar check: two pending changesets * describe that check as judging the two calendar spellings only, and it still - * does. ⛔ Not a mirror of `listViews` either, for every reason the calendar - * check's docblock gives: it judges ONE key, at the two nestings, and refuses - * neither an undeclared sibling in the block (`swimlaneField`, say) nor a block - * without `columns` nor the legacy bag itself. The SCOPE CONTROLS in - * `named-view-kanban-stray-group-by-10321.test.ts` assert each of those. + * does. ⛔ Not the mirror of `listViews` either: it judges ONE key and refuses + * nothing on its own. + * + * ⚠️ Since objectui#7928 `listViews` IS mirrored, by reference to the + * protocol's strict record, so the protocol refuses `kanban.groupBy` itself + * (`unrecognized_keys` at `listViews.KEY.kanban`) and drops it from the parse + * output. This check reads that refusal ({@link namedViewKindRefusals}) and adds + * this repository's pointer at `listViews.KEY.kanban.groupBy`; the protocol + * answers `groupBy` as a plain unrecognized key with no pointer. The legacy + * `options.kanban` nesting is refused WHOLE (`options` by name) and no longer + * read here, and an undeclared sibling in the block or a block without its + * required keys is refused by the record, not by this check. The SCOPE CONTROLS + * in `named-view-kanban-stray-group-by-10321.test.ts` were inverted to that + * tree. */ function checkNamedViewKanbanStrayGroupBy(ctx: { value: unknown; issues: unknown[] }): void { - for (const [block, path] of namedViewKindBlocks(ctx.value, 'kanban')) { - const written = block.groupBy; - if (written === undefined) continue; + // Collected before the loop, which pushes onto the same array. + for (const refusal of namedViewKindRefusals(ctx.issues, 'kanban')) { + if (!refusal.keys.includes('groupBy')) continue; // ⛔ One string, read off the arm's own `.description`, so the named-view // door and the `list-view` route cannot answer an author differently. ctx.issues.push({ code: 'custom', message: KanbanStrayGroupByRefusal.description as string, - input: written, - path: [...path, 'groupBy'], + input: refusal.input?.groupBy, + path: [...refusal.path, 'groupBy'], }); } }