diff --git a/.changeset/named-list-view-protocol-members-8980.md b/.changeset/named-list-view-protocol-members-8980.md new file mode 100644 index 0000000000..49d57a6520 --- /dev/null +++ b/.changeset/named-list-view-protocol-members-8980.md @@ -0,0 +1,55 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-view': minor +--- + +**`NamedListView` declares the 17 members the protocol declares on the same +surface, and each one now has a read point** (objectui#8980, director-seat +class-one adjudication of 2026-09-13, under the maintainer's standing principle +that the objectstack protocol is the source of truth and objectui catches up to +it rather than the other way round). + +`appearance` `calendar` `chart` `data` `fieldOrder` `gallery` `gantt` `grouping` +`kanban` `map` `name` `pageName` `rowColor` `tabs` `timeline` `tree` +`userActions` — all seventeen were declared by `ObjectListViewSchema` +(`@objectstack/spec/ui`), the declared value type of both `ViewSchema.listViews` +and `ObjectSchema.listViews`, and by none of them by `@object-ui/types`. An +author writing the shape the protocol teaches got a view that rendered as though +nothing had been configured, with no diagnostic anywhere. + +**Types are taken from the protocol, not restated.** Sixteen index this +package's own spec-derived `list-view` node type (`ListViewSchema`), whose +members arrive from `SpecListViewSchema.shape` by reference — the derivation +`NamedListView.userFilters` already used, and the one that keeps a named view +and the `list-view` node it is relayed into the same type for every key that +crosses. `name` indexes the protocol's own published authored type (`ListView`, +`@objectstack/spec/ui`) directly, because on the node that spelling resolves +through `BaseSchema.name` — the component-name slot, a different contract +wearing the same word. + +**What an author can now do that silently did nothing before:** + +| key | reaches | +| --- | --- | +| `kanban` `calendar` `gallery` `timeline` `gantt` `map` | the renderer `ObjectView` dispatches to for that `type` — `ObjectKanban`, `ObjectCalendar`, `ObjectGallery`, `ObjectTimeline`, `ObjectGantt`, `ObjectMap` — at the protocol's own top level. The legacy `options.` nesting keeps working; a canonical block wins key-by-key over it, so a partially declared block does not blank its legacy neighbour | +| `chart` `tree` | the same dispatch, on the one route objectui#5321 leaves open to a named view (it declares no `type`, a host `views` entry selects the kind) | +| `grouping` `rowColor` | `ObjectGrid` on the authored grid path, and `ListView` through the host delegation | +| `fieldOrder` `appearance` `userActions` | `ListView` through the host delegation. `fieldOrder` is the live third key of the protocol's `columns` × `hiddenFields` × `fieldOrder` composition (objectstack#15184 ruling B) | +| `data` | `ListView` — and the `as any` cast the renderer used to reach this key through is gone, which answers objectui#7928's open half | +| `name` | the named-view tab strip, between `label` and the record key | + +**Two members are declared and NOT read, and that is the ruled outcome rather +than an oversight** — the ruling requires a member with no renderer behaviour to +be reported with its measurement, never silently dropped from the type. +`tabs` on the list shape is the `ViewTabSchema[]` multi-tab definition list (not +`userFilters.tabs`, which the protocol omits from an object view as page-only), +and objectui's tab bar for an object is the host-owned saved-view switcher +(ADR-0053). `pageName` configures the protocol's `type: 'page'` branch, and +`page` is not a member of `NamedListView['type']`. Both measurements are reported +on objectui#8980. + +**Compatibility.** Every addition is an optional member, and every read is a new +rung whose source could not be authored before this change — so on existing +documents the renderer produces the same nodes it produced before, proven by an +absence-control case beside each fix. The 19 legacy spellings `NamedListView` +declares beyond the protocol are objectui#7924's remedy and are untouched. diff --git a/.changeset/object-view-unmirrored-keys-7779.md b/.changeset/object-view-unmirrored-keys-7779.md index 65ab787d01..b3d50af093 100644 --- a/.changeset/object-view-unmirrored-keys-7779.md +++ b/.changeset/object-view-unmirrored-keys-7779.md @@ -45,20 +45,21 @@ three spec-modelled keys are optional slots on `ListViewSchema` and `ObjectListViewSchema`; the six local keys have no spec slot anywhere. **`listViews` stays unmirrored, on the ruling's own fallback clause.** The -declaration's value is the local `NamedListView` — **47 declared top-level -members**, of which the renderer reads six: `label`, `type`, `columns`, -`filter`, `sort`, `options`. It reads a seventh key off a named view, `data`, -but `data` is **not a declared member of `NamedListView` at all**: it reaches -the renderer through an `as any` cast on the named-view config in -`packages/plugin-view/src/ObjectView.tsx`, so it never was one of the declared -members a mirror would have to carry. The spec's `ViewSchema.listViews` is a +declaration's value is the local `NamedListView` — **64 declared top-level +members** (⚠️ re-taken at objectui#8980, which added the seventeen the protocol +declares on this surface to the 47 this entry first measured), of which the +renderer reads **21** off a named view. `data` is one of the 21 now: it used to +reach the renderer through an `as any` cast on the named-view config in +`packages/plugin-view/src/ObjectView.tsx` and be declared nowhere, and the +objectui#8980 ruling declared it by name — objectui#7928's open half, answered. +The spec's `ViewSchema.listViews` is a record of the STRICT `ObjectListViewSchema`, which requires `columns` and refuses `options`, ObjectQL tuple filters and `default` — that is, it refuses the named views this package's own README and `content/docs/api/schema-reference.md` teach (`{ label: 'All Users' }` fails at `columns`; `filter: [["owner", "=", "..."]]` fails at `filter.0`). Mirroring the spec value would lose documented behaviour; mirroring the local value would -enforce **41 unread members** (47 declared, minus the 6 that are both declared +enforce **43 unread members** (64 declared, minus the 21 that are both declared and read) into the contract — the very thing ruling B refused for the six local keys. The key therefore stays in the parity ledger with that measurement, pinned, until the maintainer decides its diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 008aa55149..b6c0cc566b 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -1317,7 +1317,51 @@ export const ObjectView: React.FC = ({ // 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 viewOptions = currentNamedViewConfig?.options || activeView || {}; + const legacyViewOptions: Record = currentNamedViewConfig?.options || activeView || {}; + + // ⭐ objectui#8980 — THE CANONICAL, PROTOCOL-DECLARED PLACE FOR THE EIGHT + // VIEW-KIND BLOCKS, read here for the first time. + // + // The protocol carries `kanban` / `calendar` / `gallery` / `timeline` / + // `gantt` / `map` / `chart` / `tree` at the TOP LEVEL of a list view + // (`ObjectListViewSchema`, the declared value type of `ViewSchema.listViews`). + // objectui read them only out of the legacy untyped `options` bag above, so + // all eight were undeclared on `NamedListView` and an author writing the + // shape the protocol teaches got a view that rendered as if nothing had been + // configured. Director-seat ruling of 2026-09-13 on objectui#8980, item 2: + // each declared member gets a read point in the same delivery. + // + // MERGE, not replace, and per-KIND rather than wholesale: the legacy nesting + // stays working (stored views carry it, and it is where the legacy field + // 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. + // + // ⚠️ IDENTITY IS PRESERVED WHEN NOTHING CANONICAL IS DECLARED — the `else` + // arm hands back the very object the line above produced. That is the whole + // population today (the keys could not be authored before this change), so + // this change is provably inert on every existing document. + const canonicalViewKindBlocks: Record = { + kanban: currentNamedViewConfig?.kanban, + calendar: currentNamedViewConfig?.calendar, + gallery: currentNamedViewConfig?.gallery, + timeline: currentNamedViewConfig?.timeline, + gantt: currentNamedViewConfig?.gantt, + map: currentNamedViewConfig?.map, + chart: currentNamedViewConfig?.chart, + tree: currentNamedViewConfig?.tree, + }; + const declaredKinds = Object.keys(canonicalViewKindBlocks) + .filter((kind) => canonicalViewKindBlocks[kind] != null); + const viewOptions: Record = declaredKinds.length === 0 + ? legacyViewOptions + : declaredKinds.reduce( + (acc, kind) => ({ + ...acc, + [kind]: { ...(legacyViewOptions[kind] ?? {}), ...(canonicalViewKindBlocks[kind] as object) }, + }), + { ...legacyViewOptions } as Record, + ); // Dev-mode warning for flat property access violations if (process.env.NODE_ENV === 'development') { @@ -1742,6 +1786,20 @@ export const ObjectView: React.FC = ({ sort: viewSort || schema.table?.sort, pagination: schema.table?.pagination, selection: schema.table?.selection, + // ⭐ objectui#8980 — the AUTHOR-REACHABLE read point for two of the + // seventeen. `ObjectGrid` already reads both (`schema.grouping` in its + // group-field memo and its reference collector, `useRowColor(schema.rowColor)`), + // and this default content renderer is where an authored `object-view` + // with a `type: 'grid'` named view actually lands — the `renderListView` + // delegation above runs only for a HOST (objectui#5097). + // + // NAMED-VIEW SOURCED ONLY. ⛔ No `activeView` rung: the host `views` path + // has never fed these two slots on this branch, and widening it here would + // be a behaviour change on a surface this card does not own. Undefined is + // what `ObjectGrid` reads today for both keys, so the value only ever + // changes for a document that authors the protocol key. + grouping: currentNamedViewConfig?.grouping, + rowColor: currentNamedViewConfig?.rowColor, pageSize: schema.table?.pageSize, selectable: schema.table?.selectable, className: schema.table?.className, @@ -1963,7 +2021,11 @@ export const ObjectView: React.FC = ({ densityMode: activeView?.densityMode, groupBy: activeView?.groupBy, groupBy2: activeView?.groupBy2, - grouping: activeView?.grouping, + // objectui#8980 — the protocol declares `grouping` on a named list + // view and `ListView` reads it (`schema.grouping`); the named view had + // 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, // Toolbar policy — one vocabulary (#2890). The host node and the // active view may still carry the legacy bare `show*` flags, so both @@ -1973,6 +2035,11 @@ export const ObjectView: React.FC = ({ userActions: { ...(normalizeListViewSchema(schema ?? {}) as { userActions?: object }).userActions, ...(normalizeListViewSchema(activeView ?? {}) as { userActions?: object }).userActions, + // objectui#8980 — the named view is the most specific source, so it + // folds in LAST. Spread rather than `??` on purpose: this slot is a + // merge of toggle sets, not a winner-takes-all pick, and a named + // view that toggles one action must not blank the rest. + ...currentNamedViewConfig?.userActions, }, compactToolbar: activeView?.compactToolbar ?? (schema as any).compactToolbar, allowExport: activeView?.allowExport ?? (schema as any).allowExport, @@ -1991,7 +2058,10 @@ export const ObjectView: React.FC = ({ // the objectui#5097 HOST-COMPOSITION exemption the 2026-08-18 // ruling fixed at 27, which is a ruling and not a refactor. // `grouping` above is the precedent for a view-only rung here. - rowColor: activeView?.rowColor, + // objectui#8980 adds the named-view rung ahead of it — still + // VIEW-SOURCED ONLY, still no `(schema as any)` fallback, so the + // objectui#5097 exemption stays fixed at 27 names. + rowColor: currentNamedViewConfig?.rowColor ?? activeView?.rowColor, // Propagate view-config properties (Bug 4 / items 14-22) inlineEdit: activeView?.inlineEdit ?? (schema as any).inlineEdit, wrapHeaders: activeView?.wrapHeaders ?? (schema as any).wrapHeaders, @@ -2005,7 +2075,13 @@ export const ObjectView: React.FC = ({ // ViewData source override (spec `data` key) — e.g. gantt views fed // by a composite api endpoint; without this pick the api provider // never reaches the renderer. - data: (currentNamedViewConfig as any)?.data ?? (activeView as any)?.data ?? (schema as any).data, + // objectui#8980 / objectui#7928's open half: the cast on the + // named-view config is GONE — `data` is a declared `NamedListView` + // member now, so the read and the declaration are one fact. The + // `activeView` and node rungs keep their casts: the host `views` entry + // is an untyped host shape and `data` on the node is one of the 27 + // objectui#5097 host-composition keys. + data: currentNamedViewConfig?.data ?? (activeView as any)?.data ?? (schema as any).data, // Propagate new spec properties (P0/P1/P2) navigation: activeView?.navigation ?? (schema as any).navigation, selection: activeView?.selection ?? (schema as any).selection, @@ -2014,6 +2090,23 @@ export const ObjectView: React.FC = ({ filterableFields: activeView?.filterableFields ?? (schema as any).filterableFields, resizable: activeView?.resizable ?? (schema as any).resizable, hiddenFields: activeView?.hiddenFields ?? (schema as any).hiddenFields, + // objectui#8980 — TWO SLOTS THAT HAD NO RUNG ON THIS BRANCH AT ALL. + // + // `fieldOrder` is the live third key of the protocol's + // `columns` x `hiddenFields` x `fieldOrder` composition — objectstack#15184 + // ruling B (2026-09-11) KEPT it and ruled the composition into the + // contract: `columns` projects, `hiddenFields` (the line above) + // subtracts, `fieldOrder` orders what survives. `ListView` applies all + // three in one memo; only this relay never carried the third. + // + // `appearance` carries `showDescription` and the ADR-0047 + // `allowedVisualizations` whitelist, both read by `ListView`. + // + // View-sourced only, like `grouping` / `rowColor` above: ⛔ no + // `(schema as any)` fallback, so the objectui#5097 exemption is + // untouched. + fieldOrder: currentNamedViewConfig?.fieldOrder, + appearance: currentNamedViewConfig?.appearance ?? activeView?.appearance, rowActions: activeView?.rowActions ?? (schema as any).rowActions, rowActionDefs: (activeView as any)?.rowActionDefs ?? (schema as any).rowActionDefs, bulkActions: activeView?.bulkActions ?? (schema as any).bulkActions, @@ -2089,7 +2182,19 @@ export const ObjectView: React.FC = ({ {entries.map(([key, view]) => ( - {view.label || key} + {/* + * objectui#8980 — `name` is the protocol's own identity for a + * view, distinct from the record KEY it is filed under. It slots + * between the two as the display fallback: a view that declares + * one shows it instead of a synthetic key. + * + * ⚠️ MEASURED INERT ON THE LIVE PRODUCER, deliberately: + * `@object-ui/app-shell`'s `applyViewItem` stamps `name: key` on + * every composed `listViews` entry, so `view.name || key` is the + * 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} ))} diff --git a/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx new file mode 100644 index 0000000000..920f80f8b0 --- /dev/null +++ b/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx @@ -0,0 +1,436 @@ +/** + * 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#8980 — a READ POINT per protocol member a named list view declares. + * + * ## The card, and the ruling + * + * The protocol declares 17 live members on the value type of + * `ViewSchema.listViews` / `ObjectSchema.listViews` (`ObjectListViewSchema`, + * `@objectstack/spec/ui`) that `NamedListView` declared NONE of — objectui + * NARROWER than the protocol, the direction the maintainer's standing principle + * forbids. Director-seat class-one adjudication of 2026-09-13 ruled: declare all + * 17 with their types taken from the protocol, and give each a read point IN THE + * SAME DELIVERY. This file is that second half. + * + * ⛔ The declaration half is NOT re-pinned here; it lives with the census in + * `packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts`, which + * partitions the interface into read (21) and unread (43) and fails in EITHER + * direction when a member moves between them. + * + * ## The three routes out of `ObjectView`, and which member uses which + * + * The component has three exits, and the card's members do not all leave by the + * same one. Pinning only one of them would have left two thirds of the ruling + * unmeasured. + * + * 1. `generateViewSchema` — the AUTHORED path for a non-grid view: no host + * supplied `renderListView`, so this is what the REGISTERED `object-view` + * renderer runs. Carries the 8 view-kind blocks to `ObjectKanban`, + * `ObjectCalendar`, `ObjectGallery`, `ObjectTimeline`, `ObjectGantt`, + * `ObjectMap`, `ObjectChart`, `ObjectTree`. + * 2. `gridSchema` → `ObjectGrid` — the AUTHORED path for `type: 'grid'`, and + * the read point for `grouping` / `rowColor`, both of which `ObjectGrid` + * already reads (`collectGroupingFieldRefs(schema.grouping)`, + * `useRowColor(schema.rowColor)`). + * 3. the `renderListView` delegation — the HOST path (objectui#5097), whose + * sink is `ListView`, the renderer that reads `fieldOrder`, `grouping`, + * `rowColor`, `userActions`, `appearance` and `data`. + * + * ## ⚠️ THREE MEMBERS ARE REPORTED, NOT WIRED — the ruling's own item 2 + * + * "A member for which the renderer has no behaviour to attach is REPORTED on + * this card with the measurement, ⛔ not silently declared inert and ⛔ not + * dropped from the type." + * + * - `tabs` and `pageName` — no reader on this surface at all. The last describe + * block below pins BOTH the absence and the control that makes it a reading. + * - `chart` / `tree` — READ (case 8 below proves it), but objectui#5321 ruled + * both view KINDS host-composition-only, so a named view reaches them only by + * declaring no `type` of its own while a host `views` entry selects one. That + * narrow route is pinned rather than described. + * + * ## Reverse verification — direction predicted BEFORE the run + * + * Each FIX case was predicted to go RED on the base tree (`bbc9dc34e3`), where + * the canonical top-level blocks were undeclarable and none of the six relay / + * grid rungs existed; each CONTROL case rides a rung this card did not touch and + * was predicted GREEN. The asymmetry is the point: a control that moves with the + * fix is a control that was carrying the fix. Measured outcome on the PR. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import { ObjectView } from '../ObjectView'; +import type { NamedListView, ObjectViewSchema } from '@object-ui/types'; + +/** Every node handed to SchemaRenderer, in order — route 1's sink. */ +const rendered: any[] = []; +/** Every schema handed to ObjectGrid — route 2's sink. */ +const gridSchemas: any[] = []; + +vi.mock('@object-ui/react', async (importOriginal) => { + const React = await import('react'); + return { + ...(await importOriginal>()), + SchemaRenderer: ({ schema }: any) => { + rendered.push(schema); + return
{schema?.type}
; + }, + SchemaRendererContext: React.createContext(null), + subscribeDataChanges: () => () => {}, + notifyDataChanged: () => {}, + }; +}); +vi.mock('@object-ui/plugin-grid', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectGrid: ({ schema }: any) => { + gridSchemas.push(schema); + return
; + }, +})); +vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectForm: () =>
, +})); + +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 = { type: 'object-view', objectName: 'task' } as unknown as ObjectViewSchema; + +/** + * ⚠️ `cleanup()` is load-bearing, not hygiene — the trap recorded on + * objectui#9242: without it the previously mounted `ObjectView`s keep pushing + * into the sinks, so the last entry answers the PREVIOUS fixture's question and + * every reading comes back shifted by one. + */ +function resetSinks() { + cleanup(); + rendered.length = 0; + gridSchemas.length = 0; +} + +/** Route 1 — the node `generateViewSchema` emits for a NAMED view. */ +async function generatedNodeFor(view: NamedListView, views?: any[]): Promise { + resetSinks(); + render( + , + ); + await waitFor(() => expect(rendered.length).toBeGreaterThan(0)); + return rendered[rendered.length - 1]; +} + +/** Route 2 — the `object-grid` schema a `type: 'grid'` named view produces. */ +async function gridSchemaFor(view: NamedListView): Promise { + resetSinks(); + render( + , + ); + await waitFor(() => expect(gridSchemas.length).toBeGreaterThan(0)); + return gridSchemas[gridSchemas.length - 1]; +} + +/** Route 3 — the `list-view` schema the host delegation hands down. */ +function delegatedSchemaFor(view: NamedListView, nodeExtra: Record = {}): any { + resetSinks(); + const seen: any[] = []; + render( + { + seen.push(s); + return
; + }} + />, + ); + expect(seen.length).toBeGreaterThan(0); + return seen[0]; +} + +beforeEach(resetSinks); + +/* ───────────────────────────────────────────────────────────────────────────── + * Route 1 — the eight view-KIND blocks, at the protocol's own top level + * ────────────────────────────────────────────────────────────────────────── */ + +describe('objectui#8980 — a canonical top-level view-kind block reaches the renderer ObjectView dispatches to', () => { + it('`kanban` → `object-kanban` (ObjectKanban)', async () => { + const node = await generatedNodeFor({ label: 'Board', type: 'kanban', kanban: { groupByField: 'stage' } }); + expect(node.type).toBe('object-kanban'); + // The lane the branch resolves is the one the CANONICAL block declared. + expect(node.groupBy).toBe('stage'); + }); + + it('`calendar` → `object-calendar` (ObjectCalendar)', async () => { + const node = await generatedNodeFor({ label: 'Cal', type: 'calendar', calendar: { startDateField: 'due_at' } }); + expect(node.type).toBe('object-calendar'); + expect(node.startDateField).toBe('due_at'); + }); + + it('`gallery` → `object-gallery` (ObjectGallery)', async () => { + const node = await generatedNodeFor({ label: 'Cards', type: 'gallery', gallery: { coverField: 'photo' } }); + expect(node.type).toBe('object-gallery'); + expect(node.imageField).toBe('photo'); + }); + + it('`timeline` → `object-timeline` (ObjectTimeline)', async () => { + const node = await generatedNodeFor({ label: 'Feed', type: 'timeline', timeline: { startDateField: 'created_at' } }); + expect(node.type).toBe('object-timeline'); + expect(node.startDateField).toBe('created_at'); + }); + + it('`gantt` → `object-gantt` (ObjectGantt)', async () => { + // ⚠️ `titleField` is not decoration here: unlike the four blocks this + // package keeps a local `.partial()` dialect for, `gantt` flows into the + // mirror straight from `SpecListViewSchema.shape`, so the protocol's own + // three required bindings are required on the declared face too. Measured + // by tsc while this file was written — the fixture without it does not + // compile, which is the narrowing being declared rather than described. + const node = await generatedNodeFor({ + label: 'Plan', + type: 'gantt', + gantt: { startDateField: 'start_at', endDateField: 'end_at', titleField: 'subject' }, + }); + expect(node.type).toBe('object-gantt'); + expect(node.startDateField).toBe('start_at'); + expect(node.endDateField).toBe('end_at'); + }); + + it('`map` → `object-map` (ObjectMap)', async () => { + const node = await generatedNodeFor({ label: 'Where', type: 'map', map: { latitudeField: 'lat', longitudeField: 'lng' } }); + expect(node.type).toBe('object-map'); + expect(node.latitudeField).toBe('lat'); + expect(node.longitudeField).toBe('lng'); + }); + + it('`chart` and `tree` are READ, on the ONE route objectui#5321 leaves open to a named view', async () => { + // ⚠️ Neither kind is a member of `NamedListView['type']`, so a named view + // cannot select the branch itself. It reaches it by declaring no `type` + // while a host `views` entry selects one — and the CONFIG still comes off + // the named view, which is what makes the declaration meaningful. This is + // the reachability reading reported on objectui#8980; ⛔ not a reason to + // drop either member from the type. + // ⚠️ MEASURED WHILE WRITING THIS FILE, and reported on objectui#8980: the + // protocol's chart config is the ADR-0021 DATASET-BOUND shape alone + // (`dataset` + `values`, required). The renderer still carries a legacy + // inline-aggregate branch below it (`xAxisField` / `valueField` / + // `aggregation`), and the declared face cannot express that branch — tsc + // 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. + const chartNode = await generatedNodeFor( + { label: 'Agg', chart: { dataset: 'deals_by_stage', dimensions: ['stage'], values: ['amount'], chartType: 'line' } }, + [{ id: 'c', label: 'Agg', type: 'chart' }], + ); + expect(chartNode.type).toBe('object-chart'); + expect(chartNode.chartType).toBe('line'); + expect(chartNode.dataset).toBe('deals_by_stage'); + expect(chartNode.xAxisKey).toBe('stage'); + + const treeNode = await generatedNodeFor( + { label: 'Tree', tree: { parentField: 'parent_id' } }, + [{ id: 't', label: 'Tree', type: 'tree' }], + ); + expect(treeNode.type).toBe('object-tree'); + expect(treeNode.parentField).toBe('parent_id'); + }); +}); + +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. + const node = await generatedNodeFor({ label: 'Board', type: 'kanban', options: { kanban: { groupByField: 'legacy_lane' } } } as any); + expect(node.groupBy).toBe('legacy_lane'); + }); + + it('the canonical block WINS for a key both spell', async () => { + const node = await generatedNodeFor({ + label: 'Board', + type: 'kanban', + kanban: { groupByField: 'canonical_lane' }, + options: { kanban: { groupByField: 'legacy_lane' } }, + } as any); + expect(node.groupBy).toBe('canonical_lane'); + 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. + const node = await generatedNodeFor({ + label: 'Board', + type: 'kanban', + kanban: { groupByField: 'canonical_lane' }, + options: { kanban: { titleField: 'subject' } }, + } as any); + expect(node.groupBy).toBe('canonical_lane'); + expect(node.titleField).toBe('subject'); + }); +}); + +/* ───────────────────────────────────────────────────────────────────────────── + * Route 2 — the authored grid path + * ────────────────────────────────────────────────────────────────────────── */ + +describe('objectui#8980 — `grouping` and `rowColor` reach ObjectGrid from a named view', () => { + const GROUPING = { fields: [{ field: 'owner' }] }; + const ROW_COLOR = { field: 'stage', colors: { won: '#16a34a' } }; + + it('THE FIX: both keys arrive on the `object-grid` schema', async () => { + const grid = await gridSchemaFor({ label: 'All', type: 'grid', grouping: GROUPING, rowColor: ROW_COLOR } as any); + expect(grid.type).toBe('object-grid'); + expect(grid.grouping).toEqual(GROUPING); + expect(grid.rowColor).toEqual(ROW_COLOR); + }); + + it('ABSENCE CONTROL: a named view that declares neither leaves both undefined — the value before this card, for every document', async () => { + const grid = await gridSchemaFor({ label: 'All', type: 'grid' }); + expect(grid.grouping).toBeUndefined(); + expect(grid.rowColor).toBeUndefined(); + // …and the rest of the grid schema is still assembled, so the absence above + // is a reading of these two keys and not of a renderer that never ran. + expect(grid.objectName).toBe('task'); + }); +}); + +/* ───────────────────────────────────────────────────────────────────────────── + * Route 3 — the host delegation, whose sink is `ListView` + * ────────────────────────────────────────────────────────────────────────── */ + +describe('objectui#8980 — the host delegation relays the named view\'s protocol keys', () => { + const VIEW: NamedListView = { + label: 'All', + type: 'grid', + data: { provider: 'api' } as any, + fieldOrder: ['name', 'stage'], + grouping: { fields: [{ field: 'owner' }] } as any, + rowColor: { field: 'stage' } as any, + userActions: { group: true } as any, + appearance: { showDescription: false } as any, + }; + + it('THE FIX: all six reach the `list-view` schema the host receives', () => { + const s = delegatedSchemaFor(VIEW); + expect(s.type).toBe('list-view'); + expect(s.data).toEqual({ provider: 'api' }); + expect(s.fieldOrder).toEqual(['name', 'stage']); + expect(s.grouping).toEqual({ fields: [{ field: 'owner' }] }); + expect(s.rowColor).toEqual({ field: 'stage' }); + expect(s.appearance).toEqual({ showDescription: false }); + expect(s.userActions.group).toBe(true); + }); + + it('`userActions` MERGES rather than replaces — a named view toggling one action does not blank the node\'s others', () => { + // Spread, not `??`. This slot is a merge of toggle sets: the node's legacy + // `show*` flags fold into it through `normalizeListViewSchema`, and a named + // view that sets `group` must not delete the `search: false` the node set. + const s = delegatedSchemaFor(VIEW, { showSearch: false }); + expect(s.userActions.group).toBe(true); + expect(s.userActions.search).toBe(false); + }); + + it('ABSENCE CONTROL: with none of the six declared, every slot reads undefined and the relay still runs', () => { + const s = delegatedSchemaFor({ label: 'All', type: 'grid', columns: ['name'] }); + expect(s.fieldOrder).toBeUndefined(); + expect(s.appearance).toBeUndefined(); + expect(s.grouping).toBeUndefined(); + expect(s.rowColor).toBeUndefined(); + // The firing control on the same object: a rung this card did not touch. + expect(s.columns).toEqual(['name']); + }); + + it('`data` is relayed WITHOUT the `as any` cast on the named-view config — objectui#7928\'s open half', () => { + // The cast is gone from the source; this is the behavioural half of the + // same fact. The source half is pinned in the types census + // (`object-view-unmirrored-keys-7779.test.ts`). + const s = delegatedSchemaFor({ label: 'All', type: 'grid', data: { provider: 'api' } as any }); + expect(s.data).toEqual({ provider: 'api' }); + }); +}); + +/* ───────────────────────────────────────────────────────────────────────────── + * `name` — the tab strip's display fallback + * ────────────────────────────────────────────────────────────────────────── */ + +describe('objectui#8980 — `name` is read on the named-view tab strip', () => { + const twoViews = (v1: NamedListView) => ({ ...NODE, listViews: { v1, v2: { label: 'Other', type: 'grid' } } } as unknown as ObjectViewSchema); + + it('THE FIX: a view declaring `name` and no `label` shows the NAME, not the record key', async () => { + resetSinks(); + const { findByText } = render(); + expect(await findByText('my_deals')).toBeTruthy(); + }); + + it('PRECEDENCE CONTROL: `label` still wins over `name` — the existing rung is untouched', async () => { + resetSinks(); + const { findByText, queryByText } = render( + , + ); + expect(await findByText('My Deals')).toBeTruthy(); + expect(queryByText('my_deals')).toBeNull(); + }); + + it('KEY CONTROL: with neither, the record key is still the label — the value before this card', async () => { + resetSinks(); + const { findByText } = render(); + expect(await findByText('v1')).toBeTruthy(); + }); +}); + +/* ───────────────────────────────────────────────────────────────────────────── + * The REPORTED members — ruling item 2's "no behaviour to attach" clause + * ────────────────────────────────────────────────────────────────────────── */ + +describe('objectui#8980 — `tabs` and `pageName` are declared and NOT read here, which is the ruled outcome', () => { + it('neither reaches the host delegation, and the control on the same object fires', () => { + // ⭐ The measurement the ruling requires to be REPORTED rather than silently + // absorbed. `tabs` on the list shape is the `ViewTabSchema[]` multi-tab + // definition list (⛔ NOT `userFilters.tabs`, which `ObjectUserFiltersSchema` + // omits as page-only); objectui's tab bar for an object is the HOST-owned + // saved-view switcher (ADR-0053), so nothing on this surface reads it. + // `pageName` configures the protocol's `type: 'page'` branch, and `page` is + // not a member of `NamedListView['type']`, so no authored named view can + // select it. + // + // ⛔ Do NOT "fix" this by relaying the keys: a rung with no reader behind it + // is objectui#7924's defect pointed the other way. Either the reader lands + // on its own card, or the protocol card the report feeds decides otherwise. + const s = delegatedSchemaFor({ + label: 'All', + type: 'grid', + columns: ['name'], + tabs: [{ name: 'open' }] as any, + pageName: 'deals_page' as any, + }); + expect(s.tabs).toBeUndefined(); + expect(s.pageName).toBeUndefined(); + // The firing control: a key declared on the SAME fixture that the same relay + // does carry. Without it these two `undefined`s are an unrun probe. + expect(s.columns).toEqual(['name']); + }); +}); 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 bbdc133c2f..c31eb60ffb 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 @@ -44,14 +44,21 @@ * ## The one that stayed, and why it is pinned too * * `listViews` is NOT mirrored. The ruling's own fallback clause fires on the - * measurement below: the declaration's value is the local `NamedListView` — 47 - * declared top-level members, of which the renderer reads six (`label`, `type`, - * `columns`, `filter`, `sort`, `options`), leaving 41 that a key-for-key local - * mirror would enforce unread. The renderer reads a SEVENTH key off a named - * view, `data`, which is NOT a declared member of `NamedListView`: it arrives - * through an `as any` cast on the named-view config in the renderer - * (`(currentNamedViewConfig as any)?.data`), which is why the read set below - * has seven entries while the arithmetic subtracts only six. The spec's + * 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. + * + * ⚠️ 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 + * SEVENTH read, `data`, that was declared nowhere and reached the renderer + * through an `as any` cast — which is why the read set had seven entries while + * the arithmetic subtracted only six. The director-seat ruling of 2026-09-13 + * (objectui#8980) declared the seventeen members the protocol declares on this + * surface and objectui did not, `data` among them with the cast removed, and + * wired a read point for each. So the read set and the declared set no longer + * disagree on a single name, and the arithmetic subtracts the whole read set. + * ⛔ Do not quote 47 / 6 / 41 from anywhere: they are this file's history, not + * its reading. The spec's * `ViewSchema.listViews` is a record of the STRICT `ObjectListViewSchema`, and * the spec value refuses the named views this package's docs teach. Both facts * are asserted against the SPEC schema here, so the day the spec relaxes (or @@ -162,8 +169,16 @@ const READ_CONTROL_KEY = 'objectName'; */ const CONTROL_KEY = 'viewSwitcherPosition'; -/** The seven `NamedListView` members the renderer reads off a named view. */ -const NAMED_VIEW_READS = ['columns', 'data', 'filter', 'label', 'options', 'sort', 'type'] as const; +/** + * 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. + */ +const NAMED_VIEW_READS = [ + 'appearance', 'calendar', 'chart', 'columns', 'data', 'fieldOrder', 'filter', 'gallery', + 'gantt', 'grouping', 'kanban', 'label', 'map', 'name', 'options', 'rowColor', 'sort', + 'timeline', 'tree', 'type', 'userActions', +] as const; /* ── objectui#7924 — the per-member liveness census ─────────────────────────── * Both halves below are RE-DERIVED at test time (`namedListViewMembers()` / @@ -171,44 +186,71 @@ const NAMED_VIEW_READS = ['columns', 'data', 'filter', 'label', 'options', 'sort * that moves between the two sets — in EITHER direction — fails by name. */ -/** Every top-level member `NamedListView` declares. 47 today, sorted. */ +/** Every top-level member `NamedListView` declares. 64 today (47 + objectui#8980's seventeen), sorted. */ const NAMED_LIST_VIEW_DECLARED = [ 'addDeleteRecordsInline', 'addRecord', 'addRecordViaForm', 'allowExport', 'allowPrinting', - 'aria', 'bulkActionDefs', 'bulkActions', 'clickIntoRecordDetails', 'collapseAllByDefault', - 'color', 'columns', 'compactToolbar', 'conditionalFormatting', 'densityMode', 'description', - 'emptyState', 'exportOptions', 'fieldTextColor', 'filter', 'filterableFields', 'hiddenFields', - 'inlineEdit', 'label', 'navigation', 'options', 'pagination', 'prefixField', 'resizable', - 'rowActions', 'rowHeight', 'searchableFields', 'selection', 'sharing', 'showColor', - 'showDensity', 'showDescription', 'showFilters', 'showGroup', 'showHideFields', - 'showRecordCount', 'showSearch', 'showSort', 'sort', 'type', 'userFilters', 'wrapHeaders', + 'appearance', 'aria', 'bulkActionDefs', 'bulkActions', 'calendar', 'chart', + 'clickIntoRecordDetails', 'collapseAllByDefault', 'color', 'columns', 'compactToolbar', + 'conditionalFormatting', 'data', 'densityMode', 'description', 'emptyState', 'exportOptions', + 'fieldOrder', 'fieldTextColor', 'filter', 'filterableFields', 'gallery', 'gantt', 'grouping', + 'hiddenFields', 'inlineEdit', 'kanban', 'label', 'map', 'name', 'navigation', 'options', + 'pageName', 'pagination', 'prefixField', 'resizable', 'rowActions', 'rowColor', 'rowHeight', + 'searchableFields', 'selection', 'sharing', 'showColor', 'showDensity', 'showDescription', + 'showFilters', 'showGroup', 'showHideFields', 'showRecordCount', 'showSearch', 'showSort', + 'sort', 'tabs', 'timeline', 'tree', 'type', 'userActions', 'userFilters', 'wrapHeaders', ] as const; -/** The six DECLARED members the renderer reads off a named view. */ -const NAMED_VIEW_READ_DECLARED = ['columns', 'filter', 'label', 'options', 'sort', 'type'] 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}. + */ +const NAMED_VIEW_READ_DECLARED = [ + 'appearance', 'calendar', 'chart', 'columns', 'data', 'fieldOrder', 'filter', 'gallery', + 'gantt', 'grouping', 'kanban', 'label', 'map', 'name', 'options', 'rowColor', 'sort', + 'timeline', 'tree', 'type', 'userActions', +] as const; /** - * Read off a named view but NOT declared on `NamedListView`: it arrives through - * an `as any` cast (`(currentNamedViewConfig as any)?.data`). objectui#7928 - * requires this card to answer the key: it is still declared nowhere, so it is - * the one member of the read set the "47 minus the read set" arithmetic must - * NOT subtract. + * Read off a named view but NOT declared on `NamedListView`. EMPTY since + * objectui#8980, and the emptiness is the reading. + * + * This held exactly one name, `data`, reached through an `as any` cast on the + * named-view config — objectui#7928's open half. The director-seat ruling of + * 2026-09-13 answered it by name ("`data` replaces the `as any` read …: declare + * it"), so the member is declared, the cast is gone, and the census arithmetic + * now subtracts the whole read set rather than the read set minus one. + * + * ⛔ A name reappearing here is not a test to update: it is a renderer reading a + * key off a named view that nothing declares, which is the class objectui#7928 + * sends to a ruling. */ -const NAMED_VIEW_READ_UNDECLARED = ['data'] as const; +const NAMED_VIEW_READ_UNDECLARED = [] as const; /** - * The census result: declared, and NOT read off a named view. 41 today. A + * The census result: declared, and NOT read off a named view. 43 today. A * document authoring any of these validates green (`BaseSchema` is * `.passthrough()`) and changes nothing — the finding objectui#7924 records. + * + * ⭐ TWO OF THE 43 ARE objectui#8980's OWN, AND THAT IS THE RULED OUTCOME, not + * an oversight: `tabs` and `pageName`. The ruling's item 2 requires a member + * with no renderer behaviour to attach to be DECLARED and REPORTED with its + * measurement — ⛔ not silently declared inert and ⛔ not dropped from the type. + * The measurements, reported on objectui#8980: objectui's tab bar for an object + * is the host-owned saved-view switcher (ADR-0053), so nothing on this surface + * reads the list shape's `ViewTabSchema[]`; and `page` is not a member of + * `NamedListView['type']`, so no authored named view can select the branch + * `pageName` configures. */ const NAMED_LIST_VIEW_UNREAD = [ 'addDeleteRecordsInline', 'addRecord', 'addRecordViaForm', 'allowExport', 'allowPrinting', 'aria', 'bulkActionDefs', 'bulkActions', 'clickIntoRecordDetails', 'collapseAllByDefault', 'color', 'compactToolbar', 'conditionalFormatting', 'densityMode', 'description', 'emptyState', 'exportOptions', 'fieldTextColor', 'filterableFields', 'hiddenFields', 'inlineEdit', - 'navigation', 'pagination', 'prefixField', 'resizable', 'rowActions', 'rowHeight', + 'navigation', 'pageName', 'pagination', 'prefixField', 'resizable', 'rowActions', 'rowHeight', 'searchableFields', 'selection', 'sharing', 'showColor', 'showDensity', 'showDescription', 'showFilters', 'showGroup', 'showHideFields', 'showRecordCount', 'showSearch', 'showSort', - 'userFilters', 'wrapHeaders', + 'tabs', 'userFilters', 'wrapHeaders', ] as const; /** @@ -879,17 +921,23 @@ describe('objectui#7779 — `listViews` stays unmirrored on the ruling\'s fallba expect(readRepo('content/docs/api/schema-reference.md')).toContain('"filter": [["owner", "=", "${currentUser.id}"]],'); }); - it('the renderer reads exactly seven keys off a named view — six of them declared `NamedListView` members, the seventh (`data`) an `as any` cast — of a declaration with far more, the reason a local key-for-key mirror was not the answer either', () => { - expect(namedViewReads()).toEqual([...NAMED_VIEW_READS]); - // The tab strip reads `label` off the entries too — same member, second site. - expect(readRepo(READER)).toContain('{view.label || key}'); - // Six of those seven are declared `NamedListView` members. The seventh, - // `data`, is not declared at all — it reaches the renderer through an - // `as any` cast on the named-view config — so the "unread" arithmetic - // below subtracts six, not seven. Both directions are already pinned - // without a new assertion: were `data` ever declared, the exact count - // moves 47 → 48 and fails here; were the cast read dropped, - // `namedViewReads()` returns six entries and fails above. + 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 + // 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: + // 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" + // arithmetic below subtracts the whole read set rather than the read set + // minus one. Both directions stay pinned: a member added or removed moves + // the exact count and fails here; a read dropped shortens + // `namedViewReads()` and fails above. const declared = namedListViewMembers().names.length; // HOW THIS NUMBER IS TAKEN: `namedListViewMembers()` — the TypeScript parser // walking the interface's own `PropertySignature` members (objectui#7924). @@ -908,8 +956,8 @@ describe('objectui#7779 — `listViews` stays unmirrored on the ruling\'s fallba // figures could stale silently in either direction. expect( declared, - 'NamedListView\'s top-level member count moved (was 47). Re-derive it with this file\'s own namedListViewMemberCount() regex, then update the "47 declared / 41 unread" figures in the three files that carry them together — .changeset/object-view-unmirrored-keys-7779.md, packages/types/src/zod/objectql.zod.ts and packages/types/src/__tests__/zod-mirror-parity.test.ts — plus this file\'s own header. A shrink toward the read set also re-opens the listViews decision (objectui#7928).', - ).toBe(47); + 'NamedListView\'s top-level member count moved (was 64 — 47 plus objectui#8980\'s seventeen). Re-derive it with this file\'s own namedListViewMemberCount() regex, then update the "64 declared / 43 unread" figures in the three files that carry them together — .changeset/object-view-unmirrored-keys-7779.md, packages/types/src/zod/objectql.zod.ts and packages/types/src/__tests__/zod-mirror-parity.test.ts — plus this file\'s own header. A shrink toward the read set also re-opens the listViews decision (objectui#7928).', + ).toBe(64); expect(declared).toBeGreaterThan(NAMED_VIEW_READS.length); }); @@ -936,20 +984,23 @@ describe('objectui#7924 — the per-member liveness census on `NamedListView`, r it('the declared member set is EXACTLY the census, by name', () => { expect([...namedListViewMembers().names].sort()).toEqual([...NAMED_LIST_VIEW_DECLARED].sort()); - expect(NAMED_LIST_VIEW_DECLARED).toHaveLength(47); - expect(new Set(NAMED_LIST_VIEW_DECLARED).size).toBe(47); + expect(NAMED_LIST_VIEW_DECLARED).toHaveLength(64); + expect(new Set(NAMED_LIST_VIEW_DECLARED).size).toBe(64); }); it('all three instruments read the same declaration, and the two that disagree disagree for a stated reason', () => { const ast = namedListViewMembers().names.length; // The parser and the strict regex agree — that agreement is what licenses // replacing the regex without re-opening the number. - expect(ast).toBe(47); + expect(ast).toBe(64); expect(namedListViewMemberCount()).toBe(ast); // …and the loose regex does NOT, by 12, because it also counts nested - // object-literal lines. Pinned so "52 is between two instruments and is - // neither" stays a reading rather than a remembered sentence. - expect(namedListViewLooseMemberCount()).toBe(59); + // object-literal lines. The gap is still exactly 12 after objectui#8980: + // every one of the seventeen new members is a single-line type reference, + // so none of them adds a nested object literal for the loose instrument to + // over-count. Pinned so "a figure between two instruments is neither" stays + // a reading rather than a remembered sentence. + expect(namedListViewLooseMemberCount()).toBe(76); expect(namedListViewLooseMemberCount()).toBeGreaterThan(ast); }); @@ -976,42 +1027,59 @@ describe('objectui#7924 — the per-member liveness census on `NamedListView`, r } }); - it('the renderer reads exactly seven names off a named view — six declared, plus the undeclared `data`', () => { + it('the renderer reads exactly twenty-one names off a named view — every one of them declared', () => { const d = deriveNamedViewReads(); expect(d.reads).toEqual([...NAMED_VIEW_READS]); - expect(d.reads).toHaveLength(7); - // The AST derivation reproduces the regex reading it replaces: the - // instrument changed, the reading did not. ⭐ It also finds STRICTLY more - // sites — `{view.label || key}` on the tab strip is a named-view read the - // `currentNamedViewConfig?.KEY` regex cannot see — so the agreement is on - // the SET, and the AST's extra site is asserted rather than lost. - expect(d.reads).toEqual(namedViewReads()); + expect(d.reads).toHaveLength(21); + // ⭐ 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 + // for SITES (`{view.label || …}` on the tab strip is a named-view read the + // regex cannot see), and objectui#8980 made it true for a NAME as well: + // `name` is read ONLY at that tab strip. So the regex is a subset, and the + // members it cannot see are asserted by name. + const regexOnly = namedViewReads(); + expect(regexOnly.filter((r) => !d.reads.includes(r)), 'the regex found a read the AST did not — the AST is the authority and must be extended').toEqual([]); + expect( + d.reads.filter((r) => !regexOnly.includes(r)), + 'the set of named-view reads the `currentNamedViewConfig?.KEY` regex cannot see moved. ' + + 'It is the tab strip\'s `view.KEY` reads; re-derive it rather than widening this expectation.', + ).toEqual(['name']); expect(d.readSites.label.length, '`label` is read at two sites: the delegation and the tab strip').toBe(2); + 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: 6 read + 41 unread = 47, disjoint and exhaustive', () => { + it('the census partitions the declaration exactly: 21 read + 43 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(6); - expect(unread).toHaveLength(41); + expect(read).toHaveLength(21); + expect(unread).toHaveLength(43); expect(read.length + unread.length).toBe(declared.size); expect(read.filter((m) => unread.includes(m))).toEqual([]); }); - it('`data` is READ off a named view and declared NOWHERE — the cast class objectui#7928 sends here', () => { + it('`data` is READ off a named view and now DECLARED — objectui#7928\'s open half, answered by the objectui#8980 ruling', () => { const declared = new Set(namedListViewMembers().names); const reads = deriveNamedViewReads(); const undeclared = reads.reads.filter((r) => !declared.has(r)); - // Still exactly one, and still `data`. If a SECOND cast-only read appears, - // this fails and the ruling request gets the new name with the old one. + // EMPTY, and the emptiness is the reading: nothing the renderer reads off a + // named view is undeclared any more. A name appearing here is a new + // cast-only read, which is the class objectui#7928 sends to a ruling — ⛔ do + // not add it to NAMED_VIEW_READ_UNDECLARED to make this green. expect(undeclared).toEqual([...NAMED_VIEW_READ_UNDECLARED]); - expect(declared.has('data'), '`data` became a declared member; the census arithmetic now subtracts seven, not six').toBe(false); - // …and it is genuinely reached through the cast, not through a declared path. - expect(readRepo(READER)).toContain('data: (currentNamedViewConfig as any)?.data'); + expect(undeclared).toHaveLength(0); + expect(declared.has('data'), '`data` is a declared member since objectui#8980; the census arithmetic subtracts the whole read set').toBe(true); + expect(reads.reads).toContain('data'); + // …and it is reached through the DECLARED path: the cast on the named-view + // config is gone. The two neighbouring casts are untouched and stay — the + // host `views` entry is an untyped host shape, and `data` on the node is one + // of the 27 objectui#5097 host-composition keys. + expect(readRepo(READER)).toContain('data: currentNamedViewConfig?.data ?? (activeView as any)?.data ?? (schema as any).data,'); + expect(readRepo(READER)).not.toContain('(currentNamedViewConfig as any)?.data'); }); it.each(NAMED_VIEW_READ_DECLARED)('READ — `%s` is declared AND read off a named view', (member) => { diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index dcf8b1961e..8822f83404 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -2126,18 +2126,20 @@ interface UnmirroredDeclared { * `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: the declaration's value is the local `NamedListView`, 47 - * declared top-level members, six of which the renderer reads — `label`, `type`, - * `columns`, `filter`, `sort`, `options`. The renderer reads a seventh key off a - * named view, `data`, and it is NOT a declared member: it arrives through an - * `as any` cast on the named-view config in `plugin-view/src/ObjectView.tsx`, so - * it is outside the 47 this ledger counts. The spec slot `ViewSchema.listViews` is + * 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 41 unread members - * (47 declared, minus the 6 that are both declared and read) into the contract + * 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 diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 941cc00d04..9c9a69491c 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -118,6 +118,11 @@ import type { NavigationConfig, ChartAggregate, GanttConfig as SpecGanttConfig, + // objectui#8980 — the protocol's OWN authored type for a list view + // (`z.input`, published under this name). One member + // of `NamedListView` indexes it; see that member for why it cannot take its + // type from this package's mirror like the other sixteen. + ListView as SpecListView, CalendarConfig as SpecCalendarConfig, // objectui#9239 — `ComponentPropsMap['object-calendar']`'s author state, so // `ObjectCalendarSchema.data` below DERIVES the protocol's `data` row rather @@ -2262,6 +2267,189 @@ export interface NamedListView { describedBy?: string; live?: 'polite' | 'assertive' | 'off'; }; + + /* ── objectui#8980 — the SEVENTEEN members the protocol declares on this very + * surface and this interface did not ─────────────────────────────────────── + * + * Director-seat class-one adjudication of 2026-09-13 (objectui#8980), under + * the maintainer's standing principle — quoted verbatim, deliberately + * untranslated, because a translated ruling is a second ruling: + * + * 「我们的项目以 objectstack 协议为准,文档应该以实际实现为准。协议不正确的应该先修改协议。」 + * + * The value type of an entry in `ViewSchema.listViews` / `ObjectSchema.listViews` + * is the protocol's `ObjectListViewSchema` (`@objectstack/spec/ui`, built from + * `ListViewShapeSchema`). Re-measured on this tree against the resolved + * `@objectstack/spec@17.4.0`: that shape carries 50 keys, 22 of which this + * interface did not declare, 5 of those 22 being the protocol's own + * `retiredKey()` tombstones (`bordered` `performance` `responsive` `striped` + * `virtualScroll` — each refuses a plausible value with a message naming the + * 17.0.0 removal). 22 − 5 = the seventeen below. objectui was NARROWER than + * the protocol on every one of them, which is the direction the principle + * forbids; the remedy is to catch up, ⛔ not to declare-and-ignore (ADR-0049), + * so each member here has a READ POINT landing in the same change. + * + * ⭐ TYPES ARE TAKEN FROM THE PROTOCOL, ⛔ never restated. Sixteen of the + * seventeen index {@link ListViewSchema} — this package's own spec-derived + * `list-view` node type, whose members arrive from `SpecListViewSchema.shape` + * by reference (`zod/objectql.zod.ts`, `specFieldsExcept`). That is the + * derivation this interface already uses one member up (`userFilters`), and + * it buys a second property the relay needs: `ObjectView` forwards a named + * view INTO a `list-view` node, so the two faces are provably the same type + * for every key that crosses. `name` is the exception and says so on its own + * line. + * + * ⚠️ WHAT IS NOT CLAIMED HERE. Declaring a key is not the same as the renderer + * having behaviour to attach to it. Three of the seventeen are reported with + * their measurement on objectui#8980 rather than wired, exactly as the + * ruling's item 2 requires — `tabs` and `pageName` have no reader on this + * surface at all, and `chart` / `tree` are read but only reachable through a + * host `views` prop (objectui#5321). ⛔ None of them may be dropped from the + * type on that basis: the report is the evidence a protocol card would need. + * + * The 19 legacy spellings this interface declares BEYOND the protocol are + * objectui#7924's remedy and stay there (ruling item 4) — ⛔ not touched here. + */ + + /** + * Internal view name (lowercase snake_case) — the protocol's own identity for + * this view, distinct from the record KEY it is filed under in `listViews`. + * + * ⭐ THE ONE MEMBER NOT DERIVED FROM {@link ListViewSchema}, and the reason is + * a measurement: `name` sits in that mirror's `LIST_VIEW_LOCAL_OVERRIDES`, so + * on the `list-view` node it resolves through `BaseSchema.name` — the + * COMPONENT name slot, a different contract that happens to share a spelling. + * Indexing the protocol's own published authored type keeps this member + * tracking `SnakeCaseIdentifierSchema` rather than that neighbour. + * + * `ListView` rather than the object-scoped `ObjectListView`: the protocol + * publishes a TS type for the first and not the second, and the two shapes + * differ ONLY in `userFilters` (`ObjectListViewSchema` omits and re-extends + * that one key). `name` is the same declaration on both, straight off + * `ListViewShapeSchema`. + * + * LIVE, and written by a producer today: `@object-ui/app-shell`'s + * `mergeViewsIntoObjects` stamps `name: key` onto every composed `listViews` + * entry (`applyViewItem`), and its primary-view promotion matches on it — so + * this key has been travelling on this surface undeclared. + */ + name?: SpecListView['name']; + + /** + * Data source configuration (defaults to the `object` provider). + * + * objectui#7928's open half, answered by ruling item 2: DECLARE it. The + * renderer already read this key off a named view through an `as any` cast on + * the named-view config; that cast is gone in the same change, so the read and + * the declaration are now one fact. + */ + data?: ListViewSchema['data']; + + /** Explicit field display order — the live third key of the protocol's + * `columns` × `hiddenFields` × `fieldOrder` composition (objectstack#15184 + * ruling B, 2026-09-11: `columns` projects, `hiddenFields` subtracts, + * `fieldOrder` orders what survives). `ListView` reads it. */ + fieldOrder?: ListViewSchema['fieldOrder']; + + /** Grouping configuration. Read by `ListView` and by `ObjectGrid`. */ + grouping?: ListViewSchema['grouping']; + + /** Row colouring configuration — the spec-canonical form of the legacy bare + * `color` shorthand two dozen lines up. Read by `ListView` and `ObjectGrid`. */ + rowColor?: ListViewSchema['rowColor']; + + /** User action toggles for the view toolbar — the protocol's canonical home + * for the eight legacy `show*` spellings this interface still declares + * (objectui#7924 owns their retirement, ⛔ not this card). */ + userActions?: ListViewSchema['userActions']; + + /** Appearance and visualization configuration (`showDescription`, + * `allowedVisualizations`). Read by `ListView`. */ + appearance?: ListViewSchema['appearance']; + + /** + * Tab definitions for a multi-tab view interface (`ViewTabSchema[]`). + * + * ⭐ RULING ITEM 3 — MEASURED BEFORE DECLARED, because the protocol spends the + * word `tabs` on two different keys. This is the TOP-LEVEL one on the list + * shape, and it survives `ObjectListViewSchema` untouched. The other is + * `userFilters.tabs`, which that schema OMITS as page-only + * (`ObjectUserFiltersSchema`: "an object view's tab bar is its saved-view + * switcher (ViewTabBar), and a second one would collide"). So the key declared + * here is the array of `ViewTabSchema`, ⛔ not the user-filter preset bar — + * objectui's own `userFilters` dialect keeps carrying that one separately. + * + * ⚠️ NO RENDERER BEHAVIOUR ON THIS SURFACE, measured and REPORTED on + * objectui#8980 rather than silently declared inert: objectui's tab bar for an + * object is the saved-view switcher the HOST owns (ADR-0053), and the only + * `tabs` read in `packages/plugin-list` is `UserFilters`' `config.tabs` — the + * page-only key, not this one. + */ + tabs?: ListViewSchema['tabs']; + + /** + * Name of the published `page` a `type: 'page'` view mounts. + * + * ⚠️ NO RENDERER BEHAVIOUR ON THIS SURFACE, measured and REPORTED on + * objectui#8980: `page` is not a member of this interface's own `type` union + * (seven values; the protocol's is ten), so no authored named view can select + * the branch this key configures. Declared because the protocol declares it + * and the ruling forbids dropping a member to avoid the report. + */ + pageName?: ListViewSchema['pageName']; + + /* ── The eight view-KIND configuration blocks ────────────────────────────── + * The protocol carries each at the TOP LEVEL of a list view. objectui read + * them only out of the legacy untyped `options` bag + * (`options.kanban`, `options.calendar`, …), which is why none of the eight + * was declared. `ObjectView.generateViewSchema` now resolves the canonical + * top-level block first and merges the legacy nesting under it, so both + * spellings work and the declared one wins key-by-key; each block reaches the + * renderer `ObjectView` already dispatches to for that `type`. + * + * The four with a local dialect (`kanban` `calendar` `gallery` `timeline`) + * index this package's mirror deliberately: those shapes are the spec config + * `.partial()`-ed plus the legacy field aliases the renderers still read + * (`groupField`, `imageField`, `dateField`). `gantt` `map` `chart` `tree` + * arrive in that mirror straight from `SpecListViewSchema.shape`. + */ + + /** Kanban board configuration. Consumed by `ObjectKanban` (`@object-ui/plugin-kanban`). */ + kanban?: ListViewSchema['kanban']; + + /** Calendar configuration. Consumed by `ObjectCalendar` (`@object-ui/plugin-calendar`). */ + calendar?: ListViewSchema['calendar']; + + /** Gallery configuration. Consumed by `ObjectGallery` (`@object-ui/plugin-gallery`). */ + gallery?: ListViewSchema['gallery']; + + /** Timeline configuration. Consumed by `ObjectTimeline` (`@object-ui/plugin-timeline`). */ + timeline?: ListViewSchema['timeline']; + + /** Gantt configuration. Consumed by `ObjectGantt` (`@object-ui/plugin-gantt`). */ + gantt?: ListViewSchema['gantt']; + + /** Map configuration. Consumed by `ObjectMap` (`@object-ui/plugin-map`). */ + map?: ListViewSchema['map']; + + /** + * Chart configuration. Consumed by `ObjectChart` (`@object-ui/plugin-charts`). + * + * ⚠️ REACHABILITY, measured and REPORTED on objectui#8980: `chart` is not a + * member of this interface's `type` union — objectui#5321 ruled the branch + * HOST-COMPOSITION ONLY — so a named view reaches it only when it declares no + * `type` of its own and a host `views` entry selects `chart`. The read is + * real; the authored route to it is not. ⛔ Not a reason to drop the member. + */ + chart?: ListViewSchema['chart']; + + /** + * Tree configuration. Consumed by `ObjectTree` (`@object-ui/plugin-tree`). + * + * ⚠️ Same reachability reading as `chart` above (objectui#5321), reported on + * objectui#8980 with it. + */ + tree?: ListViewSchema['tree']; } /** diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 7472638a46..8521112d29 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -368,20 +368,21 @@ export const ObjectFormSchema = BaseSchema.extend({ * (`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: the declaration's value is the local `NamedListView`, - * 47 declared top-level members, of which the renderer reads six — - * `label`, `type`, `columns`, `filter`, `sort`, `options`. A seventh key, - * `data`, is read off a named view but is NOT declared on - * `NamedListView`: the renderer reaches it through an `as any` cast on the - * named-view config (`ObjectView.tsx`, `(currentNamedViewConfig as any)?.data`), - * so it is not one of the 47 and never was a member a mirror would carry. + * 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 - * 41 unread members (47 declared, minus the 6 that are both declared and + * 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.