diff --git a/.changeset/24190-line-items-hydrated-check.md b/.changeset/24190-line-items-hydrated-check.md index 620e32ec808..25067a8a53e 100644 --- a/.changeset/24190-line-items-hydrated-check.md +++ b/.changeset/24190-line-items-hydrated-check.md @@ -12,7 +12,7 @@ Clause-②: yes (narrowing) **`@objectstack/spec`** -- **`defineStack`'s cross-reference check** now reaches a `record:line_items` block in a page region's components, in a container's component children and in a slot. A block under a `page:tabs` item's `children` is not yet judged. It judges a column that declares no `type` as the type it renders, and reports the column schema's own refusal (`STACK_CROSS_REFERENCE_INVALID`, 422). Today that means `scale` on an identity-only column over a `currency` field of the child object. The child object is the one the panel lists: the object `dataSource` binding's `object` when the node carries one, else `childObject`. A child object the stack does not declare, a column naming no field of it, and a column the column schema refuses on its own (left to the component-props gate) are not judged. +- **`defineStack`'s cross-reference check** now reaches a `record:line_items` block in a page region's components, in a container's component children, in a slot and, since #24349, in a `page:tabs` or `page:accordion` item's `children`. It judges a column that declares no `type` as the type it renders, and reports the column schema's own refusal (`STACK_CROSS_REFERENCE_INVALID`, 422). Today that means `scale` on an identity-only column over a `currency` field of the child object. The child object is the one the panel lists: the object `dataSource` binding's `object` when the node carries one, else `childObject`. A child object the stack does not declare, a column naming no field of it, and a column the column schema refuses on its own (left to the component-props gate) are not judged. - The step-18 migration entry `ui-record-line-items-props-closed` now says so, and no longer says that this block draws a column exactly as declared. ## FROM → TO @@ -25,4 +25,4 @@ The one-line fix: delete `scale` from a `record:line_items` column that renders ## Who is affected, measured -On `origin/main` `ec749fbb44`: two authored `record:line_items` blocks in the examples (the showcase project detail page, five typed columns, its block under a `page:tabs` item, so not yet judged; the showcase invoice document page, four `{ name, label }` columns, two of them over `currency` fields) and one in the published skills (four `{ name }` columns). None carries `scale`, so no example, skill or fixture outside this change's own pins changes verdict. Deployed metadata was not measured. +On `origin/main` `ec749fbb44`: two authored `record:line_items` blocks in the examples (the showcase project detail page, five typed columns, its block under a `page:tabs` item; the showcase invoice document page, four `{ name, label }` columns, two of them over `currency` fields) and one in the published skills (four `{ name }` columns). None carries `scale`, so no example, skill or fixture outside this change's own pins changes verdict. Deployed metadata was not measured. diff --git a/.changeset/24349-page-walk-panel-children.md b/.changeset/24349-page-walk-panel-children.md new file mode 100644 index 00000000000..93ecd6575f5 --- /dev/null +++ b/.changeset/24349-page-walk-panel-children.md @@ -0,0 +1,31 @@ +--- +'@objectstack/spec': minor +--- + +fix(spec)!: `defineStack`'s page cross-reference checks judge a component inside a `page:tabs` or `page:accordion` item's `children`, as they judge one at region level (#24349) + +Clause-②: yes (narrowing) + + + +**BREAKING** — an accept-set narrowing of what `defineStack` and `objectstack validate` publish, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. `defineStack` reads a page's components through one traversal, shared by its inline-action target check and its inline grid column check. A tab or accordion item is a panel record (`{ label, value?, children }`), not a component, and the traversal stopped at it, so nothing inside a tab or an accordion panel was judged: a button whose modal target names no page built green in a tab and was refused one level up. + +**`@objectstack/spec`** + +- **`defineStack`'s page cross-reference checks** now reach a component in a `page:tabs` or `page:accordion` item's `children`, at any depth (tabs in an accordion panel, a panel in a container or under a slot). The traversal descends the panel positions the component rows declare (`pageComponentSlotPositions`), the list the other page walks already read, so it follows the declared shapes rather than naming tabs. A record under a key no row declares a panel list is not descended. +- A component in a panel gets the region-level verdict and message, located through the panel: `STACK_CROSS_REFERENCE_INVALID` (422), for example `Inline action 'open_detail' on page 'invoice_record' (regions.0.components.0.properties.items.1.children.0) references page 'no_such_modal' (via modal target) which is not defined in pages.` +- Two checks reach further: an inline action's `type: 'modal'` / `type: 'flow'` `target`, and an identity-only column (one that declares no `type`) carrying `scale` over a `currency` child field, on an `object-master-detail-form` block's `details[].columns` or a `record:line_items` block's `columns`. That includes a `record:line_items` block in a tab or accordion panel, a position the block's identity-only check did not reach before this change. +- New step-18 migration entry `ui-page-panel-children-cross-references-judged`; the entry `ui-record-line-items-props-closed` now names the panel positions in its reach. + +## FROM → TO + +| you wrote, inside a `page:tabs` or `page:accordion` item's `children` | write instead | +|:--|:--| +| `{ type: 'element:button', properties: { action: { type: 'modal', target: 'no_such_page' } } }` | a `target` naming a page the stack declares (or a declared flow, for `type: 'flow'`) | +| `{ type: 'record:line_items', properties: { childObject: 'invoice_line', relationshipField: 'invoice', columns: [{ name: 'amount', scale: 2 }] } }` where `amount` is a `currency` field of `invoice_line` | the same block with `columns: [{ name: 'amount' }]` | + +The one-line fix: give the component inside the panel the same fix it would need at region level. Nothing about the panel or its items changes. + +## Who is affected, measured + +On `origin/main` `31e7d0ba2`: four authored pages nest components under a panel item (the showcase project detail page, whose `record:line_items` block declares a `type` on all five columns; the platform user and organization record pages; the approval request record page), one documentation example does (three tabs of record blocks), and the published skills carry none. None holds an inline action target or an identity-only column under a panel, so no example, skill or fixture outside this change's own pins changes verdict. Deployed metadata was not measured. diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-page-panel-children-cross-references-judged.ts b/packages/spec/src/migrations/entries/semantic/18.ui-page-panel-children-cross-references-judged.ts new file mode 100644 index 00000000000..52251393d2c --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-page-panel-children-cross-references-judged.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The reach of `defineStack`'s page cross-reference checks, extended into tab +// and accordion panels. Both checks (inline-action targets, inline grid +// columns) read a page through one traversal, which descended only arrays of +// component-shaped entries; a `page:tabs` / `page:accordion` item is a panel +// record, so the traversal stopped at it and every component inside a panel +// went unjudged. It now also descends the panel positions the component rows +// declare, the list the other page walks read. D3 only: the refusal is at +// authoring time, nothing on the metadata load path refuses the shape, and a +// dangling target or a refused column key has no lossless rewrite. +export const entry: SemanticMigration = { + id: 'ui-page-panel-children-cross-references-judged', + surface: 'page components under a `page:tabs` or `page:accordion` item\'s `children` ' + + '(`properties.items[].children[]`, at any depth), as `defineStack`\'s page cross-reference ' + + 'checks judge them: an inline action\'s `type: \'modal\'` or `type: \'flow\'` `target` ' + + '(`properties.action`), and an inline grid column that declares no `type` in an ' + + '`object-master-detail-form` block\'s `details[].columns[]` or a `record:line_items` block\'s ' + + '`columns[]`', + replacement: 'what the same component is held to at region level: an inline `type: \'modal\'` ' + + 'action\'s `target` names a page the stack declares and a `type: \'flow\'` action\'s `target` a ' + + 'flow it declares; an identity-only column over a `currency` field of the child object carries ' + + 'no `scale` — delete the key, nothing replaces it, the currency\'s ISO 4217 minor unit decides. ' + + 'The panel, its items and their keys do not change.', + reason: '`defineStack` judges a page\'s components through one traversal, shared by its ' + + 'inline-action target check and its inline grid column check. The traversal descended an ' + + 'array under `properties` only when its entries were component-shaped (an object carrying a ' + + 'string `type`), and a tab or accordion item is a panel record (`{ label, value?, children }`), ' + + 'not a component, so it stopped at the item and nothing inside a tab or an accordion panel was ' + + 'judged. A button whose modal target named no page built green inside a tab while the same ' + + 'button at region level was refused, and an identity-only `scale` column over a `currency` ' + + 'child field split the same way on both page blocks that draw an inline grid. The traversal ' + + 'now also descends the panel positions the component rows declare (`pageComponentSlotPositions` ' + + 'in `ui/component.zod.ts`; today `items[].children`, declared by `page:tabs` and ' + + '`page:accordion`), the list the exported page walk, the ADR-0087 conversion walker and the ' + + 'component-props gate already read, matched by shape on every component as they match it. A ' + + 'component in a panel is refused with the region-level message and status 422, located through ' + + 'the panel (`regions.0.components.0.properties.items.1.children.0`). A record under a key no ' + + 'row declares a panel list is not descended. No conversion is registered: the refusal is at ' + + 'authoring time (`defineStack`, and so `objectstack validate` and `objectstack build`), nothing ' + + 'on the metadata load path refuses the shape, and a dangling target has no right value to ' + + 'guess. Population measured at the change, on origin/main 31e7d0ba2: four authored pages nest ' + + 'components under a panel item (the showcase project detail page, whose `record:line_items` ' + + 'block declares a `type` on all five columns; the platform user and organization record pages; ' + + 'the approval request record page), one documentation example does (three tabs of record ' + + 'blocks), and the published skills carry none. None of them holds an inline action target or ' + + 'an identity-only column under a panel, so none changes verdict. Deployed metadata NOT MEASURED.', + acceptanceCriteria: '`objectstack validate` and `defineStack` report no cross-reference finding on ' + + 'a path through `properties.items.N.children`. Every inline `type: \'modal\'` action inside a tab ' + + 'or accordion panel names a page the stack declares, every `type: \'flow\'` action a declared ' + + 'flow, and no identity-only column there over a `currency` child field carries `scale` — the ' + + 'verdicts the same components get at region level.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-record-line-items-props-closed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-record-line-items-props-closed.ts index e8cebee6bf5..ba9736eab4b 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-record-line-items-props-closed.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-record-line-items-props-closed.ts @@ -51,11 +51,12 @@ export const entry: SemanticMigration = { + 'page component\'s `properties` is not parsed on the metadata load path, and the next save of the page meets the component-props gate at the metadata save door. The ' + 'identity-only half is `defineStack`\'s cross-reference check: the panel hydrates a column ' + 'that declares no `type` from the child object\'s field, with the same rule the other carriers ' - + 'use, so the check reaches the block in a page region\'s components, in a container\'s ' - + 'component children and in a slot, and refuses an identity-only column over a `currency` ' - + 'child field that carries `scale`, with the column schema\'s own message; reach: a block under ' - + 'a `page:tabs` item\'s `children` is not yet judged, the child object — the object ' - + '`dataSource` binding\'s `object` when the node carries one, else `childObject` — must be ' + + 'use, so the check reaches the block wherever a page carries it — in a page region\'s ' + + 'components, in a container\'s component children, in a `page:tabs` or `page:accordion` ' + + 'item\'s `children` and in a slot — and refuses an identity-only column over a `currency` ' + + 'child field that carries `scale`, with the column schema\'s own message; reach: the child ' + + 'object — the object `dataSource` binding\'s `object` when the node carries one, else ' + + '`childObject` — must be ' + 'declared in the same stack, and a column the column schema refuses on its own is left to the ' + 'component-props gate. No conversion is registered: ' + 'nothing on the load path refuses the shape, and the one `field`-keyed producer was respelled ' diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index 53a93fc2127..8f2464fb6aa 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -3192,6 +3192,21 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + '`os migrate meta` replay it. Its D3 record is the semantic entry ' + '`ui-page-action-row-navigation-executor-refused`.', }, + { + id: 'ui-page-panel-children-cross-references-judged', + order: 103, + text: + 'It also extends `defineStack`\'s page cross-reference checks into tab and accordion panels. The ' + + 'one traversal the inline-action target check and the inline grid column check share descended ' + + 'an array under `properties` only when its entries were components, and a `page:tabs` or ' + + '`page:accordion` item is a panel record, not a component, so nothing inside a panel was judged: ' + + 'a dangling inline modal target built green in a tab and was refused at region level. The ' + + 'traversal now also descends the panel positions the component rows declare, the list every ' + + 'other page walk reads, and a component in a panel is refused with the region-level message, ' + + 'located through the panel. The refusal is at authoring time and nothing stored is rewritten, ' + + 'so no conversion is registered. Its D3 record is the semantic entry ' + + '`ui-page-panel-children-cross-references-judged`.', + }, { id: 'ui-record-json-retired', order: 100, @@ -3220,10 +3235,10 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'other carriers do, so `defineStack`\'s identity-only check reaches the block, with the reach ' + '`inline-grid-column-identity-only-currency-scale-refused` records for the first two ' + 'carriers, against the child object the panel lists (the object `dataSource` binding\'s ' - + '`object` when the node carries one, else `childObject`). It judges the block in a page ' - + 'region\'s components, in a container\'s component children and in a slot; a block under a ' - + '`page:tabs` item\'s `children` is not yet judged. Its D3 record is the semantic entry ' - + '`ui-record-line-items-props-closed`.', + + '`object` when the node carries one, else `childObject`). It judges the block wherever a page ' + + 'carries it: in a page region\'s components, in a container\'s component children, in a ' + + '`page:tabs` or `page:accordion` item\'s `children` and in a slot. Its D3 record is the ' + + 'semantic entry `ui-record-line-items-props-closed`.', }, { id: 'ui-report-joined-block-dataset-required', diff --git a/packages/spec/src/stack-page-walk-panels.test.ts b/packages/spec/src/stack-page-walk-panels.test.ts new file mode 100644 index 00000000000..764cb92f6cf --- /dev/null +++ b/packages/spec/src/stack-page-walk-panels.test.ts @@ -0,0 +1,261 @@ +/** + * `defineStack`'s page cross-reference checks judge a component wherever the + * page carries it — a `page:tabs` or `page:accordion` item's `children` + * included. + * + * Two checks read a page through one traversal (`forEachPageComponent`, + * `stack.zod.ts`): the inline-action target check and the inline grid column + * check. A tab or accordion item is a panel record (`{ label, value?, + * children }`), not a component, so a walk that descends only arrays of + * component-shaped entries stops at the item, and everything inside a tab or + * an accordion panel went unjudged: a dangling inline modal target there built + * green while the same button at region level was refused. The walk now + * descends the panel positions the component rows declare + * (`pageComponentSlotPositions`), the list every other page walk reads. + * + * Every refusal here is pinned against its REGION-LEVEL TWIN: the same + * component built at `regions.0.components.0` gives the reference finding, and + * the panel case must give exactly that finding with only its location changed. + * So these pins hold the message to the region-level one rather than restating + * it, and assert the envelope (`code`, `status`, the whole `issues` list), not + * a bare throw. + */ +import { describe, expect, it } from 'vitest'; +import { defineStack } from './stack.zod'; +import { pageComponentSlotPositions } from './ui/component.zod'; + +const manifest = { + id: 'com.example.pagewalkpanels', + name: 'page-walk-panels-test', + version: '1.0.0', + type: 'app' as const, +}; + +const PARENT = { name: 'crm_invoice', label: 'Invoice', fields: { title: { type: 'text' as const } } }; + +const CHILD = { + name: 'crm_invoice_line', + label: 'Invoice Line', + fields: { + invoice: { type: 'master_detail' as const, reference: 'crm_invoice' }, + quantity: { type: 'number' as const }, + amount: { type: 'currency' as const }, + }, +}; + +const PAGE = 'crm_invoice_record'; +const REGION = 'regions.0.components.0'; + +// --------------------------------------------------------------------------- +// The judged components — one per check, each refused at region level. +// --------------------------------------------------------------------------- + +const button = (target = 'no_such_modal_xyz') => ({ + type: 'element:button', + properties: { label: 'Open', action: { name: 'open_detail', type: 'modal', target } }, +}); + +const lineItems = (columns: unknown[] = [{ name: 'amount', scale: 2 }]) => ({ + type: 'record:line_items', + properties: { childObject: 'crm_invoice_line', relationshipField: 'invoice', columns }, +}); + +const masterDetail = (columns: unknown[] = [{ name: 'amount', scale: 2 }]) => ({ + type: 'object-master-detail-form', + properties: { objectName: 'crm_invoice', details: [{ title: 'Lines', childObject: 'crm_invoice_line', columns }] }, +}); + +const JUDGED = [ + ['an inline action with a dangling modal target', button], + ['a `record:line_items` identity-only column carrying `scale` over a currency child field', lineItems], + ['an `object-master-detail-form` identity-only column carrying `scale` over a currency child field', masterDetail], +] as const; + +// --------------------------------------------------------------------------- +// Panels and pages. +// --------------------------------------------------------------------------- + +/** A `page:tabs` node, one tab per child list. */ +const tabs = (...panels: unknown[][]) => ({ + type: 'page:tabs', + properties: { items: panels.map((children, i) => ({ label: `Tab ${i}`, value: `tab_${i}`, children })) }, +}); + +/** A `page:accordion` node, one panel per child list. */ +const accordion = (...panels: unknown[][]) => ({ + type: 'page:accordion', + properties: { items: panels.map((children, i) => ({ label: `Panel ${i}`, children })) }, +}); + +/** A stack whose one record page carries `components` in its main region. */ +const stackWith = (components: unknown[]) => ({ + manifest, + objects: [PARENT, CHILD], + pages: [{ + name: PAGE, + label: 'Invoice', + type: 'record' as const, + object: 'crm_invoice', + regions: [{ name: 'main', components }], + }], +}); + +/** The same page with `components` under a slot instead. */ +const slottedStackWith = (components: unknown[]) => ({ + manifest, + objects: [PARENT, CHILD], + pages: [{ + name: PAGE, + label: 'Invoice', + type: 'record' as const, + object: 'crm_invoice', + kind: 'slotted', + regions: [{ name: 'main', components: [] }], + slots: { details: components }, + }], +}); + +type Refusal = { code?: string; status?: number; issues?: unknown[]; message: string }; + +const build = (stack: unknown) => defineStack(stack as Parameters[0]); + +/** Run `defineStack` and return the refusal it threw — fails the test when it accepted. */ +function refusalOf(stack: unknown): Refusal { + try { + build(stack); + } catch (error) { + return error as Refusal; + } + throw new Error('expected defineStack to REFUSE this stack, and it accepted it'); +} + +/** The one finding `component` raises at region level — the reference every panel case is held to. */ +function regionLevelFinding(component: unknown): string { + const refusal = refusalOf(stackWith([component])); + expect({ code: refusal.code, status: refusal.status }).toEqual({ code: 'STACK_CROSS_REFERENCE_INVALID', status: 422 }); + expect(refusal.issues).toHaveLength(1); + const finding = String(refusal.issues![0]); + expect(finding.split(`(${REGION})`), finding).toHaveLength(2); + return finding; +} + +/** + * `stack` is refused with the envelope and the one finding `twin` raises at + * region level, located at `path` instead. + */ +function expectRefusedLikeRegionLevel(stack: unknown, twin: unknown, path: string): void { + const expected = regionLevelFinding(twin).replace(`(${REGION})`, `(${path})`); + const refusal = refusalOf(stack); + expect({ code: refusal.code, status: refusal.status, issues: refusal.issues }).toEqual({ + code: 'STACK_CROSS_REFERENCE_INVALID', + status: 422, + issues: [expected], + }); +} + +describe('defineStack judges a component inside a tab or accordion panel as it judges one at region level', () => { + it('refuses a dangling inline modal target under a `page:tabs` item, located through the panel', () => { + const path = `${REGION}.properties.items.1.children.0`; + expectRefusedLikeRegionLevel(stackWith([tabs([], [button()])]), button(), path); + // The full finding, once, as an author reads it. + expect(refusalOf(stackWith([tabs([], [button()])])).issues).toEqual([ + `Inline action 'open_detail' on page '${PAGE}' (${path}) ` + + "references page 'no_such_modal_xyz' (via modal target) which is not defined in pages.", + ]); + }); + + it.each(JUDGED)('refuses %s under a `page:tabs` item', (_label, component) => { + expectRefusedLikeRegionLevel( + stackWith([tabs([], [component()])]), + component(), + `${REGION}.properties.items.1.children.0`, + ); + }); + + it.each(JUDGED)('refuses %s under a `page:accordion` item', (_label, component) => { + expectRefusedLikeRegionLevel( + stackWith([accordion([component()])]), + component(), + `${REGION}.properties.items.0.children.0`, + ); + }); + + it.each(JUDGED)('refuses %s in tabs nested in an accordion panel', (_label, component) => { + expectRefusedLikeRegionLevel( + stackWith([accordion([tabs([], [component()])])]), + component(), + `${REGION}.properties.items.0.children.0.properties.items.1.children.0`, + ); + }); + + it('refuses it in a panel inside a container, and in a panel under a slot — the panel rule composes with every root and nesting', () => { + expectRefusedLikeRegionLevel( + stackWith([{ type: 'page:card', properties: { children: [tabs([button()])] } }]), + button(), + `${REGION}.properties.children.0.properties.items.0.children.0`, + ); + const refusal = refusalOf(slottedStackWith([accordion([lineItems()])])); + expect({ code: refusal.code, status: refusal.status, issues: refusal.issues }).toEqual({ + code: 'STACK_CROSS_REFERENCE_INVALID', + status: 422, + issues: [regionLevelFinding(lineItems()).replace(`(${REGION})`, '(slots.details.0.properties.items.0.children.0)')], + }); + }); + + it('reports each panel\'s finding at its own location, in document order', () => { + const refusal = refusalOf(stackWith([tabs([button('no_such_a')], [], [button('no_such_b')])])); + expect(refusal.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refusal.issues).toEqual([ + regionLevelFinding(button('no_such_a')).replace(`(${REGION})`, `(${REGION}.properties.items.0.children.0)`), + regionLevelFinding(button('no_such_b')).replace(`(${REGION})`, `(${REGION}.properties.items.2.children.0)`), + ]); + }); + + it('following the remedy builds: a declared modal target, and the column with `scale` deleted', () => { + expect(() => build(stackWith([tabs([button(PAGE)], [lineItems([{ name: 'amount' }])])]))).not.toThrow(); + expect(() => build(stackWith([accordion([masterDetail([{ name: 'amount' }, { name: 'quantity', scale: 1 }])])]))).not.toThrow(); + }); +}); + +describe('the walk descends the declared panel positions, and only those', () => { + const panelPositions = pageComponentSlotPositions().filter((position) => position.panelKey !== undefined && !position.retired); + + it('descends every authorable panel position the component rows declare, on any component carrying that shape', () => { + // Read off the one list, so a panel position a row declares later is + // covered here without this file naming it. Non-empty, or the loop below + // proves nothing. + expect(panelPositions.length).toBeGreaterThan(0); + for (const { key, panelKey } of panelPositions) { + // A type no row declares: positions are matched by shape, as every + // page walk matches them, because custom types compose the same vocabulary. + const node = { type: 'acme:panels', properties: { [key]: [{ [panelKey as string]: [button()] }] } }; + expectRefusedLikeRegionLevel(stackWith([node]), button(), `${REGION}.properties.${key}.0.${panelKey}.0`); + } + }); + + it('CONTROL — a panel record is not itself a component: its own keys are not judged, and a child list holding no component visits nothing', () => { + const items = [ + { label: 'Looks like a node', properties: { action: { type: 'modal', target: 'no_such_modal_xyz' } }, children: [] }, + { label: 'No children' }, + { label: 'Not components', children: ['bare_component_id', null, 42, [button()]] }, + ]; + expect(() => build(stackWith([{ type: 'page:tabs', properties: { items } }]))).not.toThrow(); + }); + + it('CONTROL — a record under a key no row declares a panel list is not walked, even one carrying a `type` key and a list of components', () => { + // A detail entry with a `type` key looks component-shaped, and its + // `children` holds a real dangling button; `details` is a non-container + // prop, so the renderer draws nothing from that list. + const detail = { title: 'Lines', childObject: 'crm_invoice_line', type: 'grid', children: [button()] }; + const nonContainer = { type: 'object-master-detail-form', properties: { objectName: 'crm_invoice', details: [detail] } }; + // A panel-shaped list under an undeclared key, and an undeclared key inside a declared panel. + const undeclaredList = { type: 'page:tabs', properties: { panels: [{ label: 'A', children: [button()] }] } }; + const undeclaredPanelKey = { type: 'page:tabs', properties: { items: [{ label: 'A', content: [button()] }] } }; + for (const component of [nonContainer, undeclaredList, undeclaredPanelKey]) { + expect(() => build(stackWith([component])), JSON.stringify(component)).not.toThrow(); + } + // …while the same button in the declared position is refused, so the + // controls above are not passing on a broken fixture. + expect(refusalOf(stackWith([tabs([button()])])).code).toBe('STACK_CROSS_REFERENCE_INVALID'); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index ab9c960b1b4..a35d4fb7651 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -37,6 +37,7 @@ import { lintUnknownAuthoringKeys, lintUnknownStackKeys } from './kernel/metadat import { ObjectSchema, ObjectExtensionSchema } from './data/object.zod'; import { controlledByParentMasterRefusal } from './data/controlled-by-parent-master'; import { InlineGridColumnSchema } from './data/field.zod'; +import { pageComponentSlotPositions } from './ui/component.zod'; import { SeedSchema } from './data/seed.zod'; // UI Protocol @@ -2392,7 +2393,7 @@ const isComponentNode = (value: unknown): value is Record => * `visit` receives the node and its `properties` record; a node without one is * not visited, since every check here reads under `properties`. * - * Two traversal facts make this a walk rather than a loop: + * Three traversal facts make this a walk rather than a loop: * * - **Roots are plural.** Components hang off `regions[].components[]` AND off * `slots.`, where each slot is one component *or* an array of them. @@ -2401,12 +2402,28 @@ const isComponentNode = (value: unknown): value is Record => * children as `z.array(z.unknown())` (`children`, plus a card's `footer`), so * a button nested inside a container survives parse as raw data. We recurse * into any array under `properties` whose entries are component-shaped, which - * reaches every container spelling without this walk enumerating them. + * reaches a child list directly under `properties` without this walk naming + * its key. + * - **Panels nest one record deeper.** A `page:tabs` or `page:accordion` node + * carries its components as `properties.items[].children`, and each item is + * a panel record (`{ label, value?, children }`), not a component, so the + * shape rule above stops at it. Where panels hang is not this walk's to + * list: it descends the panel positions of `pageComponentSlotPositions` + * (`ui/component.zod.ts`), the one list the component rows derive and the + * other page walks read (the exported `walkAddressedPageComponents`, the + * ADR-0087 conversion walker, lint's `walkPageComponents`), authorable + * spellings only, matched by shape on every component as they match it. A + * record under a key no row declares a panel list is not descended, nor is + * a key of a panel record no row declares: the renderer draws nothing from + * either, so nothing there is a component of the page. */ function forEachPageComponent( page: Record, visit: (node: Record, props: Record, path: string) => void, ): void { + const panelPositions = pageComponentSlotPositions().flatMap(({ key, panelKey, retired }) => + panelKey !== undefined && !retired ? [{ key, panelKey }] : []); + const visitComponent = (node: unknown, path: string): void => { if (!isComponentNode(node)) return; const props = isRecord(node.properties) ? node.properties : undefined; @@ -2420,6 +2437,20 @@ function forEachPageComponent( if (isComponentNode(child)) visitComponent(child, `${path}.properties.${key}.${index}`); }); } + + // A panel list: only each panel's child list is descended, never the + // panel record itself. + for (const { key, panelKey } of panelPositions) { + const panels = props[key]; + if (!Array.isArray(panels)) continue; + panels.forEach((panel, index) => { + const children = isRecord(panel) ? panel[panelKey] : undefined; + if (!Array.isArray(children)) return; + children.forEach((child, childIndex) => { + visitComponent(child, `${path}.properties.${key}.${index}.${panelKey}.${childIndex}`); + }); + }); + } }; if (Array.isArray(page.regions)) { @@ -2456,7 +2487,8 @@ function forEachPageComponent( * with its behaviour written right there — so it is the surface that most needs * authoring-time rejection. * - * The traversal is {@link forEachPageComponent}'s: every root, every nesting. + * The traversal is {@link forEachPageComponent}'s: every root, every nesting, + * a `page:tabs` or `page:accordion` item's `children` included. * * Candidates are normalized by parsing with `InlineActionSchema` rather than * read key-by-key: that schema's preprocess is what canonicalizes the legacy @@ -3568,12 +3600,12 @@ const hasOwnKey = (record: object, key: string): boolean => Object.prototype.has * - [#20928] an `object-master-detail-form` page block's `details[].columns`, * wherever the block sits on a page ({@link forEachPageComponent}) — a column * names a field of the detail entry's `childObject`; - * - [#24190] a `record:line_items` page block's `columns`, in the positions - * {@link forEachPageComponent} visits — a region's components, a - * container's component children, a slot; a block under a `page:tabs` - * item's `children` is not yet visited, so not yet judged — a column names - * a field of the child object the panel lists, resolved the way the - * renderer resolves it ({@link lineItemsChildObject}). + * - [#24190] a `record:line_items` page block's `columns`, wherever the block + * sits on a page ({@link forEachPageComponent}) — a region's components, a + * container's component children, a `page:tabs` or `page:accordion` item's + * `children`, a slot — a column names a field of the child object the panel + * lists, resolved the way the renderer resolves it + * ({@link lineItemsChildObject}). * * Resolution is against the stack's own `objects`: a `childObject` this stack * does not declare, or a column naming no field of it, is not judged here — an