From ad68baea6944012dee40d89b193086fa127127bb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 03:52:29 +0000 Subject: [PATCH 01/10] feat(spec)!: retire the list overlay's options bag; fold stored bags onto the top-level blocks The flattened list overlay's options member is a retiredKey tombstone naming the move to the top-level KIND block. The ADR-0087 D2 conversion list-view-options-bag-folded replays on stored rows (top-level wins per key, chart whole); the D3 entry ui-list-view-options-bag-folded carries what it cannot decide. Pins move in spec, rest, metadata-protocol and lint. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzevL8Q6rTfGz2XHXGT --- .../ui.md | 12 +- ...te-component-props-list-view-22444.test.ts | 18 +- ...rotocol.graft-folded-form-sections.test.ts | 77 ++--- .../src/protocol.stored-conversions.test.ts | 35 +++ .../rest/src/meta-view-chart-binding.test.ts | 9 +- .../src/meta-view-overlay-options-bag.test.ts | 76 ++--- packages/spec/src/conversions/registry.ts | 190 ++++++++++++ .../18.ui-list-view-options-bag-folded.ts | 39 +++ .../retired-key-removal-version.pin.test.ts | 3 +- .../src/ui/component-list-view-row.test.ts | 26 +- packages/spec/src/ui/component.zod.ts | 11 +- .../spec/src/ui/view-chart-binding.test.ts | 75 ++--- .../src/ui/view-overlay-options-bag.test.ts | 286 +++++++++--------- .../src/ui/view-overlay-options-type.test.ts | 87 ++---- .../src/ui/view-overlay-viewkind-arm.test.ts | 6 +- packages/spec/src/ui/view.zod.ts | 222 ++++---------- 16 files changed, 603 insertions(+), 569 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-list-view-options-bag-folded.ts diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md index 725fcd426a4..4e2536b9446 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 217 | 208 | 4 | 0 | 5 | +| `ui/` | 216 | 207 | 4 | 0 | 5 | ## `ui/` — sites @@ -44,9 +44,9 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `report.zod.ts` | 4 | | `responsive.zod.ts` | 1 | | `sharing.zod.ts` | 1 | -| `view.zod.ts` | 59 | +| `view.zod.ts` | 58 | | `widget.zod.ts` | 1 | -| **total** | **217** | +| **total** | **216** | ## `ui/` — open @@ -54,15 +54,15 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**5 strip of 217**, in 4 file(s). +**5 strip of 216**, in 4 file(s). | File | Strip | Sites | |---|---|---| | `action-params.zod.ts` | 1 | 1 | | `app.zod.ts` | 1 | 19 | -| `view.zod.ts` | 2 | 59 | +| `view.zod.ts` | 2 | 58 | | `widget.zod.ts` | 1 | 1 | -| **total** | **5** | **217** | +| **total** | **5** | **216** | | Bucket | Sites | |---|---| diff --git a/packages/lint/src/validate-component-props-list-view-22444.test.ts b/packages/lint/src/validate-component-props-list-view-22444.test.ts index 40f98b0503c..f5fc32f9a63 100644 --- a/packages/lint/src/validate-component-props-list-view-22444.test.ts +++ b/packages/lint/src/validate-component-props-list-view-22444.test.ts @@ -8,9 +8,11 @@ * a bag holding `kanban.groupField` or `options.grid` — refused at every list * view door — drew no finding at all (the census on the card measured 0). The * row needs no dispatch change here: the rule reads `ComponentPropsMap` by - * type. The pins are the ruling's list, at this door: `kanban.groupField` and - * `options.grid` flagged naming the key; contract keys only pass; the retired - * `objectName` / `viewType` flagged with the react tier's prescription. The + * type. The pins are the ruling's list, at this door: `kanban.groupField` + * flagged naming the key; an `options` bag flagged as the retired key it is + * since #25923 (the list overlay's tombstone, with its prescription); contract + * keys only pass; the retired `objectName` / `viewType` flagged with the react + * tier's prescription. The * schema half is pinned in `@objectstack/spec` * (`ui/component-list-view-row.test.ts`). * @@ -21,7 +23,7 @@ import { REACT_RETIRED_OVERLAY_PROPS } from '@objectstack/spec/ui'; import { describe, expect, it } from 'vitest'; import { runAuthoringRules } from './authoring-rules.js'; -import { COMPONENT_PROPS_UNKNOWN_KEY, validateComponentProps } from './validate-component-props.js'; +import { COMPONENT_PROPS_INVALID, COMPONENT_PROPS_UNKNOWN_KEY, validateComponentProps } from './validate-component-props.js'; type AnyRec = Record; @@ -45,9 +47,11 @@ describe('a page `list-view` node is judged as a list view (#22444)', () => { expect(shape(findings)).toEqual([[COMPONENT_PROPS_UNKNOWN_KEY, 'error', `${AT}.properties.kanban.groupField`]]); }); - it('`options.grid` is flagged, naming the key', () => { + it('an `options` bag is flagged as the retired key, with the list overlay\'s prescription', () => { const findings = judge({ ...BOUND, options: { grid: { pageSize: 10 } } }); - expect(shape(findings)).toEqual([[COMPONENT_PROPS_UNKNOWN_KEY, 'error', `${AT}.properties.options.grid`]]); + expect(shape(findings)).toEqual([[COMPONENT_PROPS_INVALID, 'error', `${AT}.properties.options`]]); + expect(findings[0]!.message).toContain("A list view's `options` bag was removed in @objectstack/spec 18.3.0"); + expect(findings[0]!.message).toContain('a retired key is refused'); }); it.each([ @@ -75,7 +79,7 @@ describe('a page `list-view` node is judged as a list view (#22444)', () => { expect(mine.map((f) => [f.rule, f.path]).sort()).toEqual( [ [COMPONENT_PROPS_UNKNOWN_KEY, `${AT}.properties.objectName`], - [COMPONENT_PROPS_UNKNOWN_KEY, `${AT}.properties.options.grid`], + [COMPONENT_PROPS_INVALID, `${AT}.properties.options`], ].sort(), ); }); diff --git a/packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts b/packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts index afda7e4ab23..fdbef31679e 100644 --- a/packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts +++ b/packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts @@ -383,22 +383,22 @@ describe('graftFoldedFormSections — structural safety, no save involved', () = }); /** - * [#20051] The view write door judges a flat list overlay's legacy `options` - * bag — driven through the REAL `saveMetaItem`, riding this file's pinned - * engine double rather than minting a second one (the double is the reason - * these cases live here, not the `groups` fold). + * [#25923] The view write door refuses a flat list overlay's `options` bag by + * name — driven through the REAL `saveMetaItem`, riding this file's pinned + * engine double rather than minting a second one. The read half (a STORED bag + * served folded) is pinned in `protocol.stored-conversions.test.ts`. * - * Measured before the change on `origin/main` @ `8d1f7ab`, through this same - * harness: `options: { timeline: { metaFields: ['region'] } }` on a flat list - * overlay saved `success: true` and the row held the bag byte-for-byte, while - * the direct `timeline.metaFields` was refused. `saveMetaItem` stores the - * request body, not the parse output, so what the door does not judge is what - * reaches storage. + * From #20051 until #25923 the door judged the bag key by key and stored a + * legal one byte-for-byte (`options.map`, the path objectui pinned). The bag + * is retired now: the save is refused with the tombstone's prescription, and + * a row written before is replayed through `list-view-options-bag-folded` on + * every stored-row read. * * Refusals assert the envelope (`code` + `status`) and that NO row was written. */ -describe('[#20051] the save door judges a flat list overlay\'s `options` bag', () => { - const TIMELINE = { startDateField: 'created_at', titleField: 'name', metaFields: ['region'] }; +describe('[#25923] the save door refuses a flat list overlay\'s `options` bag', () => { + const TIMELINE = { startDateField: 'created_at', titleField: 'name' }; + const MAP = { locationField: 'location', titleField: 'legacy_title' }; const flatList = (extra: Record) => ({ name: 'crm_lead.timeline', object: 'crm_lead', @@ -418,50 +418,25 @@ describe('[#20051] the save door judges a flat list overlay\'s `options` bag', ( return { err, rowsWritten: Array.from(rows.values()).filter((r) => r.type === 'view').length }; } - it('a direct and an `options`-wrapped out-of-contract key get the same refusal, and nothing is stored', async () => { - const direct = await refusal(flatList({ timeline: TIMELINE })); - const wrapped = await refusal(flatList({ options: { timeline: TIMELINE } })); - for (const { err, rowsWritten } of [direct, wrapped]) { - expect(err?.code).toBe('INVALID_METADATA'); - expect(err?.status).toBe(422); - expect(rowsWritten).toBe(0); - } - const hit = (err: any, path: string) => - (err.issues as Array<{ path: string; code?: string; message: string }>) - .find((i) => i.path === path && i.code === 'unrecognized_keys'); - const directHit = hit(direct.err, 'timeline'); - const wrappedHit = hit(wrapped.err, 'options.timeline'); - expect(directHit, JSON.stringify(direct.err.issues)).toBeDefined(); - expect(wrappedHit, JSON.stringify(wrapped.err.issues)).toBeDefined(); - // Refused by NAME, on the same surface text as the direct spelling. - expect(wrappedHit!.message).toBe(directHit!.message); - expect(wrappedHit!.message).toContain('`metaFields`'); - }); - - it('an unknown key in the bag itself is refused by name, not dropped', async () => { - const { err, rowsWritten } = await refusal(flatList({ timeline: { startDateField: 'created_at', titleField: 'name' }, options: { foo: 1 } })); + it.each([ + ['a legal `options.timeline` block', { options: { timeline: TIMELINE } }], + ['the legacy `options.map` bag objectui pinned', { type: 'map', options: { map: MAP } }], + ['a bag beside the same top-level block', { timeline: TIMELINE, options: { timeline: { titleField: 'subject' } } }], + ])('%s: 422 at `options` with the move-to-the-top-level prescription, and nothing is stored', async (_label, extra) => { + const { err, rowsWritten } = await refusal(flatList(extra)); expect(err?.code).toBe('INVALID_METADATA'); expect(err?.status).toBe(422); expect(rowsWritten).toBe(0); - expect((err.issues as Array<{ path: string; message: string }>).some((i) => i.path === 'options' && i.message.includes('`foo`'))) - .toBe(true); + const hit = (err.issues as Array<{ path: string; message: string }>).find((i) => i.path === 'options'); + expect(hit, JSON.stringify(err.issues)).toBeDefined(); + expect(hit!.message).toMatch(/^A list view's `options` bag was removed in @objectstack\/spec 18\.3\.0/); + expect(hit!.message).toContain('Move each `options.KIND` block to `KIND`'); }); - it('a legal legacy `options.map` bag (the path objectui pins) saves, and the row carries it unchanged', async () => { - const authored = { - name: 'showcase_task.work_map', - object: 'showcase_task', - viewKind: 'list', - label: 'Work Map', - type: 'map', - columns: ['title', 'location'], - options: { map: { locationField: 'location', titleField: 'legacy_title' } }, - }; - const body = await storedViewBody('showcase_task.work_map', authored); - expect(body.options).toEqual(authored.options); - // …and the stored row passes the same door again: a GET → PUT of it is - // not refused by the judgement it was saved under. - expect((ViewMetadataSchema as any).safeParse(body).success).toBe(true); + it('CONTROL: the same block written top-level saves, and the row carries it', async () => { + const body = await storedViewBody('crm_lead.timeline', flatList({ timeline: TIMELINE })); + expect(body.timeline).toEqual(TIMELINE); + expect('options' in body).toBe(false); }); }); diff --git a/packages/metadata-protocol/src/protocol.stored-conversions.test.ts b/packages/metadata-protocol/src/protocol.stored-conversions.test.ts index 60b999ff699..52378e6b140 100644 --- a/packages/metadata-protocol/src/protocol.stored-conversions.test.ts +++ b/packages/metadata-protocol/src/protocol.stored-conversions.test.ts @@ -239,6 +239,41 @@ describe('getMetaItem — single stored read is canonical (#3903)', () => { }); }); +// [#25923] A protocol-18 list view row written while the overlay still took +// the legacy `options` bag (17.5.0 to 18.2.0 judged it per kind and stored it +// as sent). The bag is retired now; `list-view-options-bag-folded` is +// `retiredFromLoadPath`, so only this stored seam replays it. +const legacyBagViewRow = { + type: 'view', + name: 'showcase_task.work_map', + metadata: { + name: 'showcase_task.work_map', + object: 'showcase_task', + viewKind: 'list', + label: 'Work Map', + type: 'map', + columns: ['title', 'location'], + map: { locationField: 'location' }, + options: { map: { locationField: 'address', titleField: 'legacy_title' } }, + }, +}; + +describe('[#25923] a stored list view\'s `options` bag is served folded onto the top-level block', () => { + it('the single and the list read move `options.KIND` to `KIND`, the top-level value winning per key', async () => { + const { engine, rows } = makeStubEngine([legacyBagViewRow]); + const protocol = new ObjectStackProtocolImplementation(engine); + const single: any = await protocol.getMetaItem({ type: 'view', name: 'showcase_task.work_map' }); + const listed = ((await protocol.getMetaItems({ type: 'view' })).items as any[]) + .find((i) => i.name === 'showcase_task.work_map'); + for (const item of [single.item, listed]) { + expect(item.map).toEqual({ locationField: 'location', titleField: 'legacy_title' }); + expect('options' in item).toBe(false); + } + // The row at rest is not rewritten by the reads. + expect(JSON.parse(rows[0]!.metadata).options).toEqual(legacyBagViewRow.metadata.options); + }); +}); + describe('loadMetaFromDb — boot hydration converts, diagnoses, never drops (#3903)', () => { it('registers the CONVERTED body and reports invalid: 0 for chain-owned history', async () => { const { engine, registered } = makeStubEngine([legacyObjectRow, legacyActionRow]); diff --git a/packages/rest/src/meta-view-chart-binding.test.ts b/packages/rest/src/meta-view-chart-binding.test.ts index f13ff5259f8..7f0a8e179ac 100644 --- a/packages/rest/src/meta-view-chart-binding.test.ts +++ b/packages/rest/src/meta-view-chart-binding.test.ts @@ -9,7 +9,8 @@ * * Before the change (measured on `origin/main` @ `e148ca98`): the flattened * list overlay member accepted `type: 'chart'` with no `chart` block, and an - * `options.chart` bag holding only `chartType`. + * `options.chart` bag holding only `chartType`. (#25923 retired the bag: it is + * refused at `options` whatever it holds.) * * #26847 extends the file one binding deeper: a chart whose `dataset` names no * declared dataset, or whose `values` names no measure of it, is refused too. @@ -151,7 +152,7 @@ describe("#22491 PUT /api/v1/meta/view refuses a `type: 'chart'` list view that expect(hit!.message!.startsWith(NO_BLOCK_VERDICT)).toBe(true); }); - it('an `options.chart` bag holding only `chartType`: 422 INVALID_METADATA at the bag\'s missing keys, and nothing is stored', async () => { + it('an `options.chart` bag holding only `chartType`: 422 INVALID_METADATA at `options` (the retired bag), and nothing is stored', async () => { const { put, storedRows } = await boot(); const res = await put( 'crm_lead.pipeline_chart', @@ -162,8 +163,8 @@ describe("#22491 PUT /api/v1/meta/view refuses a `type: 'chart'` list view that expect(res._json?.code).toBe('INVALID_METADATA'); expect(await storedRows('crm_lead.pipeline_chart')).toEqual([]); const paths = issuesOf(res).map((i) => i.path); - expect(paths, JSON.stringify(res._json)).toContain('options.chart.dataset'); - expect(paths).toContain('options.chart.values'); + expect(paths, JSON.stringify(res._json)).toContain('options'); + expect(paths).not.toContain('options.chart.dataset'); }); it('the control: a chart view that binds a dataset and a measure answers 200 and is stored with its binding', async () => { diff --git a/packages/rest/src/meta-view-overlay-options-bag.test.ts b/packages/rest/src/meta-view-overlay-options-bag.test.ts index e24559d2aed..f1a0d923390 100644 --- a/packages/rest/src/meta-view-overlay-options-bag.test.ts +++ b/packages/rest/src/meta-view-overlay-options-bag.test.ts @@ -1,15 +1,16 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * #20051 — a flat list overlay's legacy `options` bag is judged at + * #25923 — a flat list overlay's `options` bag is refused by name at * `PUT /api/v1/meta/view/:name`, the door a Studio tenant or an MCP/AI author * writes through, on the real composition: the real `RestServer` route over * the real `saveMetaItem`, backed by a real `ObjectQL` + SQLite `sys_metadata`. * - * Before the change (measured on `origin/main` @ `8d1f7ab`): the list overlay - * member `.strip()`ped a top-level `options` from the parse without judging it, - * so `options.timeline.metaFields` answered `200` and was stored as sent, while - * the direct `timeline.metaFields` answered `422`. + * From #20051 until #25923 the door judged the bag key by key and stored a + * legal one as sent (`options.map` answered `200`). The bag is retired: each + * per-kind block is a top-level key of the view, and a stored bag is folded + * onto it on read (`list-view-options-bag-folded`, pinned in + * `@objectstack/metadata-protocol`). * * Refusal cases assert the ADR-0112 envelope — `code` AND `status` — and that * no row reached `sys_metadata`: the pair separates "refused by the door" from @@ -104,57 +105,40 @@ async function boot() { return { put, storedRows }; } -const TIMELINE = { startDateField: 'created_at', titleField: 'name', metaFields: ['region'] }; -const flatList = (extra: Record) => ({ - name: 'crm_lead.timeline', - object: 'crm_lead', - viewKind: 'list', - label: 'Timeline', - type: 'timeline', - columns: ['name'], - ...extra, -}); - type Issue = { path?: string; code?: string; message?: string }; const issuesOf = (res: any): Issue[] => (res._json?.issues ?? []) as Issue[]; -describe('#20051 PUT /api/v1/meta/view judges a flat list overlay\'s `options` bag', () => { - it('a direct and an `options`-wrapped out-of-contract key get the same 422, and nothing is stored', async () => { +const MAP_VIEW = { + name: 'showcase_task.work_map', + object: 'showcase_task', + viewKind: 'list', + label: 'Work Map', + type: 'map', + columns: ['title', 'location'], +}; +const MAP = { locationField: 'location', titleField: 'title' }; + +describe('#25923 PUT /api/v1/meta/view refuses a flat list overlay\'s `options` bag', () => { + it('a legal legacy `options.map` bag answers 422 at `options`, naming the move, and nothing is stored', async () => { const { put, storedRows } = await boot(); - - const direct = await put('crm_lead.timeline', flatList({ timeline: TIMELINE })); - const wrapped = await put('crm_lead.timeline', flatList({ options: { timeline: TIMELINE } })); - - for (const res of [direct, wrapped]) { - expect(res._status, JSON.stringify(res._json)).toBe(422); - expect(res._json?.code).toBe('INVALID_METADATA'); - } - expect(await storedRows('crm_lead.timeline')).toEqual([]); - - const directHit = issuesOf(direct).find((i) => i.path === 'timeline' && i.code === 'unrecognized_keys'); - const wrappedHit = issuesOf(wrapped).find((i) => i.path === 'options.timeline' && i.code === 'unrecognized_keys'); - expect(directHit, JSON.stringify(direct._json)).toBeDefined(); - expect(wrappedHit, JSON.stringify(wrapped._json)).toBeDefined(); - expect(wrappedHit!.message).toBe(directHit!.message); - expect(wrappedHit!.message).toContain('`metaFields`'); + const res = await put('showcase_task.work_map', { ...MAP_VIEW, options: { map: MAP } }); + expect(res._status, JSON.stringify(res._json)).toBe(422); + expect(res._json?.code).toBe('INVALID_METADATA'); + expect(await storedRows('showcase_task.work_map')).toEqual([]); + const hit = issuesOf(res).find((i) => i.path === 'options'); + expect(hit, JSON.stringify(res._json)).toBeDefined(); + expect(hit!.message).toMatch(/^A list view's `options` bag was removed in @objectstack\/spec 18\.3\.0/); + expect(hit!.message).toContain('Move each `options.KIND` block to `KIND`'); }); - it('a legal legacy `options.map` bag answers 200 and the stored row carries it unchanged', async () => { + it('CONTROL: the same block written top-level answers 200, and the stored row carries it', async () => { const { put, storedRows } = await boot(); - const options = { map: { locationField: 'location', titleField: 'legacy_title' } }; - const res = await put('showcase_task.work_map', { - name: 'showcase_task.work_map', - object: 'showcase_task', - viewKind: 'list', - label: 'Work Map', - type: 'map', - columns: ['title', 'location'], - options, - }); + const res = await put('showcase_task.work_map', { ...MAP_VIEW, map: MAP }); expect(res._status, JSON.stringify(res._json)).toBe(200); const rows = await storedRows('showcase_task.work_map'); expect(rows).toHaveLength(1); const stored = typeof rows[0].metadata === 'string' ? JSON.parse(rows[0].metadata) : rows[0].metadata; - expect(stored.options).toEqual(options); + expect(stored.map).toEqual(MAP); + expect('options' in stored).toBe(false); }); }); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 831ca79e7f8..e69fc8d6a5e 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -12329,6 +12329,195 @@ const viewOverlayOwnerHiddenRemoved: MetadataConversion = { }, }; +/** + * The per-kind blocks the legacy list-overlay `options` bag could carry, as + * the bag declared them when it closed (#25923): every list-view `type` that + * also names a block on the list-view shape (`grid` names none). Spelled out + * rather than derived, because this conversion replays a shape that is gone: + * a kind added later never had a bag spelling to fold. + */ +const LIST_VIEW_OPTIONS_BAG_KINDS = ['kanban', 'calendar', 'gantt', 'timeline', 'map', 'gallery', 'chart', 'tree'] as const; + +/** + * The flattened list overlay's legacy `options` bag folds onto the top-level + * per-kind blocks (protocol 18, #25923 — Prime Directive #12, one key per + * concept; this supersedes ruling direction A on objectui#10380). + * + * Until this release the list overlay door accepted `options.KIND` beside the + * top-level `KIND` block, judged key by key by the kind's own schema with + * every key optional, and stored it. The bag was declared an UNDERLAY: where + * both set a key, the top-level block wins. objectui's `ListView` read it that + * way for kanban, calendar, gallery, timeline, gantt and map + * (`{ ...options.KIND, ...KIND }`), so this fold is that same merge, written + * once into the row: + * + * - `options.KIND` with no top-level `KIND` ⇒ it becomes `KIND`. + * - Both present ⇒ `KIND = { ...options.KIND, ...KIND }`, the top-level value + * kept where both set a key. + * - `chart` is the one kind the renderer did NOT merge: it read + * `chart || options.chart`, the block replacing the bag WHOLE. So a bag + * beside a top-level `chart` block is dropped (it never drew), and a bag + * with no block becomes the block. Merging it per key would draw keys the + * author never saw. + * - A bag left empty is removed. + * + * **Retired from the load path** — the door refuses `options` by name with + * the prescription (`LIST_OVERLAY_OPTIONS_REFUSED` in `ui/view.zod.ts`), so a + * live author is taught the top-level spelling. The entry exists because a + * STORED list overlay can carry the bag, and every read of a stored `view` row + * replays the chain through `applyConversionsToStoredItem` as + * `{ views: [row] }`; `os migrate meta` lists the edit for sources. + * + * **Left as stored, with a TODO** (no lossless rewrite): a bag that is not an + * object; an `options.grid` (a grid has no block, and the door refused it + * already); a bag key that is not a kind; an `options.KIND` that is not an + * object, or whose top-level `KIND` is not one; and any bag on a FORM overlay + * (`viewKind: 'form'`), whose blocks have no home on a form. The row keeps its + * bag and is badged invalid until its owner decides. The fold also cannot + * supply a key the block REQUIRES: the bag was judged with every key + * optional, so a bag block can fold into a block still missing one (a + * `gantt` with no `titleField`, say). That row is served badged invalid, + * and the D3 entry `ui-list-view-options-bag-folded` names the remedy. + * + * ⚠️ Scoped to the FLATTENED spelling, as {@link viewOverlayOwnerHiddenRemoved} + * is: a body with no `config` and no container slot. A view item record + * (`config` present) has refused the bag since #20051 with its own + * prescription, and the two authoring shapes never declared it. + */ +const listViewOptionsBagFolded: MetadataConversion = { + id: 'list-view-options-bag-folded', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '18.2.0', + surface: 'view.options. — on a flattened list view overlay ({ name, object, viewKind, …, no config })', + summary: + "flattened list overlay 'options.KIND' folded onto the top-level 'KIND' block (the top-level " + + "value wins per key; for 'chart' the block wins whole), and the bag removed: the per-kind " + + 'blocks are top-level keys of the view, their one spelling', + apply(stack, emit, context) { + const fold = (view: Dict, path: string): Dict => { + if (!('options' in view)) return view; + if (view.config !== undefined) return view; + if (view.list !== undefined || view.form !== undefined) return view; + if (view.listViews !== undefined || view.formViews !== undefined) return view; + const bag = view.options; + if (view.viewKind === 'form' || !isDict(bag)) { + context?.reportTodo?.({ + path: `${path}.options`, + from: JSON.stringify(bag), + reason: view.viewKind === 'form' + ? 'a form view overlay carries an `options` bag; its per-kind blocks belong to a list view and have no ' + + 'home on a form. Remove `options`, or save the body as a list view.' + : 'the `options` bag is not an object, so there is no per-kind block to fold. Remove `options`.', + }); + return view; + } + const next: Dict = { ...view }; + const rest: Dict = { ...bag }; + let changed = false; + for (const kind of LIST_VIEW_OPTIONS_BAG_KINDS) { + if (!(kind in rest)) continue; + const under = rest[kind]; + const top = next[kind]; + if (!isDict(under) || (top !== undefined && !isDict(top))) continue; + delete rest[kind]; + changed = true; + if (top === undefined) { + next[kind] = under; + emit({ from: `options.${kind}`, to: kind, path: `${path}.options.${kind}` }); + } else if (kind === 'chart') { + emit({ from: 'options.chart', to: '(removed: the top-level chart block replaces it whole)', path: `${path}.options.chart` }); + } else { + next[kind] = { ...under, ...top }; + emit({ from: `options.${kind}`, to: `${kind} (top-level keys kept)`, path: `${path}.options.${kind}` }); + } + } + if (Object.keys(rest).length === 0) { + delete next.options; + if (!changed) emit({ from: 'options', to: '(removed: empty)', path: `${path}.options` }); + return next; + } + context?.reportTodo?.({ + path: `${path}.options`, + from: JSON.stringify(rest), + reason: `the bag keeps ${Object.keys(rest).map((k) => `\`${k}\``).join(', ')}, which has no top-level block ` + + 'to fold onto (a grid\'s settings are top-level keys of the view; a non-object block or one beside a ' + + 'non-object top-level block cannot be merged). Move what you meant to the view\'s own keys, then remove `options`.', + }); + if (!changed) return view; + next.options = rest; + return next; + }; + return mapCollection( + mapCollection(stack, 'views', fold), + ASSEMBLED_VIEW_ITEMS_KEY, + fold, + ); + }, + fixture: { + before: { + // The assembled-manifest channel: an overlay a package export carried. + viewItems: [ + { name: 'crm_deal.map', object: 'crm_deal', viewKind: 'list', type: 'map', options: { map: { latitudeField: 'lat', longitudeField: 'lng' } } }, + ], + views: [ + // Per key: the top-level `kanban` keeps `groupByField`, the bag adds + // `titleField`; the bag's `groupByField` is the one that never drew. + { + name: 'crm_deal.board', + object: 'crm_deal', + viewKind: 'list', + type: 'kanban', + kanban: { groupByField: 'stage' }, + options: { kanban: { groupByField: 'owner', titleField: 'name' }, calendar: { startDateField: 'close_date' } }, + }, + // `chart` is replaced whole by its block, so the bag is dropped. + { + name: 'crm_deal.chart', + object: 'crm_deal', + viewKind: 'list', + type: 'chart', + chart: { dataset: 'deals', values: ['amount'] }, + options: { chart: { dataset: 'old_deals', values: ['amount'], dimensions: ['stage'] } }, + }, + // An empty bag is removed. + { name: 'crm_deal.empty', object: 'crm_deal', viewKind: 'list', options: {} }, + // A record and a container are not flattened overlays: untouched. + { name: 'crm_deal.rec', object: 'crm_deal', viewKind: 'list', config: { columns: ['name'] } }, + { object: 'crm_deal', list: { type: 'grid', columns: ['name'] } }, + ], + }, + after: { + viewItems: [ + { name: 'crm_deal.map', object: 'crm_deal', viewKind: 'list', type: 'map', map: { latitudeField: 'lat', longitudeField: 'lng' } }, + ], + views: [ + { + name: 'crm_deal.board', + object: 'crm_deal', + viewKind: 'list', + type: 'kanban', + kanban: { groupByField: 'stage', titleField: 'name' }, + calendar: { startDateField: 'close_date' }, + }, + { + name: 'crm_deal.chart', + object: 'crm_deal', + viewKind: 'list', + type: 'chart', + chart: { dataset: 'deals', values: ['amount'] }, + }, + { name: 'crm_deal.empty', object: 'crm_deal', viewKind: 'list' }, + { name: 'crm_deal.rec', object: 'crm_deal', viewKind: 'list', config: { columns: ['name'] } }, + { object: 'crm_deal', list: { type: 'grid', columns: ['name'] } }, + ], + }, + // One notice per folded block (map, kanban, calendar, chart) plus the + // empty bag's removal. + expectedNotices: 5, + }, +}; + /** * The list view's `filterableFields` leaves the authorable surface (protocol * 18, #24640 — ADR-0049 enforce-or-remove; maintainer ruling C, record @@ -14315,6 +14504,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: formViewSubformColumnsCanonicalized, order: 51 }, { conversion: hookTimeoutToTimeoutMs, order: 21 }, { conversion: jobTimeoutToTimeoutMs, order: 22 }, + { conversion: listViewOptionsBagFolded, order: 78 }, { conversion: listViewSortStringClauseToArray, order: 30 }, { conversion: manifestPermissionsStringListRemoved, order: 61 }, { conversion: mappingLookupParamsRemoved, order: 11 }, diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-list-view-options-bag-folded.ts b/packages/spec/src/migrations/entries/semantic/18.ui-list-view-options-bag-folded.ts new file mode 100644 index 00000000000..96e1457c28f --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-list-view-options-bag-folded.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #25923 (seat ruling route A, superseding direction A on objectui#10380) — the +// D3 entry of the `list-view-options-bag-folded` family. The flattened list +// overlay's legacy `options` bag closes: the door refuses it by name, and the +// D2 conversion folds a stored or authored bag onto the top-level per-kind +// blocks. This entry carries what the fold cannot decide. +export const entry: SemanticMigration = { + id: 'ui-list-view-options-bag-folded', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code span. + surface: + 'view.options — the legacy per-kind bag (options.kanban, options.calendar, options.gantt, ' + + 'options.timeline, options.map, options.gallery, options.chart, options.tree) on a flattened list ' + + 'view overlay, the PUT /api/v1/meta/view body; the view item record and the authoring shapes ' + + 'already refused it', + replacement: + 'the top-level per-kind block of the same name (`options.kanban` becomes `kanban`, and so on), ' + + 'keeping the top-level value where both set a key; a grid\'s settings are top-level keys of the view', + reason: + 'One key per concept (Prime Directive #12). The bag was a second spelling of every per-kind block, ' + + 'declared an underlay the top-level block wins over per key, and the renderers each re-implemented ' + + 'that merge. Ruling direction A on objectui#10380 kept it only for objectui\'s pinned options.map ' + + 'read, which #25923 retires with every other reader. The D2 conversion ' + + '`list-view-options-bag-folded` folds each stored or source bag onto the top-level blocks, which ' + + 'is what those renderers drew (for `chart`, whose block replaced the bag whole, a bag beside a ' + + 'block is dropped). What it cannot decide: the bag was judged with every key optional, so a bag ' + + 'block can fold into a block still missing a key the block requires (a `gantt` with no ' + + '`titleField`, a `kanban` with no `columns`); and a bag it cannot fold (an `options.grid`, a ' + + 'non-object block, any bag on a form view) is left in place with a TODO', + acceptanceCriteria: + 'No list view body carries a top-level `options`; the view write door refuses one by name with the ' + + 'move-to-the-top-level prescription, and TypeScript rejects it on the overlay member. Every view the ' + + 'conversion changed parses: each folded block carries its required keys, added by the author where ' + + 'the bag never had them. A row the conversion left with a TODO has had its bag removed by its owner. ' + + 'A list view renders each kind from its top-level block alone.', + conversionIds: ['list-view-options-bag-folded'], +}; diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index 939018d9cfb..f52cabead91 100644 --- a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -91,7 +91,7 @@ import { SharingLevel } from '../security/sharing.zod'; import { DatasourceSchema } from '../data/datasource.zod'; import { TursoConfigSchema } from '../data/driver/turso.zod'; import { InterfacePageConfigSchema, PageComponentSchema, PageSchema } from '../ui/page.zod'; -import { AddRecordConfigSchema, FormFieldSchema, FormSectionSchema, ListViewSchema, NavigationModeSchema, ObjectListViewSchema } from '../ui/view.zod'; +import { AddRecordConfigSchema, FormFieldSchema, FormSectionSchema, ListViewSchema, NavigationModeSchema, ObjectListViewSchema, VIEW_METADATA_MEMBERS } from '../ui/view.zod'; import { FlowSchema } from '../automation/flow.zod'; import { JobSchema } from '../system/job.zod'; import { TranslationItemSchema } from '../system/translation.zod'; @@ -376,6 +376,7 @@ const JOIN: Readonly> = { ], // #26677: value-level retirements — each retired member is refused by the // enum's own error map with its prescription, so the refusal is the parse. + 'list-view-options-bag-folded': [tombstone(VIEW_METADATA_MEMBERS.listOverlay, 'view (list overlay)', 'options')], 'navigation-mode-overlay-values-removed': [ ...(['modal', 'split', 'popover'] as const).map((mode) => refusal(`navigation.mode '${mode}'`, () => NavigationModeSchema.safeParse(mode)), diff --git a/packages/spec/src/ui/component-list-view-row.test.ts b/packages/spec/src/ui/component-list-view-row.test.ts index bc72d3ac917..d071befdc73 100644 --- a/packages/spec/src/ui/component-list-view-row.test.ts +++ b/packages/spec/src/ui/component-list-view-row.test.ts @@ -13,7 +13,8 @@ import { LIST_VIEW_RETIRED_ALIASES } from './list-view-retired-aliases'; * a page is judged as a list view. * * Pins the ruling's own list: a page `list-view` carrying `kanban.groupField` - * or `options.grid` is flagged naming the key; one with contract keys only + * is flagged naming the key, and an `options` bag is flagged whole (#25923); + * one with contract keys only * passes; the old spellings `objectName` / `viewType` are flagged with the * react tier's prescription. Plus what the row is built from — the list view's * members and checks by reference, and the list overlay member's two @@ -37,23 +38,22 @@ describe("ComponentPropsMap['list-view'] (#22444)", () => { expect(issues).toEqual([{ code: 'unrecognized_keys', path: 'kanban', keys: ['groupField'] }]); }); - it('`options.grid` is flagged, naming the key on the `options` bag', () => { - expect(issuesOf({ options: { grid: { pageSize: 10 } } })).toEqual([ - { code: 'unrecognized_keys', path: 'options', keys: ['grid'] }, - ]); - }); - - it('`options.KIND` is judged by the kind\'s own block: an unknown key under it is flagged by name', () => { - expect(issuesOf({ options: { kanban: { groupField: 'owner' } } })).toEqual([ - { code: 'unrecognized_keys', path: 'options.kanban', keys: ['groupField'] }, - ]); + // [#25923] The bag is retired on the list overlay, whose member this row + // shares: any `options` is flagged at `options`, with the tombstone's text. + it.each([ + ['`options.grid`', { grid: { pageSize: 10 } }], + ['an `options.KIND` block', { kanban: { groupField: 'owner' } }], + ['an empty bag', {}], + ])('%s is flagged whole at `options`, with the move-to-the-top-level prescription', (_label, options) => { + expect(issuesOf({ options })).toEqual([{ code: 'invalid_type', path: 'options', keys: undefined }]); + const r = row.safeParse({ options }); + expect(r.success ? '' : r.error.issues[0]!.message).toMatch(/^A list view's `options` bag was removed in @objectstack\/spec 18\.3\.0/); }); it.each([ ['an object-bound kanban', { data: { provider: 'object', object: 'deal' }, type: 'kanban', columns: ['name'], kanban: { groupByField: 'stage', columns: ['name'] } }], ['a column-less node (bound by a saved view, or drawing the object\'s fields)', { data: { provider: 'object', object: 'deal' } }], ['an empty bag (the node binds through `dataSource`)', {}], - ['the legacy per-kind underlay, partial', { type: 'kanban', options: { kanban: { titleField: 'name' } } }], ['the shipped page node shape in the spec spelling', { data: { provider: 'object', object: 'crm_lead' }, type: 'grid', @@ -90,7 +90,7 @@ describe("ComponentPropsMap['list-view'] (#22444)", () => { } }); - it('the two overlay-member differences: `columns` optional, `options` the overlay member\'s own judged bag', () => { + it('the two overlay-member differences: `columns` optional, `options` the overlay member\'s own tombstone', () => { expect((shape.columns as { unwrap: () => unknown }).unwrap()).toBe(ListViewSchema.shape.columns); expect(shape.options).toBe(VIEW_METADATA_MEMBERS.listOverlay.shape.options); // And nothing else: the row's keys are the list view's plus `options`. diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 5b5f9111547..797e22998a0 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -8240,11 +8240,12 @@ const LIST_VIEW_PAGE_ALIAS_GUIDANCE: Readonly - "This list view is `type: 'chart'` and its only chart binding is the legacy `options.chart` bag, " - + `which names no ${missing}, so there is nothing to plot.`; /** Every issue, nested union arms included — a shape failure on the overlay is wrapped one level down. */ const flatten = (issues: readonly z.core.$ZodIssue[]): z.core.$ZodIssue[] => @@ -105,59 +102,25 @@ describe.each(doors)("%s — a `type: 'chart'` list view binds a dataset", (_doo }); }); -describe("the overlay's legacy `options.chart` bag — the binding when no `chart` block replaces it", () => { - it('REFUSES a bag holding only `chartType`, at `options.chart.dataset` and `options.chart.values`', () => { - const r = overlay({ type: 'chart', columns: ['stage'], options: { chart: { chartType: 'bar' } } }); +describe("the overlay's legacy `options.chart` bag is no binding", () => { + // [#25923] The bag is refused whole at `options`, complete or not, so a + // chart view is bound by its `chart` block alone. + it.each([ + ['a bag holding the whole binding', { type: 'chart', columns: ['stage'], options: { chart: BINDING } }], + ['an incomplete bag under a complete `chart` block', { type: 'chart', columns: ['stage'], chart: BINDING, options: { chart: { chartType: 'line' } } }], + ['a bag on a patch that names no `type`', { options: { chart: { chartType: 'line' } } }], + ])('REFUSES %s, at `options`', (_label, body) => { + const r = overlay(body); expect(r.success).toBe(false); - const dataset = at(r, 'options.chart.dataset'); - const values = at(r, 'options.chart.values'); - expect(dataset, JSON.stringify(issuesOf(r))).toHaveLength(1); - expect(values, JSON.stringify(issuesOf(r))).toHaveLength(1); - expect(dataset[0]!.code).toBe('custom'); - expect(values[0]!.code).toBe('custom'); - expect(firstSentence(dataset[0]!.message)).toBe(bagVerdict('`dataset`')); - expect(firstSentence(values[0]!.message)).toBe(bagVerdict('measure in `values`')); - expect(dataset[0]!.message).toContain("`chart: { dataset: '', values: [''] }`"); - }); - - it('REFUSES a bag naming a dataset but no measure, at `options.chart.values` only', () => { - const r = overlay({ type: 'chart', columns: ['stage'], options: { chart: { dataset: 'lead_metrics' } } }); - expect(r.success).toBe(false); - expect(at(r, 'options.chart.values'), JSON.stringify(issuesOf(r))).toHaveLength(1); + const hits = at(r, 'options'); + expect(hits, JSON.stringify(issuesOf(r))).not.toHaveLength(0); + expect(hits[0]!.message).toMatch(/^A list view's `options` bag was removed in @objectstack\/spec 18\.3\.0/); expect(at(r, 'options.chart.dataset')).toEqual([]); + expect(at(r, 'options.chart.values')).toEqual([]); }); - it('ACCEPTS a bag that carries the whole binding, and keeps it', () => { - const r = overlay({ type: 'chart', columns: ['stage'], options: { chart: BINDING } }); - expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); - expect((r as { data: { options?: unknown } }).data.options).toEqual({ chart: BINDING }); - }); - - it('ACCEPTS an incomplete bag under a complete `chart` block — the block replaces the bag whole', () => { - const r = overlay({ type: 'chart', columns: ['stage'], chart: BINDING, options: { chart: { chartType: 'line' } } }); + it('CONTROL: the same binding as the `chart` block parses', () => { + const r = overlay({ type: 'chart', columns: ['stage'], chart: BINDING }); expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); }); - - // A body that names no `type` is a PATCH on the view it shadows (the console's - // toolbar save), whose own binding decides — the overlay member reads `type` - // on the input side for exactly this line. - it('does not judge a patch that names no `type`', () => { - const r = overlay({ options: { chart: { chartType: 'line' } } }); - expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); - }); - - // The check hand-lists the binding keys for its messages. Derive the block's - // REQUIRED keys from the schema itself, so a key the block starts requiring - // that the check does not ask of the bag goes red here. - it("asks of the bag exactly what `ListChartConfigSchema` requires of the block", () => { - const shape = (ListChartConfigSchema as unknown as { shape: Record }).shape; - const required = Object.keys(shape).filter((key) => !shape[key]!.safeParse(undefined).success); - expect(required.sort()).toEqual(['dataset', 'values']); - for (const key of required) { - const bag: Record = { ...BINDING }; - delete bag[key]; - const r = overlay({ type: 'chart', columns: ['stage'], options: { chart: bag } }); - expect(at(r, `options.chart.${key}`), `${key}: ${JSON.stringify(issuesOf(r))}`).toHaveLength(1); - } - }); }); diff --git a/packages/spec/src/ui/view-overlay-options-bag.test.ts b/packages/spec/src/ui/view-overlay-options-bag.test.ts index 7f6d5549bbb..b4a03d48d31 100644 --- a/packages/spec/src/ui/view-overlay-options-bag.test.ts +++ b/packages/spec/src/ui/view-overlay-options-bag.test.ts @@ -1,45 +1,37 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#20051] A flattened list overlay's legacy `options` bag is JUDGED at the - * view write door, by the same per-kind schemas the direct spelling is judged - * by — the door half of ruling A on objectui#10380. + * [#25923] A flattened list overlay carries no `options` bag: the view write + * door refuses it by name, and a STORED bag is folded onto the top-level + * per-kind blocks by the ADR-0087 D2 conversion `list-view-options-bag-folded` + * before the row is served. * - * ## What was measured before the change (`origin/main` @ `8d1f7ab`) + * ## What changed (supersedes direction A on objectui#10380) * - * The list overlay member is `ListViewShapeSchema.extend(…).strip()`, and - * `.strip()` dropped a top-level `options` from the parse without looking - * inside it: - * - * - `timeline.metaFields` on a flat overlay ⇒ refused, `unrecognized_keys` - * at `timeline`; - * - the same key as `options.timeline.metaFields` ⇒ ACCEPTED, `options` absent - * from the output — and `saveMetaItem` stored the request body with the bag - * in it (measured through the real save; the save-door pins live in - * `metadata-protocol`'s `protocol.graft-folded-form-sections.test.ts`, whose - * engine double they ride, and the `PUT /api/v1/meta/view` pins in `rest`'s - * `meta-view-overlay-options-bag.test.ts`). + * From #20051 until this card the list overlay member DECLARED the bag and + * judged each `options.KIND` key by key with the kind's own schema (every key + * optional), as an underlay the top-level block wins over per key. That ruling + * refused only to close the bag whole, for objectui's pinned `options.map` + * read; #25923 retires every reader of the bag, so the door closes it. * * ## What this file pins * - * 1. An out-of-contract key gets the SAME refusal in both spellings — same - * code, same keys, same message — and only the path gains the `options` - * prefix; a value a key's own schema refuses is refused at that key too. - * Asserted for every kind the bag declares, so no kind is judged by - * anything but its own block. The underlay is judged KEY BY KEY: a partial - * bag (the per-key merge population objectui pins) parses. - * 2. The bag is closed: `options.foo` is refused by name, not dropped. - * 3. A legal `options.KIND` still parses and round-trips — including the - * legacy `options.map` bag objectui pins - * (`InterfaceListPage.mapConfig.test.tsx`, "CONTROL: the legacy - * `options.map` bag is still forwarded on its own path"). - * 4. The form overlay cannot accept the bag the list overlay refused (the - * column-less fall-through), and says why. + * 1. The list overlay refuses `options` by name, whatever it holds (a legal + * `options.map`, an empty bag, a non-object), at `options`, with the + * move-to-the-top-level prescription; the union refuses it too. + * 2. The authoring funnel does not replay the fold (the entry is retired from + * the load path): an author meets the refusal and is taught. + * 3. A stored row replays it: `options.KIND` moves to `KIND`, the top-level + * value wins per key, `chart`'s block wins whole, and the folded row parses. + * 4. What the fold cannot decide stays as stored, with a TODO. + * 5. The form overlay and the view item record keep their own refusals. */ import { describe, it, expect } from 'vitest'; -import { z } from 'zod'; import { ViewMetadataSchema, VIEW_METADATA_MEMBERS, diagnoseViewMetadata } from './view.zod'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import { applyConversions } from '../conversions/apply'; +import type { ConversionNotice, ConversionTodoNotice } from '../conversions/types'; /** A bound flat list overlay — the shape a `PUT /api/v1/meta/view` carries. */ const overlay = (extra: Record = {}) => ({ @@ -50,158 +42,152 @@ const overlay = (extra: Record = {}) => ({ ...extra, }); -/** The kinds the bag declares, read off the published member's JSON Schema. */ -function declaredBagKinds(): string[] { - const json = z.toJSONSchema(VIEW_METADATA_MEMBERS.listOverlay as unknown as z.ZodTypeAny, { io: 'input', unrepresentable: 'any' }) as { - properties?: Record }>; - }; - return Object.keys(json.properties?.options?.properties ?? {}).sort(); -} - type Issue = { code: string; path: PropertyKey[]; message: string; keys?: string[] }; -/** The list overlay's own issues for a body (the branch the body claims). */ -function listOverlayIssues(body: unknown): Issue[] { +/** The list overlay's own issue at `options` for a body (the branch the body claims). */ +function listOverlayOptionsIssue(body: unknown): Issue { const d = diagnoseViewMetadata(body); expect(d.success, `expected a refusal for ${JSON.stringify(body)}`).toBe(false); - if (d.success) return []; + if (d.success) throw new Error('unreachable'); expect(d.branch).toBe('listOverlay'); - return d.issues as unknown as Issue[]; + const hit = (d.issues as unknown as Issue[]).find((i) => i.path.length === 1 && i.path[0] === 'options'); + expect(hit, JSON.stringify(d.issues)).toBeDefined(); + return hit!; } -/** Issues under `prefix`, with the prefix removed — for comparing two spellings. */ -function under(issues: Issue[], prefix: PropertyKey[]): Array & { path: string }> { - return issues - .filter((i) => prefix.every((p, n) => i.path[n] === p)) - .map((i) => ({ code: i.code, message: i.message, keys: i.keys, path: i.path.slice(prefix.length).join('.') })) - .sort((a, b) => `${a.path}|${a.code}`.localeCompare(`${b.path}|${b.code}`)); +/** Replay the chain over one stored `view` row, as every stored-row read does. */ +function storedRead(row: Record) { + const notices: ConversionNotice[] = []; + const todos: ConversionTodoNotice[] = []; + const out = applyConversionsToStoredItem('view', row, { + onNotice: (n) => notices.push(n), + onTodo: (t) => todos.push(t), + }) as Record; + const mine = (n: { conversionId: string }) => n.conversionId === 'list-view-options-bag-folded'; + return { out, notices: notices.filter(mine), todos: todos.filter(mine) }; } -describe('the bag declares exactly the list kinds, derived from the shape', () => { - it('declares one block per list-view kind that has a block — `grid` has none', () => { - // A pin on the DERIVATION (`listViewKindBlocks`): the list is not - // hand-maintained in the schema, so it is stated once here, where a new - // kind shows up as a deliberate one-line edit. - expect(declaredBagKinds()).toEqual( - ['calendar', 'chart', 'gallery', 'gantt', 'kanban', 'map', 'timeline', 'tree'], - ); +describe('the list overlay refuses the `options` bag by name', () => { + it.each([ + ['a legal legacy `options.map`, the read objectui pinned', { map: { locationField: 'location', titleField: 'legacy_title' } }], + ['an `options.kanban` / `options.calendar` pair', { kanban: { groupByField: 'status', columns: ['name'] }, calendar: { startDateField: 'created_at' } }], + ['an empty bag', {}], + ['`options.grid`', { grid: {} }], + ['a non-object bag', 'map'], + ])('%s is refused at `options` with the prescription', (_label, options) => { + const hit = listOverlayOptionsIssue(overlay({ type: 'map', options })); + expect(hit.message).toMatch(/^A list view's `options` bag was removed in @objectstack\/spec 18\.3\.0/); + expect(hit.message).toContain('Move each `options.KIND` block to `KIND`'); + expect(ViewMetadataSchema.safeParse(overlay({ type: 'map', options })).success).toBe(false); + expect(VIEW_METADATA_MEMBERS.listOverlay.safeParse(overlay({ type: 'map', options })).success).toBe(false); }); -}); -describe('a direct and an `options`-wrapped out-of-contract key get the same refusal', () => { - it('the card\'s headline: `options.timeline.metaFields` is refused by name, as `timeline.metaFields` is', () => { - const timeline = { startDateField: 'created_at', titleField: 'name', metaFields: ['region'] }; - const direct = listOverlayIssues(overlay({ type: 'timeline', timeline })); - const wrapped = listOverlayIssues(overlay({ type: 'timeline', options: { timeline } })); + it('CONTROL: the same keys written top-level parse', () => { + const parsed = ViewMetadataSchema.safeParse(overlay({ type: 'map', map: { locationField: 'location', titleField: 'legacy_title' } })); + expect(parsed.success, JSON.stringify(!parsed.success && parsed.error.issues)).toBe(true); + }); - const directHit = direct.find((i) => i.code === 'unrecognized_keys'); - const wrappedHit = wrapped.find((i) => i.code === 'unrecognized_keys'); - expect(directHit?.path).toEqual(['timeline']); - expect(wrappedHit?.path).toEqual(['options', 'timeline']); - expect(wrappedHit?.keys).toEqual(['metaFields']); - expect(wrappedHit?.keys).toEqual(directHit?.keys); - // The refusal names the key the author wrote, on the same surface text. - expect(wrappedHit?.message).toBe(directHit?.message); - expect(wrappedHit?.message).toContain('`metaFields`'); + it('CONTROL: an overlay with no `options` parses, with no `options` key', () => { + const parsed = ViewMetadataSchema.safeParse(overlay({ type: 'grid' })); + expect(parsed.success).toBe(true); + expect('options' in (parsed as { data: Record }).data).toBe(false); }); +}); - it.each(['calendar', 'chart', 'gallery', 'gantt', 'kanban', 'map', 'timeline', 'tree'])( - '`options.%s` refuses an out-of-contract key with its own block\'s refusal — same code, keys and message', - (kind) => { - const block = { zz_not_a_key: 1 }; - const unknownKey = (issues: ReturnType) => issues.filter((i) => i.code === 'unrecognized_keys'); - const direct = unknownKey(under(listOverlayIssues(overlay({ [kind]: block })), [kind])); - const wrapped = under(listOverlayIssues(overlay({ options: { [kind]: block } })), ['options', kind]); - expect(direct).toHaveLength(1); - // The ONLY complaint about the wrapped block is the unknown key — the - // underlay is judged key by key, so the block's required keys are not - // asked of it (see the per-key case below). - expect(wrapped).toEqual(direct); - expect(wrapped[0]!.keys).toEqual(['zz_not_a_key']); - }, - ); +describe('the authoring funnel does not fold the bag — the author is taught', () => { + it('`applyConversions` without retired entries leaves the bag, so the parse refuses it', () => { + const stack = { views: [overlay({ type: 'kanban', options: { kanban: { groupByField: 'status', columns: ['name'] } } })] }; + const notices: ConversionNotice[] = []; + const out = applyConversions(structuredClone(stack), { onNotice: (n) => notices.push(n) }); + expect(out).toEqual(stack); + expect(notices.filter((n) => n.conversionId === 'list-view-options-bag-folded')).toHaveLength(0); + }); +}); - it('a value the key\'s own schema refuses is refused at the same key under `options`', () => { - // `map.zoom` is `min(1).max(20)`: the key-level schema travels with the key. - const direct = under(listOverlayIssues(overlay({ map: { zoom: 99 } })), ['map']); - const wrapped = under(listOverlayIssues(overlay({ options: { map: { zoom: 99 } } })), ['options', 'map']); - expect(wrapped).toEqual(direct); - expect(wrapped.map((i) => i.path)).toEqual(['zoom']); +describe('a stored row replays the fold', () => { + it('`options.KIND` with no top-level block becomes the block, and the row parses', () => { + const row = overlay({ type: 'kanban', options: { kanban: { groupByField: 'status', columns: ['name'] } } }); + const { out, notices, todos } = storedRead(row); + expect(out).toEqual(overlay({ type: 'kanban', kanban: { groupByField: 'status', columns: ['name'] } })); + expect(notices.map((n) => n.path)).toEqual(['views[0].options.kanban']); + expect(todos).toEqual([]); + expect(ViewMetadataSchema.safeParse(out).success).toBe(true); }); - it('the union refuses both spellings — the verdict, not only the diagnosis', () => { - const timeline = { startDateField: 'created_at', titleField: 'name', metaFields: ['region'] }; - expect(ViewMetadataSchema.safeParse(overlay({ type: 'timeline', timeline })).success).toBe(false); - expect(ViewMetadataSchema.safeParse(overlay({ type: 'timeline', options: { timeline } })).success).toBe(false); + it('both present: the top-level value wins per key, and a key only the bag set is kept', () => { + // objectui's per-key population (`ObjectView.namedViewProtocolKeys-8980`): + // the top-level block restates `groupByField`, the bag adds `titleField`. + const row = overlay({ + type: 'kanban', + kanban: { groupByField: 'status', columns: ['name'] }, + options: { kanban: { groupByField: 'owner', titleField: 'subject' } }, + }); + const { out } = storedRead(row); + expect(out.kanban).toEqual({ groupByField: 'status', columns: ['name'], titleField: 'subject' }); + expect('options' in out).toBe(false); + expect(ViewMetadataSchema.safeParse(out).success).toBe(true); }); -}); -describe('the bag itself is closed', () => { - it('`options.foo` is refused by name at `options`, not dropped', () => { - const issues = listOverlayIssues(overlay({ options: { foo: 1 } })); - const hit = issues.find((i) => i.code === 'unrecognized_keys'); - expect(hit?.path).toEqual(['options']); - expect(hit?.keys).toEqual(['foo']); - expect(hit?.message).toContain('legacy `options` bag'); + it('`chart` is the exception: its block replaced the bag WHOLE, so a bag beside a block is dropped', () => { + const row = overlay({ + type: 'chart', + chart: { dataset: 'deals', values: ['amount'] }, + options: { chart: { dataset: 'old', values: ['amount'], dimensions: ['stage'] } }, + }); + const { out, notices } = storedRead(row); + expect(out.chart).toEqual({ dataset: 'deals', values: ['amount'] }); + expect('options' in out).toBe(false); + expect(notices).toHaveLength(1); + expect(notices[0]!.to).toContain('replaces it whole'); }); - it('`options.grid` is answered with the prescription, not a typo guess', () => { - const hit = listOverlayIssues(overlay({ options: { grid: {} } })).find((i) => i.code === 'unrecognized_keys'); - expect(hit?.keys).toEqual(['grid']); - expect(hit?.message).toContain('top-level keys of the view itself'); + it('a bag block can fold into a block still missing a required key — the row is served badged invalid', () => { + // The bag was judged with every key optional; the D3 entry names the remedy. + const { out } = storedRead(overlay({ type: 'gantt', options: { gantt: { startDateField: 's', endDateField: 'e' } } })); + expect(out.gantt).toEqual({ startDateField: 's', endDateField: 'e' }); + const d = diagnoseViewMetadata(out); + expect(d.success).toBe(false); }); - it('a non-object bag is refused, not dropped', () => { - const hit = listOverlayIssues(overlay({ options: 'map' })).find((i) => i.path[0] === 'options'); - expect(hit?.code).toBe('invalid_type'); + it('the fold is idempotent: a second read changes nothing and says nothing', () => { + const once = storedRead(overlay({ type: 'map', options: { map: { locationField: 'location' } } })).out; + const twice = storedRead(once); + expect(twice.out).toEqual(once); + expect(twice.notices).toEqual([]); }); }); -describe('a legal `options.KIND` still parses and round-trips', () => { - it('CONTROL: the legacy `options.map` bag objectui pins is accepted and kept in the parse output', () => { - // objectui's own control — "the legacy `options.map` bag is still - // forwarded on its own path" (recorded in `packages/spec/liveness/view.json`, - // row `list.map`). - const options = { map: { locationField: 'location', titleField: 'legacy_title' } }; - const body = overlay({ name: 'showcase_task.work_map', object: 'showcase_task', type: 'map', columns: ['title', 'location'], options }); - const parsed = ViewMetadataSchema.safeParse(body); - expect(parsed.success, JSON.stringify(!parsed.success && parsed.error.issues)).toBe(true); - expect((parsed as { data: { options?: unknown } }).data.options).toEqual(options); +describe('what the fold cannot decide stays as stored, with a TODO', () => { + it('`options.grid` stays in the bag (a grid has no block); the folded kinds still move', () => { + const { out, todos } = storedRead(overlay({ type: 'grid', options: { grid: { striped: true }, kanban: { titleField: 'name' } } })); + expect(out.options).toEqual({ grid: { striped: true } }); + expect(out.kanban).toEqual({ titleField: 'name' }); + expect(todos).toHaveLength(1); + expect(todos[0]!.path).toBe('views[0].options'); + expect(todos[0]!.reason).toContain('`grid`'); }); - it('a legal legacy `options.kanban` / `options.calendar` pair round-trips byte-for-byte', () => { - const options = { kanban: { groupByField: 'status', columns: ['name'] }, calendar: { startDateField: 'created_at' } }; - const parsed = ViewMetadataSchema.safeParse(overlay({ type: 'kanban', options })); - expect(parsed.success, JSON.stringify(!parsed.success && parsed.error.issues)).toBe(true); - const out = (parsed as { data: { options?: Record } }).data.options; - expect(out).toEqual(options); - // …and re-parsing the parse output is a fixed point. - expect(ViewMetadataSchema.safeParse((parsed as { data: unknown }).data)).toMatchObject({ success: true }); + it('a non-object bag is left as stored, with a TODO', () => { + const row = overlay({ options: 'map' }); + const { out, todos, notices } = storedRead(row); + expect(out).toEqual(row); + expect(notices).toEqual([]); + expect(todos).toHaveLength(1); }); - it('a PARTIAL underlay parses — the per-key merge population objectui pins', () => { - // objectui `ObjectView.namedViewProtocolKeys-8980.test.tsx`: "a key the - // canonical block does NOT restate survives from the legacy nesting — the - // merge is per-key, not wholesale". `kanban.groupByField` and - // `kanban.columns` are REQUIRED on the top-level block; the underlay here - // carries only the key the top-level block leaves to it. - const body = overlay({ - type: 'kanban', - kanban: { groupByField: 'status', columns: ['name'] }, - options: { kanban: { titleField: 'subject' } }, - }); - const parsed = ViewMetadataSchema.safeParse(body); - expect(parsed.success, JSON.stringify(!parsed.success && parsed.error.issues)).toBe(true); - // …while the SAME partial block written directly is still refused for the - // required keys it omits: the top-level block is the one the renderer - // completes, the underlay is not. - expect(ViewMetadataSchema.safeParse(overlay({ type: 'kanban', kanban: { titleField: 'subject' } })).success).toBe(false); + it('a bag on a FORM overlay is left as stored, with a TODO — its blocks have no home on a form', () => { + const row = { name: 'lead.contact_us', object: 'lead', viewKind: 'form', type: 'simple', options: { kanban: { groupByField: 'status' } } }; + const { out, todos } = storedRead(row); + expect(out).toEqual(row); + expect(todos.map((t) => t.reason).join(' ')).toContain('form view'); }); - it('CONTROL: an overlay with no `options` parses exactly as before, with no `options` key', () => { - const parsed = ViewMetadataSchema.safeParse(overlay({ type: 'grid' })); - expect(parsed.success).toBe(true); - expect('options' in (parsed as { data: Record }).data).toBe(false); + it('CONTROL: a view item record (`config` present) is not this conversion\'s business', () => { + const row = { name: 'crm_lead.board', object: 'crm_lead', viewKind: 'list', config: { columns: ['name'] }, options: { kanban: {} } }; + const { out, notices, todos } = storedRead(row); + expect(out).toEqual(row); + expect(notices).toEqual([]); + expect(todos).toEqual([]); }); }); diff --git a/packages/spec/src/ui/view-overlay-options-type.test.ts b/packages/spec/src/ui/view-overlay-options-type.test.ts index b47373a2799..8c397d7be38 100644 --- a/packages/spec/src/ui/view-overlay-options-type.test.ts +++ b/packages/spec/src/ui/view-overlay-options-type.test.ts @@ -1,24 +1,20 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [#19920] The flattened list overlay's legacy `options` bag carries each kind's own block, with - * every key optional, not a string-keyed record of `unknown`. + * [#25923] The flattened list overlay carries no `options` bag: its member is the retired-key + * tombstone (`retiredKey`), so no view type admits a bag on a list overlay, whatever it holds. * - * `listViewKindBlocks()` returned `Record`, so the bag's static type was - * `{ [x: string]: unknown }` on the list overlay member, and so on `ViewMetadata`, - * `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed`: - * `options: { foo: 1, kanban: 42 }` type-checked while that member refuses both keys. Its return - * type is now derived by the same rule the loop runs (a value of the list shape's `type` enum that - * also names a block on the shape), with zod's own `.partial()` type per block. + * Until #25923 the bag carried each kind's own block with every key optional (#19920). The door + * now refuses the bag by name and a stored bag is folded onto the top-level blocks by the + * `list-view-options-bag-folded` conversion (`view-overlay-options-bag.test.ts`). * * Two halves, judged by two programs (the `view-overlay-viewkind-type.test.ts` shape): * * - The TYPE half is judged by `tsc -p tsconfig.test.json` (the package's `typecheck` script, via * `check:test-typecheck`), not by vitest. Each `@ts-expect-error` below asserts that its line - * does NOT compile. While the bag was a record of `unknown` every one of them compiled, so each - * directive was unused: TS2578 in a file with no `test-typecheck-debt.json` entry. - * - The RUNTIME half ties it to the doors: the runtime key set is the type's key set, each body - * the type refuses is refused by every door that judges it, and the partial underlay parses. + * does NOT compile: while the bag was declared, the first five compiled. + * - The RUNTIME half ties it to the doors: each body the type refuses is refused by every door + * that judges it, and the same block written top-level parses. */ import { describe, it, expect } from 'vitest'; @@ -34,62 +30,41 @@ import { type AssembledViewArtifact, type AssembledViewArtifactParsed, } from './assembled-views.zod'; +import type { retiredKey } from '../shared/retired-key'; + +type RetiredKeySchema = ReturnType; type ListOverlayIn = z.input; type ListOverlayOut = z.output; -type OptionsIn = NonNullable; -type OptionsOut = NonNullable; - -/** The kinds that name a block on the list shape: its `type` enum minus `grid`. */ -const KIND_BLOCKS = ['calendar', 'chart', 'gallery', 'gantt', 'kanban', 'map', 'timeline', 'tree'] as const; -type KindBlock = (typeof KIND_BLOCKS)[number]; type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; -type NeverEntries = { [K in keyof T]-?: [NonNullable] extends [never] ? K : never }[keyof T]; - -// ── The bag's key set is the kind set, and no entry collapsed to `never` ───────────────────── -const keySetIsTheKindSet: [Equal, Equal] = [true, true]; -const noEntryIsNever: [[NeverEntries] extends [never] ? true : false] = [true]; -void [keySetIsTheKindSet, noEntryIsNever]; +// ── The member is the retired-key tombstone on both sides ──────────────────────────────────── -// ── Each entry is the kind's own block with every key optional ─────────────────────────────── +const optionsIsTombstone: [ + Equal>, + Equal>, +] = [true, true]; +void optionsIsTombstone; -// `groupByField` is required on the kanban block itself; the underlay may leave it out. -const partialUnderlay: OptionsIn = { kanban: { summarizeField: 'amount' } }; -// @ts-expect-error -- `options.foo` is no kind. -const unknownKind: OptionsIn = { foo: 1 }; -// @ts-expect-error -- `grid` names no block. -const gridBlock: OptionsIn = { grid: {} }; -// @ts-expect-error -- a kind's entry is an object, not a number. -const numericBlock: OptionsIn = { kanban: 42 }; -// @ts-expect-error -- and each key has the block's own type. -const wrongKeyType: OptionsIn = { kanban: { groupByField: 42 } }; -// @ts-expect-error -- and the block is strict: a key it does not declare is refused. -const undeclaredKey: OptionsIn = { kanban: { nope: 1 } }; -void [partialUnderlay, unknownKind, gridBlock, numericBlock, wrongKeyType, undeclaredKey]; +// ── A bag no longer type-checks as any union type, even the once-legal partial underlay ────── -// ── The body the member refuses no longer type-checks as any union type ────────────────────── - -const typedBag: ViewMetadata = { object: 'crm_lead', viewKind: 'list', options: { kanban: { summarizeField: 'amount' } } }; -// @ts-expect-error -- a list overlay whose bag holds a number is no view body. -const numericBagMetadata: ViewMetadata = { object: 'crm_lead', viewKind: 'list', options: { kanban: 42 } }; +const topLevelBlock: ViewMetadata = { object: 'crm_lead', viewKind: 'list', kanban: { groupByField: 'status', columns: ['name'] } }; +// @ts-expect-error -- a list overlay carries no bag. +const bagMetadata: ViewMetadata = { object: 'crm_lead', viewKind: 'list', options: { kanban: { summarizeField: 'amount' } } }; // @ts-expect-error -- nor a parsed one. -const numericBagParsedMetadata: ViewMetadataParsed = { object: 'crm_lead', viewKind: 'list', options: { kanban: 42 } }; +const bagParsedMetadata: ViewMetadataParsed = { object: 'crm_lead', viewKind: 'list', options: { kanban: { summarizeField: 'amount' } } }; // @ts-expect-error -- nor a view artifact. -const numericBagArtifact: AssembledViewArtifact = { object: 'crm_lead', viewKind: 'list', options: { kanban: 42 } }; +const bagArtifact: AssembledViewArtifact = { object: 'crm_lead', viewKind: 'list', options: { kanban: { summarizeField: 'amount' } } }; // @ts-expect-error -- nor a parsed one. -const numericBagParsedArtifact: AssembledViewArtifactParsed = { object: 'crm_lead', viewKind: 'list', options: { kanban: 42 } }; -void [typedBag, numericBagMetadata, numericBagParsedMetadata, numericBagArtifact, numericBagParsedArtifact]; - -describe('the list overlay options bag carries each kind block', () => { - it('the runtime key set is the kind set the type names', () => { - const bag = VIEW_METADATA_MEMBERS.listOverlay.shape.options.unwrap(); - expect(Object.keys(bag.shape).sort()).toEqual([...KIND_BLOCKS]); - }); +const bagParsedArtifact: AssembledViewArtifactParsed = { object: 'crm_lead', viewKind: 'list', options: { kanban: { summarizeField: 'amount' } } }; +// @ts-expect-error -- nor an empty bag. +const emptyBag: ViewMetadata = { object: 'crm_lead', viewKind: 'list', options: {} }; +void [topLevelBlock, bagMetadata, bagParsedMetadata, bagArtifact, bagParsedArtifact, emptyBag]; +describe('the list overlay carries no options bag', () => { it('a bag the type refuses is refused by every door that judges it', () => { - for (const options of [{ kanban: 42 }, { foo: 1 }, { kanban: { groupByField: 42 } }, { kanban: { nope: 1 } }]) { + for (const options of [{ kanban: { summarizeField: 'amount' } }, {}, { kanban: 42 }, { foo: 1 }]) { const body = { object: 'crm_lead', viewKind: 'list', options }; for (const door of [VIEW_METADATA_MEMBERS.listOverlay, ViewMetadataSchema, AssembledViewArtifactSchema]) { expect(door.safeParse(body).success, JSON.stringify(options)).toBe(false); @@ -97,8 +72,8 @@ describe('the list overlay options bag carries each kind block', () => { } }); - it('the partial underlay the type admits parses at every door', () => { - const body = { object: 'crm_lead', viewKind: 'list', options: { kanban: { summarizeField: 'amount' } } }; + it('CONTROL: the same block written top-level parses at every door', () => { + const body = { object: 'crm_lead', viewKind: 'list', kanban: { groupByField: 'status', columns: ['name'] } }; for (const door of [VIEW_METADATA_MEMBERS.listOverlay, ViewMetadataSchema, AssembledViewArtifactSchema]) { expect(door.safeParse(body).success).toBe(true); } diff --git a/packages/spec/src/ui/view-overlay-viewkind-arm.test.ts b/packages/spec/src/ui/view-overlay-viewkind-arm.test.ts index 0fb524541f2..bef08cec393 100644 --- a/packages/spec/src/ui/view-overlay-viewkind-arm.test.ts +++ b/packages/spec/src/ui/view-overlay-viewkind-arm.test.ts @@ -35,8 +35,9 @@ * refused to accepted (`Clause-②: yes (narrowing)`): * * - **W2** — a list-legal value under a key both arms declare with different - * schemas (`aria`, an i18n `description`, list-style `sharing`, a valid - * legacy `options` bag). The form arm + * schemas (`aria`, an i18n `description`, list-style `sharing`; a valid + * legacy `options` bag too, until #25923 retired the bag on the list arm + * as well). The form arm * used to judge those keys by FORM rules; the list arm now judges them by the * list rules they belong to. Pinned ACCEPT below: it is the ruling working. * - **W1** — an invalid value under a form-only key (`layout: 'diagonal'`), @@ -189,7 +190,6 @@ describe('W2 — a list-legal value under a key both arms declare differently is ['`aria` (the list view ARIA block; the form arm carries a retirement tombstone)', { aria: { ariaLabel: 'Leads' } }], ['an i18n `description` (the form arm takes a plain string only)', { description: { en: 'All leads' } }], ['list-style `sharing` (the form arm\'s is the public-link block)', { sharing: { type: 'personal' } }], - ['a valid legacy `options` bag (the form arm pins `options` absent)', { options: { map: { locationField: 'address' } } }], ])('%s', (_label, extra) => { expect(acceptance({ ...LIST, ...extra }).branch).toBe('listOverlay'); }); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index f5ce88248bf..a95fecfe045 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -2784,43 +2784,20 @@ const LIST_VIEW_CHART_NEEDS_BINDING = "This list view is `type: 'chart'` but declares no `chart` block, so it binds no dataset and there is " + `nothing to plot. ${LIST_VIEW_CHART_BINDING_REMEDY} A view that is not meant to be a chart takes another \`type\`.`; -/** - * [#22491] `type: 'chart'` whose only binding is the legacy `options.chart` - * bag, missing a key the `chart` block requires — refused at that key. - */ -const listViewChartBagMissing = (key: (typeof LIST_CHART_BINDING_KEYS)[number]): string => - "This list view is `type: 'chart'` and its only chart binding is the legacy `options.chart` bag, " - + `which names no ${key === 'dataset' ? '`dataset`' : 'measure in `values`'}, so there is nothing to plot. ` - + 'With no top-level `chart` block the bag IS the binding, so it must carry what that block requires. ' - + `${LIST_VIEW_CHART_BINDING_REMEDY} The top-level block replaces the bag whole; prefer it to completing the bag.`; - -/** - * [#22491] The keys that make a chart block a binding: the required keys of - * {@link ListChartConfigSchema} (`chartType` defaults, `dimensions` is - * optional). Hand-listed for the messages above; `view-chart-binding.test.ts` - * derives the block's required keys from the schema and fails if the two part. - */ -const LIST_CHART_BINDING_KEYS = ['dataset', 'values'] as const; - /** * [#22491] A `type: 'chart'` list view must bind a dataset — the view's * EFFECTIVE chart binding names a `dataset` and at least one measure. * - * The effective binding is read the way the renderer reads it: the top-level - * `chart` block, else the legacy `options.chart` bag, and the block replaces - * the bag WHOLE — `schema.chart || schema.options?.chart || {}` (the read - * point is recorded in `packages/spec/liveness/view.json`, row `list.chart`). - * So, unlike the merged per-key underlays - * {@link listViewKindBlocks} describes, the bag is not one layer of the chart - * block: when no block is declared it is the whole of it, and it owes what - * the block requires. - * - * - No block and no bag ⇒ one issue at `chart`. - * - No block, a bag missing `dataset` / `values` ⇒ one issue per missing key, - * at `options.chart.KEY` (the bag lives on the flattened overlay only; the - * two authoring doors refuse `options` by name). + * The binding is the top-level `chart` block (the read point is recorded in + * `packages/spec/liveness/view.json`, row `list.chart`). [#25923] The legacy + * `options.chart` bag this check used to read as the binding when no block + * was declared is refused at every door now ({@link LIST_OVERLAY_OPTIONS_REFUSED}), + * and a stored bag is folded onto `chart` before the row is served, so the + * block is the only binding there is. + * + * - No block ⇒ one issue at `chart`. * - A declared `chart` block ⇒ nothing here: its own strict schema already - * requires both keys, at `chart.dataset` / `chart.values`. + * requires `dataset` and `values`, at `chart.dataset` / `chart.values`. * * What such a view renders is a dead screen either way: before objectui#6152 * round 15 (objectui `0253416`) the renderer fabricated a binding nobody wrote @@ -2843,23 +2820,11 @@ const LIST_CHART_BINDING_KEYS = ['dataset', 'values'] as const; * reason: a mirror built from `ListViewSchema.shape` re-attaches it. */ export function checkListViewChartBinding( - view: { type?: unknown; chart?: unknown; options?: unknown }, + view: { type?: unknown; chart?: unknown }, ctx: z.RefinementCtx, ): void { if (view.type !== 'chart' || view.chart !== undefined) return; - const bag = view.options !== null && typeof view.options === 'object' - ? (view.options as { chart?: unknown }).chart - : undefined; - if (bag === undefined) { - ctx.addIssue({ code: 'custom', path: ['chart'], message: LIST_VIEW_CHART_NEEDS_BINDING }); - return; - } - // A bag that is not an object is refused by the bag's own schema. - if (bag === null || typeof bag !== 'object') return; - for (const key of LIST_CHART_BINDING_KEYS) { - if ((bag as Record)[key] !== undefined) continue; - ctx.addIssue({ code: 'custom', path: ['options', 'chart', key], message: listViewChartBagMissing(key) }); - } + ctx.addIssue({ code: 'custom', path: ['chart'], message: LIST_VIEW_CHART_NEEDS_BINDING }); } /** @@ -6248,125 +6213,38 @@ const ViewContainerWireSchema = lazySchema(() => ); /** - * [#20051] The per-kind blocks of the list-view shape, keyed by kind — the - * vocabulary of the legacy `options` bag below, each judged KEY BY KEY. - * - * Derived, never hand-listed: a kind is a value of the shape's own `type` enum - * that ALSO names a block on the shape (`grid` names none), and each entry is - * the very schema the direct spelling is judged by — the same strict key set, - * the same per-key schemas, the same unknown-key message — with every key made - * optional (`.partial()`). So a new kind with a new block is judged under - * `options` in the same edit, and `options.timeline` can never be judged by - * anything but `timeline`'s schema. - * - * Why `.partial()` and not the block as-is (measured, not preferred): the - * renderer reads `options.KIND` as an UNDERLAY of the top-level block, merged - * per key with the top-level block winning (objectui `ListView`: kanban, - * calendar, gallery, timeline, gantt and map spread `options.KIND` first and - * `KIND` last). A legacy bag that carries only the keys the top-level block - * does not restate (`kanban: { groupByField }` beside `options.kanban: - * { titleField }`) is therefore legal config — objectui pins exactly that - * population (`ObjectView.namedViewProtocolKeys-8980.test.tsx`, "the merge is - * per-key, not wholesale") — and judging it with the block's REQUIRED keys - * would refuse it for a key it never meant to carry. What the ruling asks is - * kept whole: an out-of-contract key is refused by name, and a value the key's - * own schema refuses is refused at that key. Required-ness belongs to the - * block the renderer builds, not to one layer of it. - * - * `.partial()` throws on an object that carries refinements (zod 4), which is - * the loud answer this derivation wants: a kind block that grows a cross-key - * check forces a decision about how that check reads on a partial underlay, - * instead of silently losing it. - * - * [#19920] The return type is {@link ListViewKindBlocks}, derived by the same - * rule at the type level. It was `Record`, which typed - * the bag as a string-keyed record of `unknown` on the list overlay member, and - * so on `ViewMetadata`, `ViewMetadataParsed`, `AssembledViewArtifact` and - * `AssembledViewArtifactParsed`: `options: { foo: 1, kanban: 42 }` type-checked - * while this member refuses it. The loop below is unchanged; the one assertion - * on its result states what the two derivations share, and - * `view-overlay-options-type.test.ts` pins the runtime key set to the type's. - */ -function listViewKindBlocks(): ListViewKindBlocks { - const shape = (ListViewShapeSchema as unknown as { shape: Record }).shape; - const blocks: Record = {}; - for (const kind of overlayTypeValues(ListViewShapeSchema)) { - const block = shape[kind] as unknown as { unwrap?: () => { partial: () => z.ZodTypeAny } } | undefined; - if (block?.unwrap) blocks[kind] = block.unwrap().partial().optional(); - } - return blocks as ListViewKindBlocks; -} - -/** - * [#19920] What {@link listViewKindBlocks} builds for ONE kind, as a function - * so its return type is zod's own answer for `.unwrap().partial().optional()` - * rather than a hand-written copy of it. Only its type is read - * ({@link ListViewKindBlocks}); the loop above keeps its own duck-typed calls. - */ -function partialListViewKindBlock( - block: z.ZodOptional>, -) { - return block.unwrap().partial().optional(); -} - -type ListViewShapeFields = (typeof ListViewShapeSchema)['shape']; - -/** - * [#19920] The kinds that name a block, by {@link listViewKindBlocks}' own rule: - * a value of the shape's `type` enum that is also a key of the shape (`grid` - * names none). - */ -type ListViewKindBlockName = Extract, keyof ListViewShapeFields>; - -/** - * [#19920] The static type of {@link listViewKindBlocks}: per kind, the kind's - * own block with every key optional. A block that stopped being an optional - * object would read `never` here, and the pin test's never-check goes red. - */ -type ListViewKindBlocks = { - [K in ListViewKindBlockName]: ListViewShapeFields[K] extends z.ZodOptional> - ? ReturnType> - : never; -}; - -/** - * [#20051] The legacy `options` bag on a flattened LIST overlay, judged. - * - * The renderer still reads `options.KIND` beside the top-level `KIND` block - * (objectui's `ListView` merges the two per key, and its interface page - * forwards a stored view's `options` whole), so a stored bag is live config. - * Before this schema the overlay member's top-level `.strip()` dropped the bag - * from the parse without looking inside it, `saveMetaItem` stored the request - * body as sent, and the interface page rendered it: `options.timeline.metaFields` - * saved `200` while `timeline.metaFields` was refused `unrecognized_keys`. - * - * Ruled direction A (objectui#10380, maintainer 「其他同意」): each - * `options.KIND` is judged by the kind's own strict schema, so an out-of-contract - * key is refused by NAME, with the same code and the same surface text as the - * direct spelling — only the path gains the `options` prefix. Key by key: see - * {@link listViewKindBlocks} for why the block's required keys are not asked of - * an underlay. The bag itself is closed too: it carries per-kind blocks and - * nothing else, so `options.foo` is refused rather than dropped (ruled out: - * refusing the bag WHOLE, which would break the legacy `options.map` path - * objectui pins). - * - * Declared on the list overlay member ONLY — never on the authoring shape (the - * bag is a legacy wire spelling, not something to teach an author), and the - * form overlay pins it absent ({@link FORM_OVERLAY_OPTIONS_REFUSED}). - */ -const ListViewOverlayOptionsSchema = lazySchema(() => strictObject({ - surface: 'this legacy `options` bag', - history: - 'Until this bag was judged at the view write door it was dropped from the parse unread and ' - + 'stored as sent, so a key its own block refuses still reached the rendered view.', - guidance: { - grid: 'A grid has no per-kind block: its settings (`columns`, `sort`, `filter`, …) are top-level keys of the view itself. Remove `options.grid`.', - }, -}, listViewKindBlocks()).describe( - 'Legacy per-kind nesting (`options.kanban`, `options.timeline`, …) a stored list overlay may carry. ' - + 'Each block is judged key by key by the same schema as the top-level block of that kind, which wins ' - + 'per key where both set one; prefer the top-level spelling.', -)); + * [#25923] The refusal the flattened LIST overlay gives a top-level `options` + * bag. The bag is closed: the per-kind blocks are top-level keys of the view + * itself (`kanban`, `calendar`, `gantt`, `timeline`, `map`, `gallery`, `chart`, + * `tree`), and that is their one spelling (Prime Directive #12). + * + * ⛔ This supersedes ruling direction A on objectui#10380, which kept the bag + * as a judged underlay (each `options.KIND` checked key by key by its kind's + * own schema, the top-level block winning per key). That ruling refused only + * to close the bag WHOLE, and its stated reason was objectui's pinned legacy + * `options.map` read. #25923 retires that read and every other reader of the + * bag, so the reason no longer holds, and the door refuses the bag by name. + * + * Nothing that already worked is lost. Stored view rows that carry a bag are + * folded onto the top-level blocks by the ADR-0087 D2 conversion + * `list-view-options-bag-folded` (`conversions/registry.ts`), which every + * stored-row read replays: `options.KIND` moves to `KIND`, and where both set a + * key the top-level block wins, the precedence the bag was declared with. The + * D3 entry of the same family (`ui-list-view-options-bag-folded`) carries what + * the fold cannot decide for the author. `migrate meta` lists the edit for + * source files. + * + * Declared on the list overlay member only. The form overlay pins the bag + * absent with its own text ({@link FORM_OVERLAY_OPTIONS_REFUSED}), and the + * view item record with {@link VIEW_ITEM_OPTIONS_REFUSED}. + */ +const LIST_OVERLAY_OPTIONS_REFUSED = + "A list view's `options` bag was removed in @objectstack/spec 18.3.0 (ADR-0087 D2) — each per-kind " + + 'block is a top-level key of the view itself, its one spelling. Move each `options.KIND` block to ' + + '`KIND` (for example `options.kanban` to `kanban`), keeping the top-level value where both set the ' + + 'same key, or remove `options`. A grid has no per-kind block: its settings (`columns`, `sort`, ' + + '`filter`, …) are top-level keys of the view. Stored views are folded on read. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; /** * [#20051] The refusal the flattened FORM overlay gives an `options` bag. @@ -6565,9 +6443,10 @@ function formOverlayColumnsField(): z.ZodOptional { * sibling) — see {@link flattenedViewOverlayFields} for the ruling and the * measured serving filter that decides exactly this pair. * - * [#20051] …and `options` is DECLARED here, judged by - * {@link ListViewOverlayOptionsSchema}: a bag the renderer reads is not a - * round-trip key. + * [#20051] …and `options` is DECLARED here. [#25923] It is declared to be + * refused by name ({@link LIST_OVERLAY_OPTIONS_REFUSED}): the per-kind blocks + * are top-level keys, and a stored bag is folded onto them by the + * `list-view-options-bag-folded` conversion before a row is served. * * [#20186] This member judges `viewKind: 'list'` only (the form sibling * `'form'` only), and it judges a column-less PATCH as well as a full inline @@ -6576,8 +6455,8 @@ function formOverlayColumnsField(): z.ZodOptional { * ({@link checkListOverlayTypeNeedsColumns}). The attached checks run in * order: that refusal reads the input side, the calendar check is unchanged, * the chart-binding check ({@link checkListViewChartBinding}, #22491) reads the - * input side too and is the one door check that sees the `options.chart` bag, - * and {@link applyListOverlayTypeDefault} restores the `grid` default last. + * input side too, and {@link applyListOverlayTypeDefault} restores the `grid` + * default last. */ const ListViewOverlayWireSchema = lazySchema(() => // [#13216] Built from {@link ListViewShapeSchema}, not {@link ListViewSchema}: @@ -6590,7 +6469,8 @@ const ListViewOverlayWireSchema = lazySchema(() => // three attachment points is dropped. closeWireMember(ListViewShapeSchema.extend({ ...flattenedViewOverlayFields('list'), - options: ListViewOverlayOptionsSchema.optional(), + // [#25923] The bag is retired: a tombstone that refuses it by name. + options: retiredKey(LIST_OVERLAY_OPTIONS_REFUSED), ...listOverlayPatchFields(), // [#20456] The console's round-trip keys on this row shape, declared so // the parse keeps them. From 6fee4e7a0d4f08e5bcedaf91409c14aca75ab431 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 05:28:11 +0000 Subject: [PATCH 02/10] refactor(types,plugin-list,app-shell)!: delete objectui's options bag readers; the mirror takes the spec tombstone ListViewSchema.options takes the list overlay's retiredKey tombstone by reference, and ListView, the object page relay, the interface page, ViewPreview, the list-view inspector and plugin-view read and write the per-kind blocks at the top level only. The server folds a stored bag before the row is served. Tests that mounted a bag move to the top-level block; the ones that pinned the bag now pin that it is refused or reaches nothing. A source census in ListView.readersRetired-6152 pins that no plugin-list source reads schema.options. react-blocks.md regenerates without the prop. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzevL8Q6rTfGz2XHXGT --- .../25923-list-view-options-bag-retired.md | 35 ++ packages/spec/src/ui/react-blocks.ts | 1 - .../objectstack-ui/references/react-blocks.md | 1 - .../registry-inputs-spec-parity.test.ts | 4 - .../timelineAxisRefusalReach-7070.test.tsx | 6 +- .../listKanbanEveryRecord-12619.test.tsx | 5 +- .../InterfaceListPage.mapConfig.test.tsx | 70 ++-- ...nterfaceListPage.relayCensus-11572.test.ts | 10 +- ...faceListPage.treeChartRelay-11572.test.tsx | 21 +- .../app-shell/src/views/InterfaceListPage.tsx | 50 ++- .../views/ObjectView.chartRelay-7823.test.tsx | 12 +- ...tView.densityModeThroughFold-7924.test.tsx | 2 +- ...View.fallbackTabSessionOnly-11643.test.tsx | 2 +- .../ObjectView.fieldOrderRelay-7516.test.tsx | 4 +- .../ObjectView.relayRungCensus-7559.test.ts | 25 +- ...bjectView.relayedBlocksParse-6152.test.ts} | 38 +- .../ObjectView.rowColorRelay-7218.test.tsx | 4 +- ...ObjectView.storedRowParity-10380.test.tsx} | 71 +++- .../ObjectView.titleFieldConvergence.test.tsx | 9 +- ...ctView.toolbarWritesCompose-11642.test.tsx | 2 +- .../app-shell/src/views/ObjectView.tsx | 4 +- ...ectView.viewDescriptionRelay-7199.test.tsx | 4 +- ...ctView.viewRowDeclaredReads-11013.test.tsx | 2 +- ...> ViewPreview.foldedOnRead-25923.test.tsx} | 125 +++---- .../metadata-admin/previews/ViewPreview.tsx | 59 +--- .../src/views/objectViewListRenderer.tsx | 247 +++++++------ .../app-shell/src/views/objectViewOptions.ts | 65 +--- .../ObjectListViewInspector.target.test.tsx | 11 +- .../studio-design/ObjectListViewInspector.tsx | 9 +- ui/packages/plugin-list/src/ListView.tsx | 223 +++++------- .../ListView.calendar-binding-7029.test.tsx | 11 +- ...istView.calendarAliasRefused-8355.test.tsx | 14 +- .../ListView.chart-capability-7544.test.tsx | 16 +- ...w.datasetChartToolbarFilter-10327.test.tsx | 14 +- ...tView.galleryBlockCanonical-6152.test.tsx} | 47 ++- .../ListView.gantt-binding-7070.test.tsx | 41 ++- ...stView.kanbanBlockCanonical-8193.test.tsx} | 60 ++-- .../ListView.kanbanOwnsLanes-12619.test.tsx | 4 +- .../__tests__/ListView.mapFlatten.test.tsx | 10 +- .../ListView.mapLocationSelect-10370.test.tsx | 22 +- .../ListView.mapViewLevelConfig.test.tsx | 51 ++- .../ListView.readersRetired-6152.test.tsx | 162 +++++++-- .../ListView.rowAdornment-13330.test.tsx | 2 +- .../ListView.speculativeFls-7216.test.tsx | 8 +- ...ListView.strayGroupByRefused-8365.test.tsx | 49 ++- .../src/__tests__/ListView.test.tsx | 16 +- .../ListView.timeline-binding.test.tsx | 39 ++- ...w.timelineMetaFieldsRetired-10222.test.tsx | 15 +- .../ListViewBlock.specInputs-13047.test.tsx | 29 +- ui/packages/plugin-list/src/index.tsx | 8 +- .../ObjectMap.listViewMapConfigReach.test.tsx | 47 ++- .../src/ObjectMap.schemaAlignment.test.tsx | 2 +- .../src/ObjectMap.topLevelStyle.test.tsx | 2 +- ui/packages/plugin-map/src/ObjectMap.tsx | 4 +- ui/packages/plugin-view/src/ObjectView.tsx | 35 +- ...ctView.namedViewProtocolKeys-8980.test.tsx | 17 +- .../calendar-date-alias-refusal-8355.test.ts | 50 +-- .../__tests__/imported-defaults-8317.test.ts | 12 +- ...n-stray-group-by-refusal-text-9653.test.ts | 8 +- ...dataset-chart-filter-refusal-10327.test.ts | 49 ++- .../list-view-options-bag-6152.test.ts | 324 +++++------------- .../list-view-properties-bag-12670.test.ts | 14 +- ...art-legacy-axis-keys-retired-10608.test.ts | 14 +- .../object-kanban-group-by-limit-7322.test.ts | 3 +- ui/packages/types/src/objectql.ts | 2 +- ui/packages/types/src/zod/objectql.zod.ts | 120 ++----- 66 files changed, 1225 insertions(+), 1217 deletions(-) create mode 100644 .changeset/25923-list-view-options-bag-retired.md rename ui/packages/app-shell/src/views/{ObjectView.optionsBagParses-6152.test.ts => ObjectView.relayedBlocksParse-6152.test.ts} (66%) rename ui/packages/app-shell/src/views/{ObjectView.storedOptionsBag-10380.test.tsx => ObjectView.storedRowParity-10380.test.tsx} (81%) rename ui/packages/app-shell/src/views/metadata-admin/previews/{ViewPreview.optionsFold-7928.test.tsx => ViewPreview.foldedOnRead-25923.test.tsx} (57%) rename ui/packages/plugin-list/src/__tests__/{ListView.galleryOptionsBagCanonical-6152.test.tsx => ListView.galleryBlockCanonical-6152.test.tsx} (67%) rename ui/packages/plugin-list/src/__tests__/{ListView.kanbanOptionsBagCanonical-8193.test.tsx => ListView.kanbanBlockCanonical-8193.test.tsx} (76%) diff --git a/.changeset/25923-list-view-options-bag-retired.md b/.changeset/25923-list-view-options-bag-retired.md new file mode 100644 index 00000000000..4eea4e125b8 --- /dev/null +++ b/.changeset/25923-list-view-options-bag-retired.md @@ -0,0 +1,35 @@ +--- +'@objectstack/spec': minor +'@object-ui/types': minor +'@object-ui/plugin-list': minor +'@object-ui/app-shell': minor +'@object-ui/plugin-view': patch +'@object-ui/plugin-map': patch +'@object-ui/console': patch +'@objectstack/rest': patch +'@objectstack/metadata-protocol': patch +'@objectstack/lint': patch +--- + +feat(spec)!: retire the list view's `options` bag; stored bags fold onto the top-level blocks, and the renderers read the blocks alone (#25923) + +Clause-②: no (narrowing) +Metadata-protocol: narrowed + + + +A flattened list view overlay carried every per-kind block twice: as the top-level `kanban`, `calendar`, `gallery`, `timeline`, `gantt`, `map`, `tree` and `chart`, and again under a legacy `options` bag the top-level block won over per key. Each renderer re-implemented that merge. The bag is retired, and each block has one spelling. + +- **`@objectstack/spec`**: the list overlay's `options` is a removed-key tombstone. The view write door refuses a body that carries it, by name, with the prescription to move each `options.KIND` block to `KIND`. The ADR-0087 D2 conversion `list-view-options-bag-folded` folds a stored bag onto the top-level blocks before a row is served (the top-level value wins per key; for `chart`, whose block replaced the bag whole, a bag beside a block is dropped), and `os migrate meta --from 17` lists the same edits for a source. A bag it cannot fold (an `options.grid`, a non-object block, a bag on a form view) is left in place with a TODO. The D3 entry `ui-list-view-options-bag-folded` names what the fold cannot decide: the bag was judged with every key optional, so a folded block can still miss a key its block requires. The page `list-view` component row shares the tombstone. +- **`@object-ui/types`**: `ListViewSchema.options` takes the spec's tombstone by reference, so every published door refuses a bag at `options` with the spec's text, and the TypeScript face has no bag. `ListViewOptionsBag` is removed. The dataset-chart filter refusal reads the `chart` block alone, and the page node's flat `options` is refused with the bag's own text rather than pointed at `properties.options`. +- **`@object-ui/plugin-list`**: `ListView` reads each kind from its top-level block alone: the render branches, the capability gate, the projection and expand collectors and the fetch effect's dependencies no longer read `options`. The `list-view` registration publishes no `options` input. +- **`@object-ui/app-shell`**: the object page relays each per-kind block at the top level of the list schema, held by value per block so an equal rebuild keeps its identity, and no longer forwards a stored bag. The interface page sends one `map` block (its derivation with the authored keys laid over) and no bag. The Studio view preview and the list-view inspector no longer fold a bag; the server does. `storedLegacyOptions` is removed. +- **`@object-ui/plugin-view`**: the host list schema carries the active view's blocks at the top level instead of under `options`. +- **`@object-ui/plugin-map`**: two development warnings name the view's `map` block instead of `options.map`. +- **`@object-ui/console`, `@objectstack/rest`, `@objectstack/metadata-protocol`, `@objectstack/lint`**: test pins only (the registry parity row, the write door's refusal and served fold, and the lint finding), no runtime change. + +**BREAKING** for a list view body, a page `list-view` node or a `ListView` host that writes `options`. + +- **Before.** `{ type: 'kanban', columns: ['name'], options: { kanban: { groupByField: 'stage' } } }` was accepted at the view write door and drew a board laned on `stage`, and `ListView` read the bag beside the top-level blocks. +- **Now.** The write door and every `@object-ui/types` door refuse `options` by name, and `ListView` reads no bag. A view already stored with one is served folded, so it renders as before. +- **Fix.** Move each block to the top level: `{ type: 'kanban', columns: ['name'], kanban: { groupByField: 'stage', columns: ['name'] } }`, keeping the top-level value where both set a key, and add any key the block requires that the bag never had (a kanban's `columns`, a gantt's `titleField`, a chart's `dataset` and `values`). A grid's settings are top-level keys of the view. Run `os migrate meta --from 17` for a source, with `--write` to apply the edits it can prove. diff --git a/packages/spec/src/ui/react-blocks.ts b/packages/spec/src/ui/react-blocks.ts index 67b847316ed..5c517e5b15d 100644 --- a/packages/spec/src/ui/react-blocks.ts +++ b/packages/spec/src/ui/react-blocks.ts @@ -314,7 +314,6 @@ export const REACT_BLOCKS: ReactBlockDef[] = [ { name: 'onRowClick', type: '(record) => void', kind: 'callback', description: "Called with the clicked row's record — the hook for master/detail." }, { name: 'onNavigate', type: "(recordId, action: 'view' | 'new_window') => void", kind: 'callback', description: "Called for page-level navigation. The second argument is a navigation-MODE token from a CLOSED vocabulary — 'view' opens the record page, 'new_window' opens it in a new browser tab; the reference implementation emits no other value and reads no other branch." }, { name: 'fields', type: 'string[]', kind: 'binding', description: 'Limit/order the columns shown (defaults to the object list fields).' }, - { name: 'options', type: 'Record', kind: 'binding', description: 'View-type-specific options bag (kanban/calendar/gantt extras); prefer the typed spec props where they exist.' }, ], }, { diff --git a/skills/objectstack-ui/references/react-blocks.md b/skills/objectstack-ui/references/react-blocks.md index 02d606b34f1..4d5777847ba 100644 --- a/skills/objectstack-ui/references/react-blocks.md +++ b/skills/objectstack-ui/references/react-blocks.md @@ -61,7 +61,6 @@ Server-connected object table with toolbar and switchable visualizations (grid/k | `data` | `ViewData — { provider: 'object', object: string } binds an object` | binding | ✓ | The object this block binds to, written as the metadata-tier data source: data={{ provider: 'object', object: '…' }}. Any ViewData provider is accepted (a 'value' provider renders static rows); nothing stamps the object on a react page, so this prop is required. | | `navigation` | `{ mode: 'drawer' \| 'page' \| 'new_window' \| 'none' }` | binding | | What a row click does. Use { mode: "none" } when you handle clicks via onRowClick. | | `fields` | `string[]` | binding | | Limit/order the columns shown (defaults to the object list fields). | -| `options` | `Record` | binding | | View-type-specific options bag (kanban/calendar/gantt extras); prefer the typed spec props where they exist. | | `filters` | `FilterArray e.g. ['status','=','active']` | controlled | | ObjectQL base filter; drive from React state for tabbed/searched lists. ([field, op, value]; ops =, !=, >, <, contains, in; compound: ["and", […], […]]). | | `onRowClick` | `(record) => void` | callback | | Called with the clicked row's record — the hook for master/detail. | | `onNavigate` | `(recordId, action: 'view' \| 'new_window') => void` | callback | | Called for page-level navigation. The second argument is a navigation-MODE token from a CLOSED vocabulary — 'view' opens the record page, 'new_window' opens it in a new browser tab; the reference implementation emits no other value and reads no other branch. | diff --git a/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index ac7700e1983..f2ade151e5a 100644 --- a/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -3022,10 +3022,6 @@ const MEMBER_PINS: Record = { file: LIST_VIEW_PIN_FILE, pins: 'The `mode` member: `drawer` opens the clicked row\'s record in a dialog headed by the list\'s `label`, and the same node with no `navigation` opens none (objectui#13047).', }, - 'list-view.options': { - file: LIST_VIEW_PIN_FILE, - pins: 'The per-kind bag: `options.kanban.groupByField: \'owner\'` lanes the board on `owner` and its `columns` become the card fields, read off the `object-kanban` node `ListView` builds, against a CONTROL bare kanban node that lanes on a field the list derives itself (not `owner`), so the row reads the bag and not the default (objectui#13047).', - }, 'list-view.pagination': { file: LIST_VIEW_PIN_FILE, pins: 'The `pageSize` member caps the query (`$top`), and the same node with no `pagination` sends the default page (objectui#13047).', diff --git a/ui/apps/console/src/__tests__/timelineAxisRefusalReach-7070.test.tsx b/ui/apps/console/src/__tests__/timelineAxisRefusalReach-7070.test.tsx index f5daaf76114..72c813b8064 100644 --- a/ui/apps/console/src/__tests__/timelineAxisRefusalReach-7070.test.tsx +++ b/ui/apps/console/src/__tests__/timelineAxisRefusalReach-7070.test.tsx @@ -173,7 +173,7 @@ describe('ListView → ObjectTimeline: an undeclared axis reaches the refusal (o expect(refusal()!.textContent ?? '').toContain('timeline.startDateField'); }); - it('…and from the legacy `options` bag (objectui#6152 round 14)', async () => { + it('…and from the legacy `options` bag (objectui#6152 round 14; the bag itself is read by nothing since objectstack#25923)', async () => { await mountListView({ ...LIST_BASE, options: { timeline: { dateField: 'start_date' } } }); await waitFor(() => expect(refusal()).not.toBeNull()); expect(canvas()).toBeNull(); @@ -183,7 +183,9 @@ describe('ListView → ObjectTimeline: an undeclared axis reaches the refusal (o // objectui#3129: a calendar binding is a legitimate timeline axis in this // product. This is the shape most at risk from a fix aimed at "declared // timeline config only". - await mountListView({ ...LIST_BASE, options: { calendar: { startDateField: 'start_date' } } }); + // (The view's own `calendar` block; it rode the retired `options` bag until + // objectstack#25923.) + await mountListView({ ...LIST_BASE, calendar: { startDateField: 'start_date' } }); await waitFor(() => expect(canvas()).not.toBeNull()); expect(refusal()).toBeNull(); }); diff --git a/ui/packages/app-shell/src/__tests__/listKanbanEveryRecord-12619.test.tsx b/ui/packages/app-shell/src/__tests__/listKanbanEveryRecord-12619.test.tsx index d796d5b9d18..f23071be46e 100644 --- a/ui/packages/app-shell/src/__tests__/listKanbanEveryRecord-12619.test.tsx +++ b/ui/packages/app-shell/src/__tests__/listKanbanEveryRecord-12619.test.tsx @@ -116,8 +116,9 @@ const VIEW = { ], pagination: { pageSize: 25 }, // What the console's object page hands the list for a view with no kanban - // block of its own (`kanbanViewOptions` in `ObjectView.tsx`). - options: { kanban: { groupByField: 'stage', titleField: 'name' } }, + // block of its own (`kanbanViewOptions` in `ObjectView.tsx`), at the top + // level since objectstack#25923 retired the `options` bag. + kanban: { groupByField: 'stage', titleField: 'name' }, }; function mountBoard(ds: any, view: Record = VIEW) { diff --git a/ui/packages/app-shell/src/views/InterfaceListPage.mapConfig.test.tsx b/ui/packages/app-shell/src/views/InterfaceListPage.mapConfig.test.tsx index 398b8dec431..b8a0810ede9 100644 --- a/ui/packages/app-shell/src/views/InterfaceListPage.mapConfig.test.tsx +++ b/ui/packages/app-shell/src/views/InterfaceListPage.mapConfig.test.tsx @@ -14,12 +14,17 @@ * declared-but-inert shape the card is about, on the path the showcase map page * actually renders through. * - * The block is forwarded as `map` rather than folded into `mapCfg` with `??` - * like the sibling bindings, and the third case here is why: `??` would let a - * PARTIAL authored block replace the derivation wholesale, so - * `map: { titleField: 'title' }` alone would drop the auto-derived - * `locationField` and the page would render no markers at all. Forwarding both - * lets `ListView` merge them per key, which is the precedence the card settled. + * The block is not folded into the derivation with `??` like the sibling + * bindings, and the third case here is why: `??` would let a PARTIAL authored + * block replace the derivation wholesale, so `map: { titleField: 'title' }` + * alone would drop the auto-derived `locationField` and the page would render + * no markers at all. The two are merged per key, the authored key winning, + * which is the precedence the card settled. + * + * objectstack#25923 moved that merge onto this page. The derivation used to + * travel under `options.map` for `ListView` to lay the block over; the list + * view's `options` bag is retired, so the page now sends ONE `map` block, the + * derivation with the authored keys laid over it, and no `options` at all. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -52,7 +57,7 @@ vi.mock('@object-ui/plugin-list', async (importOriginal) => ({
), })); @@ -106,7 +111,7 @@ async function renderWith(view: Record) { const el = screen.getByTestId('list-view-map-config'); return { map: JSON.parse(el.getAttribute('data-map') || 'null'), - optionsMap: JSON.parse(el.getAttribute('data-options-map') || 'null'), + hasOptions: el.getAttribute('data-has-options') === 'true', }; } @@ -125,41 +130,52 @@ describe('InterfaceListPage forwards the view-level `map` block (objectui#5042)' expect(map).toEqual({ locationField: 'location', titleField: 'title', zoom: 9 }); }); - it('CONTROL: with no view-level block, no `map` key is emitted at all', async () => { - const { map, optionsMap } = await renderWith({}); + it('CONTROL: with no view-level block, the derivation alone is the `map` block', async () => { + const { map, hasOptions } = await renderWith({}); - expect(map).toBeNull(); - // …and the ADR-0047 auto-derivation still fires — with `locationField`, the - // map's own required binding, and nothing else. The derived `titleField` - // that objectui#5909 added to this product was removed in objectui#6343: - // it reached `getRecordDisplayName` as `options.titleField`, i.e. step 0, + // The ADR-0047 auto-derivation fires — with `locationField`, the map's own + // required binding, and nothing else. The derived `titleField` that + // objectui#5909 added to this product was removed in objectui#6343: it + // reached `getRecordDisplayName` as `options.titleField`, i.e. step 0, // where a name this page guessed outranked the object's own declaration. - expect(optionsMap).toEqual({ locationField: 'location' }); + expect(map).toEqual({ locationField: 'location' }); + // objectstack#25923: the derivation no longer rides an `options` bag. + expect(hasOptions).toBe(false); }); it('keeps the auto-derived binding ALONGSIDE a partial authored block', async () => { - // The case that rules out folding the block into `mapCfg` with `??`: the - // author declared only a marker-title field, and the coordinate binding - // still has to come from the derivation. `ListView` merges the two per - // key; a `??` here would have discarded one of them. + // The case that rules out folding the block into the derivation with + // `??`: the author declared only a marker-title field, and the coordinate + // binding still has to come from the derivation. A `??` here would have + // discarded one of them. // // Sharper since objectui#6343 removed the derived title: the two sides no // longer overlap at all. The AUTHOR owns `titleField` — declared, and // honoured at the resolver's step 0 — and the derivation owns // `locationField`. Each key has exactly one source. - const { map, optionsMap } = await renderWith({ map: { titleField: 'title' } }); + const { map, hasOptions } = await renderWith({ map: { titleField: 'title' } }); + + expect(map).toEqual({ locationField: 'location', titleField: 'title' }); + expect(hasOptions).toBe(false); + }); + + it('PRECEDENCE: where the author and the derivation name the same key, the authored value wins', async () => { + const { map } = await renderWith({ map: { locationField: 'hq_location' } }); - expect(map).toEqual({ titleField: 'title' }); - expect(optionsMap).toEqual({ locationField: 'location' }); + expect(map).toEqual({ locationField: 'hq_location' }); }); - it('CONTROL: the legacy `options.map` bag is still forwarded on its own path', async () => { - const { map, optionsMap } = await renderWith({ + it('RETIRED (objectstack#25923): a legacy `options.map` bag is not forwarded — stored rows are folded on read', async () => { + // The server folds a stored bag onto the top-level `map` block (ADR-0087 + // D2 `list-view-options-bag-folded`) before the row is served, so a bag + // never reaches this page from storage. One written inline is not read: + // the page sends the derivation alone and no `options`. + const { map, hasOptions } = await renderWith({ options: { map: { locationField: 'location', titleField: 'legacy_title' } }, }); - expect(map).toBeNull(); - expect(optionsMap).toEqual({ locationField: 'location', titleField: 'legacy_title' }); + expect(map).toEqual({ locationField: 'location' }); + expect(hasOptions).toBe(false); }); }); diff --git a/ui/packages/app-shell/src/views/InterfaceListPage.relayCensus-11572.test.ts b/ui/packages/app-shell/src/views/InterfaceListPage.relayCensus-11572.test.ts index 8e354448602..d40fdf8d879 100644 --- a/ui/packages/app-shell/src/views/InterfaceListPage.relayCensus-11572.test.ts +++ b/ui/packages/app-shell/src/views/InterfaceListPage.relayCensus-11572.test.ts @@ -555,9 +555,12 @@ describe('objectui#11572 — the population is derived, never written down', () // something, or every check below would be vacuously true. These are // members the file plainly reads, through each read shape: a plain access, // an `as any` cast, a helper that takes the schema whole, the prop alias. + // (`options` left this list with objectstack#25923, which removed every read + // of the retired bag; `map` is read through `resolveListMapConfig`.) expect(POPULATION).toEqual( - expect.arrayContaining(['columns', 'kanban', 'tree', 'chart', 'grouping', 'rowColor', 'pagination', 'options', 'showViewSwitcher']), + expect.arrayContaining(['columns', 'kanban', 'tree', 'chart', 'map', 'grouping', 'rowColor', 'pagination', 'showViewSwitcher']), ); + expect(POPULATION).not.toContain('options'); }); it('the reading answers both ways: the §1 scan, run on a fixture, sees code and not prose, and flags an unfollowable escape', () => { @@ -608,7 +611,10 @@ describe('objectui#11572 — the population is derived, never written down', () }); it('re-derives the relay literal, and finds it reading both the view and the page', () => { - expect([...WRITTEN.keys()]).toEqual(expect.arrayContaining(['columns', 'kanban', 'options', 'userActions'])); + // objectstack#25923: the page sends one `map` block (its derivation with + // the authored keys laid over) and no `options` bag. + expect([...WRITTEN.keys()]).toEqual(expect.arrayContaining(['columns', 'kanban', 'map', 'userActions'])); + expect([...WRITTEN.keys()]).not.toContain('options'); expect([...VIEW_DERIVED]).toEqual(expect.arrayContaining(['view', 'appearance', 'allowed', 'kanban', 'columns'])); // `userActions` is the page's, and must not be taken for the view's. expect([...PAGE_DERIVED]).toContain('userActions'); diff --git a/ui/packages/app-shell/src/views/InterfaceListPage.treeChartRelay-11572.test.tsx b/ui/packages/app-shell/src/views/InterfaceListPage.treeChartRelay-11572.test.tsx index b3bebe253f0..7d09a21c8d2 100644 --- a/ui/packages/app-shell/src/views/InterfaceListPage.treeChartRelay-11572.test.tsx +++ b/ui/packages/app-shell/src/views/InterfaceListPage.treeChartRelay-11572.test.tsx @@ -31,7 +31,7 @@ * pointer or a measure — so the `derives(kind)` rule (objectui#10380) has * nothing to gate, and a whitelisted kind with no declared block reaches * `ListView` with no block at all, exactly as before. How the block then - * renders (`schema.tree || schema.options.tree`, and the chart binding) is + * renders (`schema.tree || schema.tree`, and the chart binding) is * `plugin-list`'s half, pinned there, and not re-pinned here. * * ## Why the schema is captured rather than rendered @@ -95,7 +95,7 @@ const VIEW_COLUMNS = ['name', 'status', 'amount']; const TREE_BLOCK = { parentField: 'parent_id' }; /** A spec-valid ADR-0021 chart block (the spec's view schema refuses the legacy axes). */ const CHART_BLOCK = { dataset: 'ds_duly_task', dimensions: ['status'], values: ['amount'] }; -/** A legacy `options` bag block, distinct from the top-level ones so a crossed wire fails. */ +/** A retired `options` bag block, distinct from the top-level ones so a crossed wire fails. */ const BAG_TREE = { parentField: 'legacy_parent' }; function objectWith(view: Record) { @@ -184,23 +184,24 @@ describe("InterfaceListPage relays its source view's tree and chart blocks (obje expect(schema.chart).toEqual(CHART_BLOCK); }); - it("PRECEDENCE: a row carrying both its own block and the legacy bag's sends both, and `ListView` lays the block over the bag", async () => { - // The bag is forwarded verbatim under `options` (objectui#10380); the - // block goes out at the top level, the slot `ListView` reads first. + it("RETIRED (objectstack#25923): a row carrying its own block and a legacy bag sends the block alone, and no `options`", async () => { + // The list view's `options` bag is retired: the server folds a stored bag + // onto the top-level blocks before the row is served, so the page relays + // the block and never a bag (it forwarded one verbatim under objectui#10380). const schema = await relayed( { type: 'tree', tree: TREE_BLOCK, options: { tree: BAG_TREE } }, whitelisting('tree'), ); expect(schema.tree).toEqual(TREE_BLOCK); - expect(schema.options?.tree).toEqual(BAG_TREE); + expect('options' in schema).toBe(false); }); - it('CONTROL: a row whose only tree config is the legacy bag sends no top-level block — the bag rules alone', async () => { - // Green on the base by construction. What it fails is a relay that writes - // `tree: undefined` or an empty block, which would read as a second source. + it('CONTROL: a row whose only tree config is a legacy bag sends neither a block nor the bag', async () => { + // What it fails is a relay that lifts the bag's block to the top level (the + // fold is the server's, on stored rows) or still forwards `options`. const schema = await relayed({ type: 'tree', options: { tree: BAG_TREE } }, whitelisting('tree')); expect('tree' in schema).toBe(false); - expect(schema.options?.tree).toEqual(BAG_TREE); + expect('options' in schema).toBe(false); }); it('CONTROL: no page-derived default exists for either kind — a whitelisted kind with no declared block gets no invented one', async () => { diff --git a/ui/packages/app-shell/src/views/InterfaceListPage.tsx b/ui/packages/app-shell/src/views/InterfaceListPage.tsx index 732462b3d45..1bf1fc2df73 100644 --- a/ui/packages/app-shell/src/views/InterfaceListPage.tsx +++ b/ui/packages/app-shell/src/views/InterfaceListPage.tsx @@ -439,17 +439,12 @@ export function InterfaceListPage({ page, className, onConfigChange, reserveEdit // object so the switcher actually offers (and renders) it. Only derive for // whitelisted types — an un-whitelisted viz is never reachable. // - // objectui#10380 — nor for a kind the stored row's legacy `options` bag - // carries. That bag is declared metadata: the view write door judges it - // key by key and stores it, and `ListView` lays the top-level block over it - // per key. A derived default placed at the top level would therefore - // OUTRANK the row's own declaration, and the object page, which derives - // nothing, would render the row differently. So a derived default fills a - // kind only when the row declares that kind nowhere. That is the rule - // `mapCfg` below already follows (`view.options.map ?? derived`). - const legacyOptions: Record = - view.options && typeof view.options === 'object' && !Array.isArray(view.options) ? view.options : {}; - const derives = (kind: string): boolean => allowedSet.has(kind) && legacyOptions[kind] === undefined; + // objectui#10380 also held a derivation back for a kind the stored row's + // legacy `options` bag carried. #25923 retired the bag: the server serves a + // stored row with it folded onto the top-level blocks (the spec's D2 + // conversion `list-view-options-bag-folded`), so each top-level block + // below is the row's whole declaration of its kind. + const derives = (kind: string): boolean => allowedSet.has(kind); const kanban = view.kanban ?? (derives('kanban') ? defaultKanbanFromObject(objectDef) : undefined); const calendar = @@ -460,17 +455,19 @@ export function InterfaceListPage({ page, className, onConfigChange, reserveEdit view.gallery ?? (derives('gallery') ? defaultGalleryFromObject(objectDef) : undefined); const gantt = view.gantt ?? (derives('gantt') ? defaultGanttFromObject(objectDef) : undefined); - // Map binding lives under options.map (locationField); auto-derive when - // whitelisted so a map interface page renders without hand-wiring. + // Map binding: auto-derive when whitelisted so a map interface page + // renders without hand-wiring. // - // The referenced view's own spec-level `map` block is NOT folded in here — - // it is forwarded as `map` on the schema below, so `ListView` merges it - // per key over this bag (objectui#5042). Collapsing the two with `??`, the - // way the sibling bindings above do, would make a partial authored block - // REPLACE the derivation: `map: { titleField: 'title' }` alone would drop - // the auto-derived `locationField` and the page would render no markers. - const mapCfg = - (view.options as any)?.map ?? (allowedSet.has('map') ? defaultMapFromObject(objectDef) : undefined); + // The referenced view's own `map` block is laid over the derivation PER + // KEY, not collapsed with `??` the way the sibling bindings above are: a + // partial authored block must not REPLACE the derivation, or + // `map: { titleField: 'title' }` alone would drop the auto-derived + // `locationField` and the page would render no markers (objectui#5042). + // Until #25923 the derivation rode the `options.map` bag and `ListView` + // merged the block over it; the bag is retired, so the merge is made here. + const derivedMap = derives('map') ? defaultMapFromObject(objectDef) : undefined; + const authoredMap = (view as any).map as Record | undefined; + const mapBlock = derivedMap || authoredMap ? { ...(derivedMap ?? {}), ...(authoredMap ?? {}) } : undefined; // Data semantics — ADR-0047 (revised): the PAGE owns its view metadata. // Precedence everywhere: the page's own config → legacy sourceView view @@ -535,9 +532,9 @@ export function InterfaceListPage({ page, className, onConfigChange, reserveEdit gallery, timeline, gantt, - // The spec's view-level `map` block (`ListMapConfigSchema`), forwarded - // verbatim so `ListView` can merge it over the `options.map` bag below. - ...((view as any).map ? { map: (view as any).map } : {}), + // The spec's view-level `map` block (`ListMapConfigSchema`) over the + // derived default, per key (`mapBlock` above). + ...(mapBlock ? { map: mapBlock } : {}), // objectui#11572 — the view's declared `tree` and `chart` blocks, carried // as the per-kind blocks above are. `appearance.allowedVisualizations` // may whitelist either kind, and without its block `ListView`'s tree @@ -547,11 +544,10 @@ export function InterfaceListPage({ page, className, onConfigChange, reserveEdit // `derives` has nothing to gate: the declared block alone is relayed, and // a whitelisted kind with no block reaches `ListView` with none. Each is // forwarded whole, a pointer rather than a projection of its keys, as the - // object page forwards the chart block (objectui#7823). `ListView` reads - // the top-level block first and the `options` bag below only without it. + // object page forwards the chart block (objectui#7823). No `options` bag + // is relayed: the spec retired it (#25923) and `ListView` reads none. ...(view.tree !== undefined ? { tree: view.tree } : {}), ...(view.chart !== undefined ? { chart: view.chart } : {}), - ...((mapCfg || (view.options as any)) ? { options: { ...((view.options as any) ?? {}), ...(mapCfg ? { map: mapCfg } : {}) } } : {}), // Presentation policy — the page layer (ADR-0047). userFilters: cfg.userFilters ?? view.userFilters, diff --git a/ui/packages/app-shell/src/views/ObjectView.chartRelay-7823.test.tsx b/ui/packages/app-shell/src/views/ObjectView.chartRelay-7823.test.tsx index 7c797b60ee4..9aa973ae27b 100644 --- a/ui/packages/app-shell/src/views/ObjectView.chartRelay-7823.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.chartRelay-7823.test.tsx @@ -227,11 +227,11 @@ async function mountObjectList(view: Record) { , ); - // `options` is built unconditionally by the same object literal as the rung + // `kanban` is built unconditionally by the same object literal as the rung // under test, so its arrival is the signal that the relay actually ran — // waiting on `options.chart` itself would hang rather than fail. await waitFor(() => { - expect(captured?.options).toBeTruthy(); + expect(captured?.kanban).toBeTruthy(); }); } @@ -296,7 +296,7 @@ describe('the object-view relay forwards the authored chart block WHOLE (objectu // object pins BOTH directions: no declared key is dropped, and no key the // author did not write is invented on the way through. await mountObjectList({ chart: ADR_0021_BLOCK }); - expect(captured.options.chart).toEqual(ADR_0021_BLOCK); + expect(captured.chart).toEqual(ADR_0021_BLOCK); }); it('THE FIX: the pointer carries a retired `categoryField` / `valueField` block whole; the gate offers it no toggle', async () => { @@ -307,7 +307,7 @@ describe('the object-view relay forwards the authored chart block WHOLE (objectu // arrives whole and is offered nothing. expect(await chartOffered({ chart: { categoryField: 'status', valueField: 'hours' } })).toBe(false); await mountObjectList({ chart: { categoryField: 'status', valueField: 'hours' } }); - expect(captured.options.chart).toEqual({ categoryField: 'status', valueField: 'hours' }); + expect(captured.chart).toEqual({ categoryField: 'status', valueField: 'hours' }); }); it('THE FIX: keys beyond the block are relayed verbatim, not whitelisted', async () => { @@ -316,7 +316,7 @@ describe('the object-view relay forwards the authored chart block WHOLE (objectu // grows a tenth, which is precisely the trap being closed. const block = { ...ADR_0021_BLOCK, filter: [{ field: 'status', op: 'eq', value: 'open' }], config: { stacked: true } }; await mountObjectList({ chart: block }); - expect(captured.options.chart).toEqual(block); + expect(captured.chart).toEqual(block); }); it('CONTROL: the retired `xAxisField` / `yAxisFields` block resolves no toggle (objectui#6152 round 15)', async () => { @@ -336,7 +336,7 @@ describe('the object-view relay forwards the authored chart block WHOLE (objectu // observable answer is the same — this pins that it stays the same. expect(await chartOffered({})).toBe(false); await mountObjectList({}); - expect(captured.options.chart).toBeUndefined(); + expect(captured.chart).toBeUndefined(); }); it('NEGATIVE CONTROL: an EMPTY chart block is offered no toggle', async () => { diff --git a/ui/packages/app-shell/src/views/ObjectView.densityModeThroughFold-7924.test.tsx b/ui/packages/app-shell/src/views/ObjectView.densityModeThroughFold-7924.test.tsx index a8d5f0a1412..7cccd83b029 100644 --- a/ui/packages/app-shell/src/views/ObjectView.densityModeThroughFold-7924.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.densityModeThroughFold-7924.test.tsx @@ -159,7 +159,7 @@ async function objectPageSchema(view: Record): Promise { // arrival is the signal the relay ran; waiting on the density itself would // hang rather than fail on a regression. await waitFor(() => { - expect(captured?.options).toBeTruthy(); + expect(captured?.kanban).toBeTruthy(); }); return captured; } diff --git a/ui/packages/app-shell/src/views/ObjectView.fallbackTabSessionOnly-11643.test.tsx b/ui/packages/app-shell/src/views/ObjectView.fallbackTabSessionOnly-11643.test.tsx index 624cd67eba1..a82605ca986 100644 --- a/ui/packages/app-shell/src/views/ObjectView.fallbackTabSessionOnly-11643.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.fallbackTabSessionOnly-11643.test.tsx @@ -282,7 +282,7 @@ async function openPage(meta: any, object: Record, viewId?: string) async function reload(meta: any, object: Record, viewId: string) { cleanup(); await openPage(meta, object, viewId); - await waitFor(() => expect(listSchema?.options).toBeTruthy()); + await waitFor(() => expect(listSchema?.kanban).toBeTruthy()); return (normalizeListViewSchema(listSchema) as { rowHeight?: unknown }).rowHeight; } diff --git a/ui/packages/app-shell/src/views/ObjectView.fieldOrderRelay-7516.test.tsx b/ui/packages/app-shell/src/views/ObjectView.fieldOrderRelay-7516.test.tsx index 7f04ddd55bb..b47f52e8bef 100644 --- a/ui/packages/app-shell/src/views/ObjectView.fieldOrderRelay-7516.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.fieldOrderRelay-7516.test.tsx @@ -185,11 +185,11 @@ async function relayed(objects: any[]): Promise { , ); - // `options` is built unconditionally by the same object literal as the rung + // `kanban` is built unconditionally by the same object literal as the rung // under test, so its arrival is the signal that the relay actually ran — // waiting on `fieldOrder` itself would hang rather than fail on a regression. await waitFor(() => { - expect(captured?.options).toBeTruthy(); + expect(captured?.kanban).toBeTruthy(); }); return captured; } diff --git a/ui/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts b/ui/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts index 99a604b9a4f..590fda34df5 100644 --- a/ui/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts +++ b/ui/packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts @@ -175,7 +175,7 @@ const MEMBERS: string[] = [...new Set([...MIRROR_MEMBERS, ...RUNTIME_MEMBERS])]. // --------------------------------------------------------------------------- interface Written { - /** `rowColor`, `options.kanban` — spreads of object literals are transparent. */ + /** `rowColor`, or `a.b` for a key of a nested literal — spreads of object literals are transparent. */ path: string; /** Identifiers appearing anywhere in the value expression. */ reads: string[]; @@ -397,14 +397,12 @@ type Absence = const ABSENCES: Record = { // ── The per-view-type blocks: no entry, they have top-level rungs ───────── // `kanban`, `calendar`, `gallery`, `gantt`, `timeline`, `tree`, `map` and - // `chart` used to sit here as `relayed-nested`: the relay writes each one into - // `options.KIND`, where `ListView` reads it. objectui#10380 gave each of them a - // conditional TOP-LEVEL rung as well. When the stored row's legacy `options` - // bag carries a kind, the view's own block for that kind is written at the top - // level, so `ListView` lays it over the bag per key. The census therefore - // counts all eight as relayed, and an entry here would contradict it. Their - // nested paths are still written for every row and still asserted, by name, - // in the derivation checks below (`options.kanban`, `options.chart`). + // `chart` used to sit here as `relayed-nested`: the relay wrote each one into + // `options.KIND`, where `ListView` read it, and objectui#10380 added a + // conditional top-level rung beside it. objectstack#25923 retired the bag: + // each block now has one unconditional TOP-LEVEL rung (held by value per key, + // objectui#10046), so the census counts all eight as relayed and an entry + // here would contradict it. // ── Supplied by the caller, from the active view, via `...listSchema` ───── // `plugin-view`'s ObjectView composes the list schema this relay spreads. For @@ -478,6 +476,7 @@ const ABSENCES: Record = { performance: { kind: 'unread', reason: 'Retired at spec 17.0.0 with no reader in either repo; declared only as the console\'s own refusal since the 18.2 age-out (objectstack#25767).' }, requiredPermissions: { kind: 'unread', reason: 'The ADR-0066 audience gate `@objectstack/spec` 18 adds to a list view (objectui#12030). The SERVER enforces it before the view is served: a user who lacks the capabilities is never listed the view and is refused it by name, so nothing on the ListView path has a value to consume.' }, filterableFields: { kind: 'unread', reason: 'Retired by objectstack#24640 (maintainer ruling C, pulled into 18.0): no per-view Filter-panel whitelist replaces it. objectui#13118 removed its one reader, the whitelist branch of `ListView`\'s Filter-panel field list, and this relay\'s rung with it. Present only as the spec import that carries its tombstone from the pin bump that takes the retirement.' }, + options: { kind: 'unread', reason: "Retired by objectstack#25923: the list view's `options` bag is the spec list overlay's tombstone, taken by reference, so every door refuses it by name. A stored bag is folded onto the top-level blocks by the server before the row is served, and objectstack#25923 removed every `ListView` read of it along with this relay's `options` rung." }, pageName: { kind: 'unread', reason: 'Page-context key with no reader on the list path.' }, tabs: { kind: 'unread', reason: "Spec view-tab list. ListView has no reader — the object page's tab bar is `ViewTabBar`, driven by `buildViewTabs`, and the `tabs` readers in the tree belong to `plugin-detail`'s DetailView." }, dataSource: { kind: 'unread', reason: "The spec's per-element binding (declared on the list-view arm by objectui#11070). `ListView` has no read of `schema.dataSource`: the binding is resolved one layer up, by the registered `list-view` renderer `ListViewBlock` through `ElementDataSourceGate`, and `renderListView` bypasses that layer. It renders `ListView` directly with `schema={fullSchema}` and takes the ADAPTER as its separate `dataSource` argument (`ds`), not off the node." }, @@ -553,12 +552,14 @@ describe('objectui#7559 — the population is derived, never written down', () = it('re-derives the relay rung set from the literal, and finds it reading the active view', () => { expect(WRITTEN.length).toBeGreaterThan(40); expect(RELAYED.length).toBeGreaterThan(30); - // The nested paths are part of the reading, so the derivation must reach them. - expect(WRITTEN.map((w) => w.path)).toEqual(expect.arrayContaining(['options.kanban', 'options.chart'])); + // The per-kind blocks are top-level rungs since objectstack#25923 (they + // were the `options.KIND` paths), so the derivation must reach them there. + expect(WRITTEN.map((w) => w.path)).toEqual(expect.arrayContaining(['kanban', 'chart'])); + expect(RELAYED).toEqual(expect.arrayContaining(['kanban', 'calendar', 'timeline', 'map', 'gallery', 'gantt', 'tree', 'chart'])); }); it('collects view-derived locals from the relay callback only', () => { - // `calendarOptions` is the one the reading needs (`options.calendar` is + // `calendarOptions` is the one the reading needs (`calendar` is // written from it). `lane` is a local of `kanbanViewOptions`, further down // the file, whose own parameter is also called `viewDef` — it must NOT be // here, or the census would accept an unrelated local as the view's value. diff --git a/ui/packages/app-shell/src/views/ObjectView.optionsBagParses-6152.test.ts b/ui/packages/app-shell/src/views/ObjectView.relayedBlocksParse-6152.test.ts similarity index 66% rename from ui/packages/app-shell/src/views/ObjectView.optionsBagParses-6152.test.ts rename to ui/packages/app-shell/src/views/ObjectView.relayedBlocksParse-6152.test.ts index a90a0c58cf6..e0c637ca52e 100644 --- a/ui/packages/app-shell/src/views/ObjectView.optionsBagParses-6152.test.ts +++ b/ui/packages/app-shell/src/views/ObjectView.relayedBlocksParse-6152.test.ts @@ -7,18 +7,20 @@ */ /** - * objectui#6152 round 12 — what the object page writes into the `options` bag - * it hands `ListView` is a bag `@object-ui/types` accepts. + * objectui#6152 round 12 — every block the object page hands `ListView` is a + * block `@object-ui/types` accepts. * - * `ListViewSchema.options` became the `@objectstack/spec` list overlay's own - * bag that round: strict, each kind its list-view block with every key - * optional. The census before it found three keys this page's producers wrote - * that the bag refuses (`kanban.cardFields`, `gallery.imageField`, + * The census before that round found three keys this page's producers wrote + * that the list view's blocks refuse (`kanban.cardFields`, `gallery.imageField`, * `timeline.descriptionField`), and the seat ruled the producer moves first * (Q1 → A on objectui#6152). So this file RUNS each exported producer on views - * declaring their kind's block in full, and parses the block it returns at the - * bag's own door for that kind. A producer that writes an undeclared key again - * reddens here by name. + * declaring their kind's block in full, and parses what it returns. A producer + * that writes an undeclared key again reddens here by name. + * + * objectstack#25923: the blocks used to travel under the list view's `options` + * bag and were parsed at the bag's door. The bag is retired, the page relays + * each block at the top level, so each is parsed at the list view's own + * top-level slot for its kind, the one door left. * * ⚠️ The render path parses nothing: the claim is about the contract this page's * output meets, not about anything that is enforced while it renders. @@ -39,13 +41,17 @@ import { const OBJECT_WITH_STAGE = { name: 'deal', fields: { name: { type: 'text' }, stage: { type: 'select' } } }; type Issue = { code: string; path: PropertyKey[]; keys?: string[] }; -const bag = (ListViewSchema.shape.options as unknown as { unwrap: () => { safeParse: (v: unknown) => { success: boolean; error?: { issues: Issue[] } } } }).unwrap(); -const verdictOf = (options: Record) => { - const r = bag.safeParse(options); - return r.success ? [] : r.error!.issues.map((i) => ({ code: i.code, path: i.path.join('.'), keys: i.keys })); +const NODE = { type: 'list-view', objectName: 'deal' }; +/** The issues the list view raises inside the relayed blocks, the node's own keys aside. */ +const verdictOf = (blocks: Record) => { + const r = ListViewSchema.safeParse({ ...NODE, ...blocks }); + if (r.success) return []; + return (r.error.issues as Issue[]) + .filter((i) => typeof i.path[0] === 'string' && i.path[0] in blocks) + .map((i) => ({ code: i.code, path: i.path.join('.'), keys: i.keys })); }; -describe('every block the object page synthesizes parses at the bag\'s door (objectui#6152 round 12)', () => { +describe('every block the object page synthesizes parses at its top-level slot (objectui#6152 round 12, objectstack#25923)', () => { it.each([ ['kanban, a declared block', { kanban: kanbanViewOptions({ kanban: { groupByField: 'stage', columns: ['name'], titleField: 'name', summarizeField: 'amount' } }, OBJECT_WITH_STAGE) }], ['kanban, the detector path', { kanban: kanbanViewOptions({}, OBJECT_WITH_STAGE) }], @@ -59,8 +65,8 @@ describe('every block the object page synthesizes parses at the bag\'s door (obj expect(verdictOf(options)).toEqual([]); }); - it('CONTROL: the door still refuses the three keys these producers used to write', () => { - // Without this, a bag that had stopped judging anything would satisfy every + it('CONTROL: the slots still refuse the three keys these producers used to write', () => { + // Without this, a slot that had stopped judging anything would satisfy every // row above. Each key is refused at its own kind, by name. expect(verdictOf({ kanban: { groupByField: 'stage', cardFields: ['name'] } })).toContainEqual( expect.objectContaining({ path: 'kanban.cardFields' }), diff --git a/ui/packages/app-shell/src/views/ObjectView.rowColorRelay-7218.test.tsx b/ui/packages/app-shell/src/views/ObjectView.rowColorRelay-7218.test.tsx index 9b384e03b2c..2a69f42ca5b 100644 --- a/ui/packages/app-shell/src/views/ObjectView.rowColorRelay-7218.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.rowColorRelay-7218.test.tsx @@ -223,11 +223,11 @@ async function relayed(objects: any[]): Promise { , ); - // `options` is built unconditionally by the same object literal as the rung + // `kanban` is built unconditionally by the same object literal as the rung // under test, so its arrival is the signal that the relay actually ran — // waiting on `rowColor` itself would hang rather than fail on a regression. await waitFor(() => { - expect(captured?.options).toBeTruthy(); + expect(captured?.kanban).toBeTruthy(); }); return captured; } diff --git a/ui/packages/app-shell/src/views/ObjectView.storedOptionsBag-10380.test.tsx b/ui/packages/app-shell/src/views/ObjectView.storedRowParity-10380.test.tsx similarity index 81% rename from ui/packages/app-shell/src/views/ObjectView.storedOptionsBag-10380.test.tsx rename to ui/packages/app-shell/src/views/ObjectView.storedRowParity-10380.test.tsx index 4d30eccbd53..e9d5923f402 100644 --- a/ui/packages/app-shell/src/views/ObjectView.storedOptionsBag-10380.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.storedRowParity-10380.test.tsx @@ -61,6 +61,18 @@ * well. `ListView` reads `groupByField` as a flat timeline key off the * top-level block only, so the merged block exposed the bag's key on the object * page alone. Both runs are quoted in the PR body. + * + * ## objectstack#25923: the bag is folded on read, and neither page reads it + * + * The spec retired the bag. Its ADR-0087 D2 conversion + * `list-view-options-bag-folded` folds each stored `options.KIND` onto the + * top-level `KIND` block, the top-level value winning per key, before a row is + * served, and both pages stopped forwarding `options`. So every row below now + * reaches the pages as SERVED: run through the spec's own stored-item + * conversion first. The cases and their expectations are unchanged; the + * precedence the two pages used to reach through `ListView`'s merge is now the + * fold's. The last block pins that a bag the server never folded is read by + * neither page. */ import * as React from 'react'; @@ -151,13 +163,21 @@ vi.mock('@object-ui/react', async (importOriginal) => { }); import { ComponentRegistry } from '@object-ui/core'; -import { ObjectView, storedLegacyOptions } from './ObjectView'; +import { applyConversionsToStoredItem } from '@objectstack/spec'; +import { ObjectView } from './ObjectView'; import { InterfaceListPage } from './InterfaceListPage'; import { ExpressionProvider } from '../providers/ExpressionProvider'; const OBJECT_NAME = 'parity_task'; const VIEW_KEY = `${OBJECT_NAME}.ops`; +/** A stored flat list overlay as the server serves it: after the spec's stored-item conversions. */ +function served(row: Record): Record { + const out = applyConversionsToStoredItem('view', structuredClone({ name: VIEW_KEY, object: OBJECT_NAME, viewKind: 'list', ...row })); + const { object: _o, viewKind: _k, ...overlay } = out as Record; + return overlay; +} + /** One stored flat list overlay, as the console merges it into `listViews`. */ function objectsWith(row: Record) { return [ @@ -205,9 +225,9 @@ async function objectPageSchema(row: Record): Promise { , ); - // `options` is written by the same literal as every rung under test, so its - // arrival says the relay ran. - await waitFor(() => expect(capturedListSchema?.options).toBeTruthy()); + // `kanban` is built unconditionally by the same relay as every block under + // test, so its arrival says the relay ran. + await waitFor(() => expect(capturedListSchema?.kanban).toBeTruthy()); const schema = capturedListSchema; cleanup(); return schema; @@ -293,8 +313,9 @@ const RENDERERS: Record map: { rendererType: 'object-map', project: mapConfig }, }; -/** What each page's renderer receives for one stored row. */ -async function bothPages(row: Record) { +/** What each page's renderer receives for one stored row, served (folded) unless `asWritten`. */ +async function bothPages(stored: Record, asWritten = false) { + const row = asWritten ? stored : served(stored); const viewType = row.type as string; const { rendererType, project } = RENDERERS[viewType]; const objectSchema = await objectPageSchema(row); @@ -386,18 +407,38 @@ describe('objectui#10380 — the top-level block wins per key over the bag, on b kanban: { groupByField: 'region', columns: ['name'] }, options: { kanban: { groupByField: 'status' } }, }); - expect(objectPage).toEqual(interfacePage); expect(objectPage.groupBy).toBe('region'); + expect(interfacePage.groupBy).toBe('region'); + expect(objectPage.cardFields).toEqual(interfacePage.cardFields); + // objectstack#25923: the card title no longer agrees, and that is not the + // fold's doing. The object page floors an undeclared kanban `titleField` at + // `name` (pinned in `ObjectView.kanbanLane-8193`) and the interface page + // sends none, which already holds on main for every kanban row without a + // bag. This row agreed only because a bag's block used to replace the + // object page's synthesized one; served, it has no bag. Pinned as observed + // so a change to either side is seen here. + expect(objectPage.titleField).toBe('name'); + expect(interfacePage.titleField).toBeUndefined(); }); }); -describe('storedLegacyOptions — what counts as a bag', () => { - it('a plain object is the bag; anything else is no bag', () => { - const bag = { kanban: { titleField: 'region' } }; - expect(storedLegacyOptions({ options: bag })).toBe(bag); - expect(storedLegacyOptions({})).toEqual({}); - expect(storedLegacyOptions(undefined)).toEqual({}); - expect(storedLegacyOptions({ options: ['kanban'] })).toEqual({}); - expect(storedLegacyOptions({ options: 'kanban' })).toEqual({}); +describe('objectstack#25923 — a bag the server never folded is read by neither page', () => { + it('CONTROL: a timeline title carried only in an unfolded bag reaches neither renderer', async () => { + const { objectPage, interfacePage } = await bothPages( + { type: 'timeline', options: { timeline: { titleField: 'region' } } }, + true, + ); + // The two pages derive different defaults for a row with no timeline block + // (a page-side difference this card does not touch), so only the bag's own + // key is compared: neither page reads it, flat or in the block. + for (const node of [objectPage, interfacePage]) { + expect(node.titleField).not.toBe('region'); + expect(node.timeline?.titleField).not.toBe('region'); + } + }); + + it('CONTROL: the same row as served carries the title on both pages — the fold is what moves it', async () => { + const row = served({ type: 'timeline', options: { timeline: { titleField: 'region' } } }); + expect(row).toEqual({ name: VIEW_KEY, type: 'timeline', timeline: { titleField: 'region' } }); }); }); diff --git a/ui/packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx b/ui/packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx index 0a8846a0604..7672937f703 100644 --- a/ui/packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.titleFieldConvergence.test.tsx @@ -196,9 +196,10 @@ async function resolveSeams(objects: any[]) { , ); await waitFor(() => { - expect(captured?.options).toBeTruthy(); + expect(captured?.kanban).toBeTruthy(); }); - const o = captured.options; + // The per-kind blocks ride the list schema's top level since #25923. + const o = captured; return { timeline: o.timeline?.titleField, kanban: o.kanban?.titleField, @@ -243,7 +244,7 @@ describe('ObjectView view-config `titleField` — the middle rung is gone (objec gallery: 'name', tree: 'name', // objectui#7029: the calendar seam no longer exists — an undeclared view - // gets no `options.calendar` bag, so there is no title to floor. + // gets no `calendar` block, so there is no title to floor. calendar: undefined, gantt: 'name', }); @@ -259,7 +260,7 @@ describe('ObjectView view-config `titleField` — the middle rung is gone (objec gallery: 'name', tree: 'name', // objectui#7029: the calendar seam no longer exists — an undeclared view - // gets no `options.calendar` bag, so there is no title to floor. + // gets no `calendar` block, so there is no title to floor. calendar: undefined, gantt: 'name', }); diff --git a/ui/packages/app-shell/src/views/ObjectView.toolbarWritesCompose-11642.test.tsx b/ui/packages/app-shell/src/views/ObjectView.toolbarWritesCompose-11642.test.tsx index 03c87e42141..620cce2eb42 100644 --- a/ui/packages/app-shell/src/views/ObjectView.toolbarWritesCompose-11642.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.toolbarWritesCompose-11642.test.tsx @@ -288,7 +288,7 @@ async function openView(meta: any, viewId: string) { async function reload(meta: any, viewId: string) { cleanup(); await openView(meta, viewId); - await waitFor(() => expect(listSchema?.options).toBeTruthy()); + await waitFor(() => expect(listSchema?.kanban).toBeTruthy()); return { rowHeight: (normalizeListViewSchema(listSchema) as { rowHeight?: unknown }).rowHeight, hiddenFields: listSchema.hiddenFields, diff --git a/ui/packages/app-shell/src/views/ObjectView.tsx b/ui/packages/app-shell/src/views/ObjectView.tsx index 9d49e515032..cdf5b541d3d 100644 --- a/ui/packages/app-shell/src/views/ObjectView.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.tsx @@ -96,14 +96,14 @@ export { // objectstack#25992 slice 2: the view-options family and `dispatchViewPatches` // moved to `./objectViewOptions.ts`; every name exported here before stays -// exported from here. +// exported from here, except `storedLegacyOptions`, retired with the `options` +// bag it read (#25923). export { timelineViewOptions, calendarViewOptions, ganttViewOptions, galleryViewOptions, kanbanViewOptions, - storedLegacyOptions, dispatchViewPatches, } from './objectViewOptions.js'; diff --git a/ui/packages/app-shell/src/views/ObjectView.viewDescriptionRelay-7199.test.tsx b/ui/packages/app-shell/src/views/ObjectView.viewDescriptionRelay-7199.test.tsx index 6874843380b..60491da47d8 100644 --- a/ui/packages/app-shell/src/views/ObjectView.viewDescriptionRelay-7199.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.viewDescriptionRelay-7199.test.tsx @@ -205,11 +205,11 @@ async function relayedDescription(objects: any[]): Promise { , ); - // `options` is built unconditionally by the same object literal as the rung + // `kanban` is built unconditionally by the same object literal as the rung // under test, so its arrival is the signal that the relay actually ran — // waiting on `description` itself would hang rather than fail on a regression. await waitFor(() => { - expect(captured?.options).toBeTruthy(); + expect(captured?.kanban).toBeTruthy(); }); return captured.description; } diff --git a/ui/packages/app-shell/src/views/ObjectView.viewRowDeclaredReads-11013.test.tsx b/ui/packages/app-shell/src/views/ObjectView.viewRowDeclaredReads-11013.test.tsx index 6515d3dbb83..dc291384ea0 100644 --- a/ui/packages/app-shell/src/views/ObjectView.viewRowDeclaredReads-11013.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectView.viewRowDeclaredReads-11013.test.tsx @@ -185,7 +185,7 @@ async function objectPageSchema(view: Record): Promise { // `options` is written by the relay literal unconditionally, so its arrival // is the signal the relay ran. await waitFor(() => { - expect(captured?.options).toBeTruthy(); + expect(captured?.kanban).toBeTruthy(); }); return captured; } diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.optionsFold-7928.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.foldedOnRead-25923.test.tsx similarity index 57% rename from ui/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.optionsFold-7928.test.tsx rename to ui/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.foldedOnRead-25923.test.tsx index b2f8ddccce2..0feb36db2e1 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.optionsFold-7928.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.foldedOnRead-25923.test.tsx @@ -1,23 +1,24 @@ // 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`. + * objectstack#25923 — a stored list overlay's `options` bag is folded on READ, + * by the server, so `ViewPreview` relays the body it is handed and folds + * nothing. * - * 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. + * objectui#7928 (Director ruling, comment 5856694523, Q1 A) put a fold here: + * the named-view record is the protocol's strict `ObjectListViewSchema` by + * reference, which refuses `options`, and `plugin-view` reads no `options` off + * a named view, so `ViewPreview` lifted each stored `options.KIND` onto the + * top-level `KIND` block, the TOP-LEVEL value winning per key. objectstack#25923 + * retired the bag: the spec's ADR-0087 D2 conversion + * `list-view-options-bag-folded` does that same fold before a stored row is + * served, and this door's `foldStoredListOptions` was deleted. * - * 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. + * The lane cases below are the objectui#7928 pins (themselves the inverted + * objectui#8980 legacy-nesting pins) with each stored row run through the + * spec's OWN stored-item conversion first, which is the body this preview now + * receives. The node `ViewPreview` hands `SchemaRenderer` is then rendered + * through the REAL `plugin-view` `ObjectView`, as before. */ import * as React from 'react'; @@ -25,7 +26,8 @@ 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'; +import { applyConversionsToStoredItem } from '@objectstack/spec'; +import { ViewPreview } from './ViewPreview'; /** Every schema handed to `SchemaRenderer`, in order. */ const rendered: any[] = []; @@ -80,57 +82,62 @@ async function renderedKindNode(node: any): Promise { return rendered[rendered.length - 1]; } +/** + * The `config` body the preview receives for a stored flattened list overlay: + * the row as the server serves it, after the spec's stored-item conversions. + */ +function served(body: Record): Record { + const row = applyConversionsToStoredItem('view', structuredClone({ name: 'task.board', object: 'task', viewKind: 'list', ...body })); + const { name: _n, object: _o, viewKind: _k, ...config } = row as Record; + return config; +} + 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', () => { +describe('objectstack#25923 — the stored row arrives folded, and `ViewPreview` relays it as served', () => { + it('a body with no `options` is served unchanged, and relayed as the named view with its label', () => { const body = { type: 'kanban', columns: ['name'], kanban: { groupByField: 'stage', columns: ['name'] } }; - expect(foldStoredListOptions(body)).toBe(body); + expect(served(body)).toEqual(body); + const node = previewNode(served(body)); + expect(node.listViews.board).toEqual({ ...body, label: 'board' }); }); - 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('each stored `options.KIND` is served as the top-level `KIND` block, and `options` is gone', () => { + const config = served({ type: 'kanban', columns: ['name'], options: { kanban: { groupByField: 'legacy_lane' } } }); + expect(config.kanban).toEqual({ groupByField: 'legacy_lane' }); + expect(config).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({ + const config = served({ type: 'kanban', columns: ['name'], kanban: { groupByField: 'canonical_lane' }, options: { kanban: { groupByField: 'legacy_lane', titleField: 'subject' } }, }); - expect(folded.kanban).toEqual({ groupByField: 'canonical_lane', titleField: 'subject' }); + expect(config.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 }); + const config = served({ columns: ['name'], options }); + for (const k of kinds) expect(config[k], k).toEqual({ marker: k }); + expect(config).not.toHaveProperty('options'); }); 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); + // The bag carried per-kind blocks and nothing else (objectstack#20051); the + // conversion leaves the rest in place for the author, it invents no key. + const config = served({ columns: ['name'], options: { grid: { x: 1 }, zzqxNotAKind: true } }); + expect(config).not.toHaveProperty('grid'); + expect(config).not.toHaveProperty('zzqxNotAKind'); }); }); -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' } } }); +describe('objectui#7928 — the inverted objectui#8980 legacy-nesting pins, on the served row', () => { + it("the legacy nesting alone resolves the lane — through the server's fold, on the real renderer", async () => { + const node = previewNode(served({ 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); @@ -139,51 +146,49 @@ describe('objectui#7928 — the inverted objectui#8980 legacy-nesting pins, poin }); it('the canonical block WINS for a key both spell', async () => { - const node = previewNode({ + const node = previewNode(served({ 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({ + const node = previewNode(served({ 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 () => { + it('CONTROL: `ViewPreview` folds nothing — an UNFOLDED 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', - }); + // the preview as written, skipping the server's conversion. The renderer + // reads no `options` off a named view (objectui#7928) and this door no + // longer lifts the bag (objectstack#25923), so only the served fold makes + // the cases above pass. + const node = previewNode({ type: 'kanban', columns: ['name'], options: { kanban: { groupByField: 'legacy_lane' } } }); + expect(node.listViews.board).not.toHaveProperty('kanban'); + const kanban = await renderedKindNode(node); 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({ + it('a stored body carrying the legacy bag, as served, becomes a named view `ObjectViewSchema` parses green', () => { + const node = previewNode(served({ 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); }); diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.tsx index 1e00494e774..368bf88f5fe 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/ViewPreview.tsx @@ -65,55 +65,6 @@ export 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 objectstack#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 // section's fields resolve (otherwise every section comes up empty). @@ -179,9 +130,11 @@ export function ViewPreview({ name, draft, editing, locale }: MetadataPreviewPro ); // Surface the draft body as a named listView so the preview renders THIS - // 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). + // view (with unsaved edits) rather than the object's saved default. A stored + // `options.KIND` bag was folded onto the top-level blocks here + // (objectui#7928) until #25923: the server now serves a stored row with the + // bag already folded (the spec's D2 conversion `list-view-options-bag-folded`), + // and the view door refuses the bag on save. const { listViews, defaultViewId, defaultViewType } = React.useMemo(() => { if (!body) { return { listViews: {}, defaultViewId: undefined, defaultViewType: 'grid' }; @@ -190,7 +143,7 @@ export function ViewPreview({ name, draft, editing, locale }: MetadataPreviewPro return { listViews: { [id]: { - ...foldStoredListOptions(body), + ...body, label: (body as any).label ?? (draft as any).label ?? name, }, }, diff --git a/ui/packages/app-shell/src/views/objectViewListRenderer.tsx b/ui/packages/app-shell/src/views/objectViewListRenderer.tsx index b7355bc6886..4d7f0cb648f 100644 --- a/ui/packages/app-shell/src/views/objectViewListRenderer.tsx +++ b/ui/packages/app-shell/src/views/objectViewListRenderer.tsx @@ -33,7 +33,6 @@ import { ganttViewOptions, galleryViewOptions, kanbanViewOptions, - storedLegacyOptions, isSameOptionsValue, } from './objectViewOptions.js'; import { keepInlineEditModeForTheSession } from './objectViewChrome.js'; @@ -142,12 +141,13 @@ export function useObjectViewListRenderer({ // A boolean, so the relay keys on a value (AGENTS.md #10). const activeViewIsSaved = !!baseView && isSavedViewId(savedViews, baseView.id); - // objectui#10046 — the last `options` bag handed to `ListView`, so an - // equal-content rebuild keeps its identity (see `isSameOptionsValue`). + // objectui#10046 — the last per-kind blocks handed to `ListView` (the + // `options` bag until #25923), so an equal-content rebuild keeps their + // identities (see `isSameOptionsValue`). // A ref, not a `useMemo`: React may discard a memo cache and hand back a // fresh object (AGENTS.md #10); a ref survives every re-render, and the // value comparison — not the ref — is what decides a real change. - const heldListOptions = useRef(undefined); + const heldListOptions = useRef>({}); // objectui#7237 — the same hold for the resolved `filter`, same reason: // token substitution hands back a NEW array of identical content on every // call, and `ListView` names `schema.filter` by identity in its fetch @@ -327,9 +327,6 @@ export function useObjectViewListRenderer({ // objectui#7029: present only when the view actually declared one. const calendarOptions = calendarViewOptions(viewDef); - // objectui#10380: the stored row's judged legacy `options` bag. See - // `storedLegacyOptions` for how the relay below layers it. - const legacyOptions = storedLegacyOptions(viewDef); /** * objectui#12474 — the Hide Fields popover's choice is the USER's: it @@ -402,6 +399,17 @@ export function useObjectViewListRenderer({ * ledger is not a place to record a rung you did not feel like adding: * the kinds carry evidence, and each one is checked. */ + // objectui#10046 — equal content keeps the identity `ListView`'s fetch + // effect names for each per-kind block (it names each one by key); a + // real change (any differing value) still hands over the new block and + // still refetches. Pinned by `ObjectView.hostRerenderRefetch-10046.test.tsx`. + const heldKindBlocks = heldListOptions.current; + const holdBlock = (kind: string, block: T): T => { + if (isSameOptionsValue(heldKindBlocks[kind], block)) return heldKindBlocks[kind] as T; + heldKindBlocks[kind] = block; + return block; + }; + const fullSchema: ListViewSchema = { ...listSchema, // objectui#10035 — the refresh signal, in place of the remount the @@ -709,135 +717,110 @@ export function useObjectViewListRenderer({ // URL-carried sort first, this spread overrode it on every view // that declares a sort, so a link's sort reached the address bar // and never the query. The rung is the one `sort` write. - // objectui#10380 — for each kind the stored row's legacy `options` - // bag carries, the view's own top-level block goes out at the top - // level, as `InterfaceListPage` sends it. `ListView` then lays it - // over the bag per key: the top-level block wins where both set a - // key, which is the precedence the spec declares for the bag. A kind - // the bag does not carry is relayed only through `options` below, - // exactly as before. See `storedLegacyOptions`. - ...(legacyOptions.kanban !== undefined ? { kanban: viewDef.kanban } : {}), - ...(legacyOptions.calendar !== undefined ? { calendar: viewDef.calendar } : {}), - ...(legacyOptions.timeline !== undefined ? { timeline: viewDef.timeline } : {}), - ...(legacyOptions.map !== undefined ? { map: viewDef.map } : {}), - ...(legacyOptions.gallery !== undefined ? { gallery: viewDef.gallery } : {}), - ...(legacyOptions.gantt !== undefined ? { gantt: viewDef.gantt } : {}), - ...(legacyOptions.tree !== undefined ? { tree: viewDef.tree } : {}), - ...(legacyOptions.chart !== undefined ? { chart: viewDef.chart } : {}), - options: { - // objectui#10380 — the stored row's judged legacy bag, forwarded - // as the interface page forwards it. For each kind the bag - // carries, its block REPLACES this page's synthesized block below - // (the `mapCfg` rule of `InterfaceListPage`). Each synthesized - // block stands in only for a kind the bag does not carry. - ...legacyOptions, - // The lane key is the spec's `groupByField`, not the legacy - // `groupField` this used to write. See `kanbanViewOptions`. - kanban: legacyOptions.kanban ?? kanbanViewOptions(viewDef, objectDef), - // The calendar config the view DECLARED, or no calendar key at - // all — never an invented field name (objectui#7029). With the - // key absent, ListView's capability gate stops offering the - // Calendar toggle for a view that configured none, and a view - // forced onto the calendar renderer reaches its refusal screen. - ...((legacyOptions.calendar ?? calendarOptions) - ? { calendar: legacyOptions.calendar ?? calendarOptions } - : {}), - // The date axis is resolved once, in ListView — this face only - // forwards what the view declared, floored at 'name' - // (objectui#3129, objectui#6557). See `timelineViewOptions`. - timeline: legacyOptions.timeline ?? timelineViewOptions(viewDef), - map: legacyOptions.map ?? { - locationField: viewDef.map?.locationField, - titleField: viewDef.map?.titleField || 'name', - latitudeField: viewDef.map?.latitudeField, - longitudeField: viewDef.map?.longitudeField, - zoom: viewDef.map?.zoom, - center: viewDef.map?.center, - }, - // The gallery config the view DECLARED, title floored at - // 'name', and no invented cover field (objectui#7547). With no - // cover binding to forward, ListView's capability gate stops - // offering the Gallery toggle to a view that configured none. - // See `galleryViewOptions`. - gallery: legacyOptions.gallery ?? galleryViewOptions(viewDef), - // The gantt config the view DECLARED, title floored at 'name' — - // never an invented date field (`5f4514f7b`). With no date - // binding to forward, ListView's capability gate stops offering - // the Gantt toggle to a view that configured none, and a view - // forced onto the gantt renderer reaches its refusal screen. - // See `ganttViewOptions`. - gantt: legacyOptions.gantt ?? ganttViewOptions(viewDef), - tree: legacyOptions.tree ?? { - // Self-referencing tree-grid config (plugin-tree). Spread the - // full view-defined tree first so parentField/fields/ - // defaultExpandedDepth survive; labelField is floored at - // 'name'. parentField auto-detects when omitted. - // - // Read AS `TreeViewConfig` (`@object-ui/types`, objectui#8253): - // `viewDef` is `Record`, so the canonical rung - // below was an `any` property access and a misspelling was - // invisible. This is the half of objectui#7559 a declaration CAN - // close, on the one block that now has a declaration to close it - // with — it does NOT make a missing rung visible, which is what - // the census pin (`ObjectView.relayRungCensus-7559.test.ts`) is - // for. - // - // ⚠️ The cast is repeated per rung rather than hoisted into a - // local: objectui#6557's convergence pin reads these seam lines - // out of this file and requires each to name `viewDef` itself. - // - // ⛔ No `titleField` rung (objectui#6152 round 14). `TreeViewConfig` - // is the spec's `ListView.tree` block, which refuses `titleField` - // by name (objectui#8841); the undeclared fallback that still - // read it is retired here and in `ListView` alike, so a stored - // `tree.titleField` labels nothing and the tree falls to 'name'. - ...((viewDef.tree as TreeViewConfig | undefined) || {}), - labelField: (viewDef.tree as TreeViewConfig | undefined)?.labelField || 'name', - }, - // The chart block the view DECLARED, forwarded WHOLE — a - // pointer, not a copy of its key set (objectui#7823). + // [#25923] The eight per-kind blocks go out at the TOP level, the + // one place `ListView` reads them. They rode the legacy `options` + // bag until the spec retired it; a stored row's own bag is folded + // onto its top-level blocks by the server before it reaches this + // page (the spec's D2 conversion `list-view-options-bag-folded`), so + // `viewDef` is the row's whole declaration. All eight keys are set, + // so a block `listSchema` carries for the host `views` entry never + // outranks what this page relays (it never did through the bag). + // The lane key is the spec's `groupByField`, not the legacy + // `groupField` this used to write. See `kanbanViewOptions`. + kanban: holdBlock('kanban', kanbanViewOptions(viewDef, objectDef)), + // The calendar config the view DECLARED, or `undefined` — never + // an invented field name (objectui#7029). With no block, + // ListView's capability gate stops offering the + // Calendar toggle for a view that configured none, and a view + // forced onto the calendar renderer reaches its refusal screen. + calendar: holdBlock('calendar', calendarOptions), + // The date axis is resolved once, in ListView — this face only + // forwards what the view declared, floored at 'name' + // (objectui#3129, objectui#6557). See `timelineViewOptions`. + timeline: holdBlock('timeline', timelineViewOptions(viewDef)), + map: holdBlock('map', { + locationField: viewDef.map?.locationField, + titleField: viewDef.map?.titleField || 'name', + latitudeField: viewDef.map?.latitudeField, + longitudeField: viewDef.map?.longitudeField, + zoom: viewDef.map?.zoom, + center: viewDef.map?.center, + }), + // The gallery config the view DECLARED, title floored at + // 'name', and no invented cover field (objectui#7547). With no + // cover binding to forward, ListView's capability gate stops + // offering the Gallery toggle to a view that configured none. + // See `galleryViewOptions`. + gallery: holdBlock('gallery', galleryViewOptions(viewDef)), + // The gantt config the view DECLARED, title floored at 'name' — + // never an invented date field (`5f4514f7b`). With no date + // binding to forward, ListView's capability gate stops offering + // the Gantt toggle to a view that configured none, and a view + // forced onto the gantt renderer reaches its refusal screen. + // See `ganttViewOptions`. + gantt: holdBlock('gantt', ganttViewOptions(viewDef)), + tree: holdBlock('tree', { + // Self-referencing tree-grid config (plugin-tree). Spread the + // full view-defined tree first so parentField/fields/ + // defaultExpandedDepth survive; labelField is floored at + // 'name'. parentField auto-detects when omitted. // - // This was a hand-listed projection of six keys (`chartType` / - // `xAxisField` / `yAxisFields` / `aggregation` / `series` / - // `config`): the PRE-ADR-0021 set, frozen. The whole ADR-0021 - // (objectstack-ai/objectstack#1890) authoring shape (`dataset` / `dimensions` / `values`) - // and the legacy `categoryField` / `valueField` spelling had no - // rung here, so an author who declared them reached `ListView` - // with the binding stripped. Once objectui#7544 gave - // `ListView.availableViews` a chart capability check, that gate - // was handed six `undefined` keys and correctly answered - // "nothing declared" about a view whose author declared - // everything — the Chart toggle stayed hidden, with no - // diagnostic, while the legacy `xAxisField` spelling (which the - // projection did carry) went on resolving. + // Read AS `TreeViewConfig` (`@object-ui/types`, objectui#8253): + // `viewDef` is `Record`, so the canonical rung + // below was an `any` property access and a misspelling was + // invisible. This is the half of objectui#7559 a declaration CAN + // close, on the one block that now has a declaration to close it + // with — it does NOT make a missing rung visible, which is what + // the census pin (`ObjectView.relayRungCensus-7559.test.ts`) is + // for. // - // ⛔ NOT widened to nine keys. A hand-listed projection is a - // COPY of a schema's key set and copies rot silently: three more - // keys would buy this ADR's correctness and re-arm the identical - // trap for the next block key, with nothing to fire — `viewDef` - // is `Record`, so a missing rung is invisible to - // tsc (objectui#7559 owns that mechanism and disclaims the - // census this belongs to). + // ⚠️ The cast is repeated per rung rather than hoisted into a + // local: objectui#6557's convergence pin reads these seam lines + // out of this file and requires each to name `viewDef` itself. // - // Forwarding whole is safe because `ListView` never SPREADS this - // block — `resolveListChartBinding` and `case 'chart'` both read - // it BY NAME — so keys it does not consult are ignored exactly - // as they are for the `gantt` / `timeline` / `tree` blocks above, - // which are already relayed whole. Undeclared stays `undefined` - // rather than the old permanently-truthy husk, which is what - // keeps the gate from offering a chart nobody configured. - chart: legacyOptions.chart ?? viewDef.chart, - }, + // ⛔ No `titleField` rung (objectui#6152 round 14). `TreeViewConfig` + // is the spec's `ListView.tree` block, which refuses `titleField` + // by name (objectui#8841); the undeclared fallback that still + // read it is retired here and in `ListView` alike, so a stored + // `tree.titleField` labels nothing and the tree falls to 'name'. + ...((viewDef.tree as TreeViewConfig | undefined) || {}), + labelField: (viewDef.tree as TreeViewConfig | undefined)?.labelField || 'name', + }), + // The chart block the view DECLARED, forwarded WHOLE — a + // pointer, not a copy of its key set (objectui#7823). + // + // This was a hand-listed projection of six keys (`chartType` / + // `xAxisField` / `yAxisFields` / `aggregation` / `series` / + // `config`): the PRE-ADR-0021 set, frozen. The whole ADR-0021 + // (objectstack-ai/objectstack#1890) authoring shape (`dataset` / `dimensions` / `values`) + // and the legacy `categoryField` / `valueField` spelling had no + // rung here, so an author who declared them reached `ListView` + // with the binding stripped. Once objectui#7544 gave + // `ListView.availableViews` a chart capability check, that gate + // was handed six `undefined` keys and correctly answered + // "nothing declared" about a view whose author declared + // everything — the Chart toggle stayed hidden, with no + // diagnostic, while the legacy `xAxisField` spelling (which the + // projection did carry) went on resolving. + // + // ⛔ NOT widened to nine keys. A hand-listed projection is a + // COPY of a schema's key set and copies rot silently: three more + // keys would buy this ADR's correctness and re-arm the identical + // trap for the next block key, with nothing to fire — `viewDef` + // is `Record`, so a missing rung is invisible to + // tsc (objectui#7559 owns that mechanism and disclaims the + // census this belongs to). + // + // Forwarding whole is safe because `ListView` never SPREADS this + // block — `resolveListChartBinding` and `case 'chart'` both read + // it BY NAME — so keys it does not consult are ignored exactly + // as they are for the `gantt` / `timeline` / `tree` blocks above, + // which are already relayed whole. Undeclared stays `undefined` + // rather than the old permanently-truthy husk, which is what + // keeps the gate from offering a chart nobody configured. + chart: holdBlock('chart', viewDef.chart), + }; - // objectui#10046 — equal content keeps the identity `ListView`'s fetch - // effect names; a real change (any differing value) still hands over - // the new bag and still refetches. Pinned by - // `ObjectView.hostRerenderRefetch-10046.test.tsx`. - if (isSameOptionsValue(heldListOptions.current, fullSchema.options)) { - fullSchema.options = heldListOptions.current as ListViewSchema['options']; - } else { - heldListOptions.current = fullSchema.options; - } return ( { } /** - * The `options.calendar` config this page hands to `ListView` — or NOTHING. + * The `calendar` block this page hands to `ListView` — or NOTHING. * * objectui#7029 (ruled on objectstack#13748, director batch #19, option A). * This face used to fabricate `startDateField: 'due_date'` and @@ -119,7 +120,7 @@ export function calendarViewOptions(viewDef: any): Record | und } /** - * The `options.gantt` config this page hands to `ListView`. + * The `gantt` block this page hands to `ListView`. * * `5f4514f7b` — the same class objectui#7029 removed from the calendar branch, * reported separately by PR #7062 rather than fixed alongside it. This face used @@ -158,7 +159,7 @@ export function ganttViewOptions(viewDef: any): Record { } /** - * The `options.gallery` config this page hands to `ListView`. + * The `gallery` block this page hands to `ListView`. * * objectui#7547 — the last surviving member of the objectui#7029 / `5f4514f7b` / * #7500 class on this face. It floored `imageField` at `'image'` for EVERY @@ -259,7 +260,8 @@ export function galleryViewOptions(viewDef: any): Record { * `{groupBy, groupByField}` did not. The gate now reads both spellings out of * the bag, the same one-question-two-sites repair objectui#5042 made for `map` * and objectui#7544 for `chart`. Pinned in `ObjectView.kanbanLane-8193` here - * and in `ListView.kanbanOptionsBagCanonical-8193` in `plugin-list`. + * and in `ListView.kanbanOptionsBagCanonical-8193` in `plugin-list` (renamed + * `ListView.kanbanBlockCanonical-8193` when objectstack#25923 retired the bag). * * ⭐ `groupBy` IS RETIRED HERE (objectui#8213) — the THIRD spelling of the one * concept this expression already writes canonically. Measured against @@ -276,7 +278,8 @@ export function galleryViewOptions(viewDef: any): Record { * found NO runtime producer but this one; the lit control on the same * instrument (`groupByField` in the same position) returned 58 sites, so the * empty reading is a reading. The only other authors are FIXTURES: the two arms - * of `ListView.kanbanOptionsBagCanonical-8193` that pin this very bag, and one + * of `ListView.kanbanOptionsBagCanonical-8193` (now `kanbanBlockCanonical-8193`) + * that pin this very bag, and one * stale metadata payload in `data-objectstack`'s `updateView.draft.test.ts`. * The sibling producer `defaultKanbanFromObject` emits `{ groupByField }` alone * and always has. @@ -325,8 +328,7 @@ export function galleryViewOptions(viewDef: any): Record { * * objectui#11629: `summarizeField` (the spec's "Field to sum at top of column") * is relayed only when the view declares it. This block is the only kanban - * config `ListView` receives on this page when the stored row carries no - * `options.kanban` bag. Without the relay, the key never reached `ListView`'s + * config `ListView` receives on this page. Without the relay, the key never reached `ListView`'s * projection or the board's column headers, so a view that declared it * rendered counts with no totals. Like the lane, an absent key stays absent: * no default field is invented. @@ -351,46 +353,13 @@ export function kanbanViewOptions(viewDef: any, objectDef: any): Record { - const bag = viewDef && typeof viewDef === 'object' ? (viewDef as { options?: unknown }).options : undefined; - return bag && typeof bag === 'object' && !Array.isArray(bag) ? (bag as Record) : {}; -} - -/** - * objectui#10046 — is `a` the same `options` bag as `b`, compared by VALUE? - * (objectui#7237 holds the resolved `filter` through the same comparison.) - * - * `renderListView` rebuilds `fullSchema.options` as a fresh object literal on + * `renderListView` rebuilds the per-kind blocks as fresh object literals on * every call, and `plugin-view`'s `ObjectView` calls it on every one of its - * renders. `ListView` names `schema.options` BY IDENTITY in its fetch effect's + * renders. `ListView` names each block BY IDENTITY in its fetch effect's * dependency list (objectui#4567 ruled that dependency correct and put * stabilisation at the PRODUCER), so each host re-render that changed nothing * re-issued the identical `dataSource.find`: one extra at mount (the record diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.target.test.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.target.test.tsx index 4898815baf4..d670e75cdc4 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.target.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.target.test.tsx @@ -67,10 +67,13 @@ describe('namedListViewOf — the canvas copy of a view item (objectui#11823)', ).toEqual({ type: 'grid', columns: ['x'], label: 'All' }); }); - it('folds a stored `options.KIND` bag as the Studio view preview folds it', () => { - expect( - namedListViewOf({ name: 'a.b', config: { type: 'kanban', columns: [], options: { kanban: { groupByField: 's' } } } }), - ).toEqual({ type: 'kanban', columns: [], kanban: { groupByField: 's' } }); + it('lifts nothing out of an `options` bag: the server folds a stored bag before the row arrives (objectstack#25923)', () => { + // The Studio view preview folded the bag here until the spec retired it; + // the D2 conversion `list-view-options-bag-folded` now folds a stored bag + // onto the top-level blocks on read, so neither door does it again. + const view = namedListViewOf({ name: 'a.b', config: { type: 'kanban', columns: [], options: { kanban: { groupByField: 's' } } } }); + expect(view).not.toHaveProperty('kanban'); + expect(view).toMatchObject({ type: 'kanban', columns: [] }); }); }); diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx index 1569e15919f..f11f2f826e3 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx @@ -67,7 +67,6 @@ import { NavigationModeSchema, resolveI18nLabel, type I18nLabel, type Navigation import { InspectorReorderButtons, InspectorShell } from '../metadata-admin/inspectors/_shared.js'; import { FieldsListEditor } from '../metadata-admin/previews/FieldsListEditor.js'; import { useObjectFields } from '../metadata-admin/previews/useObjectFields.js'; -import { foldStoredListOptions } from '../metadata-admin/previews/ViewPreview.js'; import { colFieldName } from '../metadata-admin/previews/view-column-io.js'; import { WIDGETS, type ObjectFieldOption } from '../metadata-admin/widgets.js'; import { failed, loaded, type LoadState } from '../metadata-admin/loadState.js'; @@ -180,14 +179,14 @@ function withListBody(row: Row, body: Row): Row { } /** - * The view as one entry of `object-view`'s `listViews`: its list body, with a - * stored `options.KIND` bag folded as the Studio's view preview folds it - * (`foldStoredListOptions`), and the item's label. + * The view as one entry of `object-view`'s `listViews`: its list body and the + * item's label. (A stored `options.KIND` bag is folded onto the body's blocks + * by the server before the row reaches here, #25923.) */ export function namedListViewOf(row: Row): Row { const body = listBodyOf(row); const label = row.label ?? body.label; - return { ...foldStoredListOptions(body), ...(label !== undefined ? { label } : {}) }; + return { ...body, ...(label !== undefined ? { label } : {}) }; } /** diff --git a/ui/packages/plugin-list/src/ListView.tsx b/ui/packages/plugin-list/src/ListView.tsx index a654ba927fc..eeef7467482 100644 --- a/ui/packages/plugin-list/src/ListView.tsx +++ b/ui/packages/plugin-list/src/ListView.tsx @@ -51,7 +51,7 @@ import { RecordDetailPanel } from '@object-ui/plugin-detail'; /** * The `case 'map'` branch below builds an `object-map` schema by flattening - * `schema.options.map`'s CONTENTS to the top level — one entry per key + * the view's `map` block's CONTENTS to the top level — one entry per key * `ObjectMapConfigSchema` declares, written under the name the FLAT form uses * for that key. Whitelisted rather than a whole-bag spread: `style` is ALSO * `BaseSchema.style` (inline CSS, legal on every node), and spreading the raw @@ -136,38 +136,32 @@ function pickFlatMapConfig( /** * The effective map configuration for a list view: the spec's VIEW-LEVEL `map` - * block merged OVER the legacy `options.map` bag, per key. + * block, through the flat whitelist. * * `map` is `ListMapConfigSchema` (objectstack#9340, consumable here since the * `@objectstack/spec` 17.1.0 pin) — a strict, seven-key block that flows into * this package's own `ListViewSchema` by reference (it is not in * `LIST_VIEW_LOCAL_OVERRIDES`, so `specFieldsExcept` imports it). It was - * authorable and validated but never read: `case 'map'` forwarded only - * `schema.options?.map`, so declaring it changed nothing at runtime + * authorable and validated but never read: `case 'map'` forwarded only the + * legacy `options.map` bag, so declaring it changed nothing at runtime * (objectui#5042). * - * PRECEDENCE — the view-level block wins, per key. Both halves of that are the - * convention already set by every sibling visualization in this file, not a new - * rule: `kanban`, `calendar`, `gallery`, `timeline` and `gantt` each spread - * `schema.options?.` FIRST and `schema.` LAST, which is a per-key - * override in the view-level block's favour. (`tree` and `chart` also put the - * view-level block first, but with `||` — whole-block replacement rather than a - * merge. The direction is unanimous across all seven; only the granularity - * differs, and this follows the five that merge, which are also the five that - * flatten config into props the way the map branch does.) + * ONE SOURCE (#25923). This merged the block OVER the legacy `options.map` bag, + * per key, until the spec retired the bag: the list overlay refuses `options`, + * and a stored bag is folded onto the top-level block (per key, the block + * winning — this function's old precedence) by the spec's ADR-0087 D2 + * conversion `list-view-options-bag-folded` before a row is served. So the + * block is all there is to read, here and in every sibling visualization. * - * Both sides go through the same whitelist, so the typed block cannot - * reintroduce the `style` namespace collision that objectui#5177 closed. + * The block goes through the whitelist, so it cannot reintroduce the `style` + * namespace collision that objectui#5177 closed. * * NOT a second validation of the seven keys — that reading belongs to * `getMapConfig` in `ObjectMap.tsx` and stays there (objectui#5018). This is * the whitelist-flatten that already existed, applied to one more source. */ -function resolveListMapConfig(schema: { map?: unknown; options?: { map?: unknown } }): Record { - return { - ...pickFlatMapConfig(schema.options?.map), - ...pickFlatMapConfig(schema.map), - }; +function resolveListMapConfig(schema: { map?: unknown }): Record { + return pickFlatMapConfig(schema.map); } /** @@ -186,11 +180,11 @@ function resolveListMapConfig(schema: { map?: unknown; options?: { map?: unknown * back to `['grid']` (ADR-0047, whitelist ∩ resolvable). Same shape and same * fix as `map`'s (objectui#5042, `resolveListMapConfig` above). * - * PRECEDENCE is the render branch's existing one, not a new rule: the - * view-level block replaces the legacy `options.chart` bag WHOLESALE (`||`) — - * `chart` and `tree` are the two visualizations in this file that replace - * rather than merge, and the gate inherits whichever the renderer uses so the - * two can never judge different configs. + * ONE SOURCE (#25923): the view-level `chart` block. It used to replace the + * legacy `options.chart` bag WHOLESALE (`||`); the spec retired the bag, and its + * D2 fold keeps that precedence for a stored row (a bag beside a block is + * dropped, a bag alone becomes the block). The gate and the render branch read + * the same block, so the two can never judge different configs. * * `resolves` is the "renders from names the AUTHOR wrote" question: an ADR-0021 * (objectstack-ai/objectstack#1890) `dataset` with at least one measure in @@ -201,8 +195,7 @@ function resolveListMapConfig(schema: { map?: unknown; options?: { map?: unknown * axes (`xAxisField` / `categoryField`, `yAxisFields` / `valueField`, * `aggregation`) were the legacy leg of this resolver, read in both nestings; * every door refuses them by name (the spec's `ListChartConfigSchema` is a - * strict object of `chartType` / `dataset` / `dimensions` / `values`, and the - * bag is that block's own partial), so the leg and its `'name'` / `'value'` + * strict object of `chartType` / `dataset` / `dimensions` / `values`), so the leg and its `'name'` / `'value'` * floors retired with the render path they fed. A block that names no * `dataset` is `'unbound'`: the gate never offers a switch into it, and a view * whose `viewType` forces it reaches `case 'chart'`'s unbound node, which @@ -210,7 +203,7 @@ function resolveListMapConfig(schema: { map?: unknown; options?: { map?: unknown * nobody wrote. */ interface ListChartBinding { - /** The effective chart block — view-level `chart`, else the `options.chart` bag. */ + /** The view-level `chart` block. */ config: Record; /** `'dataset'` when the block names an ADR-0021 `dataset`, else `'unbound'`. */ shape: 'dataset' | 'unbound'; @@ -222,8 +215,8 @@ interface ListChartBinding { values: string[]; } -function resolveListChartBinding(schema: { chart?: unknown; options?: { chart?: unknown } }): ListChartBinding { - const config: Record = (schema.chart || schema.options?.chart || {}) as Record; +function resolveListChartBinding(schema: { chart?: unknown }): ListChartBinding { + const config: Record = (schema.chart || {}) as Record; if (config.dataset) { const dimensions: string[] = Array.isArray(config.dimensions) ? config.dimensions : []; @@ -756,7 +749,7 @@ export function resolveAddRecordPlacement(position: unknown): { top: boolean; bo * the capability gate (may this view offer the Timeline visualization?) and the * timeline render branch (which field does it bucket by?). Those two used to * carry separate, unequal source lists, and the gate was the wider of the pair — - * it accepted `options.calendar.startDateField` as a timeline-resolvable axis + * it accepted the calendar's `startDateField` as a timeline-resolvable axis * while the render branch never read calendar config at all. A view that binds * its dates under `calendar` therefore got the Timeline option offered and then * bucketed every record into "No date" (objectui#3129), which is exactly the @@ -768,9 +761,10 @@ export function resolveAddRecordPlacement(position: unknown): { top: boolean; bo * `defaultCalendarFromObject` helper it uses for calendars. What was missing is * that one of the two read-sites never honoured the promise the other made. * - * Both nestings are read at each level: the spec-authored `schema.timeline` / - * `schema.calendar` and the legacy `schema.options.*` twin that app-shell's - * object pages still emit. Each by its spec key only. The pre-#2231 `dateField` + * The spec-authored blocks are read, `schema.timeline` before `schema.calendar`, + * each by its spec key only. The legacy `schema.options.*` twin was read too + * until #25923 retired the bag (a stored bag is folded onto the blocks by the + * spec's D2 conversion `list-view-options-bag-folded`). The pre-#2231 `dateField` * alias for `startDateField` had a rung here until objectui#6152 round 14 * retired it: every list-view door refuses it by name on all four blocks, and * the calendar branch already ignored it (objectui#8355), so one stored row @@ -783,12 +777,7 @@ export function resolveTimelineDateBinding(schema: any): { endDateField?: string; titleField?: string; } { - const sources = [ - schema?.timeline, - schema?.options?.timeline, - schema?.calendar, - schema?.options?.calendar, - ]; + const sources = [schema?.timeline, schema?.calendar]; const pick = (read: (src: any) => unknown): string | undefined => { for (const src of sources) { if (!src) continue; @@ -1221,13 +1210,15 @@ const DEFAULT_LIST_DISPLAY_PAGE_SIZE = readSpecDisplayPageSize(); export const DEFAULT_LIST_FETCH_BATCH_SIZE = 100; /** - * The view's kanban block, as the board branch reads it: the spec's block over - * the legacy `options.kanban` bag, per key (objectui#10380). One reading for the - * branch and for `kanbanLanesPaged`, so the lane the board is built on and the - * lane its query is gated on are one answer. + * The view's kanban block, as the board branch reads it: the spec's block + * alone. It was merged over the legacy `options.kanban` bag, per key, until + * #25923 retired the bag (objectui#10380's underlay); a stored bag is folded + * onto the block by the spec's D2 conversion before a row is served. One + * reading for the branch and for `kanbanLanesPaged`, so the lane the board is + * built on and the lane its query is gated on are one answer. */ function kanbanBlockOf(schema: ListViewSchema): Record { - return { ...((schema.options as any)?.kanban || {}), ...((schema as any).kanban || {}) }; + return { ...((schema as any).kanban || {}) }; } /** @@ -2534,15 +2525,10 @@ export const ListView = React.forwardRef(({ } }; collectViewFields((schema as any).kanban); - collectViewFields((schema as any).options?.kanban); collectViewFields((schema as any).calendar); - collectViewFields((schema as any).options?.calendar); collectViewFields((schema as any).gallery); - collectViewFields((schema as any).options?.gallery); collectViewFields((schema as any).timeline); - collectViewFields((schema as any).options?.timeline); collectViewFields((schema as any).gantt); - collectViewFields((schema as any).options?.gantt); // [objectui#10370] The map, read through the resolver its render branch // and capability gate share — see the `$select` twin below for why the // resolver rather than the two blocks one at a time. A coordinate or title @@ -2613,7 +2599,6 @@ export const ListView = React.forwardRef(({ (schema as any).timeline, (schema as any).gantt, schema.map, - (schema as any).options, perms, schema.objectName, ]); @@ -2778,10 +2763,8 @@ export const ListView = React.forwardRef(({ * draws by. Declared bindings only (objectui#7029): an undeclared one stays * absent. ⚠️ Change one pair, change both. */ - const calendarStartField: string | undefined = - schema.calendar?.startDateField || schema.options?.calendar?.startDateField || undefined; - const calendarEndField: string | undefined = - schema.calendar?.endDateField || schema.options?.calendar?.endDateField || undefined; + const calendarStartField: string | undefined = schema.calendar?.startDateField || undefined; + const calendarEndField: string | undefined = schema.calendar?.endDateField || undefined; /** * Does this view fetch a DATE WINDOW for the calendar it hosts? @@ -3110,13 +3093,9 @@ export const ListView = React.forwardRef(({ for (const f of candidates) addSpeculative(f); }; collectViewFields(schema.kanban); - collectViewFields(schema.options?.kanban); collectViewFields(schema.calendar); - collectViewFields(schema.options?.calendar); collectViewFields(schema.gallery); - collectViewFields(schema.options?.gallery); collectViewFields(schema.timeline); - collectViewFields(schema.options?.timeline); // Timeline plugin shows status / priority chips inline. Auto-include // them for every timeline view so views like `task_timeline` // ({ columns: ['subject', 'status'] }) still get priority badges out @@ -3130,14 +3109,13 @@ export const ListView = React.forwardRef(({ // timeline no longer reads. The chips are always the built-in pair // now, so their values are always fetched. { - const tCfg = schema.timeline ?? schema.options?.timeline; + const tCfg = schema.timeline; if (tCfg) { addSpeculative('status'); addSpeculative('priority'); } } collectViewFields(schema.gantt); - collectViewFields(schema.options?.gantt); // [objectui#10370] The MAP's bindings, which this collector had no // arm for: a map view whose columns omit its location field asked // for `id` plus those columns, and on a backend that honours @@ -3145,11 +3123,8 @@ export const ListView = React.forwardRef(({ // markers, no error, no warning. // // Read through `resolveListMapConfig`, the resolver `case 'map'` and - // the capability gate already share, not through `schema.map` and - // `schema.options?.map` one at a time: it reads both of those, per - // key with the view-level block winning, so the projection asks for - // exactly the bindings the markers are drawn from. A bag value the - // block shadows is read by nothing and is not requested. A top-level + // the capability gate already share, so the projection asks for + // exactly the bindings the markers are drawn from. A top-level // `locationField` is no spelling at all — the spec's list view // refuses it by name and no map branch reads it — so it stays out. // @@ -3607,7 +3582,7 @@ export const ListView = React.forwardRef(({ // silently un-suppresses nothing, because the finding it was suppressing // simply moves elsewhere. Add prose ABOVE this point, never below it. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [schema.objectName, schema.data, dataSource, authoredFilter, effectivePageSize, currentSort, appliedFilters, appliedUserFilterConditions, refreshKey, searchTerm, schema.searchableFields, schema.columns, (schema as any).kanban, (schema as any).calendar, (schema as any).gallery, (schema as any).timeline, (schema as any).gantt, schema.map, (schema as any).options, objectDef?.fields, objectDefLoaded, schema.refreshTrigger, perms, fetchSkip, groupingConfig, predicateProjectionKey, invalidationNonce, groupingNeedsHeaderQuery, calendarWindowKey, rowsCarryConcurrencyToken]); // Re-fetch on filter/sort/search/refreshTrigger/perms/window change; `rowsCarryConcurrencyToken` (objectui#12292) re-reads the rows with their token when inline edit turns on + }, [schema.objectName, schema.data, dataSource, authoredFilter, effectivePageSize, currentSort, appliedFilters, appliedUserFilterConditions, refreshKey, searchTerm, schema.searchableFields, schema.columns, (schema as any).kanban, (schema as any).calendar, (schema as any).gallery, (schema as any).timeline, (schema as any).gantt, schema.map, objectDef?.fields, objectDefLoaded, schema.refreshTrigger, perms, fetchSkip, groupingConfig, predicateProjectionKey, invalidationNonce, groupingNeedsHeaderQuery, calendarWindowKey, rowsCarryConcurrencyToken]); // Re-fetch on filter/sort/search/refreshTrigger/perms/window change; `rowsCarryConcurrencyToken` (objectui#12292) re-reads the rows with their token when inline edit turns on // Any change to the result-defining inputs (object, filters, sort, search, // grouping, page size) invalidates the current page number — snap back to @@ -3637,28 +3612,25 @@ export const ListView = React.forwardRef(({ // Check for Kanban capabilities (spec config takes precedence) // - // Both nestings, by the spec key. The `options.kanban` bag was once asked - // for the LEGACY `groupField` only, so a producer writing the SPEC key into - // the bag was invisible here while rendering perfectly — objectui#5042 - // (`map`) and objectui#7544 (`chart`) a third time: the gate and the seam - // must answer one question (objectui#8193). The `groupField` alias rungs - // retired with the render branch's (objectui#6152 round 14), so the gate - // still asks exactly what the branch below reads. - if (schema.kanban?.groupByField || schema.options?.kanban?.groupByField) { + // By the spec key, of the block the branch below reads: the gate and the + // seam must answer one question (objectui#8193). The `groupField` alias + // rungs retired with the render branch's (objectui#6152 round 14), and the + // `options.kanban` bag with every other bag read (#25923). + if (schema.kanban?.groupByField) { resolvable.push('kanban'); } // Check for Gallery capabilities (spec config takes precedence) // - // Both nestings, by the spec's `coverField` (the bag's rung is objectui#6152 - // round 12, the objectui#8193 repair a fourth time). The `imageField` alias - // rungs retired with the render branch's (objectui#6152 round 14). - if (schema.gallery?.coverField || schema.options?.gallery?.coverField) { + // By the spec's `coverField`. The `imageField` alias rungs retired with the + // render branch's (objectui#6152 round 14), the bag's rung with the bag + // (#25923). + if (schema.gallery?.coverField) { resolvable.push('gallery'); } // Check for Calendar capabilities (spec config takes precedence) - if (schema.calendar?.startDateField || schema.options?.calendar?.startDateField) { + if (schema.calendar?.startDateField) { resolvable.push('calendar'); } @@ -3670,28 +3642,25 @@ export const ListView = React.forwardRef(({ } // Check for Gantt capabilities (spec config takes precedence) - if (schema.gantt?.startDateField || schema.options?.gantt?.startDateField) { + if (schema.gantt?.startDateField) { resolvable.push('gantt'); } // Check for Map capabilities (spec config takes precedence) // - // Asked of the SAME merged config the render branch forwards - // (`resolveListMapConfig`), not of `options.map` alone: the gate and the - // seam must answer one question, or a view that binds its coordinates in + // Asked of the SAME config the render branch forwards + // (`resolveListMapConfig`): the gate and the seam must answer one question, or a view that binds its coordinates in // the view-level `map` block renders fine but is filtered out of // `allowedVisualizations` below — whitelist ∩ resolvable — and falls back // to `['grid']`. That is what made the spec block inert for the SWITCHER // even where the forward alone would have been enough (objectui#5042). - // Sharing the resolver also means a split binding (`latitudeField` on the - // block, `longitudeField` in the bag) is judged the way it will render. const mapConfig = resolveListMapConfig(schema); if (mapConfig.locationField || (mapConfig.latitudeField && mapConfig.longitudeField)) { resolvable.push('map'); } // Check for Tree capabilities — a self-referencing parent pointer. - if ((schema as any).tree?.parentField || schema.options?.tree?.parentField || schema.viewType === 'tree') { + if ((schema as any).tree?.parentField || schema.viewType === 'tree') { resolvable.push('tree'); } @@ -3732,7 +3701,7 @@ export const ListView = React.forwardRef(({ } return resolvable; - }, [schema.options, schema.viewType, schema.kanban, schema.calendar, schema.gantt, schema.gallery, schema.timeline, schema.map, (schema as any).tree, (schema as any).chart, schema.appearance?.allowedVisualizations]); + }, [schema.viewType, schema.kanban, schema.calendar, schema.gantt, schema.gallery, schema.timeline, schema.map, (schema as any).tree, (schema as any).chart, schema.appearance?.allowedVisualizations]); /** * Whether the visualization switcher is actually WORTH drawing (objectui#7547). @@ -4157,8 +4126,8 @@ export const ListView = React.forwardRef(({ ...(schema.bulkActions ? { batchActions: schema.bulkActions } : {}), ...((schema as any).bulkActionDefs ? { bulkActionDefs: (schema as any).bulkActionDefs } : {}), // ⛔ No `options.grid` spread (objectui#6152 round 14). A grid has no - // per-kind block: the spec's list-overlay bag and `ListViewSchema.options` - // refuse `options.grid` (round 12), and the raw spread that still put + // per-kind block: the spec refuses the whole `options` bag (#25923; + // `options.grid` since round 12), and the raw spread that still put // its keys onto this node, after the node's own, is retired with the // other legacy readers. A grid's settings are top-level keys of the view. }; @@ -4245,12 +4214,9 @@ export const ListView = React.forwardRef(({ // ⚠️ The fetch windows on these same two reads, made again at component // scope as `calendarStartField` / `calendarEndField` (objectui#12081): // change one pair, change both. - const startDateField = - schema.calendar?.startDateField || schema.options?.calendar?.startDateField; - const endDateField = - schema.calendar?.endDateField || schema.options?.calendar?.endDateField; - const titleField = - schema.calendar?.titleField || schema.options?.calendar?.titleField; + const startDateField = schema.calendar?.startDateField; + const endDateField = schema.calendar?.endDateField; + const titleField = schema.calendar?.titleField; // ⭐ THIS BRANCH FORWARDS THE BLOCK'S DECLARED KEYS ONLY. It used to end // with two raw spreads of the authored block, which is how `dateField` / // `endField` reached the generated `object-calendar` node WITHOUT this @@ -4267,10 +4233,10 @@ export const ListView = React.forwardRef(({ // // ⛔ STRIPPING IS ONLY THE QUIET HALF. The loud half is the read door: // `@object-ui/types` declares both spellings as `aliasKeyRefusal()` arms - // on the view-level calendar block, on the legacy `options.calendar` - // nesting and on the flat node face, so an author meets a by-name - // refusal naming `startDateField` / `endDateField` instead of a calendar - // that silently stops binding. Removing one half without the other is + // on the view-level calendar block and on the flat node face (the + // legacy `options.calendar` nesting is refused whole since #25923), so + // an author meets a by-name refusal naming `startDateField` / + // `endDateField` instead of a calendar that silently stops binding. Removing one half without the other is // what broke a live authoring path once already. // // ⛔ Deliberately NOT folded onto the canonical keys. Option A @@ -4278,10 +4244,8 @@ export const ListView = React.forwardRef(({ // state: it keeps a second spelling alive at the producer, which is the // lenient alias AGENTS.md #0.1 names. The reads are canonical-only, so a // fold would re-create the dialect this closes. - const colorField = - schema.calendar?.colorField || schema.options?.calendar?.colorField; - const allDayField = - schema.calendar?.allDayField || schema.options?.calendar?.allDayField; + const colorField = schema.calendar?.colorField; + const allDayField = schema.calendar?.allDayField; // ⭐ objectstack#25923 — the five go out NESTED, as the node's own // `calendar` block: the spec's `ComponentPropsMap['object-calendar']` // refuses them flat, so the published `ObjectCalendarSchema` no longer @@ -4314,9 +4278,8 @@ export const ListView = React.forwardRef(({ }; } case 'gallery': { - // Merge spec config over legacy options into nested gallery prop + // The spec's gallery block into the nested gallery prop const mergedGallery = { - ...(schema.options?.gallery || {}), ...(schema.gallery || {}), // User's runtime override from the toolbar density button wins // over schema defaults. Persisted to localStorage in ListView. @@ -4333,16 +4296,13 @@ export const ListView = React.forwardRef(({ // refuses by name. gallery: Object.keys(mergedGallery).length > 0 ? mergedGallery : undefined, // Deprecated top-level prop for backward compat - titleField: schema.gallery?.titleField || schema.options?.gallery?.titleField || 'name', + titleField: schema.gallery?.titleField || 'name', ...(groupingConfig ? { grouping: groupingConfig } : {}), }; } case 'timeline': { - // Merge spec config over legacy options into nested timeline prop - const mergedTimeline = { - ...(schema.options?.timeline || {}), - ...(schema.timeline || {}), - }; + // The spec's timeline block into the nested timeline prop + const mergedTimeline = { ...(schema.timeline || {}) }; // The one resolution the capability gate above also uses (objectui#3129). const dateBinding = resolveTimelineDateBinding(schema); // The resolved axis has to appear on the NESTED config too, not just on @@ -4377,7 +4337,7 @@ export const ListView = React.forwardRef(({ // and made its refusal screen (objectui#7459, step ① of the same // ruling) unreachable from this route. Worse, the axis it invented was // never FETCHED: the `$select` projection is collected from the - // DECLARED `timeline` / `options.timeline` blocks above, never from + // DECLARED `timeline` block above, never from // this prop — so an undeclared view rendered a timeline bound to a // column the query had not requested and bucketed every record into // "No date". Absent is now absent, and the renderer says which keys to @@ -4432,8 +4392,8 @@ export const ListView = React.forwardRef(({ // config: `getGanttConfig` gates on the two date fields alone, so the // refusal screen stays exactly as reachable as it was (pinned in // `plugin-gantt/src/ObjectGantt.unconfiguredRefusal-7070.test.tsx`). - const startDateField = schema.gantt?.startDateField || schema.options?.gantt?.startDateField; - const endDateField = schema.gantt?.endDateField || schema.options?.gantt?.endDateField; + const startDateField = schema.gantt?.startDateField; + const endDateField = schema.gantt?.endDateField; // ⭐ objectstack#25923 — the block goes out NESTED, as the node's own // `gantt`: `ComponentPropsMap['object-gantt']` refuses every // `GanttConfig` key written flat, so the published `ObjectGanttSchema` @@ -4454,14 +4414,14 @@ export const ListView = React.forwardRef(({ const ganttBlock = startDateField && endDateField ? { - // The block's DECLARED keys, bag under block per key, exactly as - // the two raw spreads this replaced merged them (objectui#6152 - // round 14). See `GANTT_CONFIG_SPELLING` for what the spreads - // let through. - ...pickFlatMapConfig({ ...(schema.options?.gantt || {}), ...(schema.gantt || {}) }, GANTT_CONFIG_SPELLING), + // The block's DECLARED keys (objectui#6152 round 14). See + // `GANTT_CONFIG_SPELLING` for what the raw spreads this replaced + // let through. The `options.gantt` bag that sat under the block + // per key is retired (#25923). + ...pickFlatMapConfig(schema.gantt || {}, GANTT_CONFIG_SPELLING), startDateField, endDateField, - titleField: schema.gantt?.titleField || schema.options?.gantt?.titleField || 'name', + titleField: schema.gantt?.titleField || 'name', } : undefined; return { @@ -4542,17 +4502,14 @@ export const ListView = React.forwardRef(({ case 'map': { // Whitelisted flatten (objectui#5177) — see `FLAT_MAP_CONFIG_SPELLING`, // which also carries `style` out as `mapStyle` (objectui#9950). - // `schema.options.map` is an untyped bag; a raw spread here forwarded - // every key the author wrote, including `style`, which `ObjectMap`'s - // `FlatMapConfigKeys` declares OUT of this flat form. - // - // The spec's view-level `map` block merges over that bag — see - // `resolveListMapConfig` for the precedence and its sibling evidence. + // A raw spread of the block forwarded every key the author wrote, + // including `style`, which `ObjectMap`'s `FlatMapConfigKeys` declares + // OUT of this flat form. The block is the one source — see + // `resolveListMapConfig` (the `options.map` bag retired in #25923). // // Emitted in the FLAT form, deliberately, exactly as before: a nested - // `map` key would win OUTRIGHT at `getMapConfig` (objectui#5018), which - // would turn this per-key merge into whole-block replacement of the bag - // and would trip `warnOnShadowedFlatMapKeys`. That precedence rule is + // `map` key would win OUTRIGHT at `getMapConfig` (objectui#5018) and + // would trip `warnOnShadowedFlatMapKeys`. That precedence rule is // written around the flatten product — "neither flattener emits a `map` // key at all" — and this branch keeps that true. // @@ -4581,10 +4538,10 @@ export const ListView = React.forwardRef(({ }; } case 'tree': { - // Self-referencing tree-grid. Config lives under view.tree.* (direct) - // or options.tree.* (app-shell object pages). parentField auto-detects + // Self-referencing tree-grid. Config lives under view.tree.* (the + // `options.tree` bag retired in #25923). parentField auto-detects // from the object's tree/self-reference field when omitted. - const treeCfg = (schema as any).tree || schema.options?.tree || {}; + const treeCfg = (schema as any).tree || {}; return { type: 'object-tree', ...baseProps, diff --git a/ui/packages/plugin-list/src/__tests__/ListView.calendar-binding-7029.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.calendar-binding-7029.test.tsx index ffef4b9bc86..35392548d14 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.calendar-binding-7029.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.calendar-binding-7029.test.tsx @@ -145,12 +145,13 @@ describe('ListView calendar branch — only ever restates a DECLARED binding (ob expect(props.titleField).toBe('name'); }); - it('CONTROL: forwards the legacy `options.calendar` nesting unchanged', async () => { - // The nesting app-shell's object page emits. A correctly configured view - // renders exactly as it did before this card. + it('CONTROL: forwards the block app-shell\'s object page relays unchanged', async () => { + // The block that page emits: under the legacy `options.calendar` nesting + // until objectstack#25923 retired the bag, at the top level since. A + // correctly configured view renders exactly as it did before this card. const props = await calendarProps({ ...BASE, - options: { calendar: { startDateField: 'start_date', endDateField: 'end_date', colorField: 'status' } }, + calendar: { startDateField: 'start_date', endDateField: 'end_date', colorField: 'status' }, }); expect(props.startDateField).toBe('start_date'); expect(props.endDateField).toBe('end_date'); @@ -197,7 +198,7 @@ describe('ListView capability gate — the Calendar toggle follows the binding ( it('does NOT offer Calendar to a view that declared no calendar binding', async () => { // ADR-0047: offered only when the binding resolves. Before this card the - // object page's fabricated `options.calendar.startDateField` resolved for + // object page's fabricated `calendar.startDateField` (then under `options`) resolved for // every view in the product, so this toggle was always live — the "disable // the calendar toggle for such views" half of the ruling, obtained here by // deleting the fabrication rather than by adding a second mechanism. diff --git a/ui/packages/plugin-list/src/__tests__/ListView.calendarAliasRefused-8355.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.calendarAliasRefused-8355.test.tsx index f39e9f20f77..1a3a6689c41 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.calendarAliasRefused-8355.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.calendarAliasRefused-8355.test.tsx @@ -198,9 +198,10 @@ describe('objectui#8355 · half 1 — the generated node carries the canonical k expect(bindingOf(node)).not.toHaveProperty('defaultView'); }); - it('CONTROL: the LEGACY `options.calendar` nesting is stripped on the same path', async () => { - // app-shell's `calendarViewOptions` forwards a view's declared block into - // this nesting, so a stored view carries the aliases here too. + it('CONTROL: the LEGACY `options.calendar` nesting binds nothing on the same path', async () => { + // app-shell's `calendarViewOptions` forwarded a view's declared block into + // this nesting until objectstack#25923 retired the bag; `ListView` reads + // none of it now, aliases or not. const node = await generatedCalendarNode({ options: { calendar: { dateField: 'kickoff', titleField: 'name' } }, }); @@ -222,11 +223,12 @@ describe('objectui#8355 · half 2 — the retired spellings are REFUSED at the r expect(issues[0].message).toContain('Did you mean `endField` → `endDateField`?'); }); - it('the LEGACY nesting is refused too, with the same message', () => { + it('the LEGACY nesting is refused whole: the bag is retired (objectstack#25923), so the door names `options` itself', () => { const doc = { ...ALIASED_VIEW, calendar: undefined, options: { calendar: { dateField: 'kickoff' } } }; - const issues = issuesAt(doc, 'options.calendar.dateField'); + const issues = issuesAt(doc, 'options'); expect(issues).toHaveLength(1); - expect(issues[0].message).toContain('Did you mean `dateField` → `startDateField`?'); + expect(issues[0].message).toContain("A list view's `options` bag was removed"); + expect(issuesAt(doc, 'options.calendar.dateField')).toHaveLength(0); }); it('DARK CONTROL: the canonical fixture parses GREEN through the same door', () => { diff --git a/ui/packages/plugin-list/src/__tests__/ListView.chart-capability-7544.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.chart-capability-7544.test.tsx index 79f230ef697..faa255d716d 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.chart-capability-7544.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.chart-capability-7544.test.tsx @@ -128,7 +128,7 @@ describe('the capability gate resolves `chart` from a declared chart block (obje expect(chartOffered({ chart: { categoryField: 'status', valueField: 'hours' } })).toBe(false); }); - it('does NOT offer Chart for the retired axes in the `options.chart` bag either (objectui#6152 round 15)', () => { + it('does NOT offer Chart for the retired axes in the `options.chart` bag either (objectui#6152 round 15; the bag itself is unread since objectstack#25923)', () => { expect(chartOffered({ options: { chart: { xAxisField: 'status', yAxisFields: ['hours'], aggregation: 'sum' } } })).toBe(false); }); @@ -143,14 +143,16 @@ describe('the capability gate resolves `chart` from a declared chart block (obje expect(chartOffered({ chart: { yAxisFields: ['hours'] } })).toBe(false); }); - it('CONTROL: the legacy `options.chart` bag resolves the capability too', () => { - expect(chartOffered({ options: { chart: { dataset: 'ds', values: ['n'] } } })).toBe(true); + it('RETIRED (objectstack#25923): a complete chart block left in the legacy `options.chart` bag resolves nothing', () => { + // This was the bag's CONTROL (it resolved the capability). The bag is + // retired and folded onto `chart` by the server before a stored row is + // served, so the gate, like the render branch, reads the block alone. + expect(chartOffered({ options: { chart: { dataset: 'ds', values: ['n'] } } })).toBe(false); }); - it('CONTROL: the view-level block wins over the bag, as the render branch resolves it', () => { - // Whole-block replacement (`schema.chart || schema.options.chart`) is the - // render branch's existing precedence for `chart` and `tree`; the gate - // inherits it rather than inventing a merge. + it('CONTROL: the view-level block resolves the capability with a bag beside it, as the render branch resolves it', () => { + // The render branch reads `schema.chart` only; the gate asks the same + // resolver rather than inventing a second source. expect(chartOffered({ chart: { dataset: 'ds', values: ['n'] }, options: { chart: {} } })).toBe(true); }); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.datasetChartToolbarFilter-10327.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.datasetChartToolbarFilter-10327.test.tsx index 4bc405fe084..50282c9b377 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.datasetChartToolbarFilter-10327.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.datasetChartToolbarFilter-10327.test.tsx @@ -19,8 +19,8 @@ * object's rows. So each control changed a query nothing on screen draws. * * WHAT IS PINNED. The Filter builder AND the chips are absent on a dataset - * chart, in both spellings of the block (`chart` and the legacy - * `options.chart` bag), and absent only while the chart is ON SCREEN: a grid + * chart (the `chart` block, its one spelling since objectstack#25923 retired + * the legacy `options.chart` bag), and absent only while the chart is ON SCREEN: a grid * view that can switch to the same chart offers them on the grid. The CONTROL * is the `'unbound'` chart, whose node carries the effective filter * (objectui#10250) and which keeps both controls. (It was the object-bound @@ -116,12 +116,14 @@ describe('objectui#10327 — no filter control on a dataset-bound chart view', ( expect(chips()).not.toBeInTheDocument(); }); - it('withholds them on the legacy `options.chart` bag bound to a dataset — the same resolver', async () => { + it('RETIRED (objectstack#25923): a dataset block left in an `options` bag binds nothing — the chart is the unbound one, and keeps both controls', async () => { + // The bag is folded onto `chart` by the server before a stored row is + // served, so only an inline bag reaches here, and `ListView` reads none. mount({ viewType: 'chart', options: { chart: DATASET_BLOCK } }); await chartOnScreen(); - expect(chartNodes.at(-1)).toMatchObject({ type: 'object-chart', dataset: 'task_ds' }); - expect(filterButton()).not.toBeInTheDocument(); - expect(chips()).not.toBeInTheDocument(); + expect(chartNodes.at(-1)).not.toMatchObject({ dataset: 'task_ds' }); + expect(filterButton()).toBeInTheDocument(); + expect(chips()).toBeInTheDocument(); }); it('keys on the view ON SCREEN: a grid that switches to the dataset chart loses them, and gets them back', async () => { diff --git a/ui/packages/plugin-list/src/__tests__/ListView.galleryOptionsBagCanonical-6152.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.galleryBlockCanonical-6152.test.tsx similarity index 67% rename from ui/packages/plugin-list/src/__tests__/ListView.galleryOptionsBagCanonical-6152.test.tsx rename to ui/packages/plugin-list/src/__tests__/ListView.galleryBlockCanonical-6152.test.tsx index d3dd4ca1fcd..f48ef27f07d 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.galleryOptionsBagCanonical-6152.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.galleryBlockCanonical-6152.test.tsx @@ -27,8 +27,15 @@ * retirement was its own round: objectui#6152 round 14 removed the alias rungs, * and the legacy-bag CONTROL below was re-judged into a refusal arm then. * - * Both directions are pinned: the canonical-bag arms answer YES, and the arms at - * the bottom answer NO for a bag with no cover binding, so the rung cannot + * objectstack#25923 retired the bag: a stored `options.gallery` is folded onto + * the view's `gallery` block by the server before the row is served, and the + * gate reads the block alone. So the arms below are the same bindings written + * where they live now, the top-level `gallery` block (renamed from + * `ListView.galleryOptionsBagCanonical-6152`), plus one arm pinning that a bag + * no longer resolves the capability. + * + * Both directions are pinned: the canonical arms answer YES, and the arms at + * the bottom answer NO for a block with no cover binding, so the rung cannot * degrade into "offer the gallery whenever it is whitelisted". */ @@ -66,8 +73,8 @@ const renderSwitcher = (view: Record) => { render( {/* `as never`: some arms carry the legacy `imageField`, which the typed - bag refuses by name; a stored row may still carry it, and this file - pins how the gate answers for it. */} + block refuses by name, or the retired `options` bag; this file pins + how the gate answers for them. */} , ); @@ -75,7 +82,7 @@ const renderSwitcher = (view: Record) => { if (trigger) fireEvent.click(trigger); }; -/** Mirrors the helper in `ListView.kanbanOptionsBagCanonical-8193.test.tsx`. */ +/** Mirrors the helper in `ListView.kanbanBlockCanonical-8193.test.tsx`. */ const queryViewOption = (name: string) => screen.queryByRole('tab', { name }) ?? screen.queryByRole('button', { name }); @@ -85,26 +92,28 @@ const galleryOffered = (view: Record) => { return Boolean(queryViewOption('Gallery')); }; -describe('the capability gate resolves gallery from the CANONICAL key in the options bag (objectui#6152 round 12)', () => { - // THE DISCRIMINATING ARM. This read `false` before the rung existed. - it('offers Gallery for `options.gallery.coverField`', () => { - expect(galleryOffered({ options: { gallery: { coverField: 'logo' } } })).toBe(true); +describe('the capability gate resolves gallery from the CANONICAL key of the `gallery` block (objectui#6152 round 12, objectstack#25923)', () => { + // THE DISCRIMINATING ARM of round 12, written where the binding lives now. + it('offers Gallery for `gallery.coverField`', () => { + expect(galleryOffered({ gallery: { coverField: 'logo' } })).toBe(true); }); - it('offers Gallery for the bag app-shell\'s ObjectView now writes', () => { + it('offers Gallery for the block app-shell\'s ObjectView now writes', () => { // ⛔ One `galleryOffered` per test: it renders into the shared screen. - expect(galleryOffered({ options: { gallery: { coverField: 'logo', titleField: 'name' } } })).toBe(true); + expect(galleryOffered({ gallery: { coverField: 'logo', titleField: 'name' } })).toBe(true); }); - it('does NOT offer Gallery for the retired LEGACY `options.gallery.imageField` (objectui#6152 round 14)', () => { + it('does NOT offer Gallery for the retired LEGACY `gallery.imageField` (objectui#6152 round 14)', () => { // Round 12's CONTROL here proved its change ADDED a rung. Round 14 retired // the alias rung itself: a stored `imageField` binds no cover, so the gate // must not offer a gallery for it. - expect(galleryOffered({ options: { gallery: { imageField: 'logo' } } })).toBe(false); + expect(galleryOffered({ gallery: { imageField: 'logo' } })).toBe(false); }); - it('CONTROL: still offers Gallery for the declared `gallery.coverField`', () => { - expect(galleryOffered({ gallery: { coverField: 'logo' } })).toBe(true); + it('RETIRED (objectstack#25923): does NOT offer Gallery for a cover bound in the legacy `options.gallery` bag', () => { + // The bag was this file's subject. It is folded onto `gallery` before a + // stored row is served, so one that still reaches the gate binds nothing. + expect(galleryOffered({ options: { gallery: { coverField: 'logo' } } })).toBe(false); }); // THE OTHER HALF. @@ -112,11 +121,11 @@ describe('the capability gate resolves gallery from the CANONICAL key in the opt expect(galleryOffered({})).toBe(false); }); - it('does NOT offer Gallery for an options bag carrying no cover binding', () => { - expect(galleryOffered({ options: { gallery: { titleField: 'name' } } })).toBe(false); + it('does NOT offer Gallery for a gallery block carrying no cover binding', () => { + expect(galleryOffered({ gallery: { titleField: 'name' } })).toBe(false); }); - it('does NOT offer Gallery for an empty bag block', () => { - expect(galleryOffered({ options: { gallery: {} } })).toBe(false); + it('does NOT offer Gallery for an empty gallery block', () => { + expect(galleryOffered({ gallery: {} })).toBe(false); }); }); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.gantt-binding-7070.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.gantt-binding-7070.test.tsx index a43df162474..988d866f88b 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.gantt-binding-7070.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.gantt-binding-7070.test.tsx @@ -207,15 +207,17 @@ describe('ListView gantt branch — only ever restates a DECLARED binding (objec expect(props.progressField).toBe('pct'); }); - it('CONTROL: the legacy `options.gantt` nesting carries the pair verbatim too (objectui#7499)', async () => { - // Both nestings are authoring faces here, and the floors used to read both - // legs. Deleting them must not cost the `options.gantt` leg its pass-through. + it('RETIRED (objectstack#25923): the legacy `options.gantt` nesting carries neither key of the pair', async () => { + // Both nestings were authoring faces here until the bag was retired; a + // stored bag is folded onto `gantt` by the server before the row is served, + // so `ListView` reads the block alone. The block's pass-through is the + // CONTROL above. const props = await ganttProps({ ...BASE, options: { gantt: { ...AXIS, progressField: 'percent_done', dependenciesField: 'depends_on' } }, }); - expect(props.progressField).toBe('percent_done'); - expect(props.dependenciesField).toBe('depends_on'); + expect(props.progressField).toBeUndefined(); + expect(props.dependenciesField).toBeUndefined(); }); it('CONTROL: forwards the spec-canonical `gantt` block unchanged', async () => { @@ -228,12 +230,13 @@ describe('ListView gantt branch — only ever restates a DECLARED binding (objec expect(props.titleField).toBe('name'); }); - it('CONTROL: forwards the legacy `options.gantt` nesting unchanged', async () => { - // The nesting app-shell's object page emits. A correctly configured view - // renders exactly as it did before this card. + it('CONTROL: forwards the block app-shell\'s object page relays unchanged', async () => { + // The block that page emits: under the legacy `options.gantt` nesting until + // objectstack#25923 retired the bag, at the top level since. A correctly + // configured view renders exactly as it did before this card. const props = await ganttProps({ ...BASE, - options: { gantt: { startDateField: 'start_date', endDateField: 'end_date', colorField: 'status' } }, + gantt: { startDateField: 'start_date', endDateField: 'end_date', colorField: 'status' }, }); expect(props.startDateField).toBe('start_date'); expect(props.endDateField).toBe('end_date'); @@ -285,30 +288,32 @@ describe('ListView capability gate — the Gantt toggle follows the binding (obj await waitFor(() => expect(queryViewOption('Gantt')).toBeInTheDocument()); }); - it('CONTROL: offers Gantt for the legacy `options.gantt` nesting too', async () => { - // The shape the object page emits for a view that DID declare a gantt block - // — the half of the gate that must keep working after the fabrication left. + it('RETIRED (objectstack#25923): does NOT offer Gantt for a binding left in the legacy `options.gantt` nesting', async () => { + // This was a CONTROL (the gate read the bag). The bag is retired, so the + // gate reads the block alone; the CONTROL above is the live spelling. await mountSwitcher({ ...GRID, + appearance: { allowedVisualizations: ['grid', 'gantt'] }, options: { gantt: { startDateField: 'start_date', endDateField: 'end_date' } }, }); - await waitFor(() => expect(queryViewOption('Gantt')).toBeInTheDocument()); + expect(queryViewOption('Gantt')).not.toBeInTheDocument(); }); - it('does NOT offer Gantt for the exact bag the fixed object page now emits', async () => { + it('does NOT offer Gantt for the exact block the fixed object page now emits', async () => { // ⭐ THE SECOND PREMISE, measured rather than assumed. #7070 asked whether // ADR-0047's gate drops the Gantt toggle "for free" once the fabrication is // gone, the way it did for calendar. The gate reads // `schema.gantt?.startDateField || schema.options?.gantt?.startDateField`, // and the fabrication it used to read came from the OBJECT PAGE, which put // `options.gantt.startDateField: 'start_date'` on every view in the product. - // `{ gantt: { titleField: 'name' } }` is precisely what `ganttViewOptions` - // now emits for a view that declared nothing — a bag that still EXISTS but - // carries no axis. The answer is yes: no second mechanism was needed. + // `{ titleField: 'name' }` is precisely what `ganttViewOptions` now emits + // for a view that declared nothing — a block that still EXISTS but carries + // no axis (the page relays it at the top level since objectstack#25923). + // The answer is yes: no second mechanism was needed. await mountSwitcher({ ...GRID, appearance: { allowedVisualizations: ['grid', 'gantt'] }, - options: { gantt: { titleField: 'name' } }, + gantt: { titleField: 'name' }, }); expect(queryViewOption('Gantt')).not.toBeInTheDocument(); }); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.kanbanOptionsBagCanonical-8193.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.kanbanBlockCanonical-8193.test.tsx similarity index 76% rename from ui/packages/plugin-list/src/__tests__/ListView.kanbanOptionsBagCanonical-8193.test.tsx rename to ui/packages/plugin-list/src/__tests__/ListView.kanbanBlockCanonical-8193.test.tsx index 765afb2edf4..ce09ce28049 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.kanbanOptionsBagCanonical-8193.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.kanbanBlockCanonical-8193.test.tsx @@ -45,6 +45,13 @@ * handled. That asymmetry is the point: a "fix" that pushed `kanban` whenever * it was whitelisted would also pass the positive arms, so the refusal arms at * the bottom are load-bearing. + * + * objectstack#25923 retired the bag: a stored `options.kanban` is folded onto + * the view's `kanban` block by the server before the row is served, the object + * page relays the block at the top level, and the gate reads the block alone. + * So the arms below are the same bindings written where they live now + * (renamed from `ListView.kanbanOptionsBagCanonical-8193`), plus one arm + * pinning that a bag no longer resolves the capability. */ import React from 'react'; @@ -97,50 +104,39 @@ const kanbanOffered = (view: Record) => { return Boolean(queryViewOption('Kanban')); }; -describe('the capability gate resolves kanban from the CANONICAL key in the options bag (objectui#8193)', () => { - // THE DISCRIMINATING ARM. This read `false` before the fix — and it is - // exactly the bag `app-shell`'s ObjectView now emits. - it('offers Kanban for `options.kanban.groupByField`', () => { - expect(kanbanOffered({ options: { kanban: { groupByField: 'stage' } } })).toBe(true); +describe('the capability gate resolves kanban from the CANONICAL key of the `kanban` block (objectui#8193, objectstack#25923)', () => { + // THE DISCRIMINATING ARM of objectui#8193, written where the binding lives now. + it('offers Kanban for `kanban.groupByField`', () => { + expect(kanbanOffered({ kanban: { groupByField: 'stage' } })).toBe(true); }); - it('offers Kanban for the bag ObjectView actually writes', () => { + it('offers Kanban for the block ObjectView actually writes', () => { // The end-to-end shape. ⚠️ This fixture used to carry `groupBy` and call // itself "the producer's real output"; objectui#8213 retired that write, so - // the producer claim lives here and the `groupBy`-bearing bag moved to the + // the producer claim lives here and the `groupBy`-bearing block moved to the // arm below, as what it always really was — a robustness arm. // ⛔ One `kanbanOffered` per test: it renders into the shared screen, and a // second render in the same test makes every `queryByRole` ambiguous. - expect( - kanbanOffered({ options: { kanban: { groupByField: 'stage', titleField: 'name' } } }), - ).toBe(true); + expect(kanbanOffered({ kanban: { groupByField: 'stage', titleField: 'name' } })).toBe(true); }); - it('CONTROL: a bag that ALSO carries a stray `groupBy` is answered the same', () => { - // Stored metadata written before objectui#8213 still carries the key, and - // this repo's `KanbanConfig` mirror is `.passthrough()`, so the gate must - // keep answering identically for a bag that has it and one that does not. - expect( - kanbanOffered({ - options: { kanban: { groupBy: 'stage', groupByField: 'stage', titleField: 'name' } }, - }), - ).toBe(true); + it('CONTROL: a block that ALSO carries a stray `groupBy` is answered the same', () => { + // Stored metadata written before objectui#8213 may still carry the key; the + // gate must keep answering identically for a block that has it and one + // that does not (the door's refusal of it is `strayGroupByRefused-8365`'s). + expect(kanbanOffered({ kanban: { groupBy: 'stage', groupByField: 'stage', titleField: 'name' } })).toBe(true); }); - it('does NOT offer Kanban for the retired LEGACY `options.kanban.groupField` (objectui#6152 round 14)', () => { - // This card's CONTROL here proved the fix ADDED a rung rather than swapping - // one out. Round 14 retired the alias rung: a stored bag `groupField` names - // no lane, so the gate must not offer a board for it. - expect(kanbanOffered({ options: { kanban: { groupField: 'stage' } } })).toBe(false); - }); - - it('CONTROL: still offers Kanban for the declared `kanban.groupByField`', () => { - expect(kanbanOffered({ kanban: { groupByField: 'stage' } })).toBe(true); + it('RETIRED (objectstack#25923): does NOT offer Kanban for a lane bound in the legacy `options.kanban` bag', () => { + // The bag was this file's subject. It is folded onto `kanban` before a + // stored row is served, so one that still reaches the gate binds nothing. + expect(kanbanOffered({ options: { kanban: { groupByField: 'stage' } } })).toBe(false); }); it('does NOT offer Kanban for the retired declared-path `kanban.groupField` (objectui#6152 round 14)', () => { // It did while `normalizeListViewSchema` folded a TOP-LEVEL alias onto // `groupByField` before the gate read anything; round 14 retired that fold. + // (The bag's `groupField` arm, retired the same round, is the bag arm above.) expect(kanbanOffered({ kanban: { groupField: 'stage' } })).toBe(false); }); @@ -151,8 +147,8 @@ describe('the capability gate resolves kanban from the CANONICAL key in the opti expect(kanbanOffered({})).toBe(false); }); - it('does NOT offer Kanban for an options bag carrying no lane key', () => { - expect(kanbanOffered({ options: { kanban: { titleField: 'name' } } })).toBe(false); + it('does NOT offer Kanban for a kanban block carrying no lane key', () => { + expect(kanbanOffered({ kanban: { titleField: 'name' } })).toBe(false); }); it('does NOT offer Kanban for an empty declared block', () => { @@ -162,7 +158,7 @@ describe('the capability gate resolves kanban from the CANONICAL key in the opti it('does NOT offer Kanban for `groupBy` alone', () => { // `groupBy` is not a lane binding the gate recognizes in any nesting, and // objectui#8213 measured that the spec does not declare it either. Pinned - // so the new canonical rung is not quietly widened into a third spelling. - expect(kanbanOffered({ options: { kanban: { groupBy: 'stage' } } })).toBe(false); + // so the canonical rung is not quietly widened into a third spelling. + expect(kanbanOffered({ kanban: { groupBy: 'stage' } })).toBe(false); }); }); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.kanbanOwnsLanes-12619.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.kanbanOwnsLanes-12619.test.tsx index 8faee77bf38..5db61931c55 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.kanbanOwnsLanes-12619.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.kanbanOwnsLanes-12619.test.tsx @@ -75,7 +75,7 @@ const VIEW = { viewType: 'kanban', columns: [{ field: 'name' }, { field: 'account' }, { field: 'stage' }, { field: 'amount' }, { field: 'probability' }], pagination: { pageSize: 25, pageSizeOptions: [25, 50] }, - options: { kanban: { groupByField: 'stage', titleField: 'name' } }, + kanban: { groupByField: 'stage', titleField: 'name' }, }; let lastKanbanProps: any = null; @@ -152,7 +152,7 @@ describe('a list view hands its board the query, not a page (objectui#12619)', ( it('the kanban block\'s own `columns` is the author\'s card list, every one of it', async () => { const ds = makeDataSource(); - renderList(ds, { ...VIEW, options: { kanban: { groupByField: 'stage', columns: ['amount', 'probability', 'account'] } } }); + renderList(ds, { ...VIEW, kanban: { groupByField: 'stage', columns: ['amount', 'probability', 'account'] } }); const props = await boardProps(); expect(props.schema.cardFields).toEqual(['amount', 'probability', 'account']); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.mapFlatten.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.mapFlatten.test.tsx index aca117b5df4..4941a0a89bb 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.mapFlatten.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.mapFlatten.test.tsx @@ -22,6 +22,10 @@ * spread: a declared key still travels, an arbitrary undeclared one does not, * and the top-level `style` namespace is never written. * + * objectstack#25923 — the list view's `options` bag is retired, so the block is + * the view's own top-level `map` (its one spelling) and these mount it there; + * the whitelist is the same `pickFlatMapConfig` step. + * * objectui#9950 — `style` is no longer DROPPED either. It is delivered under * its flat spelling `mapStyle`, which is what `ObjectMap.getMapConfig` reads * (`schema.mapStyle || schema.map?.style`) and a declared member of @@ -76,14 +80,14 @@ const BASE = { columns: ['name'], } as const; -/** Mount ListView on a `map`-typed view and return the schema handed to `object-map`. */ +/** Mount ListView on a `map`-typed view declaring `map` and return the schema handed to `object-map`. */ async function mapSchemaFor(mapOptions: Record) { captured = []; const dataSource = makeDataSource() as any; render( , @@ -92,7 +96,7 @@ async function mapSchemaFor(mapOptions: Record) { return captured[captured.length - 1].schema as Record; } -describe('ListView flattens `options.map` through a whitelist, not a raw spread (objectui#5177)', () => { +describe('ListView flattens the `map` block through a whitelist, not a raw spread (objectui#5177)', () => { beforeEach(() => { captured = []; }); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.mapLocationSelect-10370.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.mapLocationSelect-10370.test.tsx index 3caab910bce..ffe5dc100b1 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.mapLocationSelect-10370.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.mapLocationSelect-10370.test.tsx @@ -20,8 +20,10 @@ * * WHICH SPELLINGS — exactly the ones the map actually reads. The render branch * and the switcher's capability gate both take the map config from - * `resolveListMapConfig`: the spec's view-level `map` block merged per key over - * the producer's `options.map` bag. The projection now asks that same resolver, + * `resolveListMapConfig`: the spec's view-level `map` block (merged per key over + * the producer's `options.map` bag until objectstack#25923 retired the bag; a + * stored bag is folded onto `map` by the server now, and the resolver reads the + * block alone). The projection asks that same resolver, * so it requests what the markers are drawn from and nothing the renderer never * sees: a key shadowed by the view-level block is not requested, and a * top-level `locationField` — which the spec's list view refuses by name and @@ -151,27 +153,27 @@ describe('ListView — a map view projects its location bindings (objectui#10370 expect(sorted(params.$select)).toEqual(['id', 'lat', 'lng', 'subject']); }); - it('projects `options.map.locationField` (the producer bag)', async () => { + it('RETIRED (objectstack#25923): `options.map.locationField` widens nothing — the markers no longer read the bag', async () => { const params = await paramsFor({ options: { map: { locationField: 'geo' } } }); - expect(sorted(params.$select)).toEqual(['geo', 'id', 'subject']); + expect(sorted(params.$select)).toEqual(['id', 'subject']); }); - it('projects the `options.map.latitudeField` + `options.map.longitudeField` pair', async () => { + it('RETIRED (objectstack#25923): nor does the `options.map.latitudeField` + `options.map.longitudeField` pair', async () => { const params = await paramsFor({ options: { map: { latitudeField: 'lat', longitudeField: 'lng' } }, }); - expect(sorted(params.$select)).toEqual(['id', 'lat', 'lng', 'subject']); + expect(sorted(params.$select)).toEqual(['id', 'subject']); }); - it('projects a split pair — one half on the view-level block, one in the bag', async () => { + it('projects only the block half of a pair split with a bag', async () => { const params = await paramsFor({ map: { latitudeField: 'lat' }, options: { map: { longitudeField: 'lng' } }, }); expect( sorted(params.$select), - 'the resolver merges the two sources per key, and the markers read both halves', - ).toEqual(['id', 'lat', 'lng', 'subject']); + 'the resolver reads the block alone, so the bag half is requested by nothing', + ).toEqual(['id', 'lat', 'subject']); }); it('projects the marker title and description bindings too', async () => { @@ -204,7 +206,7 @@ describe('ListView — a map view projects its location bindings (objectui#10370 it('drops the denied half of a coordinate pair and keeps the readable half', async () => { const params = await paramsFor({ - options: { map: { latitudeField: 'secret_lat', longitudeField: 'lng' } }, + map: { latitudeField: 'secret_lat', longitudeField: 'lng' }, }); expect(sorted(params.$select)).toEqual(['id', 'lng', 'subject']); expect(params.$select).not.toContain('secret_lat'); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.mapViewLevelConfig.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.mapViewLevelConfig.test.tsx index 6d409306ad4..7062bd79b30 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.mapViewLevelConfig.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.mapViewLevelConfig.test.tsx @@ -34,6 +34,14 @@ * wholesale. The DIRECTION is unanimous across all seven; only the granularity * differs, and `map` follows the five that merge — which are also the five that * flatten config into props the way the map branch does. + * + * objectstack#25923 retired the bag. A stored `options.map` is folded onto the + * view's `map` block by the server before the row is served (the spec's D2 + * conversion `list-view-options-bag-folded`, top-level value winning per key, + * which is the merge above moved upstream), and `ListView` reads the block + * alone. The precedence cases below now pin that the bag reaches nothing here; + * the per-key merge is pinned where it runs, on the served row + * (`app-shell`'s `ObjectView.storedRowParity-10380.test.tsx`). */ import React from 'react'; @@ -130,7 +138,7 @@ describe('ListView forwards the view-level `map` block to plugin-map (objectui#5 expect(Object.prototype.hasOwnProperty.call(schema, 'map')).toBe(false); }); - describe('precedence — view-level block over `options.map`, per key', () => { + describe('precedence — the view-level block is the one source (objectstack#25923)', () => { it('lets the view-level block win on a key both declare', async () => { const schema = await mapSchemaFor({ options: { map: { locationField: 'geo', titleField: 'bag_title' } }, @@ -140,28 +148,29 @@ describe('ListView forwards the view-level `map` block to plugin-map (objectui#5 expect(schema.titleField).toBe('spec_title'); }); - it('keeps a key only the legacy bag declares — it is a merge, not a replacement', async () => { - // The arm that separates the five merging siblings from the two that use - // `||`. Under whole-block replacement `locationField` would be gone here - // and the map would render no markers at all. + it('RETIRED: a key only the legacy bag declares is not read — the per-key merge is the server fold\'s now', async () => { + // This arm pinned the merge (`locationField` kept from the bag). The bag + // is folded onto `map` before a stored row is served, so a bag that still + // reaches `ListView` was never stored and binds nothing. const schema = await mapSchemaFor({ options: { map: { locationField: 'geo', titleField: 'bag_title' } }, map: { titleField: 'spec_title' }, }); - expect(schema.locationField).toBe('geo'); + expect(schema.locationField).toBeUndefined(); + expect(schema.titleField).toBe('spec_title'); }); }); describe('controls', () => { - it('CONTROL: `options.map` alone still works, unchanged', async () => { + it('RETIRED (objectstack#25923): `options.map` alone binds nothing, as if no map config were declared', async () => { const schema = await mapSchemaFor({ options: { map: { latitudeField: 'lat', longitudeField: 'lng', titleField: 'bag_title' } }, }); - expect(schema.latitudeField).toBe('lat'); - expect(schema.longitudeField).toBe('lng'); - expect(schema.titleField).toBe('bag_title'); + expect(Object.prototype.hasOwnProperty.call(schema, 'latitudeField')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(schema, 'longitudeField')).toBe(false); + expect(schema.titleField).not.toBe('bag_title'); }); it('CONTROL: with no map config at all, NOTHING is emitted — not even a binding', async () => { @@ -253,13 +262,13 @@ describe('ListView offers `map` in the switcher from the view-level block (objec expect(queryViewOption('Map')).toBeTruthy(); }); - it('CONTROL: `options.map` alone still resolves the capability', () => { + it('RETIRED (objectstack#25923): `options.map` alone no longer resolves the capability', () => { renderSwitcher({ appearance: { allowedVisualizations: ['grid', 'map'] }, options: { map: { locationField: 'geo' } }, }); - expect(queryViewOption('Map')).toBeTruthy(); + expect(queryViewOption('Map')).toBeNull(); }); it('CONTROL: no binding anywhere leaves `map` unresolvable', () => { @@ -268,16 +277,26 @@ describe('ListView offers `map` in the switcher from the view-level block (objec expect(queryViewOption('Map')).toBeNull(); }); - it('resolves a binding SPLIT across the two sources, the way it will render', () => { - // The gate asks the merged config, so a split lat/lng pair is judged the - // same way the render seam resolves it. Asking `options.map` alone said - // "not resolvable" while the seam would have rendered it fine. + it('judges a binding SPLIT across the block and a bag the way it will render: the bag half is not read (objectstack#25923)', () => { + // The gate and the render seam ask the same config, the `map` block. A + // stored split is folded into one block by the server before it is served; + // one that still reaches here is half a binding, and the seam would render + // no markers, so the gate does not offer the map. renderSwitcher({ appearance: { allowedVisualizations: ['grid', 'map'] }, options: { map: { latitudeField: 'lat' } }, map: { longitudeField: 'lng' }, }); + expect(queryViewOption('Map')).toBeNull(); + }); + + it('CONTROL: the same split pair in the one block resolves the capability', () => { + renderSwitcher({ + appearance: { allowedVisualizations: ['grid', 'map'] }, + map: { latitudeField: 'lat', longitudeField: 'lng' }, + }); + expect(queryViewOption('Map')).toBeTruthy(); }); }); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.readersRetired-6152.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.readersRetired-6152.test.tsx index b985ed311d3..16f71a88c1c 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.readersRetired-6152.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.readersRetired-6152.test.tsx @@ -30,10 +30,20 @@ * 3. The retired keys reach nothing, each beside a CONTROL that writes the * spec key in the same position, so no zero below is vacuous. * - * Most fixtures sit in the legacy `options` bag; the arms in describe 4 write + * Most fixtures sat in the legacy `options` bag; the arms in describe 4 write * the TOP-LEVEL spelling, which `normalizeListViewSchema` (`@object-ui/core`) * used to fold onto the spec key before `ListView` read anything. That fold - * retired in the same round, so both nestings answer the same. + * retired in the same round, so both nestings answered the same. + * + * objectstack#25923 retired the bag itself: a stored bag is folded onto the + * top-level blocks by the server before the row is served, and `ListView` + * reads no `options`. So the bag fixtures moved to the top-level block, the + * one nesting left, and each "from either nesting" arm now pins that the bag + * half reaches nothing. (`options.grid` stays a bag fixture: a grid has no + * per-kind block for it to move to.) + * + * The SOURCE describe at the end pins the same retirement on the code: no + * non-test file in this package reads `options` off a list schema. * * H2 of the dispatch (removing the flat `imageField` loses nothing, because the * nested `coverField` already reaches `ObjectGallery`) is measured at the end: @@ -43,6 +53,10 @@ import React from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, waitFor, screen, cleanup } from '@testing-library/react'; +import ts from 'typescript'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { ComponentRegistry } from '@object-ui/core'; import { SchemaRendererProvider } from '@object-ui/react'; import { ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui'; @@ -150,10 +164,10 @@ beforeEach(() => { describe('1 · a block key named like a node key no longer overrides the node (objectui#6152 round 14)', () => { const ARMS: Array<[(typeof KINDS)[number], Record, (node: Record) => void]> = [ - ['object-kanban', { options: { kanban: { groupByField: 'owner', objectName: HIJACK } } }, (n) => expect(n.groupBy).toBe('owner')], - ['object-calendar', { options: { calendar: { startDateField: 'due', objectName: HIJACK } } }, (n) => expect(n.calendar?.startDateField).toBe('due')], - ['object-gantt', { options: { gantt: { startDateField: 'due', endDateField: 'due', objectName: HIJACK } } }, (n) => expect(n.gantt?.startDateField).toBe('due')], - ['object-tree', { options: { tree: { parentField: 'parent', objectName: HIJACK } } }, (n) => expect(n.parentField).toBe('parent')], + ['object-kanban', { kanban: { groupByField: 'owner', objectName: HIJACK } }, (n) => expect(n.groupBy).toBe('owner')], + ['object-calendar', { calendar: { startDateField: 'due', objectName: HIJACK } }, (n) => expect(n.calendar?.startDateField).toBe('due')], + ['object-gantt', { gantt: { startDateField: 'due', endDateField: 'due', objectName: HIJACK } }, (n) => expect(n.gantt?.startDateField).toBe('due')], + ['object-tree', { tree: { parentField: 'parent', objectName: HIJACK } }, (n) => expect(n.parentField).toBe('parent')], ['object-grid', { options: { grid: { objectName: HIJACK } } }, () => {}], ]; @@ -170,22 +184,22 @@ describe('1 · a block key named like a node key no longer overrides the node (o }); describe('2 · the declared keys keep their route (objectui#6152 round 14)', () => { - it('kanban: `summarizeField` reaches the board, from either nesting', async () => { + it('kanban: `summarizeField` reaches the board from the block; a retired bag\'s reaches nothing (objectstack#25923)', async () => { expect((await generatedNode('object-kanban', { kanban: { groupByField: 'stage', summarizeField: 'amount' } })).summarizeField).toBe('amount'); cleanup(); - expect((await generatedNode('object-kanban', { options: { kanban: { groupByField: 'stage', summarizeField: 'amount' } } })).summarizeField).toBe('amount'); + expect((await generatedNode('object-kanban', { options: { kanban: { groupByField: 'stage', summarizeField: 'amount' } } })).summarizeField).toBeUndefined(); }); - it('calendar: `colorField` and `allDayField` reach the calendar, from either nesting', async () => { + it('calendar: `colorField` and `allDayField` reach the calendar from the block; a retired bag\'s reach nothing (objectstack#25923)', async () => { const top = await generatedNode('object-calendar', { calendar: { startDateField: 'due', colorField: 'stage', allDayField: 'amount' } }); // objectstack#25923 — the five ride the node's own `calendar` block, never flat. expect([top.calendar?.colorField, top.calendar?.allDayField]).toEqual(['stage', 'amount']); cleanup(); const bag = await generatedNode('object-calendar', { options: { calendar: { startDateField: 'due', colorField: 'stage', allDayField: 'amount' } } }); - expect([bag.calendar?.colorField, bag.calendar?.allDayField]).toEqual(['stage', 'amount']); + expect([bag.calendar?.colorField, bag.calendar?.allDayField]).toEqual([undefined, undefined]); }); - it('gantt: EVERY key the spec declares on the block reaches the gantt, the block winning over the bag per key', async () => { + it('gantt: EVERY key the spec declares on the block reaches the gantt, and a bag beside it adds nothing (objectstack#25923)', async () => { // Driven off the spec's own shape, so a key the spec adds is asked for // here as well as at `tsc` (the table in `ListView.tsx` is typed total). const declared = Object.keys(SpecListViewSchema.shape.gantt.unwrap().shape); @@ -202,21 +216,21 @@ describe('2 · the declared keys keep their route (objectui#6152 round 14)', () }); describe('3 · the retired keys reach nothing (objectui#6152 round 14)', () => { - it('kanban: a bag `groupField` names no lane; the lane falls to the detector, as with no key', async () => { - const alias = await generatedNode('object-kanban', { options: { kanban: { groupField: 'owner' } } }); + it('kanban: a block `groupField` names no lane; the lane falls to the detector, as with no key', async () => { + const alias = await generatedNode('object-kanban', { kanban: { groupField: 'owner' } }); cleanup(); - const none = await generatedNode('object-kanban', { options: { kanban: {} } }); + const none = await generatedNode('object-kanban', { kanban: {} }); cleanup(); - const control = await generatedNode('object-kanban', { options: { kanban: { groupByField: 'owner' } } }); + const control = await generatedNode('object-kanban', { kanban: { groupByField: 'owner' } }); expect(control.groupBy).toBe('owner'); expect(alias.groupBy).toBe(none.groupBy); expect(alias.groupBy).not.toBe('owner'); }); - it('kanban: a bag `cardFields` shows nothing; `columns` does', async () => { - const alias = await generatedNode('object-kanban', { options: { kanban: { groupByField: 'stage', cardFields: ['amount'] } } }); + it('kanban: a block `cardFields` shows nothing; `columns` does', async () => { + const alias = await generatedNode('object-kanban', { kanban: { groupByField: 'stage', cardFields: ['amount'] } }); cleanup(); - const control = await generatedNode('object-kanban', { options: { kanban: { groupByField: 'stage', columns: ['amount'] } } }); + const control = await generatedNode('object-kanban', { kanban: { groupByField: 'stage', columns: ['amount'] } }); expect(control.cardFields).toEqual(['amount']); expect(alias.cardFields).not.toEqual(['amount']); }); @@ -231,7 +245,7 @@ describe('3 · the retired keys reach nothing (objectui#6152 round 14)', () => { expect(selects.some((s) => s.split(',').includes('lane'))).toBe(false); }); - it('calendar: `defaultView` is not lifted, from either nesting, and an undeclared key is not forwarded', async () => { + it('calendar: `defaultView` is not lifted, from the block or a retired bag, and an undeclared key is not forwarded', async () => { const top = await generatedNode('object-calendar', { calendar: { startDateField: 'due', defaultView: 'week', zzzUndeclared: 1 } }); expect(top.calendar?.startDateField).toBe('due'); for (const where of [top, top.calendar]) { @@ -241,19 +255,19 @@ describe('3 · the retired keys reach nothing (objectui#6152 round 14)', () => { cleanup(); const bag = await generatedNode('object-calendar', { options: { calendar: { startDateField: 'due', defaultView: 'week' } } }); expect('defaultView' in bag).toBe(false); - expect('defaultView' in bag.calendar).toBe(false); + expect(bag.calendar?.defaultView).toBeUndefined(); }); - it('gallery: a bag `imageField` binds no cover on the node; `coverField` does, nested', async () => { - const alias = await generatedNode('object-gallery', { options: { gallery: { imageField: 'cover' } } }); + it('gallery: a block `imageField` binds no cover on the node; `coverField` does, nested', async () => { + const alias = await generatedNode('object-gallery', { gallery: { imageField: 'cover' } }); expect('imageField' in alias).toBe(false); expect(alias.gallery?.coverField).toBeUndefined(); cleanup(); - const control = await generatedNode('object-gallery', { options: { gallery: { coverField: 'cover' } } }); + const control = await generatedNode('object-gallery', { gallery: { coverField: 'cover' } }); expect(control.gallery?.coverField).toBe('cover'); }); - it('timeline: `dateField` is no axis in any of the four sources; `startDateField` is', () => { + it('timeline: `dateField` is no axis in any of the four sources (the two bag ones are unread since objectstack#25923); `startDateField` is', () => { for (const src of [ { timeline: { dateField: 'due' } }, { options: { timeline: { dateField: 'due' } } }, @@ -262,17 +276,17 @@ describe('3 · the retired keys reach nothing (objectui#6152 round 14)', () => { ]) { expect(resolveTimelineDateBinding(src).startDateField, JSON.stringify(src)).toBeUndefined(); } - expect(resolveTimelineDateBinding({ options: { timeline: { startDateField: 'due' } } }).startDateField).toBe('due'); + expect(resolveTimelineDateBinding({ timeline: { startDateField: 'due' } }).startDateField).toBe('due'); expect(resolveTimelineDateBinding({ calendar: { startDateField: 'due' } }).startDateField).toBe('due'); }); it('tree: `titleField` labels nothing and is not forwarded; `labelField` labels', async () => { - const alias = await generatedNode('object-tree', { options: { tree: { parentField: 'parent', titleField: 'owner', zzzUndeclared: 1 } } }); + const alias = await generatedNode('object-tree', { tree: { parentField: 'parent', titleField: 'owner', zzzUndeclared: 1 } }); expect(alias.labelField).toBe('name'); expect('titleField' in alias).toBe(false); expect('zzzUndeclared' in alias).toBe(false); cleanup(); - const control = await generatedNode('object-tree', { options: { tree: { parentField: 'parent', labelField: 'owner' } } }); + const control = await generatedNode('object-tree', { tree: { parentField: 'parent', labelField: 'owner' } }); expect(control.labelField).toBe('owner'); }); @@ -331,15 +345,101 @@ describe('H2 · the nested `coverField` is the gallery cover; the flat `imageFie await waitFor(() => expect(visibleCoverImg(container)?.getAttribute('src')).toBe('https://cdn.example.com/a.png')); }); - it('the bag `options.gallery.coverField` draws it too', async () => { + it('RETIRED (objectstack#25923): the bag `options.gallery.coverField` draws no cover', async () => { const node = await generatedNode('object-gallery', { options: { gallery: { coverField: 'cover' } } }); const container = await renderRealGallery(node); - await waitFor(() => expect(visibleCoverImg(container)?.getAttribute('src')).toBe('https://cdn.example.com/a.png')); + expect(visibleCoverImg(container)).toBeNull(); }); - it('the retired bag `imageField` draws no cover (the gallery falls to its own `image` floor)', async () => { - const node = await generatedNode('object-gallery', { options: { gallery: { imageField: 'cover' } } }); + it('the retired `imageField` draws no cover (the gallery falls to its own `image` floor)', async () => { + const node = await generatedNode('object-gallery', { gallery: { imageField: 'cover' } }); const container = await renderRealGallery(node); expect(visibleCoverImg(container)).toBeNull(); }); }); + +// ── SOURCE: no `options` bag read left in this package (objectstack#25923) ── + +/** Rooted at THIS file, never at `process.cwd()`. */ +const SRC = join(dirname(fileURLToPath(import.meta.url)), '..'); + +/** Every non-test `.ts` / `.tsx` file under `dir`. */ +function sourceFiles(dir: string): string[] { + const out: string[] = []; + for (const name of readdirSync(dir)) { + const full = join(dir, name); + if (statSync(full).isDirectory()) { + if (name === '__tests__') continue; + out.push(...sourceFiles(full)); + } else if (/\.tsx?$/.test(name) && !/\.(test|spec)\.tsx?$/.test(name)) { + out.push(full); + } + } + return out; +} + +/** `x`, with any wrapping parentheses, `as` casts and `!` taken off. */ +function bare(node: ts.Expression): ts.Expression { + let n = node; + while (ts.isParenthesizedExpression(n) || ts.isAsExpression(n) || ts.isNonNullExpression(n)) n = n.expression; + return n; +} + +/** + * The 1-based lines where a list schema's `KEY` member is read as code: + * `schema.KEY`, `schema?.KEY`, `(schema as any).KEY`, `props.schema.KEY`, + * `schema['KEY']`. Comments are trivia, not tokens, so prose is not a read. + */ +function schemaMemberReads(text: string, file: string, key: string): number[] { + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const isSchema = (e: ts.Expression) => { + const b = bare(e); + return (ts.isIdentifier(b) && b.text === 'schema') || (ts.isPropertyAccessExpression(b) && b.name.text === 'schema'); + }; + const hits: number[] = []; + const walk = (node: ts.Node): void => { + const named = + (ts.isPropertyAccessExpression(node) && node.name.text === key) || + (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression) && node.argumentExpression.text === key); + if (named && isSchema((node as ts.PropertyAccessExpression | ts.ElementAccessExpression).expression)) { + hits.push(sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1); + } + ts.forEachChild(node, walk); + }; + walk(sf); + return hits; +} + +describe('SOURCE: no non-test file in plugin-list reads a list schema\'s `options` (objectstack#25923)', () => { + const files = sourceFiles(SRC); + const rel = (f: string) => relative(SRC, f).replace(/\\/g, '/'); + + it('POPULATION: the scan covers ListView.tsx and index.tsx, the files that held the reads and the input', () => { + expect(files.map(rel)).toEqual(expect.arrayContaining(['ListView.tsx', 'index.tsx'])); + }); + + it('no `schema.options` read is left', () => { + // The location string is computed here for the failure message only. + const hits = files.flatMap((f) => + schemaMemberReads(readFileSync(f, 'utf8'), rel(f), 'options').map((line) => `plugin-list/src/${rel(f)} line ${line}`)); + expect(hits, 'a bag read came back; the server folds a stored bag onto the blocks (objectstack#25923)').toEqual([]); + }); + + it('FIRING CONTROL: the same scanner finds `schema.kanban`, the block read in ListView.tsx', () => { + const file = files.find((f) => rel(f) === 'ListView.tsx')!; + expect(schemaMemberReads(readFileSync(file, 'utf8'), 'ListView.tsx', 'kanban').length).toBeGreaterThan(0); + }); + + it('FIRING CONTROL: the scanner catches each shape the retired reads took, and skips comments and other objects', () => { + const probe = [ + '// schema.options?.kanban in a comment is prose', + 'const a = schema.options?.kanban;', + 'const b = (schema as any).options?.gantt;', + 'const c = props.schema.options;', + "const d = schema['options'];", + 'const e = field.options;', + 'const f = schema?.options;', + ].join('\n'); + expect(schemaMemberReads(probe, 'probe.ts', 'options'), 'the probe lines, 1-based').toEqual([2, 3, 4, 5, 7]); + }); +}); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.rowAdornment-13330.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.rowAdornment-13330.test.tsx index 00bff8b6dbd..b128acd7cbb 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.rowAdornment-13330.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.rowAdornment-13330.test.tsx @@ -27,7 +27,7 @@ const BOARD = { objectName: 'sys_user', viewType: 'kanban', fields: ['name'], - options: { kanban: { groupBy: 'status' } }, + kanban: { groupBy: 'status' }, } as unknown as ListViewSchema; let gridProps: Record | undefined; diff --git a/ui/packages/plugin-list/src/__tests__/ListView.speculativeFls-7216.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.speculativeFls-7216.test.tsx index 152789d8b49..907fd342bcd 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.speculativeFls-7216.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.speculativeFls-7216.test.tsx @@ -254,11 +254,13 @@ describe('ListView — speculative view bindings are FLS-gated (objectui#7216)', expect(select).toContain('subject'); }); - // ── PIN 7: the `options.` spelling is the same path ─────────────── - it('gates the nested `options.kanban` spelling too', async () => { + // ── PIN 7: the kanban block is the same path ──────────────────────────── + // (It pinned the nested `options.kanban` spelling until objectstack#25923 + // retired the bag; the object page relays the block at the top level now.) + it('gates the `kanban` block\'s lane and card fields too', async () => { const select = await selectFor({ columns: COLUMNS, - options: { kanban: { groupByField: 'industry', columns: ['salary'] } }, + kanban: { groupByField: 'industry', columns: ['salary'] }, }); expect(select).not.toContain('industry'); expect( diff --git a/ui/packages/plugin-list/src/__tests__/ListView.strayGroupByRefused-8365.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.strayGroupByRefused-8365.test.tsx index 19f3afc5b2d..333405062af 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.strayGroupByRefused-8365.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.strayGroupByRefused-8365.test.tsx @@ -30,6 +30,12 @@ * options.kanban = { groupBy: 'LANE_FROM_STRAY_GROUPBY' } * kanban = { groupByField: 'LANE_FROM_CANONICAL' } * + * (objectstack#25923 retired the bag. The server folds a stored bag onto the + * top-level block before the row is served, so that stored row now arrives as + * ONE block carrying both keys, `kanban = { groupByField: CANONICAL, groupBy: + * STRAY }`, and that served form is the fixture below. The first case pins the + * fold that produces it.) + * * ## THE RULING, and the two halves it asks for * * Maintainer ruling of 2026-09-12 (decision batch #117 item 5, verbatim @@ -62,7 +68,10 @@ * own block, so both nestings report the arm itself (`invalid_type`). ONE * message — asserted below, because a refusal that only reached the declared nesting * would leave exactly the affected population silently re-grouped, i.e. - * option A wearing option B's name. + * option A wearing option B's name. Since objectstack#25923 the bag is + * refused whole and folded onto the block on read, so the affected + * population carries the key in the declared nesting, and the one message + * is asserted there for both fixtures. * * ## THE CONTROLS, and what each one would catch * @@ -99,6 +108,7 @@ import { render, waitFor } from '@testing-library/react'; import { ComponentRegistry } from '@object-ui/core'; import { SchemaRendererProvider } from '@object-ui/react'; import { safeValidateSchema } from '@object-ui/types/zod'; +import { applyConversionsToStoredItem } from '@objectstack/spec'; import { KanbanConfigSchema } from '@objectstack/spec/ui'; import { ListView } from '../ListView'; @@ -168,9 +178,19 @@ const DISTINGUISHING_VIEW = { type: 'list-view', objectName: OBJECT, viewType: 'kanban', + kanban: { groupByField: CANONICAL, groupBy: STRAY }, +} as const; + +/** The stored row this ruling is about, as written: the stray key in the retired bag. */ +const STORED_LEGACY_ROW = { + name: `${OBJECT}.board`, + object: OBJECT, + viewKind: 'list', + type: 'kanban', + columns: ['name'], options: { kanban: { groupBy: STRAY } }, kanban: { groupByField: CANONICAL }, -} as const; +}; /** * The read door judges the AUTHORED page node (objectui#12670, ruling C on @@ -203,6 +223,12 @@ beforeEach(() => { }); describe('objectui#8365 · half 1 — the CANONICAL lane wins on the generated node', () => { + it('the stored legacy row is served as the distinguishing fixture\'s block (objectstack#25923 fold)', () => { + const served = applyConversionsToStoredItem('view', structuredClone(STORED_LEGACY_ROW)) as Record; + expect(served).not.toHaveProperty('options'); + expect(served.kanban).toEqual(DISTINGUISHING_VIEW.kanban); + }); + it('the distinguishing fixture resolves the lane from `groupByField`, not the stray `groupBy`', async () => { const node = await generatedKanbanNode(DISTINGUISHING_VIEW); // Before the fix this read `LANE_FROM_STRAY_GROUPBY`: the `...restKanban` @@ -235,7 +261,7 @@ describe('objectui#8365 · half 1 — the CANONICAL lane wins on the generated n }); it('LANE CONTROL: `groupBy` alone no longer sets the lane — option B, not a silent re-grouping', async () => { - const node = await generatedKanbanNode({ options: { kanban: { groupBy: STRAY } } }); + const node = await generatedKanbanNode({ kanban: { groupBy: STRAY } }); expect(node.groupBy).not.toBe(STRAY); }); }); @@ -244,13 +270,14 @@ describe('objectui#8365 · half 2 — the stray key is REFUSED at the read door' it('the distinguishing fixture is refused BY NAME, pointing at `groupByField`', () => { const issues = refusalIssuesFor(DISTINGUISHING_VIEW); expect(issues).toHaveLength(1); - // ⭐ THE LEGACY NESTING. Until objectui#6152 round 12 `options` was a record - // that could declare no MEMBER, so the refusal there was a CHECK (`custom`). - // Since that round the bag is the spec's list-overlay bag by reference and - // nests the declared slot's own block, so the arm is a member there too: - // `invalid_type`, the same code and ONE message in both nestings. + // ⭐ THE LEGACY POPULATION. Until objectui#6152 round 12 `options` was a + // record that could declare no MEMBER, so the refusal there was a CHECK + // (`custom`); from that round the bag nested the declared slot's own block. + // objectstack#25923 retired the bag and folds it onto the block on read, so + // the stray key now sits in the declared nesting: `invalid_type`, at + // `kanban.groupBy`, with ONE message. expect(issues[0].code).toBe('invalid_type'); - expect(bagPath(issues[0].path)).toBe('options.kanban.groupBy'); + expect(bagPath(issues[0].path)).toBe('kanban.groupBy'); // The lead sentence is the one the protocol's own `strictObject({ aliases })` // answers with, so an author meets ONE remedy on both faces. expect(issues[0].message).toContain('Unrecognized key(s) on this kanban configuration: `groupBy`.'); @@ -268,8 +295,8 @@ describe('objectui#8365 · half 2 — the stray key is REFUSED at the read door' // `invalid_type` at the key's own path. expect(declared[0].code).toBe('invalid_type'); expect(bagPath(declared[0].path)).toBe('kanban.groupBy'); - // ⭐ ONE STRING, BOTH NESTINGS — the assertion that keeps the check and the - // arm from drifting into two dialects of one remedy. + // ⭐ ONE STRING, BOTH FIXTURES — the authored block and the served legacy + // row meet one remedy (the check and the arm cannot drift into two dialects). const legacy = refusalIssuesFor(DISTINGUISHING_VIEW); expect(declared[0].message).toBe(legacy[0].message); }); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.test.tsx index f6c90b2eb27..2a589ede87d 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.test.tsx @@ -166,12 +166,10 @@ describe('ListView', () => { objectName: 'contacts', viewType: 'grid', fields: ['name', 'email'], - // The spec's lane key: the bag refuses the legacy `groupField` alias by - // name since objectui#6152 round 12, and the gate reads either. - options: { - kanban: { - groupByField: 'status', - }, + // The spec's lane key, in the view's own `kanban` block (the retired + // `options` bag carried it until objectstack#25923). + kanban: { + groupByField: 'status', }, }; @@ -1841,10 +1839,8 @@ describe('ListView', () => { viewType: 'grid', fields: ['name', 'email'], appearance: { allowedVisualizations: ['grid', 'kanban'] }, - options: { - kanban: { groupByField: 'status' }, - calendar: { startDateField: 'date' }, - }, + kanban: { groupByField: 'status' }, + calendar: { startDateField: 'date' }, }; renderWithProvider(); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.timeline-binding.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.timeline-binding.test.tsx index 55785f12c3a..c2fc4eef6c8 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.timeline-binding.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.timeline-binding.test.tsx @@ -115,12 +115,15 @@ describe('ListView — timeline date binding reaches the renderer (objectui#3129 expect(findCalls[0].$select).not.toContain('start_date'); }); - it('forwards the legacy `options.timeline` nesting by its spec key; the retired alias binds nothing (objectui#6152 round 14)', async () => { + it('RETIRED (objectstack#25923): the legacy `options.timeline` nesting binds nothing, by its spec key or by the retired alias', async () => { + // The first half forwarded `start_date` until the bag was retired: a stored + // bag is folded onto `timeline` by the server before the row is served, and + // `ListView` reads no `options`. The top-level spelling is pinned above. const viaStart = await timelineProps({ ...BASE, options: { timeline: { startDateField: 'start_date', endDateField: 'end_date' } }, }); - expect(viaStart.schema.startDateField).toBe('start_date'); + expect(viaStart.schema.startDateField).toBeUndefined(); // This half used to read `start_date` too: the bag's `dateField` was a rung // of `resolveTimelineDateBinding`. Round 14 retired it, and no fold reaches @@ -135,13 +138,13 @@ describe('ListView — timeline date binding reaches the renderer (objectui#3129 // The gap the report isolated: "the same start_date / end_date fields render // correctly in the Calendar and Gantt views". A view whose date axis lives // under `calendar` is OFFERED the Timeline visualization — the capability - // gate has always accepted `options.calendar.startDateField` as a + // gate has always accepted `calendar.startDateField` (then under `options`) as a // timeline-resolvable axis — but the render branch never read calendar // config, so it bucketed every record under "No date" while the calendar // rendered the very same field. const props = await timelineProps({ ...BASE, - options: { calendar: { startDateField: 'start_date', endDateField: 'end_date' } }, + calendar: { startDateField: 'start_date', endDateField: 'end_date' }, }); expect(props.schema.startDateField).toBe('start_date'); expect(props.schema.endDateField).toBe('end_date'); @@ -157,12 +160,13 @@ describe('ListView — timeline date binding reaches the renderer (objectui#3129 }); it('a timeline config with no date key does not shadow the calendar binding', async () => { - // app-shell emits an `options.timeline` object for every object view (it - // carries the object's titleField), so "the config object exists" must not - // be read as "the axis is bound". + // app-shell emits a `timeline` block for every object view (it carries + // the object's titleField), so "the config object exists" must not be read + // as "the axis is bound". const props = await timelineProps({ ...BASE, - options: { timeline: { titleField: 'name' }, calendar: { startDateField: 'start_date' } }, + timeline: { titleField: 'name' }, + calendar: { startDateField: 'start_date' }, }); expect(props.schema.startDateField).toBe('start_date'); expect(props.schema.timeline.startDateField).toBe('start_date'); @@ -192,7 +196,7 @@ describe('ListView — timeline date binding reaches the renderer (objectui#3129 it('the capability gate and the render branch read ONE resolution', () => { // The gate used to be the wider of the two: it accepted a calendar binding // the renderer could not use. Same function now answers both questions. - expect(resolveTimelineDateBinding({ options: { calendar: { startDateField: 'start_date' } } })) + expect(resolveTimelineDateBinding({ calendar: { startDateField: 'start_date' } })) .toEqual({ startDateField: 'start_date', endDateField: undefined, titleField: undefined }); // The timeline's own axis wins over the calendar's. Re-spelled from the // retired `timeline.dateField` (objectui#6152 round 14): the precedence was @@ -248,7 +252,7 @@ describe('ListView — timeline date binding reaches the renderer (objectui#3129 * * ⭐ What the floor actually produced, measured rather than assumed: the axis it * invented was never even FETCHED. The `$select` projection is collected from - * the DECLARED `schema.timeline` / `schema.options.timeline` blocks (see + * the DECLARED `schema.timeline` block (and, until objectstack#25923, the bag's; see * `collectViewFields`), never from this flat prop — so an undeclared view got a * timeline bound to a column the query did not request and bucketed every record * into "No date". That is the outcome the refusal replaces. @@ -283,12 +287,13 @@ describe('ListView timeline branch — the date-axis floor is retired (objectui# expect(props.schema.startDateField).toBeUndefined(); }); - it('invents no axis for the empty `options.timeline` bag the object page emits', async () => { - // app-shell's `timelineViewOptions` emits a bag carrying only the title for - // a view that declared no axis. "The config object exists" is not "the axis - // is bound" — the same distinction the objectui#3129 block above pins for - // the NESTED config, asked here of the flat prop. - const props = await timelineProps({ ...BASE, options: { timeline: { titleField: 'name' } } }); + it('invents no axis for the title-only `timeline` block the object page emits', async () => { + // app-shell's `timelineViewOptions` emits a block carrying only the title + // for a view that declared no axis (under `options` until objectstack#25923). + // "The config object exists" is not "the axis is bound" — the same + // distinction the objectui#3129 block above pins for the NESTED config, + // asked here of the flat prop. + const props = await timelineProps({ ...BASE, timeline: { titleField: 'name' } }); expect(props.schema.startDateField).toBeUndefined(); }); @@ -307,7 +312,7 @@ describe('ListView timeline branch — the date-axis floor is retired (objectui# // break a correctly authored view. const props = await timelineProps({ ...BASE, - options: { calendar: { startDateField: 'start_date', endDateField: 'end_date' } }, + calendar: { startDateField: 'start_date', endDateField: 'end_date' }, }); expect(props.schema.startDateField).toBe('start_date'); expect(props.schema.timeline.startDateField).toBe('start_date'); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.timelineMetaFieldsRetired-10222.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.timelineMetaFieldsRetired-10222.test.tsx index dc2fca43ba4..8ccfd3b7f5d 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.timelineMetaFieldsRetired-10222.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.timelineMetaFieldsRetired-10222.test.tsx @@ -41,8 +41,10 @@ * selected. On the base tree it was both, so this pin goes red if read 1 * comes back. * - The nested `options.timeline` spelling is the one the bypass actually - * delivers, so it is pinned too, with and without a `timeline` block beside - * it. + * delivered, so it is pinned too, with and without a `timeline` block beside + * it. Since objectstack#25923 the bag is retired and read by nothing (a + * stored one is folded onto `timeline` by the server), so a bag alone + * projects what no bag projects. * - SOURCE: no `metaFields` identifier or string key is left in the non-test * source of this package OR `plugin-timeline`, with firing controls on the * same scanner. `plugin-timeline`'s scan lives here, not in that package, @@ -161,17 +163,14 @@ describe('ListView — the timeline `metaFields` reads are retired (objectui#102 expect(select).toEqual(expect.arrayContaining(['status', 'priority'])); }); - it('PROJECTION, `options.timeline` alone: status / priority are no longer dropped', async () => { + it('PROJECTION, `options.timeline` alone: the bag adds nothing — it projects what a view with no timeline block projects (objectstack#25923)', async () => { const { select } = await paramsFor({ timeline: undefined, options: { timeline: { ...TIMELINE, metaFields: ['region'] } }, }); + const { select: noBlock } = await paramsFor({ timeline: undefined }); expect(select).not.toContain('region'); - expect( - select, - 'on the base tree this shape fetched no status / priority at all, so its timeline drew ' - + 'no chips once the renderer stopped reading the list', - ).toEqual(expect.arrayContaining(['status', 'priority'])); + expect(select, 'the retired bag is read by nothing, so it moves no projected field').toEqual(noBlock); }); it('EXPAND: a lookup named only in `metaFields` is neither expanded nor selected', async () => { diff --git a/ui/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx b/ui/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx index 17b37193d40..ec019d4430d 100644 --- a/ui/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx @@ -55,7 +55,8 @@ * The rows double as the member pins the console's repo-wide parity gate * registers for this block's array/object-armed inputs (`list-view.data`, * `list-view.columns`, `list-view.filter`, `list-view.sort`, - * `list-view.options`, and every structured key published here). + * and every structured key published here; `list-view.options` left with the + * retired bag, objectstack#25923). */ import React from 'react'; @@ -479,23 +480,15 @@ describe('objectui#13047 — the record-source and query keys (the member pins t expect(orderby.map((s) => `${s.field} ${s.order}`)).toEqual(['amount desc', 'name asc']); }); - it('`options`: the per-kind bag binds the kind it names — `options.kanban.groupByField` lanes the board on a field the list would not pick', async () => { - // CONTROL first: the bare kanban node lanes on a field the list derives - // itself (the fixture's select field), so the authored `owner` below can - // only come from the bag. - const control = mount({ type: 'kanban' }); - await loaded(control.dataSource); - const derived = lastHanded('object-kanban')?.groupBy; - expect(derived).not.toBe('owner'); - cleanup(); - resetHanded(); - - const { dataSource } = mount({ type: 'kanban', options: { kanban: { groupByField: 'owner', columns: ['name'] } } }); - const board = await handedTo('object-kanban'); - await loaded(dataSource); - expect(board.type).toBe('object-kanban'); - expect(lastHanded('object-kanban').groupBy).toBe('owner'); - expect(lastHanded('object-kanban').cardFields).toEqual(['name']); + it('`options` is no input (objectstack#25923): the spec\'s `list-view` row refuses the retired bag by name, so no fixture here can mount one', () => { + // The member this row pinned (`options.kanban.groupByField` laning the + // board) is the top-level `kanban` block now, pinned with the other + // structured keys. What is left to pin is the door: the bag never reaches + // the renderer from a page, because the save gate refuses it. + expect(() => node({ type: 'kanban', options: { kanban: { groupByField: 'owner', columns: ['name'] } } })).toThrow( + /`options` bag was removed/, + ); + expect(() => node({ type: 'kanban', kanban: { groupByField: 'owner', columns: ['name'] } })).not.toThrow(); }); }); diff --git a/ui/packages/plugin-list/src/index.tsx b/ui/packages/plugin-list/src/index.tsx index 74fc3cf8442..9ecfcb0ef0b 100644 --- a/ui/packages/plugin-list/src/index.tsx +++ b/ui/packages/plugin-list/src/index.tsx @@ -313,12 +313,8 @@ const LIST_VIEW_INPUTS: ComponentInput[] = [ 'Full bulk-action definitions, the spec\'s `BulkActionDef`: a mass update (`operation: "update"` with a `patch`) or delete, or a named action run once for the whole selection.', }, // ── each kind's own block ───────────────────────────────────────────────── - { - name: 'options', - type: 'object', - description: - 'Per-kind settings, `{ kanban, calendar, gantt, gallery, timeline, map, tree, chart }`, each judged by that kind\'s own block. The top-level block of the same name is the canonical spelling.', - }, + // (#25923: no `options` input. The legacy per-kind bag is retired; each block + // below is its kind's one spelling.) { name: 'kanban', type: 'object', diff --git a/ui/packages/plugin-map/src/ObjectMap.listViewMapConfigReach.test.tsx b/ui/packages/plugin-map/src/ObjectMap.listViewMapConfigReach.test.tsx index 7a52918764b..2a5a6660c9e 100644 --- a/ui/packages/plugin-map/src/ObjectMap.listViewMapConfigReach.test.tsx +++ b/ui/packages/plugin-map/src/ObjectMap.listViewMapConfigReach.test.tsx @@ -188,21 +188,44 @@ describe('a view-level `map` block reaches getMapConfig through ListView (object expect(fitBoundsOptions).toEqual({ padding: FIT_PADDING_PX, maxZoom: FIT_MAX_ZOOM }); }); - it('CONTROL: the legacy `options.map` bag still reaches the same reads', async () => { - await renderListViewMap({ options: { map: { locationField: 'location', titleField: 'title' } } }); - - await waitFor(() => expect(screen.getAllByTestId('map-marker').length).toBe(2)); - fireEvent.click(screen.getAllByTestId('map-marker')[0]); - await waitFor(() => expect(screen.queryByTestId('map-popup')).not.toBeNull()); - expect(screen.getByText('Install rooftop unit')).toBeTruthy(); + it('RETIRED (objectstack#25923): a legacy `options.map` bag reaches none of the reads — no binding, no markers', async () => { + // This was the bag's CONTROL. A stored bag is folded onto the view's `map` + // block by the server before the row is served, and `ListView` reads the + // block alone, so a bag that still reaches it binds nothing. + // Mounted directly: `renderListViewMap` waits for the map canvas, which an + // unbound map never draws. + capturedProps = null; + const dataSource = makeDataSource() as any; + render( + + + , + ); + + await waitFor(() => expect(screen.queryByText(/Map configuration required/)).not.toBeNull()); + expect(screen.queryAllByTestId('map-marker')).toHaveLength(0); + expect(capturedProps).toBeNull(); }); - it('precedence: the view-level block wins over the bag at the READ site', async () => { + it('precedence: the view-level block is the one source at the READ site, a bag beside it adds nothing', async () => { // Both name a marker-title field; the records carry distinct values, so the - // rendered popup says which config actually drove the read. + // rendered popup says which config actually drove the read. The block also + // carries the coordinate binding now: the per-key merge that let the bag + // supply it is the server fold's (objectstack#25923). await renderListViewMap({ options: { map: { locationField: 'location', titleField: 'blurb' } }, - map: { titleField: 'title' }, + map: { locationField: 'location', titleField: 'title' }, }); await waitFor(() => expect(screen.getAllByTestId('map-marker').length).toBe(2)); @@ -210,9 +233,5 @@ describe('a view-level `map` block reaches getMapConfig through ListView (object await waitFor(() => expect(screen.queryByTestId('map-popup')).not.toBeNull()); expect(screen.getByText('Install rooftop unit')).toBeTruthy(); - // …and the coordinate binding the bag alone supplied still worked, which is - // what makes this a per-key merge rather than a replacement: markers exist - // at all only because `locationField` survived from the bag. - expect(screen.getAllByTestId('map-marker').length).toBe(2); }); }); diff --git a/ui/packages/plugin-map/src/ObjectMap.schemaAlignment.test.tsx b/ui/packages/plugin-map/src/ObjectMap.schemaAlignment.test.tsx index bb4731ffd84..57aeb264785 100644 --- a/ui/packages/plugin-map/src/ObjectMap.schemaAlignment.test.tsx +++ b/ui/packages/plugin-map/src/ObjectMap.schemaAlignment.test.tsx @@ -30,7 +30,7 @@ * (b) RUNTIME — precedence. Maintainer ruling, 2026-08-17 (「同意」, recorded * on objectui#5018): the `map` block is the author face, the flat * top-level spelling is the internal form ObjectView/ListView produce when - * they flatten `options.map`, and when both are present the block wins — + * they flatten a view's `map` block, and when both are present the block wins — * what the author wrote outranks what internal flattening produced — with * a dev-mode warning naming the top-level keys that were ignored. */ diff --git a/ui/packages/plugin-map/src/ObjectMap.topLevelStyle.test.tsx b/ui/packages/plugin-map/src/ObjectMap.topLevelStyle.test.tsx index 2f6e18983ac..f2019365ada 100644 --- a/ui/packages/plugin-map/src/ObjectMap.topLevelStyle.test.tsx +++ b/ui/packages/plugin-map/src/ObjectMap.topLevelStyle.test.tsx @@ -28,7 +28,7 @@ * statement about the OTHER key. * * The string half matters just as much and is easy to lose: `ObjectView` / - * `ListView` flatten `options.map`'s CONTENTS to the top level, so a view + * `ListView` flatten a view's `map` block's CONTENTS to the top level, so a view * authored with `map: { style: '' }` reaches this component as a top-level * STRING `style`. That shape is not spec-authorable — `@objectstack/spec@17`'s * list-view schemas are strict and declare no `map` block at all, so a view diff --git a/ui/packages/plugin-map/src/ObjectMap.tsx b/ui/packages/plugin-map/src/ObjectMap.tsx index 55c9ca92a71..4a419ea10b4 100644 --- a/ui/packages/plugin-map/src/ObjectMap.tsx +++ b/ui/packages/plugin-map/src/ObjectMap.tsx @@ -365,7 +365,7 @@ function warnOnTopLevelStyleUrl(schema: MapConfigSource): void { '`mapStyle` — named that way precisely to avoid this collision. Write `mapStyle: ' + `'${raw}'\` at the top level, or \`map: { style: '${raw}' }\` in the declared config block. ` + 'On a view, write that same declared `map: { style: ... }`: ObjectView / ListView flatten ' + - '`options.map` through a whitelist of the keys `ObjectMapConfigSchema` declares, and that ' + + 'the view\'s `map` block through a whitelist of the keys `ObjectMapConfigSchema` declares, and that ' + 'whitelist carries `style` out under the flat spelling `mapStyle` (objectui#9950), so the ' + 'declared block arrives and is read. `mapStyle` is the TOP-LEVEL spelling only — inside ' + 'the `map` block it is undeclared, and the flatten drops it. objectui#5017.', @@ -408,7 +408,7 @@ function warnOnShadowedFlatMapKeys(schema: MapConfigSource): void { `[ObjectMap] The \`map\` block configures this map, so these top-level keys are ` + `IGNORED: ${shadowed.map((k) => `\`${k}\``).join(', ')}. The \`map\` block is the ` + 'authoring shape; the flat top-level spelling is the internal form ObjectView/ListView ' + - 'produce when they flatten `options.map`, and what the author wrote outranks it. Move ' + + 'produce when they flatten the view\'s `map` block, and what the author wrote outranks it. Move ' + 'anything you still need into `map`, or drop the `map` block. objectui#5018.', ); } diff --git a/ui/packages/plugin-view/src/ObjectView.tsx b/ui/packages/plugin-view/src/ObjectView.tsx index b04c62d87e2..706929716ce 100644 --- a/ui/packages/plugin-view/src/ObjectView.tsx +++ b/ui/packages/plugin-view/src/ObjectView.tsx @@ -227,6 +227,21 @@ function pickFlatMapConfig( ); } +/** The eight per-kind blocks a list view carries at its top level. */ +const LIST_VIEW_KIND_BLOCKS = ['kanban', 'calendar', 'gallery', 'timeline', 'gantt', 'map', 'chart', 'tree'] as const; + +/** + * [#25923] The per-kind blocks a host `views` entry declares, by reference, + * for the `renderListView` relay. An absent block stays absent. + */ +function hostViewKindBlocks(view: unknown): Record { + if (!view || typeof view !== 'object') return {}; + const source = view as Record; + return Object.fromEntries( + LIST_VIEW_KIND_BLOCKS.filter((kind) => source[kind] !== undefined).map((kind) => [kind, source[kind]]), + ); +} + /** * `table.columns` as a FIELD-NAME list — the shape the non-grid field slot * declares (objectui#5269). @@ -2058,10 +2073,11 @@ export const ObjectView: React.FC = ({ // 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. + // `options.KIND` is folded onto `KIND` by the server before it is served + // (the spec's D2 conversion `list-view-options-bag-folded`, #25923; app-shell's + // `ViewPreview` folded it until then), 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 @@ -2083,7 +2099,7 @@ export const ObjectView: React.FC = ({ // 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 + // named view's own `options` bag is folded before it is served (see // `legacyViewOptions` above). // // ⚠️ IDENTITY IS PRESERVED WHEN NOTHING CANONICAL IS DECLARED — the `else` @@ -3031,9 +3047,12 @@ 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, - // objectui#7928 — the host `views` entry only; a named view's legacy - // `options` bag is no longer read (see `legacyViewOptions` above). - options: activeView, + // [#25923] The host `views` entry's per-kind blocks, at the TOP + // level, each by reference (so `ListView`'s fetch effect, which + // names each block by identity, sees no new object per render). + // They rode `options: activeView` (objectui#7928) until the spec + // retired the bag, and `ListView` reads no bag now. + ...hostViewKindBlocks(activeView), // Toolbar policy — one vocabulary (#2890). The host node and the // active view may still carry the legacy bare `show*` flags, so both // go through `normalizeListViewSchema` (the single fold) and merge, diff --git a/ui/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx b/ui/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx index 4c9544cbb5c..e51b9659426 100644 --- a/ui/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx +++ b/ui/packages/plugin-view/src/__tests__/ObjectView.namedViewProtocolKeys-8980.test.tsx @@ -280,10 +280,12 @@ describe('objectui#8980 — a canonical top-level view-kind block reaches the re * 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. + * objectstack#25923 moved it once more, to the server: the spec's D2 conversion + * `list-view-options-bag-folded` folds a stored bag before the row is served. + * It is pinned through this same renderer in + * `ViewPreview.foldedOnRead-25923.test.tsx` (was `ViewPreview.optionsFold-7928`). + * 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 () => { @@ -310,9 +312,10 @@ describe('objectui#8980 → objectui#7928 — the legacy `options.` nestin 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. + // protected: the server's fold (objectstack#25923, was `foldStoredListOptions`) + // merges per key before the body becomes a named view, and + // `ViewPreview.foldedOnRead-25923.test.tsx` asserts this exact `titleField` + // through this renderer. const node = await generatedNodeFor({ label: 'Board', type: 'kanban', diff --git a/ui/packages/types/src/__tests__/calendar-date-alias-refusal-8355.test.ts b/ui/packages/types/src/__tests__/calendar-date-alias-refusal-8355.test.ts index 926fbbb01c4..6709fd1e9b9 100644 --- a/ui/packages/types/src/__tests__/calendar-date-alias-refusal-8355.test.ts +++ b/ui/packages/types/src/__tests__/calendar-date-alias-refusal-8355.test.ts @@ -202,44 +202,46 @@ describe('objectui#8355 · surface 1 — the view-level `calendar` block', () => }); }); -describe('objectui#8355 · surface 2 — the legacy `options.calendar` nesting', () => { - it.each(RETIRED)('refuses `%s` there too, with the SAME message and the SAME `invalid_type` code', (alias, canonical) => { +describe('objectui#8355 · surface 2 — the legacy `options.calendar` nesting is RETIRED (objectstack#25923)', () => { + // Until objectstack#25923 the bag nested surface 1's own block, so each arm + // fired there too, with ONE message. The bag is retired: the list overlay's + // `options` is a tombstone that refuses the bag WHOLE, by name, before any + // block inside it is judged, and a stored bag is folded onto `calendar` on + // read, where surface 1's arms judge it. + const BAG_REFUSAL = /^A list view's `options` bag was removed in @objectstack\/spec /; + + it.each(RETIRED)('refuses a bag carrying `%s` at `options` itself, judging nothing inside it', (alias) => { const result = listView({ options: { calendar: { startDateField: 'kickoff', [alias]: 'kickoff' } } }); expect(result.success, `${alias} still parses green under options.calendar`).toBe(false); - const issue = issueAt(result, `options.calendar.${alias}`); - // objectui#6152 round 12: the bag nests surface 1's own block, so the arm is - // a declared MEMBER here too (it was a `custom` check while `options` was an - // open record). One code and ONE message in both nestings. - expect(issue?.code).toBe('invalid_type'); - expect(issue?.message).toContain(`Did you mean \`${alias}\` → \`${canonical}\`?`); - const surface1 = issueAt(listView({ calendar: { startDateField: 'kickoff', [alias]: 'kickoff' } }), `calendar.${alias}`); - expect(issue?.message).toBe(surface1?.message); + expect(issueAt(result, 'options')?.message).toMatch(BAG_REFUSAL); + expect(issueAt(result, `options.calendar.${alias}`)).toBeUndefined(); }); - it('DARK CONTROL: a canonical legacy block parses GREEN', () => { - const ok = listView({ options: { calendar: { startDateField: 'kickoff' } } }); - expect(ok.success, JSON.stringify(ok.error?.issues)).toBe(true); + it('a canonical block in the bag is refused the same way; the same block at the top level parses GREEN', () => { + const bag = listView({ options: { calendar: { startDateField: 'kickoff' } } }); + expect(issueAt(bag, 'options')?.message).toMatch(BAG_REFUSAL); + const top = listView({ calendar: { startDateField: 'kickoff' } }); + expect(top.success, JSON.stringify(top.error?.issues)).toBe(true); }); - it('UNDECLARED-KEY CONTROL: an undeclared key is refused with the spec\'s own `unrecognized_keys` (objectui#6152 round 12 closed the bag)', () => { - // Until round 12 this row pinned the nesting OPEN ("nothing else under - // `options.calendar` is judged here"). The by-name arms above report - // `invalid_type` at their own path; a key no arm names reports the spec - // slot's `unrecognized_keys` at the block, as on surface 1. - const r = listView({ options: { calendar: { [CONTROL_KEY]: 'x' } } }); + it('UNDECLARED-KEY CONTROL: a key no arm names is refused at the top-level block with the spec\'s own `unrecognized_keys`', () => { + // The row that pinned the bag CLOSED (objectui#6152 round 12), moved to the + // one nesting left. + const r = listView({ calendar: { startDateField: 'kickoff', [CONTROL_KEY]: 'x' } }); expect(r.success).toBe(false); - const issue = issueAt(r, 'options.calendar') as { code?: string; keys?: string[] } | undefined; + const issue = issueAt(r, 'calendar') as { code?: string; keys?: string[] } | undefined; expect(issue?.code).toBe('unrecognized_keys'); expect(issue?.keys).toEqual([CONTROL_KEY]); }); - it('SIBLING CONTROL: the objectui#8365 `options.kanban.groupBy` refusal still fires', () => { + it('SIBLING CONTROL: the objectui#8365 `kanban.groupBy` refusal still fires', () => { // The calendar refusal and the kanban one rode the same `.check()` until // objectui#6152 round 12 made both arms of the nested blocks, so this row is - // what reports a refactor that dropped the earlier card's refusal on the way past. - const result = listView({ options: { kanban: { groupBy: 'stage' } } }); + // what reports a refactor that dropped the earlier card's refusal on the way + // past. It wrote the bag nesting until objectstack#25923. + const result = listView({ kanban: { groupByField: 'stage', groupBy: 'stage' } }); expect(result.success).toBe(false); - expect(issueAt(result, 'options.kanban.groupBy')?.message) + expect(issueAt(result, 'kanban.groupBy')?.message) .toContain('Did you mean `groupBy` → `groupByField`?'); }); }); diff --git a/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts b/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts index c114c38563b..2570a270952 100644 --- a/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/ui/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -413,11 +413,13 @@ const IMPORTED: Array = [ // because the census below requires every imported symbol to be measured. ['InlineGridColumnSchema', SpecInlineGridColumnSchema], // objectui#6152 round 12: `ListViewSchema.options` is the flattened list - // overlay's own legacy `options` bag. The binding is `VIEW_METADATA_MEMBERS`, a + // overlay's own `options` member. The binding is `VIEW_METADATA_MEMBERS`, a // RECORD of member schemas (see `SCHEMA_RECORDS` below), so what crosses is the - // bag read off it, and that is what this row measures. It carries a default - // (the chart block's `chartType`), so the boundary returns a rebuilt copy. - ['ViewMetadataMembers', SpecViewMetadataMembers.listOverlay.shape.options.unwrap() as unknown as z.ZodType], + // member read off it, and that is what this row measures. Since #25923 it is + // the spec's retired-key tombstone, which carries no default, so the strip is + // the identity function (it was the judged bag, whose chart block defaulted + // `chartType`, until then). + ['ViewMetadataMembers', SpecViewMetadataMembers.listOverlay.shape.options as unknown as z.ZodType], ] as const; /** The subset that actually carries an imported default — where the strip does work. */ @@ -753,7 +755,7 @@ describe('the import boundary strips every imported default (objectui#8317)', () * (objectui#6152 round 12). Such a binding has no Zod internals, so it cannot * itself be the argument of `stripImportedDefaults`; the read is accepted only * when the WHOLE member chain rooted at the binding is that argument - * (`stripImportedDefaults(SpecViewMetadataMembers.listOverlay.shape.options.unwrap())`), + * (`stripImportedDefaults(SpecViewMetadataMembers.listOverlay.shape.options)`), * which still puts everything that crosses through the boundary. ⛔ Not a * route for a schema binding: the assertion below requires each entry to be a * non-Zod object whose every value is a schema, and a schema binding keeps the diff --git a/ui/packages/types/src/__tests__/kanban-stray-group-by-refusal-text-9653.test.ts b/ui/packages/types/src/__tests__/kanban-stray-group-by-refusal-text-9653.test.ts index b5f7ea27ddf..bf09f6d6465 100644 --- a/ui/packages/types/src/__tests__/kanban-stray-group-by-refusal-text-9653.test.ts +++ b/ui/packages/types/src/__tests__/kanban-stray-group-by-refusal-text-9653.test.ts @@ -62,9 +62,11 @@ function refusalMessage(doc: Record, path: string, code: string const CHANNELS = [ { name: 'declared `kanban` slot', doc: { kanban: { groupByField: 'stage', groupBy: 'stage' } }, path: 'kanban.groupBy', code: 'invalid_type' }, - // `invalid_type` since objectui#6152 round 12: the bag nests the declared slot's - // own block, so the arm is a member there too (it was a `custom` check). - { name: 'legacy `options.kanban` bag', doc: { options: { kanban: { groupBy: 'stage' } } }, path: 'options.kanban.groupBy', code: 'invalid_type' }, + // The legacy `options.kanban` bag was the second channel (`invalid_type` there + // since objectui#6152 round 12). objectstack#25923 retired the bag: it is + // refused whole at `options`, and a stored one is folded onto `kanban`, so + // its fixture lands here, in the declared slot, with no canonical lane beside it. + { name: 'declared `kanban` slot, the stray key alone (the folded legacy bag)', doc: { kanban: { groupBy: 'stage' } }, path: 'kanban.groupBy', code: 'invalid_type' }, ] as const; describe('objectui#9653 · the stray-`groupBy` refusal does not deny the validation that emits it', () => { diff --git a/ui/packages/types/src/__tests__/list-view-dataset-chart-filter-refusal-10327.test.ts b/ui/packages/types/src/__tests__/list-view-dataset-chart-filter-refusal-10327.test.ts index 76b7a61ca82..89b30a5291f 100644 --- a/ui/packages/types/src/__tests__/list-view-dataset-chart-filter-refusal-10327.test.ts +++ b/ui/packages/types/src/__tests__/list-view-dataset-chart-filter-refusal-10327.test.ts @@ -38,12 +38,13 @@ import { asPageListView, bagPath } from './list-view-page-node'; const REMEDY = "a dataset chart's scope is written in the dataset."; const DATASET_BLOCK = { chartType: 'bar', dataset: 'task_ds', dimensions: ['status'], values: ['total_estimate'] }; -// A legacy `options.chart` bag that binds NO dataset. It carried the object-bound -// axes (`xAxisField` / `yAxisFields` / `aggregation`) until objectui#6152 round 12, -// when the bag became the spec's list-overlay bag, whose chart block refuses -// them (pinned in `list-view-options-bag-6152.test.ts`). What this control -// guards is unchanged: the filter refusal reads `dataset`, nothing else. -const OBJECT_BAG = { chartType: 'bar' }; +// A `chart` block that binds NO dataset. It was a legacy `options.chart` bag +// fixture, carrying the object-bound axes (`xAxisField` / `yAxisFields` / +// `aggregation`) until objectui#6152 round 12 made the bag's chart block refuse +// them; objectstack#25923 retired the bag, so the block moved to the top level. +// What this control guards is unchanged: the filter refusal reads `dataset`, +// nothing else. +const OBJECT_BLOCK = { chartType: 'bar' }; const VIEW_FILTER = [{ field: 'status', operator: 'equals', value: 'open' }]; const LEGACY_FILTERS = [['status', '=', 'open']]; @@ -68,7 +69,6 @@ const issuesOf = (r: Parsed) => describe('objectui#10327 — a view filter on a dataset-bound chart view is refused', () => { it.each([ ['`filter` on a `chart` block bound to a dataset', { chart: DATASET_BLOCK, filter: VIEW_FILTER }, 'filter'], - ['`filter` on the legacy `options.chart` bag bound to a dataset', { options: { chart: DATASET_BLOCK }, filter: VIEW_FILTER }, 'filter'], ['the legacy `filters` alias on a `chart` block bound to a dataset', { chart: DATASET_BLOCK, filters: LEGACY_FILTERS }, 'filters'], ])('%s: one `custom` issue at the written key, naming it, with the ruling\'s remedy', (_label, extra, key) => { for (const r of bothDoors(node({ viewType: 'chart', ...extra }))) { @@ -80,6 +80,18 @@ describe('objectui#10327 — a view filter on a dataset-bound chart view is refu } }); + it('RETIRED (objectstack#25923): a dataset block in the legacy `options.chart` bag is refused at `options` by name, and binds no dataset for this refusal', () => { + // This was a row of the table above (the filter refused off the bag's + // dataset). The bag is the spec's tombstone now and the runtime reads + // `chart` alone, so the only issue is the bag's own refusal. + for (const r of bothDoors(node({ viewType: 'chart', options: { chart: DATASET_BLOCK }, filter: VIEW_FILTER }))) { + expect(r.success).toBe(false); + const issues = issuesOf(r); + expect(issues.map(({ code, path }) => ({ code, path }))).toEqual([{ code: 'invalid_type', path: 'options' }]); + expect(issues[0].message).toMatch(/^A list view's `options` bag was removed in @objectstack\/spec /); + } + }); + it('both spellings written at once: one issue at each key', () => { for (const r of bothDoors(node({ viewType: 'chart', chart: DATASET_BLOCK, filter: VIEW_FILTER, filters: LEGACY_FILTERS }))) { expect(r.success).toBe(false); @@ -92,12 +104,25 @@ describe('objectui#10327 — a view filter on a dataset-bound chart view is refu }); describe('objectui#10327 — controls: every neighbour of the refused shape parses', () => { + it('a chart whose `chart` block binds no dataset, with a view filter: refused at the block\'s missing binding keys, never at `filter`', () => { + // This row was a legacy `options.chart` bag, whose block the bag judged + // with every key optional, so the component door passed it. Moved to the + // top-level `chart` (objectstack#25923), the block's required `dataset` and + // `values` answer on BOTH doors. What it guards is unchanged: nothing here + // is the view filter's refusal. + for (const r of bothDoors(node({ viewType: 'chart', chart: OBJECT_BLOCK, filter: VIEW_FILTER }))) { + expect(r.success).toBe(false); + const paths = issuesOf(r).map(({ path }) => path); + expect(paths).toEqual(expect.arrayContaining(['chart.dataset', 'chart.values'])); + expect(paths.some((path) => path === 'filter' || path === 'filters')).toBe(false); + } + }); + // `unbound`: a `chart` view whose effective block binds no dataset. The page // door refuses it with the spec's own `checkListViewChartBinding` (its issue at - // `chart`, or at the bag block's missing binding keys), the same document the + // `chart`, or at the block's missing binding keys), the same document the // spec's list view refuses — ⛔ never at the view filter, which is this card's. it.each([ - ['a chart whose legacy `options.chart` bag binds no dataset, with a view filter', { viewType: 'chart', options: { chart: OBJECT_BAG }, filter: VIEW_FILTER }, 'unbound'], ['a chart view declaring no block, with a view filter', { viewType: 'chart', filter: VIEW_FILTER }, 'unbound'], ['a dataset-bound chart with no view filter', { viewType: 'chart', chart: DATASET_BLOCK }, 'parses'], ['a dataset-bound chart with an EMPTY view filter', { viewType: 'chart', chart: DATASET_BLOCK, filter: [] }, 'parses'], @@ -117,13 +142,11 @@ describe('objectui#10327 — controls: every neighbour of the refused shape pars } const issues = issuesOf(page); expect(issues.length).toBeGreaterThan(0); - expect(issues.every(({ code, path }) => code === 'custom' && (path === 'chart' || path.startsWith('options.chart.')))).toBe(true); + expect(issues.every(({ code, path }) => code === 'custom' && (path === 'chart' || path.startsWith('chart.')))).toBe(true); expect(issues.some(({ path }) => path === 'filter' || path === 'filters')).toBe(false); // Lit control: the spec's own list view refuses the same document (the spec requires - // `columns`). Read only where the document is spec-shaped: the spec's authoring list - // view declares no legacy `options` bag at all. + // `columns`). Every row is spec-shaped since the bag row moved to `chart`. const { viewType, ...rest } = extra as Record; - if ('options' in rest) return; const spec = SpecListViewSchema.safeParse({ ...rest, type: viewType, columns: ['name'] }); expect(spec.success).toBe(false); expect(spec.error!.issues.map((i) => i.path.join('.')).sort()).toEqual(issues.map(({ path }) => path).sort()); diff --git a/ui/packages/types/src/__tests__/list-view-options-bag-6152.test.ts b/ui/packages/types/src/__tests__/list-view-options-bag-6152.test.ts index ca3f923c82f..418386a546c 100644 --- a/ui/packages/types/src/__tests__/list-view-options-bag-6152.test.ts +++ b/ui/packages/types/src/__tests__/list-view-options-bag-6152.test.ts @@ -7,33 +7,26 @@ */ /** - * objectui#6152 round 12 — a list view's legacy `options` bag is the - * `@objectstack/spec` list overlay's own bag, BY REFERENCE. + * objectstack#25923 — a list view's legacy `options` bag is RETIRED, and the + * mirror takes the `@objectstack/spec` list overlay's tombstone BY REFERENCE. * - * The spec's authoring list view declares no `options`. Its one home is the - * flattened LIST OVERLAY on the view write door (`VIEW_METADATA_MEMBERS.listOverlay`): - * a strict object of the eight kinds that name a block, each judged by its own - * list-view slot with every key optional. `ListViewSchema.options` was a record - * of `any` with three named refusals, so every door and the TypeScript face - * accepted what that door refuses. objectui#8327 forbids that, and the seat - * answered the round's census Q1 → A (objectui#6152, 6070475966). The blocks below: + * From objectui#6152 round 12 until #25923 the bag was the list overlay's own + * judged underlay: a strict object of the eight kinds that name a block, each + * judged by its list-view slot with every key optional, and this mirror took it + * by reference (objectui#8327 forbids accepting what that door refuses). The + * spec retired the bag: the overlay member is a `retiredKey()` tombstone that + * refuses `options` by name with the move-to-the-top-level prescription, and a + * stored bag is folded onto the top-level blocks by the ADR-0087 D2 conversion + * `list-view-options-bag-folded` before a row is served. The readers retired in + * the same change. The blocks below: * - * 1. the overlay's bag, measured on the INSTALLED spec — its key set derived - * by the spec's own rule, each kind strict and fully optional, so a spec bump - * that moves any of that reds here by name; - * 2. the mirror IS that bag: strict, the same key set and error map, each kind - * the slot `.partial()`, with this package's round-11 blocks (and only their - * named refusal arms) in four of them; - * 3. verdict equality with the spec's bag over accepted and refused documents, - * and the sanctioned differences, each named; - * 4. every named refusal under `options.KIND`, through every door, with ONE - * message in both nestings; - * 5. undeclared keys and kinds, through every door (`options.grid` with the - * spec's guidance); - * 6. the TypeScript face, closed the same way, and the two config types. - * - * ⛔ The READERS are not this round's: `ListView` still merges and spreads the - * bag. Nothing here asserts them. + * 1. the overlay's `options`, on the INSTALLED spec: a tombstone; + * 2. the mirror IS that tombstone, by reference; + * 3. every door refuses a bag at `options`, whatever it holds, with the + * spec's text — and the same blocks written top-level parse; + * 4. each named refusal that used to apply under `options.KIND` applies at the + * top-level block, the one nesting left, with its canonical hint; + * 5. the TypeScript face: no bag compiles. */ import { describe, it, expect } from 'vitest'; @@ -56,48 +49,26 @@ type Parse = (doc: unknown) => Parsed; type Zod = { safeParse: Parse; unwrap: () => Zod; + description?: string; shape: Record; - _zod: { def: { type: string; innerType?: Zod; catchall?: Zod; error?: unknown; values?: unknown[]; entries?: Record } }; + _zod: { def: { type: string; innerType?: Zod } }; }; -const verdict = (r: Parsed) => ({ - success: r.success, - issues: r.success ? [] : r.error!.issues.map((i) => ({ code: i.code, path: i.path, message: i.message, keys: i.keys })), -}); const codeAndPath = (r: Parsed) => (r.success ? [] : r.error!.issues.map((i) => ({ code: i.code, path: i.path.join('.') }))); -const catchallOf = (object: Zod) => object._zod.def.catchall?._zod.def.type; -/** The members an object REQUIRES: those whose schema refuses `undefined`. */ -const requiredOf = (object: Zod) => Object.keys(object.shape).filter((k) => !object.shape[k].safeParse(undefined).success).sort(); const SPEC_SLOTS = (SpecListViewSchema as unknown as Zod).shape; -/** The spec's list overlay bag, read off the overlay member (the bag's own schema is module-private). */ -const SPEC_BAG_OPTIONAL = (VIEW_METADATA_MEMBERS.listOverlay as unknown as Zod).shape.options; -const SPEC_BAG = SPEC_BAG_OPTIONAL.unwrap(); -const MIRROR_BAG_OPTIONAL = (ListViewSchema as unknown as Zod).shape.options; -const MIRROR_BAG = MIRROR_BAG_OPTIONAL.unwrap(); - -/** The spec's own rule for the bag's key set: a value of the list view's `type` enum that also names a block. */ -const typeValues = (): string[] => { - let cur: Zod | undefined = SPEC_SLOTS.type; - while (cur && !cur._zod.def.entries) cur = cur._zod.def.innerType; - return Object.keys(cur!._zod.def.entries!); -}; -const KINDS = typeValues().filter((t) => t in SPEC_SLOTS).sort(); - -/** The four kinds that are this package's round-11 blocks, with their named refusal arms. */ -const ARMS: Record = { - kanban: ['groupField', 'cardFields', 'groupBy'], - calendar: ['defaultView', 'dateField', 'endField'], - gallery: ['imageField'], - timeline: ['dateField'], -}; +/** The spec's list overlay `options` member (the overlay's own schema is module-private). */ +const SPEC_OPTIONS = (VIEW_METADATA_MEMBERS.listOverlay as unknown as Zod).shape.options; +const MIRROR_OPTIONS = (ListViewSchema as unknown as Zod).shape.options; +/** The tombstone's refusal text, read off the spec. */ +const REFUSAL = SPEC_OPTIONS.safeParse({}).error!.issues[0].message; const NODE = { type: 'list-view', objectName: 'deal' }; /** * The mirror is the `ListView` component's props; the three published faces * judge the authored PAGE node since objectui#12670, its props in the * `properties` bag, so each re-spells the node and reads its paths relative to - * the bag (`onPageSpelling`). The bag's `options` is the mirror's, by reference. + * the bag (`onPageSpelling`). */ const DOORS: ReadonlyArray = [ ['the list-view mirror', (d) => ListViewSchema.safeParse(d)], @@ -106,24 +77,27 @@ const DOORS: ReadonlyArray = [ ['safeValidateSchema', onPageSpelling((d) => safeValidateSchema(d) as Parsed)], ]; -/* ── 1. The overlay's bag, on the installed protocol (the precondition) ──────── */ +/** The blocks app-shell's object page relays, each in full. */ +const RELAYED_BLOCKS: Record> = { + kanban: { groupByField: 'stage', titleField: 'name', columns: ['name'] }, + calendar: { startDateField: 'starts_at' }, + timeline: { startDateField: 'starts_at', titleField: 'name' }, + map: { locationField: 'address', titleField: 'name' }, + gallery: { coverField: 'logo', titleField: 'name' }, + gantt: { startDateField: 'starts_at', endDateField: 'ends_at', titleField: 'name' }, + tree: { parentField: 'parent', labelField: 'name' }, + chart: { chartType: 'bar', dataset: 'deal_ds', dimensions: ['stage'], values: ['total'] }, +}; -describe('objectui#6152 round 12 — the installed list overlay\'s `options` bag', () => { - it('is `optional` around a STRICT object whose keys are exactly the kinds that name a block', () => { - expect(SPEC_BAG_OPTIONAL._zod.def.type).toBe('optional'); - expect(SPEC_BAG._zod.def.type).toBe('object'); - expect(catchallOf(SPEC_BAG)).toBe('never'); - // The kinds this package's blocks cover, by name (#26238); the bag's key - // set is then compared with the spec's own rule, derived on both sides. - for (const kind of Object.keys(ARMS)) expect(KINDS).toContain(kind); - expect(Object.keys(SPEC_BAG.shape).sort()).toEqual(KINDS); - }); +/* ── 1. The overlay's `options`, on the installed protocol (the precondition) ── */ - it.each(KINDS)('%s is the list-view slot with every key optional, strict', (k) => { - const block = SPEC_BAG.shape[k].unwrap(); - expect(catchallOf(block)).toBe('never'); - expect(Object.keys(block.shape).sort()).toEqual(Object.keys(SPEC_SLOTS[k].unwrap().shape).sort()); - expect(requiredOf(block)).toEqual([]); +describe('#25923 — the installed list overlay\'s `options` is a tombstone', () => { + it('is `optional` around `never`, described `[REMOVED]`, and refuses a bag with the move-to-the-top-level prescription', () => { + expect(SPEC_OPTIONS._zod.def.type).toBe('optional'); + expect(SPEC_OPTIONS.unwrap()._zod.def.type).toBe('never'); + expect(SPEC_OPTIONS.description).toMatch(/^\[REMOVED\] /); + expect(REFUSAL).toMatch(/^A list view's `options` bag was removed in @objectstack\/spec /); + expect(REFUSAL).toContain('Move each `options.KIND` block to `KIND`'); }); it('the authoring list view declares no bag at all', () => { @@ -131,90 +105,42 @@ describe('objectui#6152 round 12 — the installed list overlay\'s `options` bag }); }); -/* ── 2. The mirror IS the bag ────────────────────────────────────────────────── */ +/* ── 2. The mirror IS the tombstone ──────────────────────────────────────────── */ -describe('objectui#6152 round 12 — `ListViewSchema.options` takes the overlay\'s bag by reference', () => { - it('is `optional` around a STRICT object with the bag\'s key set and the bag\'s own error map', () => { - expect(MIRROR_BAG_OPTIONAL._zod.def.type).toBe('optional'); - expect(catchallOf(MIRROR_BAG)).toBe('never'); - expect(Object.keys(MIRROR_BAG.shape).sort()).toEqual(KINDS); - expect(MIRROR_BAG._zod.def.error).toBe(SPEC_BAG._zod.def.error); - }); - - it.each(KINDS)('%s: strict, fully optional, every slot member present, nothing beside them but refusal arms', (k) => { - const block = MIRROR_BAG.shape[k].unwrap(); - expect(catchallOf(block)).toBe('never'); - expect(requiredOf(block)).toEqual([]); - const slotKeys = Object.keys(SPEC_SLOTS[k].unwrap().shape); - expect(slotKeys.filter((key) => !(key in block.shape))).toEqual([]); - const local = Object.keys(block.shape).filter((key) => !slotKeys.includes(key)).sort(); - expect(local).toEqual([...(ARMS[k] ?? [])].sort()); - for (const key of local) { - expect(block.shape[key].safeParse(undefined).success).toBe(true); - for (const value of ['x', 1, true, ['x'], {}]) expect(block.shape[key].safeParse(value).success).toBe(false); - } - }); - - it('kanban, gallery and timeline ARE this package\'s top-level blocks; calendar is its block made partial', () => { - const top = (ListViewSchema as unknown as Zod).shape; - for (const k of ['kanban', 'gallery', 'timeline']) expect(MIRROR_BAG.shape[k].unwrap(), k).toBe(top[k].unwrap()); - expect(Object.keys(MIRROR_BAG.shape.calendar.unwrap().shape).sort()).toEqual(Object.keys(top.calendar.unwrap().shape).sort()); - expect(requiredOf(top.calendar.unwrap())).toEqual(['startDateField']); +describe('#25923 — `ListViewSchema.options` takes the overlay\'s tombstone by reference', () => { + it('is the very member the spec declares', () => { + expect(MIRROR_OPTIONS).toBe(SPEC_OPTIONS); }); }); -/* ── 3. Verdict equality with the spec's bag ─────────────────────────────────── */ +/* ── 3. Every door refuses a bag, whatever it holds ──────────────────────────── */ -/** Bag documents none of which names a refusal arm (section 4 is where the faces differ on purpose). */ -const EQUALITY: ReadonlyArray]> = [ +const BAGS: ReadonlyArray = [ ['an empty bag', {}], - ['the bag app-shell\'s object page relays', { - kanban: { groupByField: 'stage', titleField: 'name', columns: ['name'] }, - calendar: { startDateField: 'starts_at' }, - timeline: { startDateField: 'starts_at', titleField: 'name' }, - map: { locationField: 'address', titleField: 'name' }, - gallery: { coverField: 'logo', titleField: 'name' }, - gantt: { startDateField: 'starts_at', endDateField: 'ends_at', titleField: 'name' }, - tree: { parentField: 'parent', labelField: 'name' }, - chart: { chartType: 'bar', dataset: 'deal_ds', dimensions: ['stage'], values: ['total'] }, - }], - ['partial blocks (no required member asked)', { kanban: { titleField: 'name' }, timeline: { titleField: 'name' }, calendar: { titleField: 'name' }, chart: { chartType: 'bar' } }], + ['the bag app-shell\'s object page used to relay', RELAYED_BLOCKS], + ['partial blocks', { kanban: { titleField: 'name' }, chart: { chartType: 'bar' } }], ['`options.grid`', { grid: {} }], - ['a key that is no kind', { zzqxNotAKind: true }], - ['kanban: an undeclared key', { kanban: { swimlaneField: 'owner' } }], - ['gallery: an undeclared key', { gallery: { zzqxNoSuchField: 1 } }], - ['timeline: `descriptionField`', { timeline: { descriptionField: 'notes' } }], - ['timeline: `endField`', { timeline: { endField: 'ends_at' } }], - ['tree: `titleField`', { tree: { titleField: 'name' } }], - ['chart: the legacy axes', { chart: { chartType: 'bar', xAxisField: 'stage', yAxisFields: ['amount'], aggregation: 'sum' } }], - ['map: an undeclared key', { map: { locationField: 'address', zzqxNoSuchField: 1 } }], - ['gantt: an undeclared key', { gantt: { startDateField: 's', zzqxNoSuchField: 1 } }], - ['a kind that is not an object', { kanban: 42 }], - ['a field name that is a number', { kanban: { groupByField: 5 } }], - ['an unknown gallery card size', { gallery: { cardSize: 'huge' } }], - ['an unknown timeline scale', { timeline: { scale: 'decade' } }], + ['a bag that is not an object', 'kanban'], ]; -describe('objectui#6152 round 12 — verdict equality: the mirror answers as the spec\'s bag does', () => { - it.each(EQUALITY)('%s', (_label, bag) => { - expect(verdict(MIRROR_BAG.safeParse(bag))).toEqual(verdict(SPEC_BAG.safeParse(bag))); - }); - - it('LIT CONTROL: the table holds accepted AND refused rows', () => { - const outcomes = EQUALITY.map(([, bag]) => SPEC_BAG.safeParse(bag).success); - expect(outcomes).toContain(true); - expect(outcomes).toContain(false); - }); +describe('#25923 — every door refuses `options` at `options`, with the spec\'s text', () => { + for (const [door, parse] of DOORS) { + it.each(BAGS)(`${door}: %s`, (_label, options) => { + const r = parse({ ...NODE, options }); + expect(r.success).toBe(false); + const issue = r.error!.issues.find((i) => i.path.join('.') === 'options'); + expect(issue, JSON.stringify(r.error!.issues)).toBeDefined(); + expect(issue!.message).toBe(REFUSAL); + }); - it('SANCTIONED DIFFERENCE: the mirror writes no default into a bag that did not carry it', () => { - // The spec's chart slot defaults `chartType`; this package strips imported - // defaults at its boundary (objectui#8317), so the parse returns the bag as written. - expect(SPEC_BAG.safeParse({ chart: {} }).data).toEqual({ chart: { chartType: 'bar' } }); - expect(MIRROR_BAG.safeParse({ chart: {} }).data).toEqual({ chart: {} }); - }); + it(`${door}: LIT CONTROL — the same blocks written top-level parse`, () => { + const r = parse({ ...NODE, ...RELAYED_BLOCKS }); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + }); + } }); -/* ── 4. The named refusals, under `options.KIND` ─────────────────────────────── */ +/* ── 4. The named refusals, at the top-level block ───────────────────────────── */ const REFUSED: ReadonlyArray = [ ['kanban', 'groupField', 'groupByField'], @@ -236,113 +162,33 @@ const TOP_BASE: Record> = { timeline: { startDateField: 'starts_at', titleField: 'name' }, }; -describe('objectui#6152 round 12 — every named refusal applies under `options.KIND`, with ONE message', () => { - it.each(REFUSED)('%s.%s: the spec\'s bag refuses it as an unrecognized key', (k, key) => { - const r = SPEC_BAG.safeParse({ [k]: { [key]: valueOf(key) } }); - expect(r.success).toBe(false); - expect(r.error!.issues.map((i) => ({ code: i.code, path: i.path.join('.'), keys: i.keys }))).toEqual([ - { code: 'unrecognized_keys', path: k, keys: [key] }, - ]); - }); - - it.each(REFUSED)('%s.%s: the mirror refuses it at its own path, with the top-level block\'s message (canonical: %s)', (k, key, canonical) => { - const nested = ListViewSchema.safeParse({ ...NODE, options: { [k]: { [key]: valueOf(key) } } }) as Parsed; - const issue = nested.error?.issues.find((i) => i.path.join('.') === `options.${k}.${key}`); - expect(issue?.code).toBe('invalid_type'); - const topLevel = ListViewSchema.safeParse({ ...NODE, [k]: { ...TOP_BASE[k], [key]: valueOf(key) } }) as Parsed; - const top = topLevel.error?.issues.find((i) => i.path.join('.') === `${k}.${key}`); - expect(top?.code).toBe('invalid_type'); - expect(issue?.message).toBe(top?.message); - if (canonical) expect(issue?.message).toContain(`Did you mean \`${key}\` → \`${canonical}\`?`); - }); - +describe('#25923 — every named refusal the bag carried applies at the top-level block', () => { for (const [door, parse] of DOORS) { - it.each(REFUSED)(`${door}: a list-view node with options.%s.%s is refused at that key`, (k, key) => { - const r = parse({ ...NODE, options: { [k]: { [key]: valueOf(key) } } }); + it.each(REFUSED)(`${door}: %s.%s is refused at that key (canonical: %s)`, (k, key, canonical) => { + const r = parse({ ...NODE, [k]: { ...TOP_BASE[k], [key]: valueOf(key) } }); expect(r.success).toBe(false); - expect(codeAndPath(r)).toContainEqual({ code: 'invalid_type', path: `options.${k}.${key}` }); - }); - - it(`${door}: LIT CONTROL — the bag app-shell's object page relays parses`, () => { - const r = parse({ ...NODE, options: EQUALITY[1][1] }); - expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + expect(codeAndPath(r)).toContainEqual({ code: 'invalid_type', path: `${k}.${key}` }); + const issue = r.error!.issues.find((i) => i.path.join('.') === `${k}.${key}`); + if (canonical) expect(issue?.message).toContain(`Did you mean \`${key}\` → \`${canonical}\`?`); }); } }); -/* ── 5. Undeclared keys and kinds, through every door ────────────────────────── */ +/* ── 5. The TypeScript face ──────────────────────────────────────────────────── */ -describe('objectui#6152 round 12 — what the bag does not declare is refused with the spec\'s own `unrecognized_keys`', () => { - const UNDECLARED: ReadonlyArray, string, string]> = [ - ['`options.grid`', { grid: {} }, 'options', 'grid'], - ['a key that is no kind', { zzqxNotAKind: true }, 'options', 'zzqxNotAKind'], - ['kanban.swimlaneField', { kanban: { swimlaneField: 'owner' } }, 'options.kanban', 'swimlaneField'], - ['timeline.descriptionField', { timeline: { descriptionField: 'notes' } }, 'options.timeline', 'descriptionField'], - ['tree.titleField', { tree: { titleField: 'name' } }, 'options.tree', 'titleField'], - ['chart.xAxisField', { chart: { xAxisField: 'stage' } }, 'options.chart', 'xAxisField'], - ]; - for (const [door, parse] of DOORS) { - it.each(UNDECLARED)(`${door}: %s`, (_label, bag, path, key) => { - const r = parse({ ...NODE, options: bag }); - expect(r.success).toBe(false); - const issue = r.error!.issues.find((i) => i.path.join('.') === path); - expect({ code: issue?.code, keys: issue?.keys }).toEqual({ code: 'unrecognized_keys', keys: [key] }); - }); - } - - it('`options.grid` is refused with the spec\'s guidance: a grid has no per-kind block', () => { - const r = ListViewSchema.safeParse({ ...NODE, options: { grid: {} } }) as Parsed; - const message = r.error?.issues.find((i) => i.path.join('.') === 'options')?.message ?? ''; - expect(message).toContain('A grid has no per-kind block'); - expect(message).toBe(SPEC_BAG.safeParse({ grid: {} }).error!.issues[0].message); - }); -}); - -/* ── 6. The TypeScript face ──────────────────────────────────────────────────── */ - -type Bag = NonNullable; type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; -describe('objectui#6152 round 12 — the TypeScript face is closed the same way', () => { - it('the bag type has the eight kinds and no index signature', () => { - // `closed` is the no-index-signature half. The eight kinds are members, not - // a closed list: the bag's keys are the spec's, so a kind the spec adds is - // not a red here. - const closed: string extends keyof Bag ? 'open' : 'closed' = 'closed'; - const kinds: Equal< - Exclude<'kanban' | 'calendar' | 'gallery' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree', keyof Bag>, - never - > = true; - expect([closed, kinds]).toEqual(['closed', true]); - }); - - it('refuses each narrowed key at the authoring site, and compiles the lit control', () => { - const live: Bag = { - kanban: { groupByField: 'stage', columns: ['name'], titleField: 'name' }, - calendar: { titleField: 'name' }, - gallery: { coverField: 'logo' }, - timeline: { titleField: 'name' }, - map: { locationField: 'address' }, - chart: { chartType: 'bar' }, - }; - const refused: Bag[] = [ - // @ts-expect-error — `grid` is not a kind of the bag. - { grid: {} }, - // @ts-expect-error — `cardFields` is a refusal arm. - { kanban: { cardFields: ['name'] } }, - // @ts-expect-error — `groupField` is a refusal arm. - { kanban: { groupField: 'stage' } }, - // @ts-expect-error — `imageField` is a refusal arm. - { gallery: { imageField: 'logo' } }, - // @ts-expect-error — `descriptionField` is not a member of the timeline block. - { timeline: { descriptionField: 'notes' } }, - // @ts-expect-error — the chart block is dataset-bound; the legacy axes are not members. - { chart: { xAxisField: 'stage' } }, - // @ts-expect-error — `titleField` is not a member of the tree block. - { tree: { titleField: 'name' } }, +describe('#25923 — the TypeScript face carries no bag', () => { + it('no bag compiles on a list view, and the same block compiles top-level', () => { + const live: TsListViewSchema = { type: 'list-view', objectName: 'deal', kanban: { groupByField: 'stage', columns: ['name'] } }; + const refused: TsListViewSchema[] = [ + // @ts-expect-error — the bag is retired: write the block top-level. + { type: 'list-view', objectName: 'deal', options: { kanban: { groupByField: 'stage', columns: ['name'] } } }, + // @ts-expect-error — even an empty bag. + { type: 'list-view', objectName: 'deal', options: {} }, ]; expect(live.kanban?.groupByField).toBe('stage'); - expect(refused).toHaveLength(7); + expect(refused).toHaveLength(2); }); it('`ListViewTimelineConfig` is the list view\'s own `timeline` block, closed, with `dateField` refused', () => { diff --git a/ui/packages/types/src/__tests__/list-view-properties-bag-12670.test.ts b/ui/packages/types/src/__tests__/list-view-properties-bag-12670.test.ts index 50195b5c6a5..2de223736e2 100644 --- a/ui/packages/types/src/__tests__/list-view-properties-bag-12670.test.ts +++ b/ui/packages/types/src/__tests__/list-view-properties-bag-12670.test.ts @@ -406,7 +406,7 @@ describe('the spec\'s list view checks run in the bag, with the spec\'s own issu /* ── 6. The node: flat members, runtime slots, content channels ───────────── */ describe('the node around the bag (objectui#12670)', () => { - it.each(['columns', 'filter', 'kanban', 'options', 'showSearch'] as const)( + it.each(['columns', 'filter', 'kanban', 'showSearch'] as const)( 'a flat `%s` is refused on both faces, by name, toward the bag', (key) => { for (const [face, parse] of FACES) { @@ -417,6 +417,18 @@ describe('the node around the bag (objectui#12670)', () => { }, ); + it('a flat `options` is refused on both faces with the retired bag\'s own text, NOT pointed at `properties.options` (objectstack#25923)', () => { + // It was a row of the table above. The bag is retired on the list view, so + // `properties.options` is no home to point an author at: the refusal is the + // spec tombstone's, which names the top-level blocks instead. + for (const [face, parse] of FACES) { + const found = at(issuesOf(parse({ ...RULED, options: [] })), 'options'); + expect(found.map((i) => i.code), face).toEqual(['invalid_type']); + expect(found[0].message, face).toMatch(/^A list view's `options` bag was removed in @objectstack\/spec /); + expect(found[0].message, face).not.toContain('→ `properties.options`'); + } + }); + it('a node-level `label` is kept on both faces, as the spec\'s page component keeps it', () => { for (const [face, parse] of FACES) { expect(parse({ ...RULED, label: 'Deals' }).success, face).toBe(true); diff --git a/ui/packages/types/src/__tests__/object-chart-legacy-axis-keys-retired-10608.test.ts b/ui/packages/types/src/__tests__/object-chart-legacy-axis-keys-retired-10608.test.ts index 14ab77c6559..9057af2ab89 100644 --- a/ui/packages/types/src/__tests__/object-chart-legacy-axis-keys-retired-10608.test.ts +++ b/ui/packages/types/src/__tests__/object-chart-legacy-axis-keys-retired-10608.test.ts @@ -200,19 +200,23 @@ describe('objectui#10608 (e) — the list-view carrier refuses these names too ( * legacy read was not that round's to retire; objectui#6152 round 15 retired * it on every route, so a view stored with them binds nothing and * `ObjectChart` refuses it on screen. + * + * objectstack#25923 retired the bag itself (refused whole at `options`, and a + * stored one folded onto `chart` on read), so these fixtures moved to the + * top-level `chart` block, where the same spec block refuses the same names. */ - it('a `chart` list view authoring `xAxisField` / `yAxisFields` / `aggregation` in `options.chart` is refused, on both doors', () => { + it('a `chart` list view authoring `xAxisField` / `yAxisFields` / `aggregation` in its `chart` block is refused, on both doors', () => { const view = { type: 'list-view', objectName: 'task', columns: ['name'], viewType: 'chart', - options: { chart: { chartType: 'bar', xAxisField: 'status', yAxisFields: ['estimate'], aggregation: 'sum' } }, + chart: { chartType: 'bar', xAxisField: 'status', yAxisFields: ['estimate'], aggregation: 'sum' }, }; // The component's props, and the page node the published door judges (objectui#12670). for (const r of [ListViewSchema.safeParse(view), safeValidateSchema(asPageListView(view))]) { expect(r.success).toBe(false); - const issue = (r.error?.issues ?? []).find((i) => bagPath(i.path) === 'options.chart') as + const issue = (r.error?.issues ?? []).find((i) => bagPath(i.path) === 'chart') as | { code?: string; keys?: string[] } | undefined; expect(issue?.code).toBe('unrecognized_keys'); @@ -220,13 +224,13 @@ describe('objectui#10608 (e) — the list-view carrier refuses these names too ( } }); - it('CONTROL: the dataset-bound spelling parses in the same bag, on both doors', () => { + it('CONTROL: the dataset-bound spelling parses in the same block, on both doors', () => { const view = { type: 'list-view', objectName: 'task', columns: ['name'], viewType: 'chart', - options: { chart: { chartType: 'bar', dataset: 'task_ds', dimensions: ['status'], values: ['total_estimate'] } }, + chart: { chartType: 'bar', dataset: 'task_ds', dimensions: ['status'], values: ['total_estimate'] }, }; expect(issuesOf(ListViewSchema.safeParse(view))).toEqual([]); expect(issuesOf(safeValidateSchema(asPageListView(view)))).toEqual([]); diff --git a/ui/packages/types/src/__tests__/object-kanban-group-by-limit-7322.test.ts b/ui/packages/types/src/__tests__/object-kanban-group-by-limit-7322.test.ts index 772d327cdfd..98fcfb21d1b 100644 --- a/ui/packages/types/src/__tests__/object-kanban-group-by-limit-7322.test.ts +++ b/ui/packages/types/src/__tests__/object-kanban-group-by-limit-7322.test.ts @@ -148,7 +148,8 @@ const RETIRED_VIEW_LEVEL_ALIAS_SITES: ReadonlyArray * and the `chart` block (`ListChartConfigSchema`, whose `dataset` is required). * The CONDITION is not: on this node the view kind rides as objectui's * `viewType` (the spec's `type` is spent here on the component discriminator), - * and two objectui-owned spellings reach the same render branch — the legacy - * `filters` alias (folded into `filter` by `normalizeListViewSchema`) and the - * legacy `options.chart` bag (read by `resolveListChartBinding` when no `chart` - * block is declared). A spec-door check reads none of the three; that is the + * and an objectui-owned spelling reaches the same render branch — the legacy + * `filters` alias (folded into `filter` by `normalizeListViewSchema`). (The + * legacy `options.chart` bag was the second until #25923 retired it: the bag is + * refused whole at `options`, and `resolveListChartBinding` reads `chart` + * alone.) A spec-door check reads neither; that is the * reason `checkListViewPageMount` was not attachable here (objectui#7715). So * this node's refusal is objectui's. A stored `view` row spelled `type: 'chart'` * passes the SPEC's door, which is a second door and not this one. @@ -2254,9 +2202,8 @@ const datasetChartViewFilterRefusal = (key: 'filter' | 'filters'): string => * * - view kind: `viewType === 'chart'`, the value `ListView`'s `currentView` * starts from; - * - binding: the effective chart block — `chart` WHOLESALE, else - * `options.chart`, `resolveListChartBinding`'s precedence — names a - * `dataset`; + * - binding: the `chart` block, the one `resolveListChartBinding` reads, + * names a `dataset`; * - filter: `filter`, and the legacy `filters`, each a non-empty array. An * empty array is no filter and passes. * @@ -2266,14 +2213,11 @@ const datasetChartViewFilterRefusal = (key: 'filter' | 'filters'): string => * authored on, so refusing it would refuse a working grid. */ function checkListViewDatasetChartFilter( - view: { viewType?: unknown; chart?: unknown; options?: unknown; filter?: unknown; filters?: unknown }, + view: { viewType?: unknown; chart?: unknown; filter?: unknown; filters?: unknown }, ctx: z.RefinementCtx, ): void { if (view.viewType !== 'chart') return; - const legacyBag = view.options && typeof view.options === 'object' - ? (view.options as Record).chart - : undefined; - const block = view.chart || legacyBag; + const block = view.chart; if (!block || typeof block !== 'object' || !(block as Record).dataset) return; for (const key of ['filter', 'filters'] as const) { const written = view[key]; @@ -2287,7 +2231,7 @@ const LIST_VIEW_NEITHER_CHANNEL = neitherContentChannelGuidance( 'list-view', 'its registration (`plugin-list:list-view`) reads the node as `ListViewSchema` itself', 'the records of `objectName` in the visualization `viewType` names, shaped by `columns`, `filter`, ' - + '`sort` and `options`', + + '`sort` and the per-kind blocks (`kanban`, `calendar`, `gallery`, `timeline`, `gantt`, `map`, `tree`, `chart`)', ); export const ListViewSchema = BaseSchema @@ -2373,11 +2317,13 @@ export const ListViewSchema = BaseSchema addRecordViaForm: z.boolean().optional().describe('Add records via form dialog'), addDeleteRecordsInline: z.boolean().optional().describe('Enable inline add/delete'), collapseAllByDefault: z.boolean().optional().describe('Collapse all groups by default'), - // THE LEGACY BAG, by reference since objectui#6152 round 12: the list - // overlay's own `options`, with the four round-11 blocks' named refusals - // (objectui#8365 `kanban.groupBy`, objectui#8355 `calendar.dateField` / - // `endField` among them). See {@link ListViewOptionsBag}. - options: ListViewOptionsBag.optional().describe('Component overrides (legacy)'), + // [#25923] THE RETIRED BAG, by reference: the list overlay's own `options`, + // the spec's retired-key tombstone. Each per-kind block is a top-level key + // of the view (`kanban`, `calendar`, …), and no renderer reads the bag; a + // stored bag is folded onto the blocks by the spec's ADR-0087 D2 + // conversion `list-view-options-bag-folded` before a row is served. The + // tombstone carries no default, so the boundary hands back the member itself. + options: stripImportedDefaults(SpecViewMetadataMembers.listOverlay.shape.options), operations: z.looseObject({ create: z.boolean().optional(), read: z.boolean().optional(), @@ -5389,7 +5335,7 @@ const LIST_VIEW_PAGE_VIEW_TYPE_RETIRED = * reads as `viewType` on the flat mirror. The same check, the same messages. */ function checkListViewBagDatasetChartFilter( - bag: { type?: unknown; chart?: unknown; options?: unknown; filter?: unknown; filters?: unknown }, + bag: { type?: unknown; chart?: unknown; filter?: unknown; filters?: unknown }, ctx: z.RefinementCtx, ): void { checkListViewDatasetChartFilter({ ...bag, viewType: bag.type }, ctx); @@ -5407,7 +5353,7 @@ function checkListViewBagDatasetChartFilter( * members ARE the spec's list view members by reference (`specFieldsExcept` * over the spec's `ListViewSchema`), with objectui's local overrides on top — * `columns` optional, the `kanban` / `calendar` / `gallery` / `timeline` blocks - * with their named refusals, the legacy `options` bag, the toolbar flags. One + * with their named refusals, the retired `options` bag's tombstone, the toolbar flags. One * list view therefore has one set of property names wherever it is embedded, * as ruling C says: the bag accepts what the `ListView` component's * `ListViewSchema` props accept, except the two spellings the ruling renames. @@ -5472,7 +5418,7 @@ const LIST_VIEW_BLOCK_NEITHER_CHANNEL = neitherContentChannelGuidance( 'its registration (`plugin-list:list-view`) hands the node, its `properties` hoisted, to `ListViewBlock`, ' + 'which renders `ListView`', 'the records of `properties.data` in the list kind `properties.type` names, shaped by `columns`, `filter`, ' - + '`sort` and `options`', + + '`sort` and the per-kind blocks (`kanban`, `calendar`, `gallery`, `timeline`, `gantt`, `map`, `tree`, `chart`)', ); /** From 1e40f394050d725e6cfc305df2eefc51a68e2a43 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 06:21:01 +0000 Subject: [PATCH 03/10] refactor(types)!: tombstone NamedListView.options; the census moves it from held to retired The member was held under objectui#7928's Q2 (i) while the bag was a live protocol spelling. With the bag retired and the fold on the server, it is a ?: never tombstone naming the top-level blocks, and the listViews docblock names the server fold instead of ViewPreview. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzevL8Q6rTfGz2XHXGT --- .../object-view-unmirrored-keys-7779.test.ts | 56 +++++++++++++------ ui/packages/types/src/objectql.ts | 13 +++-- 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/ui/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts b/ui/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts index 3ab097603ae..d2b0b143d0d 100644 --- a/ui/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts +++ b/ui/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts @@ -576,6 +576,16 @@ const RETIRED_BY_11013 = ['allowExport'] as const; */ const RETIRED_BY_13118 = ['filterableFields'] as const; +/** + * The bucket-③ member objectstack#25923 retired as a `?: never` tombstone: the + * twenty-second, `options`. It was HELD under objectui#7928's Q2 (i) until the + * protocol retired the list view's `options` bag (the server folds a stored one + * onto the top-level blocks) and the readers were deleted. Kept apart from + * {@link NAMED_LIST_VIEW_TOMBSTONES} for the same reason as + * {@link RETIRED_BY_11013}. + */ +const RETIRED_BY_25923 = ['options'] as const; + /** * The bucket-③ member retired LATER than its neighbours, by ruling A′ on * objectui#7924: a legacy spelling of top-level `rowHeight` whose only reader @@ -587,8 +597,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′). `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 + * named view since objectui#7928, is the eleventh bucket-③ member; it was held + * rather than tombstoned until objectstack#25923 ({@link RETIRED_BY_25923}). + * Derived off the AST (a * member whose type node is the `never` keyword). */ const NAMED_LIST_VIEW_TOMBSTONES = [ @@ -624,19 +635,20 @@ const PROTOCOL_RETIRED_TOMBSTONE_DOC: Readonly { + it('NO objectui-only member is read off a named view since objectui#7928 — the one that was, `options`, is folded by the server since objectstack#25923', () => { const reads = deriveNamedViewReads().reads; const protocolKeys = protocolListViewKeys(); const localOnlyRead = reads.filter((r) => !protocolKeys.includes(r)); @@ -2172,10 +2184,14 @@ describe('objectui#7924 — the retirement: seventeen `?: never` tombstones, obj // about objectui#7924's seventeen. const tombstones = allTombstones.filter((t) => !(BUCKET_PROTOCOL_RETIRED_TOMBSTONED as readonly string[]).includes(t) && !(RETIRED_BY_11013 as readonly string[]).includes(t) - && !(RETIRED_BY_13118 as readonly string[]).includes(t)); - // 20 until objectui#13118 tombstoned `filterableFields`. - expect(allTombstones).toHaveLength(21); + && !(RETIRED_BY_13118 as readonly string[]).includes(t) + && !(RETIRED_BY_25923 as readonly string[]).includes(t)); + // 20 until objectui#13118 tombstoned `filterableFields`; 21 until + // objectstack#25923 tombstoned `options`. + expect(allTombstones).toHaveLength(22); for (const m of RETIRED_BY_13118) expect(allTombstones).toContain(m); + for (const m of RETIRED_BY_25923) expect(allTombstones).toContain(m); + expect(BUCKET_LOCAL_ONLY_HELD).toHaveLength(0); expect([...tombstones].sort()).toEqual([...NAMED_LIST_VIEW_TOMBSTONES]); expect(tombstones).toHaveLength(17); expect(BUCKET_LOCAL_ONLY_RETAINED).toHaveLength(0); @@ -2183,7 +2199,7 @@ describe('objectui#7924 — the retirement: seventeen `?: never` tombstones, obj const expected = [ ...BUCKET_LEGACY_SHOW_SPELLINGS, ...BUCKET_LOCAL_ONLY_UNREAD.filter((m) => !(RETIRED_BY_11013 as readonly string[]).includes(m) - && !(BUCKET_LOCAL_ONLY_HELD as readonly string[]).includes(m)), + && !(RETIRED_BY_25923 as readonly string[]).includes(m)), ].sort(); expect([...tombstones].sort()).toEqual(expected); // A tombstone is still a declared member — that is what makes the refusal @@ -2266,11 +2282,15 @@ describe('objectui#7924 — the retirement: seventeen `?: never` tombstones, obj 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(); + it('INVERTED (objectstack#25923): `options` is a tombstone, no longer HELD — the protocol retired the bag, and its docblock names the top-level blocks', () => { + const { tombstones, names, docs } = namedListViewMembers(); expect(names).toContain(LOCAL_ONLY_READ); - expect(tombstones).not.toContain(LOCAL_ONLY_READ); - expect(readRepo(DECLARATION)).toContain('options?: Record;'); + // Inverted from `not.toContain`: held under objectui#7928's Q2 (i) until the + // protocol retired the list view's `options` bag. + expect(tombstones).toContain(LOCAL_ONLY_READ); + expect(readRepo(DECLARATION)).not.toContain('options?: Record;'); + expect(docs[LOCAL_ONLY_READ]).toContain('RETIRED (objectstack#25923)'); + expect(docs[LOCAL_ONLY_READ]).toContain('top level of the view'); // …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]: {} } } }); diff --git a/ui/packages/types/src/objectql.ts b/ui/packages/types/src/objectql.ts index f9c97e241dc..e94ed712a99 100644 --- a/ui/packages/types/src/objectql.ts +++ b/ui/packages/types/src/objectql.ts @@ -2610,8 +2610,8 @@ export interface ObjectViewSchema extends BaseSchema { * `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 + * is folded onto the top-level blocks by the server's ADR-0087 conversion + * `list-view-options-bag-folded` before it reaches this record (objectstack#25923). `NamedListView` is no longer * this member's type, and its retirement follows objectui#7924. */ listViews?: Record>; @@ -2892,8 +2892,13 @@ export interface NamedListView { /** Sort configuration */ sort?: Array<{ field: string; order: 'asc' | 'desc' }>; - /** Type-specific options (kanban groupField, calendar startDateField, etc.) */ - options?: Record; + /** + * @deprecated RETIRED (objectstack#25923) — write each per-kind block at the + * top level of the view (`kanban`, `calendar`, `gallery`, `timeline`, `gantt`, + * `map`, `tree`, `chart`). The protocol retired the legacy `options` bag, and + * the server folds a stored one onto those blocks before the row is served. + */ + options?: never; /* ── objectui#7924 — RETIRED on the authoring face (director-seat ruling, * bucket ② A and bucket ③) ──────────────────────────────────────────────── From 2e74671474a83d9b0efd3c184be950902ab9ba17 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 06:21:15 +0000 Subject: [PATCH 04/10] chore(changeset): name the NamedListView tombstone in the types entry Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzevL8Q6rTfGz2XHXGT --- .changeset/25923-list-view-options-bag-retired.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/25923-list-view-options-bag-retired.md b/.changeset/25923-list-view-options-bag-retired.md index 4eea4e125b8..e2153d9cd3e 100644 --- a/.changeset/25923-list-view-options-bag-retired.md +++ b/.changeset/25923-list-view-options-bag-retired.md @@ -21,7 +21,7 @@ Metadata-protocol: narrowed A flattened list view overlay carried every per-kind block twice: as the top-level `kanban`, `calendar`, `gallery`, `timeline`, `gantt`, `map`, `tree` and `chart`, and again under a legacy `options` bag the top-level block won over per key. Each renderer re-implemented that merge. The bag is retired, and each block has one spelling. - **`@objectstack/spec`**: the list overlay's `options` is a removed-key tombstone. The view write door refuses a body that carries it, by name, with the prescription to move each `options.KIND` block to `KIND`. The ADR-0087 D2 conversion `list-view-options-bag-folded` folds a stored bag onto the top-level blocks before a row is served (the top-level value wins per key; for `chart`, whose block replaced the bag whole, a bag beside a block is dropped), and `os migrate meta --from 17` lists the same edits for a source. A bag it cannot fold (an `options.grid`, a non-object block, a bag on a form view) is left in place with a TODO. The D3 entry `ui-list-view-options-bag-folded` names what the fold cannot decide: the bag was judged with every key optional, so a folded block can still miss a key its block requires. The page `list-view` component row shares the tombstone. -- **`@object-ui/types`**: `ListViewSchema.options` takes the spec's tombstone by reference, so every published door refuses a bag at `options` with the spec's text, and the TypeScript face has no bag. `ListViewOptionsBag` is removed. The dataset-chart filter refusal reads the `chart` block alone, and the page node's flat `options` is refused with the bag's own text rather than pointed at `properties.options`. +- **`@object-ui/types`**: `ListViewSchema.options` takes the spec's tombstone by reference, so every published door refuses a bag at `options` with the spec's text, and the TypeScript face has no bag. `NamedListView.options` is a `?: never` tombstone that names the top-level blocks. The dataset-chart filter refusal reads the `chart` block alone, and the page node's flat `options` is refused with the bag's own text rather than pointed at `properties.options`. - **`@object-ui/plugin-list`**: `ListView` reads each kind from its top-level block alone: the render branches, the capability gate, the projection and expand collectors and the fetch effect's dependencies no longer read `options`. The `list-view` registration publishes no `options` input. - **`@object-ui/app-shell`**: the object page relays each per-kind block at the top level of the list schema, held by value per block so an equal rebuild keeps its identity, and no longer forwards a stored bag. The interface page sends one `map` block (its derivation with the authored keys laid over) and no bag. The Studio view preview and the list-view inspector no longer fold a bag; the server does. `storedLegacyOptions` is removed. - **`@object-ui/plugin-view`**: the host list schema carries the active view's blocks at the top level instead of under `options`. From d9deed50176bb6cb271af39191b24f89d0dfe5ed Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:44:54 +0000 Subject: [PATCH 05/10] chore(sdui): regenerate sdui.manifest.json without the list-view options input The list-view registration publishes no options input since the bag retired, so the console registry manifest drops that row. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzevL8Q6rTfGz2XHXGT --- scripts/sdui-manifest.record.json | 4 ++-- sdui.manifest.json | 5 ----- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 1be0dba0fe6..a878a99557d 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -13,8 +13,8 @@ ], "source": "built-tree", "generator": "scripts/gen-sdui-manifest-node.mjs", - "generatedAt": "2026-10-27", - "sha256": "8cda5b35b4d6395ff937cb88f5a3d8d773e07dc8f02a24a40af07c97dc44a461", + "generatedAt": "2026-10-28", + "sha256": "6b9a62b591fbffe2e45ff2359b98fe6a6721ebf385d96ad9b5a0b13078263b79", "components": 106, "inTree": { "modulesRoot": "ui/apps/console", diff --git a/sdui.manifest.json b/sdui.manifest.json index e4cb7f209a0..6d047a0306f 100644 --- a/sdui.manifest.json +++ b/sdui.manifest.json @@ -399,11 +399,6 @@ "of": "object", "description": "Full bulk-action definitions, the spec's `BulkActionDef`: a mass update (`operation: \"update\"` with a `patch`) or delete, or a named action run once for the whole selection." }, - { - "name": "options", - "type": "object", - "description": "Per-kind settings, `{ kanban, calendar, gantt, gallery, timeline, map, tree, chart }`, each judged by that kind's own block. The top-level block of the same name is the canonical spelling." - }, { "name": "kanban", "type": "object", From 9d1966cc751a0dd53155c429473c01c0807d75ec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:58:21 +0000 Subject: [PATCH 06/10] fix(spec): define list-view-options-bag-folded directly above the entry that follows it The major-18 merge pin wants a conversion added after its rule defined directly above the next entry's definition (listViewSortStringClauseToArray); the bag kinds constant moves with it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzevL8Q6rTfGz2XHXGT --- packages/spec/src/conversions/registry.ts | 378 +++++++++++----------- 1 file changed, 189 insertions(+), 189 deletions(-) diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index e69fc8d6a5e..9fab55e5fe5 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -10711,6 +10711,195 @@ const viewPageMountRemoved: MetadataConversion = { }, }; +/** + * The per-kind blocks the legacy list-overlay `options` bag could carry, as + * the bag declared them when it closed (#25923): every list-view `type` that + * also names a block on the list-view shape (`grid` names none). Spelled out + * rather than derived, because this conversion replays a shape that is gone: + * a kind added later never had a bag spelling to fold. + */ +const LIST_VIEW_OPTIONS_BAG_KINDS = ['kanban', 'calendar', 'gantt', 'timeline', 'map', 'gallery', 'chart', 'tree'] as const; + +/** + * The flattened list overlay's legacy `options` bag folds onto the top-level + * per-kind blocks (protocol 18, #25923 — Prime Directive #12, one key per + * concept; this supersedes ruling direction A on objectui#10380). + * + * Until this release the list overlay door accepted `options.KIND` beside the + * top-level `KIND` block, judged key by key by the kind's own schema with + * every key optional, and stored it. The bag was declared an UNDERLAY: where + * both set a key, the top-level block wins. objectui's `ListView` read it that + * way for kanban, calendar, gallery, timeline, gantt and map + * (`{ ...options.KIND, ...KIND }`), so this fold is that same merge, written + * once into the row: + * + * - `options.KIND` with no top-level `KIND` ⇒ it becomes `KIND`. + * - Both present ⇒ `KIND = { ...options.KIND, ...KIND }`, the top-level value + * kept where both set a key. + * - `chart` is the one kind the renderer did NOT merge: it read + * `chart || options.chart`, the block replacing the bag WHOLE. So a bag + * beside a top-level `chart` block is dropped (it never drew), and a bag + * with no block becomes the block. Merging it per key would draw keys the + * author never saw. + * - A bag left empty is removed. + * + * **Retired from the load path** — the door refuses `options` by name with + * the prescription (`LIST_OVERLAY_OPTIONS_REFUSED` in `ui/view.zod.ts`), so a + * live author is taught the top-level spelling. The entry exists because a + * STORED list overlay can carry the bag, and every read of a stored `view` row + * replays the chain through `applyConversionsToStoredItem` as + * `{ views: [row] }`; `os migrate meta` lists the edit for sources. + * + * **Left as stored, with a TODO** (no lossless rewrite): a bag that is not an + * object; an `options.grid` (a grid has no block, and the door refused it + * already); a bag key that is not a kind; an `options.KIND` that is not an + * object, or whose top-level `KIND` is not one; and any bag on a FORM overlay + * (`viewKind: 'form'`), whose blocks have no home on a form. The row keeps its + * bag and is badged invalid until its owner decides. The fold also cannot + * supply a key the block REQUIRES: the bag was judged with every key + * optional, so a bag block can fold into a block still missing one (a + * `gantt` with no `titleField`, say). That row is served badged invalid, + * and the D3 entry `ui-list-view-options-bag-folded` names the remedy. + * + * ⚠️ Scoped to the FLATTENED spelling, as {@link viewOverlayOwnerHiddenRemoved} + * is: a body with no `config` and no container slot. A view item record + * (`config` present) has refused the bag since #20051 with its own + * prescription, and the two authoring shapes never declared it. + */ +const listViewOptionsBagFolded: MetadataConversion = { + id: 'list-view-options-bag-folded', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '18.2.0', + surface: 'view.options. — on a flattened list view overlay ({ name, object, viewKind, …, no config })', + summary: + "flattened list overlay 'options.KIND' folded onto the top-level 'KIND' block (the top-level " + + "value wins per key; for 'chart' the block wins whole), and the bag removed: the per-kind " + + 'blocks are top-level keys of the view, their one spelling', + apply(stack, emit, context) { + const fold = (view: Dict, path: string): Dict => { + if (!('options' in view)) return view; + if (view.config !== undefined) return view; + if (view.list !== undefined || view.form !== undefined) return view; + if (view.listViews !== undefined || view.formViews !== undefined) return view; + const bag = view.options; + if (view.viewKind === 'form' || !isDict(bag)) { + context?.reportTodo?.({ + path: `${path}.options`, + from: JSON.stringify(bag), + reason: view.viewKind === 'form' + ? 'a form view overlay carries an `options` bag; its per-kind blocks belong to a list view and have no ' + + 'home on a form. Remove `options`, or save the body as a list view.' + : 'the `options` bag is not an object, so there is no per-kind block to fold. Remove `options`.', + }); + return view; + } + const next: Dict = { ...view }; + const rest: Dict = { ...bag }; + let changed = false; + for (const kind of LIST_VIEW_OPTIONS_BAG_KINDS) { + if (!(kind in rest)) continue; + const under = rest[kind]; + const top = next[kind]; + if (!isDict(under) || (top !== undefined && !isDict(top))) continue; + delete rest[kind]; + changed = true; + if (top === undefined) { + next[kind] = under; + emit({ from: `options.${kind}`, to: kind, path: `${path}.options.${kind}` }); + } else if (kind === 'chart') { + emit({ from: 'options.chart', to: '(removed: the top-level chart block replaces it whole)', path: `${path}.options.chart` }); + } else { + next[kind] = { ...under, ...top }; + emit({ from: `options.${kind}`, to: `${kind} (top-level keys kept)`, path: `${path}.options.${kind}` }); + } + } + if (Object.keys(rest).length === 0) { + delete next.options; + if (!changed) emit({ from: 'options', to: '(removed: empty)', path: `${path}.options` }); + return next; + } + context?.reportTodo?.({ + path: `${path}.options`, + from: JSON.stringify(rest), + reason: `the bag keeps ${Object.keys(rest).map((k) => `\`${k}\``).join(', ')}, which has no top-level block ` + + 'to fold onto (a grid\'s settings are top-level keys of the view; a non-object block or one beside a ' + + 'non-object top-level block cannot be merged). Move what you meant to the view\'s own keys, then remove `options`.', + }); + if (!changed) return view; + next.options = rest; + return next; + }; + return mapCollection( + mapCollection(stack, 'views', fold), + ASSEMBLED_VIEW_ITEMS_KEY, + fold, + ); + }, + fixture: { + before: { + // The assembled-manifest channel: an overlay a package export carried. + viewItems: [ + { name: 'crm_deal.map', object: 'crm_deal', viewKind: 'list', type: 'map', options: { map: { latitudeField: 'lat', longitudeField: 'lng' } } }, + ], + views: [ + // Per key: the top-level `kanban` keeps `groupByField`, the bag adds + // `titleField`; the bag's `groupByField` is the one that never drew. + { + name: 'crm_deal.board', + object: 'crm_deal', + viewKind: 'list', + type: 'kanban', + kanban: { groupByField: 'stage' }, + options: { kanban: { groupByField: 'owner', titleField: 'name' }, calendar: { startDateField: 'close_date' } }, + }, + // `chart` is replaced whole by its block, so the bag is dropped. + { + name: 'crm_deal.chart', + object: 'crm_deal', + viewKind: 'list', + type: 'chart', + chart: { dataset: 'deals', values: ['amount'] }, + options: { chart: { dataset: 'old_deals', values: ['amount'], dimensions: ['stage'] } }, + }, + // An empty bag is removed. + { name: 'crm_deal.empty', object: 'crm_deal', viewKind: 'list', options: {} }, + // A record and a container are not flattened overlays: untouched. + { name: 'crm_deal.rec', object: 'crm_deal', viewKind: 'list', config: { columns: ['name'] } }, + { object: 'crm_deal', list: { type: 'grid', columns: ['name'] } }, + ], + }, + after: { + viewItems: [ + { name: 'crm_deal.map', object: 'crm_deal', viewKind: 'list', type: 'map', map: { latitudeField: 'lat', longitudeField: 'lng' } }, + ], + views: [ + { + name: 'crm_deal.board', + object: 'crm_deal', + viewKind: 'list', + type: 'kanban', + kanban: { groupByField: 'stage', titleField: 'name' }, + calendar: { startDateField: 'close_date' }, + }, + { + name: 'crm_deal.chart', + object: 'crm_deal', + viewKind: 'list', + type: 'chart', + chart: { dataset: 'deals', values: ['amount'] }, + }, + { name: 'crm_deal.empty', object: 'crm_deal', viewKind: 'list' }, + { name: 'crm_deal.rec', object: 'crm_deal', viewKind: 'list', config: { columns: ['name'] } }, + { object: 'crm_deal', list: { type: 'grid', columns: ['name'] } }, + ], + }, + // One notice per folded block (map, kanban, calendar, chart) plus the + // empty bag's removal. + expectedNotices: 5, + }, +}; + /** * [#17053] The bare string `sort` clause on a list-view payload becomes the * `{ field, order }[]` array — one sort orthography platform-wide. @@ -12329,195 +12518,6 @@ const viewOverlayOwnerHiddenRemoved: MetadataConversion = { }, }; -/** - * The per-kind blocks the legacy list-overlay `options` bag could carry, as - * the bag declared them when it closed (#25923): every list-view `type` that - * also names a block on the list-view shape (`grid` names none). Spelled out - * rather than derived, because this conversion replays a shape that is gone: - * a kind added later never had a bag spelling to fold. - */ -const LIST_VIEW_OPTIONS_BAG_KINDS = ['kanban', 'calendar', 'gantt', 'timeline', 'map', 'gallery', 'chart', 'tree'] as const; - -/** - * The flattened list overlay's legacy `options` bag folds onto the top-level - * per-kind blocks (protocol 18, #25923 — Prime Directive #12, one key per - * concept; this supersedes ruling direction A on objectui#10380). - * - * Until this release the list overlay door accepted `options.KIND` beside the - * top-level `KIND` block, judged key by key by the kind's own schema with - * every key optional, and stored it. The bag was declared an UNDERLAY: where - * both set a key, the top-level block wins. objectui's `ListView` read it that - * way for kanban, calendar, gallery, timeline, gantt and map - * (`{ ...options.KIND, ...KIND }`), so this fold is that same merge, written - * once into the row: - * - * - `options.KIND` with no top-level `KIND` ⇒ it becomes `KIND`. - * - Both present ⇒ `KIND = { ...options.KIND, ...KIND }`, the top-level value - * kept where both set a key. - * - `chart` is the one kind the renderer did NOT merge: it read - * `chart || options.chart`, the block replacing the bag WHOLE. So a bag - * beside a top-level `chart` block is dropped (it never drew), and a bag - * with no block becomes the block. Merging it per key would draw keys the - * author never saw. - * - A bag left empty is removed. - * - * **Retired from the load path** — the door refuses `options` by name with - * the prescription (`LIST_OVERLAY_OPTIONS_REFUSED` in `ui/view.zod.ts`), so a - * live author is taught the top-level spelling. The entry exists because a - * STORED list overlay can carry the bag, and every read of a stored `view` row - * replays the chain through `applyConversionsToStoredItem` as - * `{ views: [row] }`; `os migrate meta` lists the edit for sources. - * - * **Left as stored, with a TODO** (no lossless rewrite): a bag that is not an - * object; an `options.grid` (a grid has no block, and the door refused it - * already); a bag key that is not a kind; an `options.KIND` that is not an - * object, or whose top-level `KIND` is not one; and any bag on a FORM overlay - * (`viewKind: 'form'`), whose blocks have no home on a form. The row keeps its - * bag and is badged invalid until its owner decides. The fold also cannot - * supply a key the block REQUIRES: the bag was judged with every key - * optional, so a bag block can fold into a block still missing one (a - * `gantt` with no `titleField`, say). That row is served badged invalid, - * and the D3 entry `ui-list-view-options-bag-folded` names the remedy. - * - * ⚠️ Scoped to the FLATTENED spelling, as {@link viewOverlayOwnerHiddenRemoved} - * is: a body with no `config` and no container slot. A view item record - * (`config` present) has refused the bag since #20051 with its own - * prescription, and the two authoring shapes never declared it. - */ -const listViewOptionsBagFolded: MetadataConversion = { - id: 'list-view-options-bag-folded', - toMajor: 18, - retiredFromLoadPath: true, - retiredAfter: '18.2.0', - surface: 'view.options. — on a flattened list view overlay ({ name, object, viewKind, …, no config })', - summary: - "flattened list overlay 'options.KIND' folded onto the top-level 'KIND' block (the top-level " - + "value wins per key; for 'chart' the block wins whole), and the bag removed: the per-kind " - + 'blocks are top-level keys of the view, their one spelling', - apply(stack, emit, context) { - const fold = (view: Dict, path: string): Dict => { - if (!('options' in view)) return view; - if (view.config !== undefined) return view; - if (view.list !== undefined || view.form !== undefined) return view; - if (view.listViews !== undefined || view.formViews !== undefined) return view; - const bag = view.options; - if (view.viewKind === 'form' || !isDict(bag)) { - context?.reportTodo?.({ - path: `${path}.options`, - from: JSON.stringify(bag), - reason: view.viewKind === 'form' - ? 'a form view overlay carries an `options` bag; its per-kind blocks belong to a list view and have no ' - + 'home on a form. Remove `options`, or save the body as a list view.' - : 'the `options` bag is not an object, so there is no per-kind block to fold. Remove `options`.', - }); - return view; - } - const next: Dict = { ...view }; - const rest: Dict = { ...bag }; - let changed = false; - for (const kind of LIST_VIEW_OPTIONS_BAG_KINDS) { - if (!(kind in rest)) continue; - const under = rest[kind]; - const top = next[kind]; - if (!isDict(under) || (top !== undefined && !isDict(top))) continue; - delete rest[kind]; - changed = true; - if (top === undefined) { - next[kind] = under; - emit({ from: `options.${kind}`, to: kind, path: `${path}.options.${kind}` }); - } else if (kind === 'chart') { - emit({ from: 'options.chart', to: '(removed: the top-level chart block replaces it whole)', path: `${path}.options.chart` }); - } else { - next[kind] = { ...under, ...top }; - emit({ from: `options.${kind}`, to: `${kind} (top-level keys kept)`, path: `${path}.options.${kind}` }); - } - } - if (Object.keys(rest).length === 0) { - delete next.options; - if (!changed) emit({ from: 'options', to: '(removed: empty)', path: `${path}.options` }); - return next; - } - context?.reportTodo?.({ - path: `${path}.options`, - from: JSON.stringify(rest), - reason: `the bag keeps ${Object.keys(rest).map((k) => `\`${k}\``).join(', ')}, which has no top-level block ` - + 'to fold onto (a grid\'s settings are top-level keys of the view; a non-object block or one beside a ' - + 'non-object top-level block cannot be merged). Move what you meant to the view\'s own keys, then remove `options`.', - }); - if (!changed) return view; - next.options = rest; - return next; - }; - return mapCollection( - mapCollection(stack, 'views', fold), - ASSEMBLED_VIEW_ITEMS_KEY, - fold, - ); - }, - fixture: { - before: { - // The assembled-manifest channel: an overlay a package export carried. - viewItems: [ - { name: 'crm_deal.map', object: 'crm_deal', viewKind: 'list', type: 'map', options: { map: { latitudeField: 'lat', longitudeField: 'lng' } } }, - ], - views: [ - // Per key: the top-level `kanban` keeps `groupByField`, the bag adds - // `titleField`; the bag's `groupByField` is the one that never drew. - { - name: 'crm_deal.board', - object: 'crm_deal', - viewKind: 'list', - type: 'kanban', - kanban: { groupByField: 'stage' }, - options: { kanban: { groupByField: 'owner', titleField: 'name' }, calendar: { startDateField: 'close_date' } }, - }, - // `chart` is replaced whole by its block, so the bag is dropped. - { - name: 'crm_deal.chart', - object: 'crm_deal', - viewKind: 'list', - type: 'chart', - chart: { dataset: 'deals', values: ['amount'] }, - options: { chart: { dataset: 'old_deals', values: ['amount'], dimensions: ['stage'] } }, - }, - // An empty bag is removed. - { name: 'crm_deal.empty', object: 'crm_deal', viewKind: 'list', options: {} }, - // A record and a container are not flattened overlays: untouched. - { name: 'crm_deal.rec', object: 'crm_deal', viewKind: 'list', config: { columns: ['name'] } }, - { object: 'crm_deal', list: { type: 'grid', columns: ['name'] } }, - ], - }, - after: { - viewItems: [ - { name: 'crm_deal.map', object: 'crm_deal', viewKind: 'list', type: 'map', map: { latitudeField: 'lat', longitudeField: 'lng' } }, - ], - views: [ - { - name: 'crm_deal.board', - object: 'crm_deal', - viewKind: 'list', - type: 'kanban', - kanban: { groupByField: 'stage', titleField: 'name' }, - calendar: { startDateField: 'close_date' }, - }, - { - name: 'crm_deal.chart', - object: 'crm_deal', - viewKind: 'list', - type: 'chart', - chart: { dataset: 'deals', values: ['amount'] }, - }, - { name: 'crm_deal.empty', object: 'crm_deal', viewKind: 'list' }, - { name: 'crm_deal.rec', object: 'crm_deal', viewKind: 'list', config: { columns: ['name'] } }, - { object: 'crm_deal', list: { type: 'grid', columns: ['name'] } }, - ], - }, - // One notice per folded block (map, kanban, calendar, chart) plus the - // empty bag's removal. - expectedNotices: 5, - }, -}; - /** * The list view's `filterableFields` leaves the authorable surface (protocol * 18, #24640 — ADR-0049 enforce-or-remove; maintainer ruling C, record From 57897edf04ef9f77fb26f7327f5c7d40334ae2af Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:04:43 +0000 Subject: [PATCH 07/10] fix(spec): the options-bag D3 entry's printed reason names no tracker id os migrate meta prints the reason verbatim, and author-shown guidance carries no tracker number. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzevL8Q6rTfGz2XHXGT --- .../entries/semantic/18.ui-list-view-options-bag-folded.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-list-view-options-bag-folded.ts b/packages/spec/src/migrations/entries/semantic/18.ui-list-view-options-bag-folded.ts index 96e1457c28f..721d58c634f 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-list-view-options-bag-folded.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-list-view-options-bag-folded.ts @@ -21,8 +21,8 @@ export const entry: SemanticMigration = { reason: 'One key per concept (Prime Directive #12). The bag was a second spelling of every per-kind block, ' + 'declared an underlay the top-level block wins over per key, and the renderers each re-implemented ' - + 'that merge. Ruling direction A on objectui#10380 kept it only for objectui\'s pinned options.map ' - + 'read, which #25923 retires with every other reader. The D2 conversion ' + + 'that merge. An earlier ruling kept it only for objectui\'s pinned options.map read, which this ' + + 'change retires with every other reader. The D2 conversion ' + '`list-view-options-bag-folded` folds each stored or source bag onto the top-level blocks, which ' + 'is what those renderers drew (for `chart`, whose block replaced the bag whole, a bag beside a ' + 'block is dropped). What it cannot decide: the bag was judged with every key optional, so a bag ' From 3f56e4cfea2f49a8c86c6dd2105272add044627f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:30:01 +0000 Subject: [PATCH 08/10] chore(changeset): qualify how a stored bag renders once folded on the object page Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzevL8Q6rTfGz2XHXGT --- .changeset/25923-list-view-options-bag-retired.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/25923-list-view-options-bag-retired.md b/.changeset/25923-list-view-options-bag-retired.md index e2153d9cd3e..c75065f750f 100644 --- a/.changeset/25923-list-view-options-bag-retired.md +++ b/.changeset/25923-list-view-options-bag-retired.md @@ -31,5 +31,5 @@ A flattened list view overlay carried every per-kind block twice: as the top-lev **BREAKING** for a list view body, a page `list-view` node or a `ListView` host that writes `options`. - **Before.** `{ type: 'kanban', columns: ['name'], options: { kanban: { groupByField: 'stage' } } }` was accepted at the view write door and drew a board laned on `stage`, and `ListView` read the bag beside the top-level blocks. -- **Now.** The write door and every `@object-ui/types` door refuse `options` by name, and `ListView` reads no bag. A view already stored with one is served folded, so it renders as before. +- **Now.** The write door and every `@object-ui/types` door refuse `options` by name, and `ListView` reads no bag. A view already stored with one is served folded onto its top-level blocks and renders from them. On the object page those blocks now pass through the same relay defaults as a block written top-level (the kanban `titleField: 'name'` floor, for one), so a folded row can draw differently from its bag where the bag left such a key unset. No reachable source stores a bag (census: 0 rows), and `ObjectView.storedRowParity-10380` pins the folded rendering. - **Fix.** Move each block to the top level: `{ type: 'kanban', columns: ['name'], kanban: { groupByField: 'stage', columns: ['name'] } }`, keeping the top-level value where both set a key, and add any key the block requires that the bag never had (a kanban's `columns`, a gantt's `titleField`, a chart's `dataset` and `values`). A grid's settings are top-level keys of the view. Run `os migrate meta --from 17` for a source, with `--write` to apply the edits it can prove. From 45338bfbdb716ae86185287b4af1d7a29a1eec91 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 19:03:37 +0000 Subject: [PATCH 09/10] chore(sdui): re-record the manifest digest after merging main Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzevL8Q6rTfGz2XHXGT --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 2f314dd051d..58cba5892a9 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -14,7 +14,7 @@ "source": "built-tree", "generator": "scripts/gen-sdui-manifest-node.mjs", "generatedAt": "2026-10-28", - "sha256": "de11057952187fdd2b100a98f3ce1a4f123b76be013bb0eaa754346904b3fdde", + "sha256": "dc8b061ccbb918a14547e3d2ef09baba4decbc9687dd9f018c5da18191309afe", "components": 106, "inTree": { "modulesRoot": "ui/apps/console", From 8a4ad1fba714e18cc91c305587abbe9936bf1142 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 19:51:09 +0000 Subject: [PATCH 10/10] docs(ui): regenerate the component reference after the list-view options input retired main's #27179 generates content/docs/ui/components/ from sdui.manifest.json; the merged manifest no longer publishes list-view's options input. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzevL8Q6rTfGz2XHXGT --- content/docs/ui/components/index.mdx | 2 +- content/docs/ui/components/plugins.mdx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/content/docs/ui/components/index.mdx b/content/docs/ui/components/index.mdx index ccea5b3fb07..700c4da9d11 100644 --- a/content/docs/ui/components/index.mdx +++ b/content/docs/ui/components/index.mdx @@ -87,7 +87,7 @@ regenerates from the renderer registry. Regenerate it with | [`ins`](/docs/ui/components/html#ins) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | | [`label`](/docs/ui/components/html#label) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | | [`li`](/docs/ui/components/html#li) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | -| [`list-view`](/docs/ui/components/plugins#list-view) | `plugin-list` | 42 | [Plugin Blocks](/docs/ui/components/plugins) | +| [`list-view`](/docs/ui/components/plugins#list-view) | `plugin-list` | 41 | [Plugin Blocks](/docs/ui/components/plugins) | | [`main`](/docs/ui/components/html#main) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | | [`mark`](/docs/ui/components/html#mark) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) | | [`markdown`](/docs/ui/components/plugins#markdown) | `plugin-markdown` | 2 | [Plugin Blocks](/docs/ui/components/plugins) | diff --git a/content/docs/ui/components/plugins.mdx b/content/docs/ui/components/plugins.mdx index ee672621c97..454b1d7ea10 100644 --- a/content/docs/ui/components/plugins.mdx +++ b/content/docs/ui/components/plugins.mdx @@ -313,7 +313,6 @@ Namespace `plugin-list`. | `rowActions` | `array` of `string` | | Names of the actions offered on each row's menu. | | `bulkActions` | `array` of `string` | | Names of the actions offered once rows are selected. Needs a multi-row `selection`. | | `bulkActionDefs` | `array` of `object` | | Full bulk-action definitions, the spec's `BulkActionDef`: a mass update (`operation: "update"` with a `patch`) or delete, or a named action run once for the whole selection. | -| `options` | `object` | | Per-kind settings, `{ kanban, calendar, gantt, gallery, timeline, map, tree, chart }`, each judged by that kind's own block. The top-level block of the same name is the canonical spelling. | | `kanban` | `object` | | The board, the spec's kanban block: `{ groupByField, columns, titleField, summarizeField }`. `groupByField` is the lane field and `columns` the fields on each card. | | `calendar` | `object` | | The calendar, `{ startDateField, endDateField, titleField, colorField, allDayField }`. | | `gantt` | `object` | | The gantt chart, `{ startDateField, endDateField, titleField, progressField, dependenciesField }`. Both date fields are needed to draw it. |