From f7baa6ae38bd6233163c4bc78799cfecc48a2cc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:27:35 +0000 Subject: [PATCH 1/5] feat(types): arm seven registered node types that pass the criterion (objectui#11440) `safeValidateSchema`, and so `objectui validate`, accepts the spec page kinds `record` / `home` / `utility`, and `app-schema-renderer`, `object-pivot`, `embeddable-form` and `detail-section`, each refused at `type` before. `REFUSED_AT_TYPE` falls 8 -> 1; `spec-report` remains for the fold into `report`. - `PageKindNodeSchema`: the page node's members by reference under the spec's `PageTypeSchema` narrowed to the three registered kinds. - `AppSchemaRendererNodeSchema`: `basePath` and `mobileNavMode` from the registration inputs; `schema` is not declared (never delivered). - `ObjectPivotBlockSchema` / `EmbeddableFormBlockSchema`: required bags of the registration inputs, flat spelling refused by name. - `DetailSectionNodeSchema`: the ten registered section members, flat. - `object-pivot` publishes `drillDown` as a registration input. Claude-Session: https://claude.ai/code/session_01YLg8XqWGJ785fwQ5v4pH37 Co-authored-by: Claude --- .changeset/10685-drilldown-per-block.md | 11 + .changeset/11440-arm-passing-types.md | 18 + .../11440-object-pivot-drilldown-input.md | 7 + ...tered-types-validate-ratchet-10859.test.ts | 73 +++- .../validate-passing-keys-11440.test.ts | 159 +++++++++ ...-schema-renderer-arm-inputs-11440.test.tsx | 67 ++++ .../plugin-dashboard/src/ObjectPivotTable.tsx | 6 +- .../object-pivot-arm-inputs-11440.test.ts | 66 ++++ packages/plugin-dashboard/src/index.tsx | 4 + .../detail-section-arm-inputs-11440.test.ts | 51 +++ .../embeddable-form-arm-inputs-11440.test.ts | 58 ++++ .../app-mobile-nav-mode-refusal-11363.test.ts | 22 +- .../flat-props-refusal-10872.test.ts | 25 +- .../__tests__/passing-keys-arms-11440.test.ts | 327 ++++++++++++++++++ ...blic-block-responsive-styles-10872.test.ts | 14 +- .../registered-type-arms-10859-b2.test.ts | 5 +- .../src/__tests__/zod-mirror-parity.test.ts | 20 ++ packages/types/src/data-display.ts | 9 +- packages/types/src/zod/README.md | 14 +- packages/types/src/zod/app.zod.ts | 67 ++++ packages/types/src/zod/data-display.zod.ts | 3 +- packages/types/src/zod/index.zod.ts | 13 +- packages/types/src/zod/layout.zod.ts | 79 ++++- packages/types/src/zod/objectql.zod.ts | 226 +++++++++++- packages/types/src/zod/views.zod.ts | 63 ++++ 25 files changed, 1362 insertions(+), 45 deletions(-) create mode 100644 .changeset/11440-arm-passing-types.md create mode 100644 .changeset/11440-object-pivot-drilldown-input.md create mode 100644 packages/cli/src/__tests__/validate-passing-keys-11440.test.ts create mode 100644 packages/layout/src/__tests__/app-schema-renderer-arm-inputs-11440.test.tsx create mode 100644 packages/plugin-dashboard/src/__tests__/object-pivot-arm-inputs-11440.test.ts create mode 100644 packages/plugin-detail/src/__tests__/detail-section-arm-inputs-11440.test.ts create mode 100644 packages/plugin-form/src/__tests__/embeddable-form-arm-inputs-11440.test.ts create mode 100644 packages/types/src/__tests__/passing-keys-arms-11440.test.ts diff --git a/.changeset/10685-drilldown-per-block.md b/.changeset/10685-drilldown-per-block.md index 9dae1a1700..5e1243edad 100644 --- a/.changeset/10685-drilldown-per-block.md +++ b/.changeset/10685-drilldown-per-block.md @@ -66,3 +66,14 @@ node. Nothing ever drilled a `pivot` node; a pivot drill is authored on `object- entry says. `object-pivot` still has no zod mirror, so what this entry says of a stored `object-pivot` config stands. `.changeset/10932-pivot-drilldown-retired.md` (PR objectui#10972) states what ships; the text above is kept as the reading of this change. + +⚠️ **Dated note, 2026-10-02 — `object-pivot` has a zod arm now — objectui#11440.** +Later in this same release `object-pivot` gained an arm in `@object-ui/types/zod`, +`ObjectPivotBlockSchema`, whose `properties.drillDown` is this entry's per-block shape: the shared +drill config with `mode` refused by name (`invalid_type` at `properties.drillDown.mode`). So "A +stored JSON pivot config carrying `mode` is still accepted" and "neither `object-pivot` nor +`PivotTableSchema` has a zod mirror, so no validator in this repository reads the members of a +pivot's `drillDown`" above, and "`object-pivot` still has no zod mirror" in the 2026-09-29 note, no +longer hold: `safeValidateSchema` and the strict face refuse that `mode`. +`.changeset/11440-arm-passing-types.md` states what ships. The rest of this entry is kept as the +reading of this change. diff --git a/.changeset/11440-arm-passing-types.md b/.changeset/11440-arm-passing-types.md new file mode 100644 index 0000000000..300970ba3b --- /dev/null +++ b/.changeset/11440-arm-passing-types.md @@ -0,0 +1,18 @@ +--- +'@object-ui/types': minor +--- + +`safeValidateSchema` — and so `objectui validate` — accepts seven more registered node types: the spec page kinds `record`, `home` and `utility`, and `app-schema-renderer`, `object-pivot`, `embeddable-form` and `detail-section` (objectui#11440, the objectui#10859 follow-up for the keys that pass the criterion). + +**Clause-②: yes** — the accept set of `AnyComponentSchema` widens by seven `type` literals, and `@object-ui/types/zod` exports five new schemas. Nothing that parsed before is refused now: every document naming one of the seven was refused with `invalid_union` at `type`, at the root and at every child slot. + +**What changed, in observable terms.** Each new authorable spelling, and what it accepts: + +- **`{ type: 'record' | 'home' | 'utility', … }`** — a stored `@objectstack/spec` page whose `type` is its page kind, the spelling the spec's `PageSchema` declares and the node `PageView` renders. `PageKindNodeSchema` takes every member of the `page` node (`PageNodeSchema`) by reference, so the spec's own page document validates (`{ type: 'home', name, label, regions }`), each region component is judged by the node union, and the `page` node's refusals (`actions`, `breadcrumbs`, `maxWidth`, `padding`, `body`) apply. The spec's other two kinds are not armed here: `app` stays the app document's literal (`AppComponentSchema`) and `list` the `list` component's. `{ type: 'page', pageType }` validates as before; which spelling is canonical is not changed. +- **`{ type: 'app-schema-renderer', mobileNavMode?, basePath? }`** — `AppSchemaRendererNodeSchema`, the whole-shell node `@object-ui/layout` registers. `mobileNavMode` is `'drawer'` or `'bottom_nav'`, as the registration declares it, so the node the mobile guide teaches validates and a misspelt mode is refused at `mobileNavMode`. The registration's third input, `schema`, is not declared: `SchemaRenderer` strips that key, so a node never delivers it, and the strict face refuses it as an unrecognized key. `children` and `body` are refused by name: a node draws neither. +- **`{ type: 'object-pivot', properties: { … } }`** — `ObjectPivotBlockSchema`. The `properties` bag holds the registration's inputs (`objectName`, `title`, `rowField`, `columnField`, `valueField`, `aggregation`, `showRowTotals`, `showColumnTotals`, `filter`, `format`, `drillDown`) and is required: `rowField`, `columnField` and `valueField` are required members. `objectName` is required unless the node's `dataSource.object` names the object (`RECORD_SOURCE_REQUIRED`). `drillDown` refuses `mode` by name, and the retired `dataProvider` is refused by name. +- **`{ type: 'embeddable-form', properties: { … } }`** — `EmbeddableFormBlockSchema`. The required bag holds exactly the registration's inputs (`formId`, required; `objectName`, required unless `dataSource.object` names the object; `title`, `description`, `fields` as field names, `allowMultiple`). +- On both blocks, a prop written flat on the node instead of in `properties` is refused by name, with a message naming `properties.KEY`, as `@objectstack/spec`'s page component refuses it as mis-layered; the node declares the `dataSource` binding (the spec's `ElementDataSourceSchema`) and `responsiveStyles`; `children` and `body` are refused by name. +- **`{ type: 'detail-section', fields, … }`** — `DetailSectionNodeSchema`, one field section of `@object-ui/plugin-detail` as a node: the ten `DetailViewSectionSchema` members its registration publishes (`title`, `description`, `icon`, `fields`, `collapsible`, `defaultCollapsed`, `columns`, `showBorder`, `headerColor`, `hideEmpty`), flat on the node, `fields` required. The plugin-detail README's `detail-view` tab with a `detail-section` content validates. `children` and `body` are refused by name. + +The strict authoring face (`StrictAnyComponentSchema`) derives from these arms and accepts the same documents, closed to undeclared keys. diff --git a/.changeset/11440-object-pivot-drilldown-input.md b/.changeset/11440-object-pivot-drilldown-input.md new file mode 100644 index 0000000000..9e933743df --- /dev/null +++ b/.changeset/11440-object-pivot-drilldown-input.md @@ -0,0 +1,7 @@ +--- +'@object-ui/plugin-dashboard': minor +--- + +The `object-pivot` registration publishes `drillDown` as an input (objectui#11440). + +`ObjectPivotTable` has always read `drillDown` (whether the drill is on, its filter, title, target, columns, row cap and report), and the `pivot` node's refusal of `drillDown` names `object-pivot` as where a pivot drill is authored. The registration did not list it, so the `sdui-parser` manifest and the designer did not offer it. Its description says `drillDown.mode` does not apply: every click point on a pivot is an aggregated bucket, so the pivot always drills through. `@object-ui/types/zod`'s new `object-pivot` arm declares the same member (`.changeset/11440-arm-passing-types.md`). Nothing else about the block changes. diff --git a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts index e32701eddf..a42072c831 100644 --- a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts +++ b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts @@ -76,15 +76,19 @@ import { validate } from '../commands/validate.js'; * more, see `UNREGISTERED_10859_BATCH_8_PHASE_2C`; objectui#11441, run as one * more batch of this card: minus `navigation-renderer` and `responsive-grid`, * see `UNREGISTERED_11441`; batch 8 phase 2d: minus the ten `sidebar-*` - * primitives, see `UNREGISTERED_10859_BATCH_8_PHASE_2D`). LOWER it when a batch - * arms or retires more keys; never raise it. + * primitives, see `UNREGISTERED_10859_BATCH_8_PHASE_2D`; objectui#11440: minus + * the seven keys its first pull request armed, see `ARMED_11440`). LOWER it + * when a batch arms or retires more keys; never raise it. * - * The 8 that remain are named in `STILL_REFUSED_10859`, and the row below + * The 1 that remains is named in `STILL_REFUSED_10859`, and the row below * pins the refused set to exactly that list, so this comment cannot drift from - * the measurement. They are the eight the seat ruling left registered — - * `object-pivot`, `embeddable-form`, `detail-section`, `home`, `record`, - * `utility`, `spec-report` and `app-schema-renderer` (objectui#11440, which - * arms them). + * the measurement. It is `spec-report`, the eighth key the seat ruling left + * registered: an alias of `report`, retired there in the ruling's step order + * (`report` declares the wrapper shape `spec-report` carries, the dashboard's + * drill drawer moves to `report`, then the alias goes), in objectui#11440's + * second pull request. The other seven — `object-pivot`, `embeddable-form`, + * `detail-section`, `home`, `record`, `utility` and `app-schema-renderer` — + * are armed. * * `navigation-renderer` and `responsive-grid`, the two the seat sent to the * maintainer, were ruled B / B on objectui#11441 (record `5950208338`) and are @@ -110,7 +114,7 @@ import { validate } from '../commands/validate.js'; * last 2 waiting on objectui#11441. That card ran first, so the count is 8 * after phase 2d, and objectui#11440 then takes it to 0. */ -const REFUSED_AT_TYPE = 8; +const REFUSED_AT_TYPE = 1; /** * The head's refused count over the NAMESPACED keys (objectui#10872 batch 1: @@ -132,7 +136,8 @@ const REFUSED_AT_TYPE = 8; * twins of the two keys it unregistered; objectui#10859 batch 8 phase 2d: minus * the ten `ui:sidebar-*` twins of the ten `sidebar-*` primitives it * unregistered). LOWER it when a batch arms or retires more keys; never raise - * it. + * it. objectui#11440 armed seven bare keys and none of their namespaced twins + * (`ARMED_11440` maps each to its twins), so it does not move this pin. */ const NAMESPACED_REFUSED_AT_TYPE = 362; @@ -250,14 +255,28 @@ const UNREGISTERED_10859_BATCH_8_PHASE_2D = { } as const; /** - * Every bare key still refused at `type` after objectui#11441 and objectui#10859 - * batch 8 phase 2d — the `REFUSED_AT_TYPE` docblock says why each is still - * here. Alphabetical. + * The seven bare keys objectui#11440's first pull request ARMED (the seat + * ruling `5945530142`, as amended by `5945583855`), each mapped to its + * namespaced twins. A twin is a separate registry key with its own literal, so + * arming the bare key does not arm it: each twin is still refused at `type`, + * and `NAMESPACED_REFUSED_AT_TYPE` does not move. The three page kinds are + * registered in the `ui` namespace by `@object-ui/components`. */ -const STILL_REFUSED_10859 = [ - 'app-schema-renderer', 'detail-section', 'embeddable-form', 'home', - 'object-pivot', 'record', 'spec-report', 'utility', -] as const; +const ARMED_11440 = { + home: ['ui:home'], + record: ['ui:record'], + utility: ['ui:utility'], + 'app-schema-renderer': ['layout:app-schema-renderer'], + 'object-pivot': ['plugin-dashboard:object-pivot'], + 'embeddable-form': ['plugin-form:embeddable-form'], + 'detail-section': ['plugin-detail:detail-section'], +} as const; + +/** + * Every bare key still refused at `type` after objectui#11440's first pull + * request — the `REFUSED_AT_TYPE` docblock says why it is still here. + */ +const STILL_REFUSED_10859 = ['spec-report'] as const; /** Is `type` unclaimed by every arm of the validator's root union? */ function refusedAtType(type: string): boolean { @@ -397,8 +416,8 @@ describe('registered component types refused at `type` — a ratchet (objectui#1 } // Lit controls: `grid`, the spelling the `responsive-grid` retirement // points authors to, is registered and claimed; `app-schema-renderer`, the - // whole-shell door navigation goes through, stays registered (and refused, - // until objectui#11440 arms it). + // whole-shell door navigation goes through, stays registered (armed since + // objectui#11440). expect(BARE_KEYS).toContain('grid'); expect(refusedAtType('grid')).toBe(false); expect(BARE_KEYS).toContain('app-schema-renderer'); @@ -422,6 +441,24 @@ describe('registered component types refused at `type` — a ratchet (objectui#1 expect(NAMESPACED_KEYS).toContain('ui:sidebar'); }); + it('counts the seven keys objectui#11440 armed, and none of their namespaced twins (objectui#10859)', () => { + expect(Object.keys(ARMED_11440)).toHaveLength(7); + for (const [key, twins] of Object.entries(ARMED_11440)) { + // Armed: still registered, and claimed by an arm. + expect(BARE_KEYS, key).toContain(key); + expect(refusedAtType(key), key).toBe(false); + // The twin is its own literal, so the bare arm does not claim it. + for (const twin of twins) { + expect(NAMESPACED_KEYS, twin).toContain(twin); + expect(refusedAtType(twin), twin).toBe(true); + } + } + // Lit control: the eighth key, `spec-report`, is still registered and + // still refused, until objectui#11440's second pull request retires it. + expect(BARE_KEYS).toContain('spec-report'); + expect(refusedAtType('spec-report')).toBe(true); + }); + it('the refused set is exactly the named remainder — the pin comment cannot drift (objectui#10859 batch 8)', () => { const refused = BARE_KEYS.filter(refusedAtType).sort(); expect(refused).toEqual([...STILL_REFUSED_10859]); diff --git a/packages/cli/src/__tests__/validate-passing-keys-11440.test.ts b/packages/cli/src/__tests__/validate-passing-keys-11440.test.ts new file mode 100644 index 0000000000..ddd7387db7 --- /dev/null +++ b/packages/cli/src/__tests__/validate-passing-keys-11440.test.ts @@ -0,0 +1,159 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `objectui validate`, end to end, on the documents objectui#11440 arms — the + * three the card names as refused at the public door: + * + * - a stored spec page whose `type` is its page kind (`home`), in the shape + * objectstack's example apps author with `definePage`; + * - the `app-schema-renderer` node the governed mobile guide teaches, its + * `mobileNavMode` key (`skills/objectui/guides/mobile.md`); + * - the plugin-detail README's `detail-view` document, whose first tab's + * content is a `detail-section` node. + * + * The README document is TSX, and its second tab hands `record:activity` a + * host feed (`items: activityData`), which objectui#11321 refuses by name in a + * JSON document — that refusal names this README as the TSX composition it + * stays legal in. So the row below validates the README's first tab as + * written, and the second row holds the host-feed refusal as the ONLY issue + * left on the full document. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +import { validate } from '../commands/validate.js'; + +/** Rooted on this file, never on `process.cwd()`. */ +const HERE = dirname(fileURLToPath(import.meta.url)); +const PLUGIN_DETAIL_README = join(HERE, '..', '..', '..', 'plugin-detail', 'README.md'); + +/** See `validate-root-path-line.test.ts` — the escape byte is never spelled. */ +const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); + +let dir: string; +let out: string[]; +let exitCodes: number[]; +let restore: () => void; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'objectui-validate-11440-')); + out = []; + exitCodes = []; + const originalLog = console.log; + const originalError = console.error; + const capture = (...args: unknown[]) => { + out.push(args.map(String).join(' ')); + }; + console.log = capture; + console.error = capture; + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + exitCodes.push(code ?? 0); + return undefined as never; + }) as never); + restore = () => { + console.log = originalLog; + console.error = originalError; + exitSpy.mockRestore(); + }; +}); + +afterEach(() => { + restore(); + rmSync(dir, { recursive: true, force: true }); +}); + +async function run(name: string, document: unknown): Promise { + const file = join(dir, `${name}.json`); + writeFileSync(file, JSON.stringify(document, null, 2), 'utf-8'); + await validate(file); + return out.join('\n').replace(ANSI, ''); +} + +const README_DETAIL_SECTION = { + type: 'detail-section', + fields: [ + { name: 'description', label: 'Description' }, + { name: 'employees', label: 'Employee Count' }, + ], +}; + +describe('objectui validate — the documents objectui#11440 arms', () => { + it('validates a stored spec page whose `type` is its kind (`home`)', async () => { + const text = await run('home-page', { + name: 'crm_welcome', + label: 'CRM Welcome', + type: 'home', + template: 'header-sidebar-main', + kind: 'full', + regions: [ + { name: 'header', width: 'full', components: [{ type: 'page:header', properties: { title: 'Welcome to the CRM' } }] }, + { name: 'main', width: 'large', components: [{ type: 'element:text', properties: { content: 'A sample page.' } }] }, + ], + }); + expect(text).not.toContain('Schema validation failed'); + expect(text).toContain('Schema is valid'); + expect(exitCodes).toEqual([0]); + }); + + it('validates the `app-schema-renderer` node the mobile guide teaches', async () => { + const text = await run('app-schema-renderer', { type: 'app-schema-renderer', mobileNavMode: 'bottom_nav' }); + expect(text).toContain('Schema is valid'); + expect(exitCodes).toEqual([0]); + }); + + it('refuses a mode the renderer does not implement, at `mobileNavMode` (control)', async () => { + const text = await run('app-schema-renderer-misspelt', { type: 'app-schema-renderer', mobileNavMode: 'bottom-nav' }); + expect(text).toContain('Schema validation failed'); + expect(text).toContain('mobileNavMode'); + expect(exitCodes).toEqual([1]); + }); + + it('validates the plugin-detail README\'s `detail-view` with its `detail-section` tab', async () => { + // Lit control on the source: the README still teaches this node, in this shape. + const readme = readFileSync(PLUGIN_DETAIL_README, 'utf8'); + expect(readme).toContain("type: 'detail-section'"); + expect(readme).toContain("{ name: 'employees', label: 'Employee Count' }"); + const text = await run('detail-view-section', { + type: 'detail-view', + title: 'Account: Acme Corp', + objectName: 'accounts', + resourceId: '12345', + fields: [{ name: 'name', label: 'Account Name' }, { name: 'industry', label: 'Industry' }], + tabs: [{ key: 'details', label: 'Details', content: README_DETAIL_SECTION }], + showEdit: true, + showDelete: true, + }); + expect(text).not.toContain('Schema validation failed'); + expect(text).toContain('Schema is valid'); + expect(exitCodes).toEqual([0]); + }); + + it('on the README\'s full document, the only issue left is the host feed on `record:activity` (objectui#11321)', async () => { + const text = await run('detail-view-full', { + type: 'detail-view', + title: 'Account: Acme Corp', + objectName: 'accounts', + resourceId: '12345', + tabs: [ + { key: 'details', label: 'Details', content: README_DETAIL_SECTION }, + { key: 'activity', label: 'Activity', badge: '12', content: { type: 'record:activity', items: [] } }, + ], + }); + expect(text).toContain('Schema validation failed'); + // The validator prints a path as `tabs → 1 → content`. + expect(text).toContain('Path: tabs → 1 → content → items'); + expect(text).toContain('HOST FEED SLOT'); + expect(text).not.toContain('tabs → 0'); + expect(exitCodes).toEqual([1]); + }); +}); diff --git a/packages/layout/src/__tests__/app-schema-renderer-arm-inputs-11440.test.tsx b/packages/layout/src/__tests__/app-schema-renderer-arm-inputs-11440.test.tsx new file mode 100644 index 0000000000..026ddd1492 --- /dev/null +++ b/packages/layout/src/__tests__/app-schema-renderer-arm-inputs-11440.test.tsx @@ -0,0 +1,67 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11440 — the `app-schema-renderer` registration and its zod arm. + * + * `@object-ui/types/zod`'s `AppSchemaRendererNodeSchema` declares two of the + * registration's three inputs, each as the registration types it: + * `mobileNavMode` (the same two-value enum) and `basePath` (a string). The + * third input, `schema`, is NOT declared, because no node delivers it: + * `SchemaRenderer` strips the `schema` key and hands the component the NODE as + * its `schema` prop. The last row measures that through the real + * `SchemaRenderer` and registry, so the arm's omission is a reading, not an + * assumption: navigation nested under `schema` draws nothing, and the same + * navigation on the node draws. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import React from 'react'; +import { render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRenderer } from '@object-ui/react'; +import { AppSchemaRendererNodeSchema } from '@object-ui/types/zod'; +import { registerLayout } from '../index'; + +beforeAll(() => { + registerLayout(); +}); + +type Input = { name: string; type: string; enum?: unknown[] }; + +const inputs = (): Input[] => + ((ComponentRegistry.getConfig('app-schema-renderer', 'layout') as { inputs?: Input[] } | undefined)?.inputs ?? []); + +describe('app-schema-renderer — registration inputs and the arm (objectui#11440)', () => { + it('`mobileNavMode`: the arm\'s enum is the registration\'s enum', () => { + const declared = inputs().find((input) => input.name === 'mobileNavMode'); + const arm = (AppSchemaRendererNodeSchema.shape.mobileNavMode as unknown as { unwrap: () => { options: string[] } }).unwrap(); + expect(declared?.type).toBe('enum'); + expect([...arm.options].sort()).toEqual([...(declared?.enum as string[])].sort()); + }); + + it('`basePath` is a string input on both', () => { + expect(inputs().find((input) => input.name === 'basePath')?.type).toBe('string'); + expect(AppSchemaRendererNodeSchema.shape.basePath.safeParse('/apps/crm').success).toBe(true); + expect(AppSchemaRendererNodeSchema.shape.basePath.safeParse(7).success).toBe(false); + }); + + it('`schema` is an input the arm does not declare — and a node never delivers it to the component', () => { + expect(inputs().map((input) => input.name)).toContain('schema'); + expect(Object.keys(AppSchemaRendererNodeSchema.shape)).not.toContain('schema'); + + const nested = { type: 'app-schema-renderer', schema: { name: 'crm', navigation: [{ id: 'n1', type: 'url', label: 'NestedNav11440', url: '/x' }] } }; + const onNode = { type: 'app-schema-renderer', name: 'crm', navigation: [{ id: 'n2', type: 'url', label: 'NodeNav11440', url: '/y' }] }; + const drawn = (node: unknown) => + render().container.innerHTML; + expect(drawn(nested)).not.toContain('NestedNav11440'); + // Lit control: the same navigation written on the node is drawn. + expect(drawn(onNode)).toContain('NodeNav11440'); + }); +}); diff --git a/packages/plugin-dashboard/src/ObjectPivotTable.tsx b/packages/plugin-dashboard/src/ObjectPivotTable.tsx index edebf499ea..13284e7bcc 100644 --- a/packages/plugin-dashboard/src/ObjectPivotTable.tsx +++ b/packages/plugin-dashboard/src/ObjectPivotTable.tsx @@ -69,9 +69,9 @@ export interface ObjectPivotTableProps { * `BaseSchema`, whose `[key: string]: any` would absorb a deleted member * silently at any value. Licensed by prong 1 of the discriminator * (objectui#5941, #7526, in its amended form, `5f8190c8c`): it names the live - * replacement, `objectName`. ⚠️ This is the ONLY refusal for this node: - * neither `object-pivot` nor `PivotTableSchema` has a zod mirror, so a - * JSON-authored value is refused by nothing at parse time. + * replacement, `objectName`. A JSON-authored value is refused by name at + * parse time too, since objectui#11440: the `object-pivot` arm of + * `@object-ui/types/zod` declares the same retirement in its props bag. * * @deprecated Not read by `ObjectPivotTable` — write `objectName`. */ diff --git a/packages/plugin-dashboard/src/__tests__/object-pivot-arm-inputs-11440.test.ts b/packages/plugin-dashboard/src/__tests__/object-pivot-arm-inputs-11440.test.ts new file mode 100644 index 0000000000..e41b95efa1 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/object-pivot-arm-inputs-11440.test.ts @@ -0,0 +1,66 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11440 — the `object-pivot` registration and its zod arm publish ONE + * set of props. + * + * `@object-ui/types/zod`'s `ObjectPivotBlockSchema` builds its `properties` bag + * from this registration's `inputs` (the spec has no `ComponentPropsMap` row + * for the block). This file holds the two lists equal in both directions, so an + * input added here without the arm, or a bag member added there without an + * input, goes red. The one bag member that is not an input is the retired + * `dataProvider` (objectui#7353), declared there only to refuse it by name; + * the one input that is not a bag member is `dataSource`, the binding the + * gate-wrapped registration publishes (objectui#6678), which the arm declares + * on the NODE, as the spec's page component does. + * + * `drillDown` became an input in the same change: `ObjectPivotTable` reads it, + * and the `pivot` node's refusal of `drillDown` names this block as where a + * pivot drill is authored (objectui#10932). + */ + +import { describe, it, expect } from 'vitest'; +import { ComponentRegistry } from '@object-ui/core'; +import { ObjectPivotBlockSchema } from '@object-ui/types/zod'; +// Module scope, not a hook: this import IS the registration (AGENTS.md +// test-discipline section). +import '../index'; + +const inputNames = (): string[] => + ((ComponentRegistry.getConfig('object-pivot', 'plugin-dashboard') as { inputs?: { name: string }[] } | undefined) + ?.inputs ?? []).map((input) => input.name); + +const bagKeys = (): string[] => + Object.keys((ObjectPivotBlockSchema.shape.properties as unknown as { shape: Record }).shape); + +describe('object-pivot — registration inputs and the arm\'s bag agree (objectui#11440)', () => { + it('the registration resolves, with inputs (non-vacuity)', () => { + expect(inputNames()).toContain('rowField'); + expect(inputNames().length).toBeGreaterThan(5); + }); + + it('every input but `dataSource` is a bag member, and every bag member but the retired `dataProvider` is an input', () => { + expect(bagKeys().filter((key) => key !== 'dataProvider').sort()) + .toEqual(inputNames().filter((name) => name !== 'dataSource').sort()); + }); + + it('`dataSource` is an input, and the arm declares it on the node beside the bag', () => { + expect(inputNames()).toContain('dataSource'); + expect(Object.keys(ObjectPivotBlockSchema.shape)).toContain('dataSource'); + expect(bagKeys()).not.toContain('dataSource'); + }); + + it('`drillDown` is published as an object input, and its description says `mode` does not apply', () => { + const drill = (ComponentRegistry.getConfig('object-pivot', 'plugin-dashboard') as { + inputs?: { name: string; type: string; description?: string }[]; + }).inputs?.find((input) => input.name === 'drillDown'); + expect(drill?.type).toBe('object'); + expect(drill?.description).toContain('`drillDown.mode` does not apply'); + }); +}); diff --git a/packages/plugin-dashboard/src/index.tsx b/packages/plugin-dashboard/src/index.tsx index 8a84c9f79e..ed88fea706 100644 --- a/packages/plugin-dashboard/src/index.tsx +++ b/packages/plugin-dashboard/src/index.tsx @@ -399,6 +399,10 @@ ComponentRegistry.register( { name: 'showColumnTotals', type: 'boolean' }, { name: 'filter', type: 'array' }, { name: 'format', type: 'string' }, + // objectui#11440: read by `ObjectPivotTable` (`isDrillEnabled`, + // `computeDrillFilter`, the `DrillDownDrawer` it opens) and declared by the + // block's zod arm in `@object-ui/types`, so it is published here too. + { name: 'drillDown', type: 'object', description: 'Click-through config that opens the records behind a clicked cell, header or total — in a drawer, a dialog, the object’s list page (`target: "navigate"`), or an analytical report (`report`). The drilled list is this block’s `filter` narrowed by the clicked value. `drillDown.mode` does not apply: every click point on a pivot is an aggregated bucket, so it always drills through; `mode` belongs on `object-data-table`.' }, ], defaultProps: { rowField: '', diff --git a/packages/plugin-detail/src/__tests__/detail-section-arm-inputs-11440.test.ts b/packages/plugin-detail/src/__tests__/detail-section-arm-inputs-11440.test.ts new file mode 100644 index 0000000000..db547fdb36 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/detail-section-arm-inputs-11440.test.ts @@ -0,0 +1,51 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11440 — the `detail-section` registration, the fold + * `DetailSectionNode` performs, and the node's zod arm name ONE set of keys. + * + * `DETAIL_SECTION_NODE_INPUTS` is already pinned equal to the registration's + * declared inputs (`detailSectionAuthoredNode-8626.test.tsx`). This file adds + * the third face: `@object-ui/types/zod`'s `DetailSectionNodeSchema` declares + * exactly those keys as the node's own members (beside `type`, the two + * content-channel refusals and the `BaseSchema` envelope), so an input added + * here without the arm goes red. + */ + +import { describe, it, expect } from 'vitest'; +import { ComponentRegistry } from '@object-ui/core'; +import { BaseSchema, DetailSectionNodeSchema } from '@object-ui/types/zod'; +import { DETAIL_SECTION_NODE_INPUTS } from '../DetailSectionNode'; +// Module scope, not a hook: this import IS the registration (AGENTS.md +// test-discipline section). +import '../index'; + +const inputNames = (): string[] => + ((ComponentRegistry.getConfig('detail-section', 'plugin-detail') as { inputs?: { name: string }[] } | undefined) + ?.inputs ?? []).map((input) => input.name); + +/** The arm's own members: everything but `type`, the channel refusals, and base keys it does not redeclare. */ +const armMembers = (): string[] => { + const base = new Set(Object.keys(BaseSchema.shape)); + return Object.keys(DetailSectionNodeSchema.shape).filter( + (key) => !['type', 'body', 'children'].includes(key) + && (!base.has(key) || DetailSectionNodeSchema.shape[key as keyof typeof DetailSectionNodeSchema.shape] !== BaseSchema.shape[key as keyof typeof BaseSchema.shape]), + ); +}; + +describe('detail-section — registration, fold and arm agree (objectui#11440)', () => { + it('the registration resolves, with inputs (non-vacuity)', () => { + expect(inputNames()).toContain('fields'); + }); + + it('the arm declares exactly the registration\'s inputs, which are exactly the folded names', () => { + expect(armMembers().sort()).toEqual([...inputNames()].sort()); + expect([...DETAIL_SECTION_NODE_INPUTS].sort()).toEqual([...inputNames()].sort()); + }); +}); diff --git a/packages/plugin-form/src/__tests__/embeddable-form-arm-inputs-11440.test.ts b/packages/plugin-form/src/__tests__/embeddable-form-arm-inputs-11440.test.ts new file mode 100644 index 0000000000..5cbfa2e17d --- /dev/null +++ b/packages/plugin-form/src/__tests__/embeddable-form-arm-inputs-11440.test.ts @@ -0,0 +1,58 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11440 — the `embeddable-form` registration and its zod arm publish + * ONE set of props. + * + * `@object-ui/types/zod`'s `EmbeddableFormBlockSchema` builds its `properties` + * bag from exactly this registration's `inputs` (the spec has no + * `ComponentPropsMap` row for the block), and requires what the registration + * requires: `formId` always, and `objectName` unless the node's + * `dataSource.object` names the object (the registration is gate-wrapped). + * This file holds the two lists equal in both directions. The one input that + * is not a bag member is `dataSource`, the binding the gate-wrapped + * registration publishes (objectui#6678), which the arm declares on the NODE. + */ + +import { describe, it, expect } from 'vitest'; +import { ComponentRegistry } from '@object-ui/core'; +import { EmbeddableFormBlockSchema } from '@object-ui/types/zod'; +// Module scope, not a hook: this import IS the registration (AGENTS.md +// test-discipline section). +import '../index'; + +type Input = { name: string; required?: boolean }; + +const inputs = (): Input[] => + ((ComponentRegistry.getConfig('embeddable-form', 'plugin-form') as { inputs?: Input[] } | undefined)?.inputs ?? []); + +const bag = (): Record boolean }> => + (EmbeddableFormBlockSchema.shape.properties as unknown as { shape: Record boolean }> }).shape; + +describe('embeddable-form — registration inputs and the arm\'s bag agree (objectui#11440)', () => { + it('the registration resolves, with inputs (non-vacuity)', () => { + expect(inputs().map((input) => input.name)).toContain('formId'); + }); + + it('the bag members are exactly the input names but `dataSource`, which the arm declares on the node', () => { + const names = inputs().map((input) => input.name); + expect(Object.keys(bag()).sort()).toEqual(names.filter((name) => name !== 'dataSource').sort()); + expect(names).toContain('dataSource'); + expect(Object.keys(EmbeddableFormBlockSchema.shape)).toContain('dataSource'); + }); + + it('`formId` is required on both; `objectName` is required by the registration and waived by the binding in the arm', () => { + const required = inputs().filter((input) => input.required).map((input) => input.name).sort(); + expect(required).toEqual(['formId', 'objectName']); + expect(bag().formId.isOptional()).toBe(false); + // The arm keeps `objectName` omissible as a member and enforces it with + // `requireRecordSource` unless `dataSource.object` is present. + expect(bag().objectName.isOptional()).toBe(true); + }); +}); diff --git a/packages/types/src/__tests__/app-mobile-nav-mode-refusal-11363.test.ts b/packages/types/src/__tests__/app-mobile-nav-mode-refusal-11363.test.ts index c7dd96d20f..a24660923b 100644 --- a/packages/types/src/__tests__/app-mobile-nav-mode-refusal-11363.test.ts +++ b/packages/types/src/__tests__/app-mobile-nav-mode-refusal-11363.test.ts @@ -26,9 +26,12 @@ * (c) the validator `objectui validate` runs (`safeValidateSchema`) and the * strict authoring face refuse it at the same path; * (d) the TypeScript face refuses it at the authoring site; - * (e) the refusal does not reach an `app-schema-renderer` node: the key adds - * no issue there, on either face, so the node channel is not judged by - * this schema; + * (e) the refusal does not reach an `app-schema-renderer` node: a legal + * value adds no issue there, on either face, so the node channel is not + * judged by this schema. Since objectui#11440 the node has an arm of its + * own, which declares the key as the registration's two-value enum, so a + * misspelt value is refused there by THAT enum (`invalid_value`), never by + * this document's tombstone (`invalid_type`); * (f) CONTROLS: the same document without the key parses on every face, and * the spec refuses the key, so (a) to (c) are judged by schemas that can * fail, and they match a refusal the platform already gives. @@ -123,15 +126,24 @@ describe('objectui#11363 — the refusal does not reach an `app-schema-renderer` navigation: [{ id: 'accounts', type: 'url', label: 'Accounts', url: '/accounts' }], }; const verdict = (issues: Issue[] | null) => issues?.map((i) => [i.code, i.path]) ?? null; + const LEGAL = VALUES.filter((value) => value !== 'bottom-nav'); - it.each(VALUES)('the key (%j) adds no issue to the node on the validator face', (value) => { + it.each(LEGAL)('the key (%j) adds no issue to the node on the validator face', (value) => { expect(verdict(validatorIssues({ ...NODE, mobileNavMode: value }))).toEqual(verdict(validatorIssues(NODE))); }); - it.each(VALUES)('the key (%j) adds no issue to the node on the strict face', (value) => { + it.each(LEGAL)('the key (%j) adds no issue to the node on the strict face', (value) => { expect(verdict(strictIssues({ ...NODE, mobileNavMode: value }))).toEqual(verdict(strictIssues(NODE))); }); + it('a misspelt value is refused by the node\'s own enum (objectui#11440), never by the app document\'s tombstone', () => { + for (const issues of [validatorIssues({ ...NODE, mobileNavMode: 'bottom-nav' }), strictIssues({ ...NODE, mobileNavMode: 'bottom-nav' })]) { + const atKey = (issues ?? []).filter((i) => i.path === 'mobileNavMode'); + expect(atKey.map((i) => i.code)).toEqual(['invalid_value']); + expect(atKey[0].message).not.toContain('not a key of the app document'); + } + }); + it('no issue on the node names `mobileNavMode`', () => { const all = [...(validatorIssues({ ...NODE, mobileNavMode: 'bottom_nav' }) ?? []), ...(strictIssues({ ...NODE, mobileNavMode: 'bottom_nav' }) ?? [])]; expect(all.filter((i) => i.path.includes('mobileNavMode') || i.message.includes('mobileNavMode'))).toEqual([]); diff --git a/packages/types/src/__tests__/flat-props-refusal-10872.test.ts b/packages/types/src/__tests__/flat-props-refusal-10872.test.ts index a75d642a1a..5712cfdd5d 100644 --- a/packages/types/src/__tests__/flat-props-refusal-10872.test.ts +++ b/packages/types/src/__tests__/flat-props-refusal-10872.test.ts @@ -103,8 +103,15 @@ const ARMS: ReadonlyMap = new Map( */ const EARLIER = new Set(['object-form', 'object-map', 'object-gantt', 'object-chart']); -/** The row of an arm: its `properties` member with `.optional()` peeled off. */ -const rowOf = (arm: z.ZodObject): z.ZodObject => (arm.shape.properties as z.ZodOptional).unwrap() as z.ZodObject; +/** + * The row of an arm: its `properties` member with `.optional()` peeled off. Two + * arms (objectui#11440's `object-pivot` and `embeddable-form`) require their + * bag, so their member is the bag itself. + */ +const rowOf = (arm: z.ZodObject): z.ZodObject => { + const member = arm.shape.properties as z.ZodOptional | z.ZodObject; + return (member._zod.def.type === 'optional' ? (member as z.ZodOptional).unwrap() : member) as z.ZodObject; +}; /** Does this member refuse every value — the shape of a `z.never` retirement? */ const isNeverMember = (member: unknown): boolean => { @@ -164,9 +171,17 @@ const VALID_BAG: Readonly>> = { // spreads this helper over its row, so it is covered here like the three // ObjectQL blocks above; `data` is the base-declared row key in play. 'object-grid': { objectName: 'task', title: 'Tasks', columns: ['subject'], data: { provider: 'object', object: 'task' } }, + // objectui#11440: the two Tier A blocks armed with a bag of their + // registration inputs spread this helper over that bag; `title` and + // `description` are the keys a flat node shares with the node base. + 'object-pivot': { objectName: 'deal', title: 'Pipeline', rowField: 'stage', columnField: 'owner', valueField: 'amount' }, + 'embeddable-form': { formId: 'contact-us', objectName: 'lead', title: 'Contact us', description: 'We reply within a day' }, }; const COVERED = Object.keys(VALID_BAG); +/** The arms whose bag is REQUIRED — its registration-required members live in it (objectui#11440). */ +const REQUIRES_BAG: ReadonlySet = new Set(['object-pivot', 'embeddable-form']); + /** The node with its bag hoisted flat — every bag key written on the node itself. */ const flattened = (type: string): Record => ({ type, ...VALID_BAG[type] }); @@ -227,7 +242,11 @@ describe('objectui#10872 batch 10 — every arm: the flat node is refused, the b continue; } expect(result.success, face).toBe(false); - const named = namedAtNode(result); + // A bag-requiring arm (objectui#11440) also reports the bag itself missing, + // at `properties`; that is not a flat refusal, so it is held apart here. + const missingBag = namedAtNode(result).filter((issue) => issue.path[0] === 'properties'); + expect(missingBag.length, face).toBe(REQUIRES_BAG.has(type) ? 1 : 0); + const named = namedAtNode(result).filter((issue) => issue.path[0] !== 'properties'); expect(named.map((issue) => String(issue.path[0])).sort(), face).toEqual(owed); for (const issue of named) expect(issue.message, face).toContain(`\`properties.${String(issue.path[0])}\``); // Nothing is left to the strict face's unnamed `unrecognized_keys`. diff --git a/packages/types/src/__tests__/passing-keys-arms-11440.test.ts b/packages/types/src/__tests__/passing-keys-arms-11440.test.ts new file mode 100644 index 0000000000..9d47223118 --- /dev/null +++ b/packages/types/src/__tests__/passing-keys-arms-11440.test.ts @@ -0,0 +1,327 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Seven registered node types get an arm in `AnyComponentSchema` + * (objectui#11440, the follow-up the seat ruling `5945530142` on objectui#10859 + * filed, with its amendment `5945583855`). + * + * Each of `home`, `record`, `utility`, `app-schema-renderer`, `object-pivot`, + * `embeddable-form` and `detail-section` was registered and refused by + * `safeValidateSchema` (what `objectui validate` runs) with one + * `invalid_union` at `type`. The bare-key count is ratcheted in + * `packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts`; + * this file pins what each arm accepts and refuses, on BOTH faces — the + * tolerant validator and the strict authoring face — with a lit control per + * refusal, so no row passes for the wrong reason. + * + * Each registration's own inputs are pinned against its arm in the package + * that registers it (`@object-ui/plugin-dashboard`, `@object-ui/plugin-form`, + * `@object-ui/plugin-detail`, `@object-ui/layout`): this package imports none + * of them. + */ + +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; +import { ComponentPropsMap, PageSchema as SpecPageSchema, PageTypeSchema as SpecPageTypeSchema } from '@objectstack/spec/ui'; +import { + AppSchemaRendererNodeSchema, + DetailSectionNodeSchema, + DetailViewSectionSchema, + EmbeddableFormBlockSchema, + ObjectPivotBlockSchema, + PageKindNodeSchema, + PageNodeSchema, + StrictAnyComponentSchema, + safeValidateSchema, +} from '../zod/index.zod'; + +type Issue = { code: string; path: PropertyKey[]; message: string; keys?: string[]; errors?: Issue[][] }; +type Result = { success: boolean; error?: { issues: unknown[] } }; + +const FACES: ReadonlyArray Result]> = [ + ['tolerant', (document) => safeValidateSchema(document)], + ['strict', (document) => StrictAnyComponentSchema.safeParse(document)], +]; + +/** Every issue, union branches unfolded and paths made absolute. */ +const allIssues = (issues: Issue[] | undefined, prefix: PropertyKey[] = []): Issue[] => + (issues ?? []).flatMap((issue) => { + const path = [...prefix, ...issue.path]; + return [{ ...issue, path }, ...(issue.errors ?? []).flatMap((branch) => allIssues(branch, path))]; + }); +const issuesOf = (result: Result): Issue[] => allIssues(result.error?.issues as Issue[] | undefined); +const at = (result: Result, path: string): Issue[] => issuesOf(result).filter((issue) => issue.path.join('.') === path); +/** Is the document's `type` unclaimed by every arm — the ratchet's reading? */ +const refusedAtType = (result: Result): boolean => + issuesOf(result).some((issue) => issue.code === 'invalid_union' && issue.path.join('.') === 'type'); + +/* ── The page kinds ──────────────────────────────────────────────────────── */ + +/** A stored spec page, in the shape objectstack's example apps author (`definePage`). */ +const specPage = (kind: string) => ({ + name: `${kind}_page`, + label: 'Welcome', + type: kind, + template: 'header-sidebar-main', + kind: 'full', + regions: [ + { name: 'header', width: 'full', components: [{ type: 'page:header', properties: { title: 'Welcome' } }] }, + { name: 'main', width: 'large', components: [{ type: 'element:text', properties: { content: 'Hello' } }] }, + ], +}); + +const KINDS = ['record', 'home', 'utility'] as const; + +describe('the page kinds `record` / `home` / `utility` (objectui#11440)', () => { + it('the arm claims exactly these three, each a kind the spec\'s own `PageTypeSchema` declares', () => { + const literals = (PageKindNodeSchema.shape.type as unknown as z.ZodEnum).options; + expect([...literals].sort()).toEqual([...KINDS].sort()); + for (const kind of literals) expect(SpecPageTypeSchema.safeParse(kind).success, String(kind)).toBe(true); + // The two kinds left out are claimed by other arms, not by this one. + for (const kind of ['app', 'list']) { + expect(SpecPageTypeSchema.safeParse(kind).success, kind).toBe(true); + expect(literals, kind).not.toContain(kind); + } + }); + + it.each(KINDS)('`%s` — the spec\'s own page document validates on both faces', (kind) => { + const page = specPage(kind); + // Lit control: it IS a spec page — the spec's strict `PageSchema` accepts it. + expect(SpecPageSchema.safeParse(page).success).toBe(true); + for (const [face, judge] of FACES) { + const result = judge(page); + expect(result.success, `${face}: ${JSON.stringify(result.error?.issues)}`).toBe(true); + } + }); + + it('every member but `type` is the `page` node\'s own, by reference', () => { + const { type: _kindType, ...kindMembers } = PageKindNodeSchema.shape; + const { type: _pageType, ...pageMembers } = PageNodeSchema.shape; + expect(Object.keys(kindMembers).sort()).toEqual(Object.keys(pageMembers).sort()); + for (const key of Object.keys(pageMembers)) { + expect((kindMembers as Record)[key], key).toBe((pageMembers as Record)[key]); + } + }); + + it.each(FACES)('%s face: a region component is judged by the node union, at its own path', (face, judge) => { + const bad = { ...specPage('home'), regions: [{ name: 'main', components: [{ type: 'no-such-block-11440' }] }] }; + const result = judge(bad); + expect(result.success, face).toBe(false); + expect(at(result, 'regions.0.components.0.type').map((issue) => issue.code), face).toEqual(['invalid_union']); + }); + + it.each(FACES)('%s face: the `page` node\'s refusals carry over (`actions`, by name)', (face, judge) => { + const result = judge({ ...specPage('record'), actions: [{ type: 'button', label: 'Go' }] }); + expect(at(result, 'actions').map((issue) => issue.code), face).toEqual(['invalid_type']); + // Lit control: the same document without the key validates. + expect(judge(specPage('record')).success, face).toBe(true); + }); + + it.each(FACES)('%s face: a spelling outside the three is still refused at `type` (lit control)', (face, judge) => { + expect(refusedAtType(judge({ ...specPage('home'), type: 'homepage' })), face).toBe(true); + }); +}); + +/* ── app-schema-renderer ─────────────────────────────────────────────────── */ + +describe('`app-schema-renderer` (objectui#11440)', () => { + it.each(FACES)('%s face: the node the governed mobile guide teaches validates — its `mobileNavMode` key', (face, judge) => { + for (const mode of ['bottom_nav', 'drawer']) { + const result = judge({ type: 'app-schema-renderer', mobileNavMode: mode, basePath: '/apps/crm' }); + expect(result.success, `${face} ${mode}: ${JSON.stringify(result.error?.issues)}`).toBe(true); + } + }); + + it.each(FACES)('%s face: a mode the renderer does not implement is refused at `mobileNavMode`', (face, judge) => { + expect(at(judge({ type: 'app-schema-renderer', mobileNavMode: 'bottom-nav' }), 'mobileNavMode').map((i) => i.code), face) + .toEqual(['invalid_value']); + expect(at(judge({ type: 'app-schema-renderer', basePath: 7 }), 'basePath').map((i) => i.code), face) + .toEqual(['invalid_type']); + }); + + it.each(FACES)('%s face: both content channels are refused by name — a node draws neither', (face, judge) => { + for (const key of ['children', 'body']) { + const issues = at(judge({ type: 'app-schema-renderer', [key]: [{ type: 'text', content: 'x' }] }), key); + expect(issues.map((i) => i.code), `${face} ${key}`).toEqual(['invalid_type']); + expect(issues[0].message).toContain('reads NEITHER content channel'); + } + }); + + it('the registration\'s `schema` input is not declared: no node delivers it (measured on objectui#11440)', () => { + expect(Object.keys(AppSchemaRendererNodeSchema.shape)).not.toContain('schema'); + const node = { type: 'app-schema-renderer', schema: { name: 'crm' } }; + // Tolerant face: unjudged, as every undeclared key. Strict face: refused as an unrecognized key. + expect(safeValidateSchema(node).success).toBe(true); + expect(issuesOf(StrictAnyComponentSchema.safeParse(node)).flatMap((i) => i.keys ?? [])).toEqual(['schema']); + }); +}); + +/* ── object-pivot and embeddable-form ────────────────────────────────────── */ + +const PIVOT_BAG = { objectName: 'deal', rowField: 'stage', columnField: 'owner', valueField: 'amount', aggregation: 'sum' }; +const FORM_BAG = { formId: 'contact-us', objectName: 'lead', fields: ['name', 'email'], allowMultiple: false }; + +describe('`object-pivot` and `embeddable-form` — the bag is the contract (objectui#11440)', () => { + const BLOCKS = [ + ['object-pivot', PIVOT_BAG, ObjectPivotBlockSchema], + ['embeddable-form', FORM_BAG, EmbeddableFormBlockSchema], + ] as const; + + it.each(BLOCKS)('%s — neither row exists in the spec, so the bag is objectui\'s own', (type) => { + expect(Object.keys(ComponentPropsMap)).not.toContain(type); + }); + + it.each(BLOCKS)('%s — the bag node validates on both faces, and with the node\'s `dataSource` binding in place of `objectName`', (type, bag) => { + const { objectName: _object, ...unbound } = bag; + for (const [face, judge] of FACES) { + expect(judge({ type, properties: bag }).success, face).toBe(true); + expect(judge({ type, properties: unbound, dataSource: { object: 'deal' } }).success, face).toBe(true); + } + }); + + it.each(BLOCKS)('%s — each prop written flat is refused by name, toward `properties.KEY`', (type, bag) => { + for (const [face, judge] of FACES) { + const result = judge({ type, ...bag }); + for (const key of Object.keys(bag)) { + const issues = at(result, key); + expect(issues.map((i) => i.code), `${face} ${key}`).toEqual(['invalid_type']); + expect(issues[0].message, `${face} ${key}`).toContain(`\`properties.${key}\``); + } + } + }); + + it.each(BLOCKS)('%s — no record source is refused, keyed `RECORD_SOURCE_REQUIRED`', (type, bag) => { + const { objectName: _object, ...unbound } = bag; + for (const [face, judge] of FACES) { + const issues = at(judge({ type, properties: unbound }), 'properties.objectName'); + expect(issues.map((i) => i.code), face).toEqual(['custom']); + expect((issues[0] as unknown as { params?: { code?: string } }).params?.code, face).toBe('RECORD_SOURCE_REQUIRED'); + } + }); + + it.each(BLOCKS)('%s — an undeclared bag key: unjudged by the tolerant face, refused as unrecognized by the strict one', (type, bag) => { + const node = { type, properties: { ...bag, inventedKey11440: true } }; + expect(safeValidateSchema(node).success).toBe(true); + expect(issuesOf(StrictAnyComponentSchema.safeParse(node)).flatMap((i) => i.keys ?? [])).toEqual(['inventedKey11440']); + }); + + it.each(BLOCKS)('%s — both content channels are refused by name', (type, bag) => { + for (const [face, judge] of FACES) { + for (const key of ['children', 'body']) { + const issues = at(judge({ type, properties: bag, [key]: [] }), key); + expect(issues.map((i) => i.code), `${face} ${key}`).toEqual(['invalid_type']); + } + } + }); +}); + +describe('`object-pivot` — its required members and its drill shape (objectui#11440)', () => { + it.each(FACES)('%s face: a missing cross-tab field is refused at its bag path', (face, judge) => { + const { rowField: _row, ...noRow } = PIVOT_BAG; + expect(at(judge({ type: 'object-pivot', properties: noRow }), 'properties.rowField').map((i) => i.code), face) + .toEqual(['invalid_type']); + expect(at(judge({ type: 'object-pivot', properties: { ...PIVOT_BAG, aggregation: 'median' } }), 'properties.aggregation') + .map((i) => i.code), face).toEqual(['invalid_value']); + }); + + it.each(FACES)('%s face: `drillDown` is accepted, and its `mode` refused by name', (face, judge) => { + const drill = { enabled: true, target: 'drawer', columns: ['name'], maxRows: 50 }; + expect(judge({ type: 'object-pivot', properties: { ...PIVOT_BAG, drillDown: drill } }).success, face).toBe(true); + const refused = at(judge({ type: 'object-pivot', properties: { ...PIVOT_BAG, drillDown: { ...drill, mode: 'record' } } }), 'properties.drillDown.mode'); + expect(refused.map((i) => i.code), face).toEqual(['invalid_type']); + expect(refused[0].message).toContain('object-data-table'); + }); + + it.each(FACES)('%s face: the retired `dataProvider` is refused by name, in the bag and written flat', (face, judge) => { + const provider = { provider: 'object', object: 'deal' }; + expect(at(judge({ type: 'object-pivot', properties: { ...PIVOT_BAG, dataProvider: provider } }), 'properties.dataProvider') + .map((i) => i.code), face).toEqual(['invalid_type']); + expect(at(judge({ type: 'object-pivot', properties: PIVOT_BAG, dataProvider: provider }), 'dataProvider') + .map((i) => i.code), face).toEqual(['invalid_type']); + }); +}); + +describe('`embeddable-form` — its required member (objectui#11440)', () => { + it.each(FACES)('%s face: a missing `formId` is refused at its bag path; `fields` takes field names', (face, judge) => { + const { formId: _form, ...noForm } = FORM_BAG; + expect(at(judge({ type: 'embeddable-form', properties: noForm }), 'properties.formId').map((i) => i.code), face) + .toEqual(['invalid_type']); + expect(at(judge({ type: 'embeddable-form', properties: { ...FORM_BAG, fields: [{ field: 'name' }] } }), 'properties.fields.0') + .map((i) => i.code), face).toEqual(['invalid_type']); + }); +}); + +/* ── detail-section ──────────────────────────────────────────────────────── */ + +/** The `detail-section` node the plugin-detail README teaches inside a `detail-view` tab. */ +const README_SECTION = { + type: 'detail-section', + fields: [ + { name: 'description', label: 'Description' }, + { name: 'employees', label: 'Employee Count' }, + ], +}; + +describe('`detail-section` (objectui#11440)', () => { + it.each(FACES)('%s face: the README\'s node validates, alone and inside a `detail-view` tab', (face, judge) => { + expect(judge(README_SECTION).success, face).toBe(true); + const view = { + type: 'detail-view', + title: 'Account: Acme Corp', + objectName: 'accounts', + resourceId: '12345', + tabs: [{ key: 'details', label: 'Details', content: README_SECTION }], + }; + const result = judge(view); + expect(result.success, `${face}: ${JSON.stringify(result.error?.issues)}`).toBe(true); + }); + + it('its members are exactly the ten section members the registration publishes, by reference', () => { + const { type: _type, body: _body, children: _children, ...members } = DetailSectionNodeSchema.shape; + const published = [ + 'title', 'description', 'icon', 'fields', 'collapsible', + 'defaultCollapsed', 'columns', 'showBorder', 'headerColor', 'hideEmpty', + ]; + const own = Object.keys(members).filter((key) => published.includes(key)); + expect(own.sort()).toEqual([...published].sort()); + for (const key of published) { + expect((members as Record)[key], key).toBe((DetailViewSectionSchema.shape as Record)[key]); + } + }); + + it.each(FACES)('%s face: `fields` is required, and `headerColor` keeps its six tokens', (face, judge) => { + expect(at(judge({ type: 'detail-section' }), 'fields').map((i) => i.code), face).toEqual(['invalid_type']); + expect(at(judge({ ...README_SECTION, headerColor: 'bg-red-500' }), 'headerColor').map((i) => i.code), face) + .toEqual(['invalid_value']); + expect(judge({ ...README_SECTION, headerColor: 'muted', columns: 2, collapsible: true }).success, face).toBe(true); + }); + + it('it is not `record:details`\'s section shape — the spec row refuses the README\'s section (why it is not folded)', () => { + const row = ComponentPropsMap['record:details'] as unknown as z.ZodType; + const result = row.safeParse({ sections: [{ title: 'Details', fields: README_SECTION.fields }] }) as Result; + expect(result.success).toBe(false); + const codes = issuesOf(result).map((issue) => `${issue.code}@${issue.path.join('.')}`); + expect(codes).toContain('unrecognized_keys@sections.0'); + expect(codes).toContain('invalid_type@sections.0.fields.0'); + // Lit control: the spec's own spelling of a section parses. + expect(row.safeParse({ sections: [{ label: 'Details', fields: ['description'] }] }).success).toBe(true); + }); +}); + +/* ── The ratchet's reading, per key ──────────────────────────────────────── */ + +describe('none of the seven is refused at `type` any more (objectui#11440)', () => { + it.each([...KINDS, 'app-schema-renderer', 'object-pivot', 'embeddable-form', 'detail-section'])('%s', (type) => { + for (const [face, judge] of FACES) expect(refusedAtType(judge({ type })), face).toBe(false); + }); + + it('lit control: `spec-report`, the eighth key, is still refused there', () => { + for (const [face, judge] of FACES) expect(refusedAtType(judge({ type: 'spec-report' })), face).toBe(true); + }); +}); diff --git a/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts b/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts index ff8822232d..926ca64bac 100644 --- a/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts +++ b/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts @@ -122,6 +122,10 @@ const DECLARES_DATA_SOURCE: ReadonlySet = new Set([ // `ObjectQLComponentSchema`, carrying the binding its flat mirror has // declared since objectui#11070. 'object-grid', + // objectui#11440: two gate-wrapped Tier A blocks armed with the binding at + // node level, beside their bags. + 'object-pivot', + 'embeddable-form', ]); /** @@ -131,15 +135,19 @@ const DECLARES_DATA_SOURCE: ReadonlySet = new Set([ * and 6, `requireRecordSource`), and `object-chart` keeps the flat mirror's * chart-family floor, read in its bag (objectui#11276, * `requireObjectChartFamilyInBag`). `object-grid` keeps its flat mirror's - * record-source rule the same way (objectui#11276's `object-grid` batch). Each - * node carries the smallest bag that satisfies its rule; every other arm takes - * the bare node. + * record-source rule the same way (objectui#11276's `object-grid` batch). + * `object-pivot` and `embeddable-form` (objectui#11440) REQUIRE their bag: its + * registration-required members (the three cross-tab fields; `formId`) and a + * record source. Each node carries the smallest bag that satisfies its rule; + * every other arm takes the bare node. */ const RECORD_SOURCE: Readonly>> = { 'object-map': { properties: { objectName: 'store' } }, 'object-gantt': { properties: { objectName: 'task' } }, 'object-chart': { properties: { chartType: 'bar' } }, 'object-grid': { properties: { objectName: 'task' } }, + 'object-pivot': { properties: { objectName: 'deal', rowField: 'stage', columnField: 'owner', valueField: 'amount' } }, + 'embeddable-form': { properties: { formId: 'contact-us', objectName: 'lead' } }, }; /** The five envelope keys the spec declares and this batch leaves undeclared. */ diff --git a/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts b/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts index 9957157ac6..c918aceb9f 100644 --- a/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts +++ b/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts @@ -355,7 +355,8 @@ describe('the spec-row arms read the row by reference (objectui#10859)', () => { // `object-map` in batch 5 (`object-map-properties-bag-10859-b5.test.ts`), // `object-chart` in objectui#11276 (`object-chart-properties-bag-11276.test.ts`), // `object-gantt` in batch 6 (`object-gantt-properties-bag-10859-b6.test.ts`), - // `object-grid` in objectui#11276 (`object-grid-properties-bag-11276.test.ts`). + // `object-grid` in objectui#11276 (`object-grid-properties-bag-11276.test.ts`), + // `object-pivot` and `embeddable-form` in objectui#11440 (`passing-keys-arms-11440.test.ts`). expect(literals(ObjectQLPublicBlockComponentSchema)).toEqual([ 'object-metric', 'object-master-detail-form', @@ -365,6 +366,8 @@ describe('the spec-row arms read the row by reference (objectui#10859)', () => { 'object-chart', 'object-gantt', 'object-grid', + 'object-pivot', + 'embeddable-form', ]); expect(literals(DataDisplaySchema)).toContain('pivot'); }); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index fdf9620030..e01d53344d 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4712,6 +4712,10 @@ const EXCLUSIONS: Readonly> = { "recursive; declared `z.ZodType`, which exposes no `.shape` to read — and accepts `any`, so it cannot be narrower than any declaration. One of the three consts that REFUSED the objectui#7760 type argument (the mirror accepts more than the declaration states), so this reason is still literally true here", 'app.zod.ts#MenuItemSchema': "recursive; a `z.lazy` exposes no `.shape` to read, so there is no key set for the per-key comparison. Since objectui#7760 it carries its TS declaration as BOTH type arguments, so the pair IS compared — as a whole type, by `tsc`, at the annotation itself", + // objectui#11440 — the `app-schema-renderer` whole-shell node, declared from + // its registration `inputs`. Pinned by `./passing-keys-arms-11440.test.ts`. + 'app.zod.ts#AppSchemaRendererNodeSchema': + "registration-owned — `BaseSchema` + the `app-schema-renderer` literal + two of the registration's three `inputs` (`basePath`, `mobileNavMode`; the third, `schema`, is stripped by `SchemaRenderer` and never reaches the component) + the objectui#9256 `body` / `children` refusals; no TS declaration in this package restates the node (`@object-ui/layout`'s `AppSchemaRendererProps` is the component's prop type)", 'app.zod.ts#AppContextSelectorSchema': "spec-owned BY REFERENCE — the local `.extend(…)` adds renderer props that no TS declaration in this package restates", 'base.zod.ts#SchemaNodeSchema': @@ -4765,6 +4769,11 @@ const EXCLUSIONS: Readonly> = { "spec-owned BY REFERENCE — the local `.extend(…)` adds renderer props that no TS declaration in this package restates", 'layout.zod.ts#LayoutSchema': "a union OVER the mirrors, not an object of its own — its members are checked individually above", + // objectui#11440 — the spec page kinds `record` / `home` / `utility` as node + // types, served by the `page` node's renderer and `pageMeta`. Pinned by + // `./passing-keys-arms-11440.test.ts`. + 'layout.zod.ts#PageKindNodeSchema': + "mirror-owned BY REFERENCE — every member is `layout.zod.ts#PageNodeSchema`'s own (the same schema objects, the spec's `PageSchema` fields among them through `SpecPageFields`), under the spec's own `PageTypeSchema` narrowed to the three kinds `@object-ui/components` registers on `PageRenderer`; no TS declaration in this package restates the node (the `page` node's TS twin is `type: 'page'`, paired with `PageNodeSchema`)", // objectui#11276 (the `flex` batch, the maintainer's ruling A on // objectui#11300) — the authored `flex` arm, built as the `object-chart` arm // with NO spec row: its bag is the flat mirror's own members. Its TS twin is @@ -4833,6 +4842,13 @@ const EXCLUSIONS: Readonly> = { // reading and still builds the `object-view` `table` slot. 'objectql.zod.ts#ObjectGridBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `object-grid` literal + `NODE_ENVELOPE` + `properties`, which IS `ComponentPropsMap['object-grid']`, + the node's `dataSource` binding, which IS the spec's `ElementDataSourceSchema`, + `flatPropRefusals` over the row (one `aliasKeyRefusal` per member but the node-level `label`, pointed at `properties.KEY`, and the row's own `defaultSort` retirement; the key set is read off the row), + the flat mirror's own objectui#9739 / objectui#11068 tombstones and objectui#9256 `body` / `children` refusals, by reference, + its objectui#6124 `onNavigate` runtime slot, declared as the mirror declares it; the TS declaration of the node, `ObjectGridSchema` (`../objectql.ts`), is the renderer's post-hoist reading and is paired with the flat `objectql.zod.ts#ObjectGridSchema` mirror, not with this arm", + // objectui#11440 — two Tier A public blocks with NO spec row and no flat + // mirror: each bag is built from the block's registration `inputs`. Pinned by + // `./passing-keys-arms-11440.test.ts`. + 'objectql.zod.ts#ObjectPivotBlockSchema': + "registration-owned — `BaseSchema` + the `object-pivot` literal + `NODE_ENVELOPE` + a required `properties` bag of the registration's `inputs` (its cross-tab members ARE the `data-display.zod.ts#PivotTableSchema` mirror's own, by reference; `drillDown` is `DrillDownConfigSchema` with `mode` refused) + `flatPropRefusals` over that bag + the node's `dataSource` binding, which IS the spec's `ElementDataSourceSchema`, + the objectui#9256 `body` / `children` refusals; no TS declaration in this package restates the node (`@object-ui/plugin-dashboard`'s `ObjectPivotTableProps` is the component's prop type)", + 'objectql.zod.ts#EmbeddableFormBlockSchema': + "registration-owned — `BaseSchema` + the `embeddable-form` literal + `NODE_ENVELOPE` + a required `properties` bag of exactly the registration's `inputs` + `flatPropRefusals` over that bag + the node's `dataSource` binding, which IS the spec's `ElementDataSourceSchema`, + the objectui#9256 `body` / `children` refusals; no TS declaration in this package restates the node (`@object-ui/plugin-form`'s `EmbeddableFormConfig` is the component's config type)", 'objectql.zod.ts#ObjectQLPublicBlockComponentSchema': "a union OVER the public-block arms above, not an object of its own — its members are checked individually", 'overlay.zod.ts#MenuItemSchema': @@ -4853,6 +4869,10 @@ const EXCLUSIONS: Readonly> = { "a bare vocabulary with no `.shape`; it is checked where a mirrored KEY declares it", 'views.zod.ts#ViewComponentSchema': "a union OVER the mirrors, not an object of its own — its members are checked individually above", + // objectui#11440 — one field section as a node. Pinned by + // `./passing-keys-arms-11440.test.ts`. + 'views.zod.ts#DetailSectionNodeSchema': + "mirror-owned BY REFERENCE — `BaseSchema` + the `detail-section` literal + the ten members of `views.zod.ts#DetailViewSectionSchema` the registration publishes as `inputs` (the same schema objects, `.pick`ed), flat on the node, + the objectui#9256 `body` / `children` refusals; no TS declaration in this package restates the node (`@object-ui/plugin-detail`'s `DetailSectionNodeProps` is the component's prop type)", 'index.zod.ts#AnyComponentSchema': "the barrel union OVER the mirrors, not an object of its own — its members are checked individually above", 'expression.zod.ts#ExpressionWireSchema': diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index cf29e55813..ae11a11240 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -2629,10 +2629,11 @@ export interface ObjectMetricDrillDownConfig extends DrillDownConfig { * an AI) sees. * * ⚠️ This is a TypeScript declaration, so it refuses the key where an author - * types against it (`ObjectPivotTable`'s `schema.drillDown`). `object-pivot` - * has no zod mirror, so a stored JSON config is checked by no validator and - * reaches the block unchanged. (`PivotTableSchema`, the plain `pivot` node, - * gained one in objectui#10859 batch 2, and there `drillDown` is refused whole: + * types against it (`ObjectPivotTable`'s `schema.drillDown`). A stored JSON + * config is judged by the `object-pivot` arm of `@object-ui/types/zod` since + * objectui#11440, which refuses `mode` by name in the bag's `drillDown`. + * (`PivotTableSchema`, the plain `pivot` node, gained its own zod mirror in + * objectui#10859 batch 2, and there `drillDown` is refused whole: * the key is a retirement tombstone on both faces since objectui#10932, because * nothing drills a `pivot` node. This type is where a pivot drill is authored.) */ diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md index f31b19d3c7..c3029291c0 100644 --- a/packages/types/src/zod/README.md +++ b/packages/types/src/zod/README.md @@ -192,7 +192,9 @@ function validateComponent(config: unknown) { - `ContainerSchema`, `FlexSchema`, `FlexBlockSchema`, `StackSchema` - `GridSchema`, `CardSchema`, `TabsSchema` - `ScrollAreaSchema`, `ResizableSchema` -- `AspectRatioSchema`, `PageSchema` +- `AspectRatioSchema`, `PageSchema`, `PageKindNodeSchema` + +`PageKindNodeSchema` (objectui#11440) is a stored `@objectstack/spec` page whose `type` is its page kind, `record`, `home` or `utility`: the spelling the spec's `PageSchema` declares, and the node `PageView` hands to the renderer. `@object-ui/components` registers the three kinds on the `page` node's renderer, so every member is `PageNodeSchema`'s own, by reference, and `type` is the spec's `PageTypeSchema` narrowed to those three. The spec's other two kinds are claimed elsewhere: `app` by `AppComponentSchema`, the app document, and `list` by the `list` component. Which spelling of a page is canonical is not decided here; `{ type: 'page', pageType }` validates as before. `FlexBlockSchema` is the authored `flex` node (objectui#11276's `flex` batch, the maintainer's ruling A on objectui#11300), built the way `ObjectChartBlockSchema` is: `@objectstack/spec` has no `ComponentPropsMap['flex']` row, so the bag's `direction`, `justify`, `align`, `gap` and `wrap` are the flat `FlexSchema` mirror's own members, by reference, and no row is invented. The child list is in the bag too, as `properties.children`: one node or a list, the list's entries judged by the spec's page walk like a `page:` container's. Each bag member written flat on the node is refused by name, naming `properties.KEY`, and `body` is refused toward `properties.children`. `FlexSchema` stays exported as the node as the `flex` renderer reads it after the hoist, and as code composes it; it is no longer an arm of `AnyComponentSchema`. `stack` and the other layout arms keep their props on the node. @@ -233,14 +235,16 @@ function validateComponent(config: unknown) { - `KanbanSchema`, `CalendarViewSchema` - `FilterBuilderSchema`, `CarouselSchema`, `ChatbotSchema` -### ObjectQL Public Blocks (8) +### ObjectQL Public Blocks (10) The ADR-0080 public blocks `object-metric`, `object-master-detail-form`, `object-timeline`, `object-form`, `object-map`, `object-chart`, `object-gantt` and `object-grid`. Every arm's `properties` but `object-chart`'s is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference; `object-chart` has no row (below). Each of those seven arms also declares the node's `dataSource` binding, the spec's `ElementDataSourceSchema`, which all seven registrations read through `ElementDataSourceGate`. None of the eight renderers reads a content channel, so every arm refuses `children` and `body` by name (objectui#9256). On all eight, a member of the row written flat on the node instead of in `properties` is refused by name, naming `properties.KEY`: `object-metric`, `object-master-detail-form`, `object-timeline` and `object-grid` through the public blocks' shared helper since objectui#10872 batch 10 (below), the other four through their own maps. `ObjectFormBlockSchema` is the authored `object-form` node (objectui#10859 batch 4): its props go in the `properties` bag, and each member of the row written flat on the node is refused by name, with a message naming `properties.KEY`. `ObjectFormSchema`, the flat mirror of the TypeScript `ObjectFormSchema`, stays exported as the node as `ObjectForm` reads it after `SchemaRenderer` hoists the bag; it is no longer an arm of `AnyComponentSchema`. `ObjectMapBlockSchema` is the authored `object-map` node, built the same way (objectui#10859 batch 5). The flat mirror's `locationField` and `titleField` are refused by name too, pointing at `properties.map.KEY`. The flat mirror's record-source rule stays, read in the bag: the node needs one of `properties.data`, `properties.staticData` or `properties.objectName`, or the node's `dataSource` binding. `ObjectMapSchema`, the flat mirror of the TypeScript `ObjectMapSchema`, stays exported as the node as `ObjectMap` reads it; it is no longer an arm of `AnyComponentSchema`. `ObjectChartBlockSchema` is the authored `object-chart` node (objectui#11276), built the same way with one difference: `@objectstack/spec` has no `ComponentPropsMap['object-chart']` row, so the bag's members are the flat `ObjectChartSchema` mirror's own members, by reference, and no row is invented. Each of them written flat on the node is refused by name, naming `properties.KEY`; the three list-view spellings objectui#10608 retired keep their retirement message instead. The flat mirror's chart-family floor stays, read in the bag: the node needs `properties.chartType` (or `properties.specType`). It does not declare `dataSource` (objectui#11070 left that binding undeclared on this node) and refuses both content channels. `ObjectChartSchema` stays exported as the node as `ObjectChart` reads it, and as the composers build it; it is no longer an arm of `AnyComponentSchema`. `ObjectGanttBlockSchema` is the authored `object-gantt` node, built the way `ObjectMapBlockSchema` is (objectui#10859 batch 6). Each member of the row written flat on the node is refused by name, naming `properties.KEY`, except `label`, which `@objectstack/spec`'s page component also declares on the node itself. The flat mirror's `GanttConfig` keys (`startDateField`, `viewMode`, …) and its `dependencyField` alias are refused by name too, pointing at the bag's `gantt` block, `properties.gantt.KEY`. The record-source rule stays, read in the bag, as on `object-map`. `ObjectGanttSchema`, the flat mirror of the TypeScript `ObjectGanttSchema`, stays exported as the node as `ObjectGantt` reads it, and as the list and object views compose it; it is no longer an arm of `AnyComponentSchema`. `ObjectGridBlockSchema` is the authored `object-grid` node (objectui#11276's `object-grid` batch), built the way `ObjectGanttBlockSchema` is from the spec's `ComponentPropsMap['object-grid']` row. `label` stays on the node, as on `object-gantt`, and a flat `defaultSort` keeps the row's own retirement. The flat mirror's retirements of keys the row does not declare (`operators`, `rowSpecActions`, `bulkSpecActions`, `name`, `placeholder`, `showFilters`) and its content-channel refusals ride onto the arm by reference, and its `onNavigate` runtime slot is declared as the mirror declares it. The record-source rule stays, read in the bag: the node needs `properties.objectName`, or the node's `dataSource` binding. `ObjectGridSchema`, the flat mirror of the TypeScript `ObjectGridSchema`, stays exported as the node as `ObjectGrid` reads it, and still builds the `object-view` `table` slot; it is no longer an arm of `AnyComponentSchema`. +`ObjectPivotBlockSchema` and `EmbeddableFormBlockSchema` (objectui#11440) are the authored `object-pivot` and `embeddable-form` nodes, two more blocks with no `ComponentPropsMap` row. Each bag is built from the block's registration `inputs`, and it is required, because those inputs include required members: `rowField`, `columnField` and `valueField` on the pivot, `formId` on the form. `objectName` is required unless the node's `dataSource` binding names the object. The pivot's cross-tab members are the `PivotTableSchema` mirror's own, by reference; its `drillDown` refuses `mode` by name (a pivot always drills through), and its retired `dataProvider` is refused by name. A member written flat is refused by name toward `properties.KEY`, the node declares the `dataSource` binding, and both content channels are refused. - `ObjectMetricBlockSchema`, `ObjectMasterDetailFormBlockSchema`, `ObjectTimelineBlockSchema`, `ObjectFormBlockSchema`, `ObjectMapBlockSchema`, `ObjectChartBlockSchema`, `ObjectGanttBlockSchema`, `ObjectGridBlockSchema` +- `ObjectPivotBlockSchema`, `EmbeddableFormBlockSchema` ### AI Components (3) - `AIFormAssistSchema`, `AIRecommendationsSchema`, `NLQuerySchema` @@ -253,7 +257,7 @@ The `@object-ui/plugin-designer` nodes (objectui#10859 batch 7). Each arm mirror ### ADR-0080 Public Blocks (27) Each arm's `properties` is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference. `ElementNumberBlockSchema` also declares the spec's `dataSource` binding, and mirrors the spec gate's one waiver: its bag may omit `object` when `dataSource.object` names the object. A member of an arm's row written flat on the node instead of in `properties` is refused by name on both faces, at its own path, with a message naming `properties.KEY`, as `@objectstack/spec`'s `PageComponentSchema` refuses it as mis-layered (objectui#10872 batch 10; one shared helper, `flatPropRefusals`, spread into every arm whose row declares a member). That includes the row members `BaseSchema` also declares (`visible`, `disabled`, `name`, `description`), which the base used to accept flat. Two kinds stay as they were: a member the spec's page component declares on the node itself (`label`, `aria`), and one the row retires, which keeps the row's own retirement message when written flat. An arm's own refusal of a row key (`record:alert`'s `body`, the `action:` controls' `onSuccess`, the content-channel refusals) keeps its message. -Every arm here, and each of the seven ObjectQL public blocks above, declares the node-level `responsiveStyles` (ADR-0065 per-breakpoint style maps, which `SchemaRenderer` compiles to scoped CSS) as the spec's `ResponsiveStylesSchema`, by reference, from one shared fragment, `NODE_ENVELOPE` (objectui#10872 batch 8; named `PUBLIC_BLOCK_ENVELOPE` until batch 9). The other node-level keys the spec's `PageComponentSchema` declares (`events`, `aria`, `visibility`, `responsive`, and `dataSource` outside the arms that read it) stay undeclared, so the strict face refuses them by name. +Every arm here, and each of the ObjectQL public blocks above, declares the node-level `responsiveStyles` (ADR-0065 per-breakpoint style maps, which `SchemaRenderer` compiles to scoped CSS) as the spec's `ResponsiveStylesSchema`, by reference, from one shared fragment, `NODE_ENVELOPE` (objectui#10872 batch 8; named `PUBLIC_BLOCK_ENVELOPE` until batch 9). The other node-level keys the spec's `PageComponentSchema` declares (`events`, `aria`, `visibility`, `responsive`, and `dataSource` outside the arms that read it) stay undeclared, so the strict face refuses them by name. Outside this set, `FlexSchema`, `ObjectGridSchema` and `ObjectChartSchema` spread the same fragment (objectui#10872 batch 9; all three are post-hoist mirrors since objectui#11276, the authored arms being `FlexBlockSchema`, `ObjectChartBlockSchema` and `ObjectGridBlockSchema` above, and `FlexBlockSchema` spreads it too), and their TypeScript twins declare the key as the spec's `ResponsiveStyles`: they are the arms outside this set that objectui#10872's producer reading found `responsiveStyles` written on (a dated reading, not re-derived here). `BaseSchema` and every other arm leave it undeclared, so the strict face refuses it there by name. Every arm except the four `page:` containers (`page:card`, `page:section`, `page:footer`, `page:sidebar`) refuses `children` and `body` by name, because its renderer reads neither content channel (objectui#9256; the last six below since objectui#10872 batch 5). The four containers do render a child list, and its home is `properties.children`, the member their spec row declares; they refuse the node-level `children` and `body` with a message naming it, as `@objectstack/spec`'s `PageComponentSchema` refuses a node-level `children` (objectui#10872 batch 6). Two carve-outs: `record:alert` refuses `children` with its own message, while its `body` is the message text and belongs in `properties`, so a flat `body` is refused with a pointer to `properties.body` rather than to `children` (objectui#10872); and `page:tabs` / `page:accordion` refuse the node's own `children` while each item's `children` in `items` stays live. - `PageHeaderBlockSchema`, `PageTabsBlockSchema`, `PageCardBlockSchema`, `PageAccordionBlockSchema` @@ -267,6 +271,10 @@ Every arm except the four `page:` containers (`page:card`, `page:section`, `page The last six arrived with `@objectstack/spec` 17.5.0, whose rows for them were measured at their renderers' reads (objectui#10872 batch 4). Their content channels were measured by the objectui#9256 method in objectui#10872 batch 5, and none of their renderers reads either one, so each refuses `children` and `body` like the arms above. The message names where the content goes instead: `properties.actions` for `action:group` and `action:menu`, `properties.items` for `element:definition-list`, and the label and icon for the two buttons. `element:repeater` has no content channel, and its message says so. `ActionButtonBlockSchema` and `ActionIconBlockSchema` also refuse two keys their renderers read off the node: `onClick`, a runtime slot for a host-supplied function (objectui#6124), and a flat `onSuccess`, pointed at `properties.onSuccess`, the row's member. +### Shell and Section Nodes +- `AppSchemaRendererNodeSchema` — `app-schema-renderer`, `@object-ui/layout`'s whole-shell node (objectui#11440). It declares two of its registration's inputs: `mobileNavMode` (`'drawer'` | `'bottom_nav'`) and `basePath`. The third input, `schema`, is not declared, because `SchemaRenderer` strips that key and the component reads the app document off the node itself. Both content channels are refused: a node draws neither. +- `DetailSectionNodeSchema` — `detail-section`, one field section of `@object-ui/plugin-detail` as a node (objectui#11440): the ten `DetailViewSectionSchema` members its registration publishes, flat on the node, `fields` required. It is not `record:details`'s section shape (that one names its heading `label` and takes field names), so it is a node of its own. + ### Cloud Widgets (1) `@object-ui/app-shell`'s `cloud:` SDUI widgets. `@objectstack/spec` has no `ComponentPropsMap` row for them, so each arm is declared here from the keys its widget reads. - `CloudPlanStatusSchema` — `cloud:plan-status`, the pricing page's "current plan" marker: `properties` must be exactly `{ plan }`, a non-empty plan code. diff --git a/packages/types/src/zod/app.zod.ts b/packages/types/src/zod/app.zod.ts index 684bee4560..f58688ea03 100644 --- a/packages/types/src/zod/app.zod.ts +++ b/packages/types/src/zod/app.zod.ts @@ -675,6 +675,73 @@ export const AppComponentSchema = BaseSchema.extend(SpecAppFields.shape).extend( actions: retirementTombstone(APP_ACTIONS_REFUSAL), }); +/** + * objectui#11440 — ONE refusal string for both content channels of + * {@link AppSchemaRendererNodeSchema}. The registration declares + * `isContainer: true`, but the parser tier's containment check reads the + * `children` INPUT, never `isContainer` (objectui#9910), and the registration + * declares no such input, so the `not-a-container` clause holds here too. + */ +const APP_SCHEMA_RENDERER_NEITHER_CHANNEL = + 'REFUSED (objectui#9256, ADR-0049) — `app-schema-renderer` reads NEITHER content channel: `SchemaRenderer` ' + + 'strips `children` and `body` out of the props it hands `AppSchemaRenderer`, and the component reads no ' + + '`schema.children` / `schema.body`. Measured through the real `SchemaRenderer` and registry (objectui#11440): ' + + 'a node carrying `children` drew none of them. An authored value therefore rendered NOTHING — no render-time ' + + 'error or warning and no element; only the parser tier\'s `not-a-container` warning (objectui#9910) noticed it ' + + '(the registration declares no `children` input). What it renders instead: the shell — its sidebar and, with ' + + '`mobileNavMode: \'bottom_nav\'`, the bottom bar — around an empty main area. Page content reaches the shell ' + + 'only as React `children`, when a host renders `AppSchemaRenderer` itself.'; + +/** + * `app-schema-renderer` — the whole-shell node `@object-ui/layout` registers + * (objectui#11440, under the seat's amendment `5945583855` on objectui#10859: + * "declaring the node from its registration inputs"). + * + * ## The defect this closes + * + * objectui#4841's ruling (`5307574602`) keeps `app-schema-renderer` "the one + * JSON door for 'render a whole shell from a schema'", and the governed guide + * `skills/objectui/guides/mobile.md` teaches the `mobileNavMode` key of this + * node (as does {@link APP_MOBILE_NAV_MODE_REFUSAL} above). No arm claimed the + * literal, so `objectui validate` refused that node with `invalid_union` at + * `type`. + * + * ## The members, and the input that is NOT one + * + * The registration declares three `inputs`: `schema` (object), `basePath` + * (string) and `mobileNavMode` (the enum `'drawer'` | `'bottom_nav'`). Two are + * declared here, each as the registration types it: + * - `mobileNavMode` — the two modes the renderer implements; `'bottom_nav'` + * adds the fixed bottom bar. + * - `basePath` — the prefix of every href the shell generates. + * + * ⛔ `schema` is NOT declared, because no node delivers it. `SchemaRenderer` + * strips the `schema` key out of the props it spreads and hands the component + * the NODE as its `schema` prop, so `AppSchemaRenderer` reads the app document + * keys off the node itself and never the nested `schema` object. Measured + * through the real `SchemaRenderer` and registry (objectui#11440): a node with + * its navigation nested under `schema` drew no navigation, and the same + * navigation written on the node drew. Where the app document belongs on this + * node is left to the seat, and declaring `schema` would publish an input + * that renders nothing. + * + * Neither content channel is read (see + * {@link APP_SCHEMA_RENDERER_NEITHER_CHANNEL}), so both are refused by name. + */ +export const AppSchemaRendererNodeSchema = BaseSchema.extend({ + type: z.literal('app-schema-renderer'), + basePath: z.string().optional().describe('URL prefix for the hrefs the shell generates (e.g. "/apps/crm")'), + mobileNavMode: z + .enum(['drawer', 'bottom_nav']) + .optional() + .describe( + 'Mobile navigation mode: "drawer" (the default) puts the sidebar in the mobile sheet overlay; "bottom_nav" ' + + 'additionally renders a fixed bottom bar. These are the only two modes the renderer implements.', + ), + body: retirementTombstone(APP_SCHEMA_RENDERER_NEITHER_CHANNEL), + children: retirementTombstone(APP_SCHEMA_RENDERER_NEITHER_CHANNEL), +}); + /** * Export type inference helpers */ diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index 8d1e619403..a6955d7ea8 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -936,7 +936,8 @@ const CHART_Y_AXIS_IS_A_LIST_GUIDANCE = * `ObjectDataTableSchema` (`objectql.zod.ts`) reference it. `PivotTableSchema` * below referenced it from objectui#10859 (batch 2) until objectui#10932 * retired the key on the `pivot` node, which nothing drills; `object-pivot`, - * the block that does drill, has no zod mirror. Until this mirror existed + * the block that does drill, extends it with `mode` refused (`objectql.zod.ts`, + * objectui#11440). Until this mirror existed * neither declaring mirror had heard of the key, so under * `BaseSchema`'s `.passthrough()` a `drillDown: { enabled: 'yes' }` parsed green * and reached a widget that reads `enabled` as truthy — `declared !== enforced`, diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index 9fd3b1e0bb..eea82f1d03 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -66,6 +66,8 @@ export { NavigationItemTypeSchema, NavigationAreaSchema, MenuItemSchema as AppMenuItemSchema, + // objectui#11440 — the `app-schema-renderer` whole-shell node. + AppSchemaRendererNodeSchema, } from './app.zod.js'; // ============================================================================ @@ -118,6 +120,8 @@ export { PageVariableSchema, PageTypeSchema, PageNodeSchema, + // objectui#11440 — the spec page kinds `record` / `home` / `utility`. + PageKindNodeSchema, LayoutSchema, // ⛔ `SemanticElementSchema` and `HtmlElementSchema` are deliberately NOT // exported (objectui#9067, decision batch #121 item 5, maintainer 2026-09-12). @@ -350,6 +354,10 @@ export { ObjectChartBlockSchema, ObjectGanttBlockSchema, ObjectGridBlockSchema, + // objectui#11440 — two Tier A public blocks with no spec row, each bag built + // from the block's registration inputs. + ObjectPivotBlockSchema, + EmbeddableFormBlockSchema, ObjectQLPublicBlockComponentSchema, } from './objectql.zod.js'; @@ -413,6 +421,8 @@ export { DetailViewSectionSchema, DetailViewTabSchema, DetailViewSchema, + // objectui#11440 — one field section as a node. + DetailSectionNodeSchema, ViewSwitcherSchema, FilterUISchema, SortUISchema, @@ -508,7 +518,7 @@ export { CloudPlanStatusSchema } from './cloud.zod.js'; import { z } from 'zod'; import { defineNodeComponentUnion } from './base.zod.js'; -import { AppComponentSchema } from './app.zod.js'; +import { AppComponentSchema, AppSchemaRendererNodeSchema } from './app.zod.js'; import { LayoutSchema } from './layout.zod.js'; import { FormComponentSchema } from './form.zod.js'; import { DataDisplaySchema } from './data-display.zod.js'; @@ -579,6 +589,7 @@ import { nestedComponentJudgment } from './nested-component-walk.js'; */ export const AnyComponentSchema = defineNodeComponentUnion(z.discriminatedUnion('type', [ AppComponentSchema, + AppSchemaRendererNodeSchema, LayoutSchema, FormComponentSchema, DataDisplaySchema, diff --git a/packages/types/src/zod/layout.zod.ts b/packages/types/src/zod/layout.zod.ts index 62e8a89685..47f50b0981 100644 --- a/packages/types/src/zod/layout.zod.ts +++ b/packages/types/src/zod/layout.zod.ts @@ -823,7 +823,8 @@ export const PageTypeSchema = stripImportedDefaults(SpecPageTypeSchema); * is the renderer half of the spec's `PageTypeSchema`, ⛔ not a component * family and ⛔ not names registered without a schema. Two cards read it the * second way (objectui#9263, re-ruled letter E "⛔ not a defect", and - * objectui#9576). + * objectui#9576). Since objectui#11440 the validator answers that node too: + * {@link PageKindNodeSchema} below claims `record` / `home` / `utility`. * * ⭐ `app` is one token carrying two vocabularies: `AppComponentSchema`'s * `'app'` is the APP-LEVEL DOCUMENT, read structurally by the runner / @@ -1126,6 +1127,80 @@ export const PageNodeSchema = BaseSchema.extend(SpecPageFields.shape).extend({ // the count, so a check the spec adds to `PageSchema` later reddens there. .superRefine(checkPageSourceCompleteness); +/** + * The spec page KINDS a node may carry in `type` (objectui#11440): `record`, + * `home` and `utility`, picked out of `@objectstack/spec`'s own + * `PageTypeSchema` by reference (`.extract`), so a kind the spec drops fails to + * compile here. + * + * Exactly the kinds `@object-ui/components` registers on `PageRenderer` and no + * other arm claims. The spec declares two more, and neither is armed here: + * - `app` — the token `AppComponentSchema` already claims for the APP-LEVEL + * document (the `app` note on {@link SpecPageFields} above). A spec page of + * kind `app` is judged by that arm. + * - `list` — not registered under that key (an interface-mode kind, which + * `PageView` renders before the registry), and the `list` data-display + * component owns the literal. + */ +const PAGE_KIND_NODE_TYPE = PageTypeSchema.extract(['record', 'home', 'utility']); + +/** + * The `page` node's own members, minus its `type` literal, read off + * {@link PageNodeSchema} by reference — the shape the page kinds below share. + */ +const { type: _pageNodeTypeLiteral, ...PAGE_NODE_MEMBERS } = PageNodeSchema.shape; + +/** + * Page Kind Node Schema — a stored spec page document whose `type` is its page + * KIND (`record` / `home` / `utility`), the spelling `@objectstack/spec`'s + * `PageSchema` declares (objectui#11440, under the seat ruling `5945530142` on + * objectui#10859: "the spec's own page kinds"). + * + * ## The defect this closes + * + * `PageView` (`@object-ui/app-shell`) hands a stored page to `SchemaRenderer` + * with the kind written verbatim into `type`, and `@object-ui/components` + * registers the three kinds on `PageRenderer` for exactly that reason + * (objectui#9642). No arm claimed the literals, so `safeValidateSchema`, and + * `objectui validate` with it, refused the spec's own page document — + * `{ type: 'home', name, label, regions }` — with one `invalid_union` at + * `type`, while `{ type: 'page', pageType: 'home' }` passed. + * + * ## Why this shape + * + * The five page registrations share ONE renderer and ONE `pageMeta` (its + * `inputs`), so a kind node is the `page` node under a different literal: every + * member is {@link PageNodeSchema}'s own, by reference, and that node already + * takes the spec's `PageSchema` fields by reference through + * {@link SpecPageFields}. Its four refusals (`actions`, `breadcrumbs`, + * `maxWidth`, `padding`), the `body` refusal and the spec's object-level + * `source` check come with it. `regions` stays the node's own + * {@link PageNodeRegionSchema}, so each region component is judged by this + * union at every depth, as on `page`. + * + * ⛔ Which spelling of a page is canonical — `type: 'page'` + `pageType`, or + * the spec's `type: KIND` — is not decided here. Both validate, as both render. + */ +export const PageKindNodeSchema: PageKindNodeSchemaType = BaseSchema.extend({ + ...PAGE_NODE_MEMBERS, + type: PAGE_KIND_NODE_TYPE.describe('The spec page kind — `record`, `home` or `utility` (`@objectstack/spec` `PageTypeSchema`)'), +}).superRefine(checkPageSourceCompleteness); + +/** + * The TYPE of {@link PageKindNodeSchema}, written out BY REFERENCE to the + * `page` node's shape. Without it, declaration emit re-serializes the whole + * page shape a second time inside `AnyComponentSchema`, and `tsc` refuses that + * union with TS7056 ("The inferred type of this node exceeds the maximum length + * the compiler will serialize"), measured on objectui#11440. A named type is + * emitted by name. + */ +export type PageKindNodeSchemaType = z.ZodObject< + Omit<(typeof PageNodeSchema)['shape'], 'type'> & { + type: z.ZodEnum<{ record: 'record'; home: 'home'; utility: 'utility' }>; + }, + z.core.$loose +>; + /** * Semantic Element Schema — the seven HTML sectioning tags * `packages/components/src/renderers/layout/semantic.tsx` registers @@ -1256,6 +1331,8 @@ export const LayoutSchema = z.discriminatedUnion('type', [ ResizableSchema, AspectRatioSchema, PageNodeSchema, + // objectui#11440 — the spec page kinds `record` / `home` / `utility`. + PageKindNodeSchema, SemanticElementSchema, HtmlElementSchema, ]); diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index b3fb5a6522..7868187acf 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -67,7 +67,7 @@ import { } from '@objectstack/spec/ui'; import { BaseSchema, specFieldsExcept } from './base.zod.js'; import { aliasKeyRefusal, handlerKeyRefusal, neitherContentChannelGuidance, retirementTombstone } from './tombstone.zod.js'; -import { DataTableSchema, DrillDownConfigSchema } from './data-display.zod.js'; +import { DataTableSchema, DrillDownConfigSchema, PivotTableSchema } from './data-display.zod.js'; // The kanban CARD vocabulary has one authority (`./complex.zod.ts`); the // `object-kanban` lane below reads it rather than restating it (objectui#8913). import { KanbanCardSchema } from './complex.zod.js'; @@ -2285,7 +2285,9 @@ type RecordSourceRung = 'bind' | 'data' | 'staticData' | 'objectName'; * naming it in the message would prescribe a write the renderer drops. */ function requireRecordSource( - type: 'object-map' | 'object-gantt' | 'object-calendar' | 'object-kanban' | 'object-grid' | 'list-view' | 'object-tree', + type: + | 'object-map' | 'object-gantt' | 'object-calendar' | 'object-kanban' | 'object-grid' | 'list-view' | 'object-tree' + | 'object-pivot' | 'embeddable-form', rungs: readonly RecordSourceRung[], at: 'node' | 'properties' = 'node', bindingRung: 'counts' | 'not-read' = 'counts', @@ -4579,6 +4581,218 @@ export const ObjectGridBlockSchema = BaseSchema.extend({ children: ObjectGridSchema.shape.children, }).superRefine(...requireRecordSource('object-grid', ['objectName'], 'properties')); +/** + * objectui#11440 — `object-pivot`'s drill shape, the zod twin of + * `ObjectPivotDrillDownConfig` (`../data-display.ts`): the shared + * {@link DrillDownConfigSchema} with `mode` refused by name (objectui#10685). + * Every click point on a pivot is an aggregated bucket, so it always drills + * through to the records behind the value; there is no row to open as a record. + */ +const ObjectPivotDrillDownSchema = DrillDownConfigSchema.extend({ + mode: retirementTombstone( + 'REFUSED on `object-pivot` (objectui#10685) — `drillDown.mode` chooses drill-to-record for a clicked ROW, ' + + 'and every click point on a pivot is an aggregated bucket (a cell, a header or a total), so a pivot always ' + + 'drills through to the records behind the clicked value. `mode` applies on `object-data-table`, whose row ' + + 'click reads it. Delete the key.', + ), +}); + +/** + * objectui#11440 — the `object-pivot` props bag, built from the block's + * registration `inputs` (`@object-ui/plugin-dashboard`). `@objectstack/spec` + * has no `ComponentPropsMap['object-pivot']` row, so these are objectui's own + * members. The cross-tab members are the `pivot` mirror's own + * ({@link PivotTableSchema}), by reference; `objectName` and `filter` are the + * two the object block adds, and `drillDown` is this block's drill shape + * above, which the registration publishes as an input since the same change + * (the `pivot` refusal of `drillDown` names this block as where a pivot drill + * is authored, objectui#10932). + * + * Not declared, because the registration publishes neither: `data` (inline + * rows — the `pivot` node's job) and `columnColors`. `dataProvider` is declared + * as the retirement the block's TypeScript prop type already spells + * (objectui#7353). + * + * `.passthrough()` like the other bags of objectui's own members + * (`object-chart`, `flex`): a key the block does not declare is unjudged by the + * tolerant face and refused by name by the strict one. + */ +const ObjectPivotPropsBag = z.looseObject({ + objectName: z.string().optional().describe(OBJECT_NAME_BINDING_WAIVER_DESCRIPTION), + title: PivotTableSchema.shape.title, + rowField: PivotTableSchema.shape.rowField, + columnField: PivotTableSchema.shape.columnField, + valueField: PivotTableSchema.shape.valueField, + aggregation: PivotTableSchema.shape.aggregation, + showRowTotals: PivotTableSchema.shape.showRowTotals, + showColumnTotals: PivotTableSchema.shape.showColumnTotals, + format: PivotTableSchema.shape.format, + filter: z + .array(z.unknown()) + .optional() + .describe( + 'Query filter, forwarded as $filter with its context tokens ({current_user_id}, {current_org_id}) resolved ' + + 'first; a drill composes it with the clicked cell. A FilterArray — the registration publishes `filter` as an array.', + ), + drillDown: ObjectPivotDrillDownSchema.optional().describe( + 'Click-through config that opens the records behind a cell, header or total (drawer / dialog / navigate, or an ' + + 'analytical report). `mode` is refused: a pivot always drills through (objectui#10685)', + ), + // objectui#7353 — the twin of `ObjectPivotTable`'s `dataProvider?: never`, as + // `ObjectDataTableSchema` declares it: refused by name, pointing at `objectName`. + dataProvider: retirementTombstone( + 'REFUSED (objectui#7353, ADR-0049) — `object-pivot` does not read `dataProvider`. The dashboard producers used ' + + 'to copy the widget provider config onto the node beside `objectName`, and nothing read it. Write ' + + '`objectName` — the key the block fetches through.', + ), +}); + +/** + * The ONE refusal detail every `object-pivot` prop written flat on the node + * gets (objectui#11440). `aliasKeyRefusal` puts the key and its bag member in + * front of it: "Did you mean `rowField` → `properties.rowField`?". + */ +const OBJECT_PIVOT_FLAT_PROP = + 'An `object-pivot` node takes its props in its `properties` bag: write `{ "type": "object-pivot", ' + + '"properties": { "objectName": "…", "rowField": "…", "columnField": "…", "valueField": "…" } }` ' + + '(objectui#11440). `@objectstack/spec`\'s own page component refuses a prop written on the node as ' + + 'mis-layered (ADR-0089 D3a), so this face and `os validate` agree. The spec has no ' + + '`ComponentPropsMap[\'object-pivot\']` row, so the bag\'s members are the registration\'s own inputs. Moving ' + + 'it changes nothing at render time: `SchemaRenderer` hoists every `properties` key onto the node before ' + + '`ObjectPivotTable` reads it.'; + +/** objectui#11440 / objectui#9256: ONE refusal string for both content channels of `ObjectPivotBlockSchema`. */ +const OBJECT_PIVOT_NEITHER_CHANNEL = + 'REFUSED (objectui#9256, ADR-0049) — `object-pivot` reads NEITHER content channel: its registration hands the ' + + 'node through `ElementDataSourceGate` to `ObjectPivotTable`, which reads no `children` or `body` (nor does ' + + 'the `PivotTable` it renders), and `SchemaRenderer` strips both out of the props bag it spreads. An authored ' + + 'value would render NOTHING — no render-time error or warning and no element; only the parser tier\'s ' + + '`not-a-container` warning (objectui#9910) noticed it (the registration declares no `children` input). What ' + + 'it renders instead: a cross-tab of the records of `properties.objectName` — ' + + '`rowField` down, `columnField` across, `valueField` aggregated by `aggregation`.'; + +/** + * `object-pivot` — the AUTHORED node (objectui#11440, under the seat ruling + * `5945530142` on objectui#10859: "Passes the criterion"). + * + * ## The defect this closes + * + * `@object-ui/plugin-dashboard` registers `object-pivot` (`ObjectPivotBlock`), + * ADR-0080 curates it in `PUBLIC_BLOCKS` Tier A, and no arm claimed it, so + * `safeValidateSchema`, and `objectui validate` with it, refused every + * document naming it with `invalid_union` at `type`. + * + * ## The construct + * + * The public-block construct of this module, the one `object-chart` uses for a + * block with no spec row: `BaseSchema` + the `type` literal + `NODE_ENVELOPE` + + * `properties` through `propsBag` + `flatPropRefusals` over the bag, so a + * member written FLAT on the node is refused by name toward `properties.KEY` + * (objectui#10872's triage answer A: the bag is the contract on a public + * block). The bag ({@link ObjectPivotPropsBag}) is built from the + * registration's `inputs`. The cross-tab members `rowField`, `columnField` and + * `valueField` are required there, so the bag is required here; `objectName` + * is required unless the node's `dataSource.object` names the object, because + * the registration is `elementDataSourceBlock`-wrapped and the gate lands the + * binding's object on `objectName` (`requireRecordSource`, keyed + * `RECORD_SOURCE_REQUIRED`). `dataSource` is the spec's + * `ElementDataSourceSchema` on the node, by reference, as on the other + * gate-wrapped arms here. + * + * Neither content channel is read, so both are refused by name (objectui#9256). + */ +export const ObjectPivotBlockSchema = BaseSchema.extend({ + type: z.literal('object-pivot'), + ...NODE_ENVELOPE, + ...flatPropRefusals('object-pivot', ObjectPivotPropsBag, OBJECT_PIVOT_FLAT_PROP), + properties: ObjectPivotPropsBag.describe( + 'The `object-pivot` props bag — the block\'s registration inputs (`objectName`, `title`, `rowField`, ' + + '`columnField`, `valueField`, `aggregation`, `showRowTotals`, `showColumnTotals`, `filter`, `format`, ' + + '`drillDown`). `@objectstack/spec` has no `ComponentPropsMap[\'object-pivot\']` row, so these are ' + + 'objectui\'s own members (objectui#11440).', + ), + dataSource: stripImportedDefaults(SpecElementDataSourceSchema) + .optional() + .describe(ELEMENT_DATA_SOURCE_BINDING_DESCRIPTION), + body: retirementTombstone(OBJECT_PIVOT_NEITHER_CHANNEL), + children: retirementTombstone(OBJECT_PIVOT_NEITHER_CHANNEL), +}).superRefine(...requireRecordSource('object-pivot', ['objectName'], 'properties')); + +/** + * objectui#11440 — the `embeddable-form` props bag: exactly the block's + * registration `inputs` (`@object-ui/plugin-form`). `@objectstack/spec` has no + * `ComponentPropsMap['embeddable-form']` row, so these are objectui's own + * members, each typed as `EmbeddableFormConfig` (`@object-ui/plugin-form`) + * types it. `formId` is required there, so the bag is required on the node; + * `objectName` is required unless the node's `dataSource.object` names the + * object (the registration is gate-wrapped). + * + * The component reads more of its config than the registration publishes + * (`branding`, `thankYouPage`, the anti-spam keys and others). Those are not + * declared: the registration is the published surface, and nothing teaches + * them as JSON. + */ +const EmbeddableFormPropsBag = z.looseObject({ + formId: z.string().describe('The form\'s identifier — submissions are recorded against it'), + objectName: z.string().optional().describe(OBJECT_NAME_BINDING_WAIVER_DESCRIPTION), + title: z.string().optional().describe('Form title, drawn above the fields'), + description: z.string().optional().describe('Instructions drawn under the title'), + fields: z + .array(z.string()) + .optional() + .describe('Bare field names to show, in order, each looked up in the object schema'), + allowMultiple: z.boolean().optional().describe('Let one visitor submit more than once'), +}); + +/** + * The ONE refusal detail every `embeddable-form` prop written flat on the node + * gets (objectui#11440). + */ +const EMBEDDABLE_FORM_FLAT_PROP = + 'An `embeddable-form` node takes its props in its `properties` bag: write `{ "type": "embeddable-form", ' + + '"properties": { "formId": "…", "objectName": "…" } }` (objectui#11440). `@objectstack/spec`\'s own page ' + + 'component refuses a prop written on the node as mis-layered (ADR-0089 D3a), so this face and `os validate` ' + + 'agree. The spec has no `ComponentPropsMap[\'embeddable-form\']` row, so the bag\'s members are the ' + + 'registration\'s own inputs. Moving it changes nothing at render time: `SchemaRenderer` hoists every ' + + '`properties` key onto the node before `EmbeddableForm` reads it.'; + +/** objectui#11440 / objectui#9256: ONE refusal string for both content channels of `EmbeddableFormBlockSchema`. */ +const EMBEDDABLE_FORM_NEITHER_CHANNEL = + 'REFUSED (objectui#9256, ADR-0049) — `embeddable-form` reads NEITHER content channel: its registration hands ' + + 'the node through `ElementDataSourceGate` to `EmbeddableForm` as its `config`, which reads no `children` or ' + + '`body`, and `SchemaRenderer` strips both out of the props bag it spreads. An authored value would render ' + + 'NOTHING — no render-time error or warning and no element; only the parser tier\'s `not-a-container` warning ' + + '(objectui#9910) noticed it (the registration declares no `children` input). What it renders instead: a ' + + 'standalone public form over `properties.objectName`, its fields from ' + + 'the object schema (or `properties.fields`).'; + +/** + * `embeddable-form` — the AUTHORED node (objectui#11440, under the seat ruling + * `5945530142` on objectui#10859: "Passes the criterion"). + * + * `@object-ui/plugin-form` registers it (`EmbeddableFormRenderer`), ADR-0080 + * curates it in `PUBLIC_BLOCKS` Tier A, and no arm claimed it, so every + * document naming it was refused with `invalid_union` at `type`. Built as + * {@link ObjectPivotBlockSchema} above: the bag is the contract, a member + * written flat is refused by name toward `properties.KEY`, `dataSource` is the + * spec's binding on the node, and neither content channel is read. + */ +export const EmbeddableFormBlockSchema = BaseSchema.extend({ + type: z.literal('embeddable-form'), + ...NODE_ENVELOPE, + ...flatPropRefusals('embeddable-form', EmbeddableFormPropsBag, EMBEDDABLE_FORM_FLAT_PROP), + properties: EmbeddableFormPropsBag.describe( + 'The `embeddable-form` props bag — the block\'s registration inputs (`formId`, `objectName`, `title`, ' + + '`description`, `fields`, `allowMultiple`). `@objectstack/spec` has no ' + + '`ComponentPropsMap[\'embeddable-form\']` row, so these are objectui\'s own members (objectui#11440).', + ), + dataSource: stripImportedDefaults(SpecElementDataSourceSchema) + .optional() + .describe(ELEMENT_DATA_SOURCE_BINDING_DESCRIPTION), + body: retirementTombstone(EMBEDDABLE_FORM_NEITHER_CHANNEL), + children: retirementTombstone(EMBEDDABLE_FORM_NEITHER_CHANNEL), +}).superRefine(...requireRecordSource('embeddable-form', ['objectName'], 'properties')); + /** * The public blocks above, as one arm of `AnyComponentSchema` * (objectui#10859, batches 2 to 6). @@ -4600,6 +4814,11 @@ export const ObjectGridBlockSchema = BaseSchema.extend({ * `object-form`: its TypeScript twin is the node as `ObjectGrid` reads it after * the hoist, and its flat mirror keeps building the `object-view` `table` slot. * + * `object-pivot` and `embeddable-form` joined in objectui#11440, two more arms + * with NO spec row and, unlike `object-chart`, no flat mirror either: each bag + * is built from the block's registration `inputs`, and no TypeScript + * declaration in this package restates the node. + * * Each arm also spreads `NODE_ENVELOPE` from `./public-blocks.zod.ts`, * the node-level `responsiveStyles` every public block declares by reference to * the spec's `PageComponentSchema` (objectui#10872 batch 8) — the same one @@ -4615,4 +4834,7 @@ export const ObjectQLPublicBlockComponentSchema = z.discriminatedUnion('type', [ ObjectChartBlockSchema, ObjectGanttBlockSchema, ObjectGridBlockSchema, + // objectui#11440 — two Tier A public blocks with no spec row; bags from their registration inputs. + ObjectPivotBlockSchema, + EmbeddableFormBlockSchema, ]); diff --git a/packages/types/src/zod/views.zod.ts b/packages/types/src/zod/views.zod.ts index e9266150e2..9d30d9eb60 100644 --- a/packages/types/src/zod/views.zod.ts +++ b/packages/types/src/zod/views.zod.ts @@ -505,6 +505,67 @@ export const SortUISchema = BaseSchema.extend({ ), }); +/** objectui#11440 / objectui#9256: ONE refusal string for both content channels of `DetailSectionNodeSchema`. */ +const DETAIL_SECTION_NEITHER_CHANNEL = + 'REFUSED (objectui#9256, ADR-0049) — `detail-section` reads NEITHER content channel: its registration hands the ' + + 'node to `DetailSectionNode`, which folds the declared inputs into the one `section` object `DetailSection` ' + + 'draws, and neither reads `children` or `body`; `SchemaRenderer` strips both out of the props bag it spreads. ' + + 'An authored value would render NOTHING — no render-time error or warning and no element; only the parser ' + + 'tier\'s `not-a-container` warning (objectui#9910) noticed it (the registration declares no `children` input). ' + + 'What it renders instead: a titled section of `fields`, laid out in ' + + '`columns`.'; + +/** + * `detail-section` — one field section as a node (objectui#11440, under the + * seat ruling `5945530142` on objectui#10859: "taught by the plugin-detail + * README, refused at `tabs.0.content` today"). + * + * ## The defect this closes + * + * `@object-ui/plugin-detail` registers `detail-section` (`DetailSectionNode`), + * and its README teaches the node inside a `detail-view` tab's `content`. No + * arm claimed the literal, so `safeValidateSchema` refused that document at + * `tabs.0.content` with `invalid_union` at the nested `type`. + * + * ## Why a node of its own, and not `record:details` sections + * + * The card allowed folding it into `record:details`'s `properties.sections` + * "if measurement shows that is the same shape". It does not: the spec's + * `record:details` section (`ComponentPropsMap['record:details']`) names its + * heading `label`, takes `fields` as bare field NAMES and carries `name` / + * `group`, while this node names its heading `title` and takes `fields` as + * `DetailViewField` objects (`{ name, label, … }`). Measured: the spec's row + * refuses the README's section with `unrecognized_keys` on `title` and + * `invalid_type` on each field object. + * + * ## The members + * + * Exactly the registration's `inputs` — the ten names `DetailSectionNode` + * folds (`DETAIL_SECTION_NODE_INPUTS`): {@link DetailViewSectionSchema}'s own + * members, by reference, flat on the node as the registration publishes them. + * `fields` is required there, so it is required here. `name` and `visible` are + * not section members on this node (`DetailSection` reads neither), so they + * keep `BaseSchema`'s node-level meaning. Neither content channel is read, so + * both are refused by name (objectui#9256). + */ +export const DetailSectionNodeSchema = BaseSchema.extend({ + type: z.literal('detail-section'), + ...DetailViewSectionSchema.pick({ + title: true, + description: true, + icon: true, + fields: true, + collapsible: true, + defaultCollapsed: true, + columns: true, + showBorder: true, + headerColor: true, + hideEmpty: true, + }).shape, + body: retirementTombstone(DETAIL_SECTION_NEITHER_CHANNEL), + children: retirementTombstone(DETAIL_SECTION_NEITHER_CHANNEL), +}); + /** * Union of all view schemas */ @@ -513,6 +574,8 @@ export const ViewComponentSchema = z.discriminatedUnion('type', [ ViewSwitcherSchema, FilterUISchema, SortUISchema, + // objectui#11440 — one field section as a node. + DetailSectionNodeSchema, ]); /** From 5a33770987d849274261a26b3890acb0cec11079 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:36:39 +0000 Subject: [PATCH 2/5] test(cli): the 11440 validate rows read no markdown; date-note the drill mirror's referencing declarations (objectui#11440) The end-to-end rows restate the plugin-detail example instead of reading its README, so the markdown-input ledger has nothing new to adjudicate. `.changeset/7352-drill-down-config-mirror.md` gains a pure-append dated note: the `object-pivot` arm is a third declaration extending the mirror. Claude-Session: https://claude.ai/code/session_01YLg8XqWGJ785fwQ5v4pH37 Co-authored-by: Claude --- .changeset/7352-drill-down-config-mirror.md | 10 ++++++ .../validate-passing-keys-11440.test.ts | 36 ++++++++----------- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/.changeset/7352-drill-down-config-mirror.md b/.changeset/7352-drill-down-config-mirror.md index f41764b95e..dc04575c36 100644 --- a/.changeset/7352-drill-down-config-mirror.md +++ b/.changeset/7352-drill-down-config-mirror.md @@ -68,3 +68,13 @@ change's reading, not the release's, and that mirror's `drillDown` member refuse the key rather than mirroring it. `.changeset/10932-pivot-drilldown-retired.md` (PR objectui#10972) states what ships; the text above is kept as the reading of this change. + +⚠️ **Dated note, 2026-10-02 — `object-pivot`'s arm references this mirror — objectui#11440.** +Later in this same release `object-pivot` gained an arm in `@object-ui/types/zod` +(`ObjectPivotBlockSchema`), whose `properties.drillDown` extends this entry's +`DrillDownConfigSchema` per block, with `mode` refused by name (objectui#10685). So "for the +release the referencing declarations are the opening paragraph's two" in the 2026-09-29 note +above no longer holds: they are three, `ChartSchema`, `ObjectDataTableSchema` and the +`object-pivot` arm, the last two each extending the mirror per block. +`.changeset/11440-arm-passing-types.md` states what ships. The rest of this entry is kept as the +reading of this change. diff --git a/packages/cli/src/__tests__/validate-passing-keys-11440.test.ts b/packages/cli/src/__tests__/validate-passing-keys-11440.test.ts index ddd7387db7..0e0f8d39e7 100644 --- a/packages/cli/src/__tests__/validate-passing-keys-11440.test.ts +++ b/packages/cli/src/__tests__/validate-passing-keys-11440.test.ts @@ -14,29 +14,25 @@ * objectstack's example apps author with `definePage`; * - the `app-schema-renderer` node the governed mobile guide teaches, its * `mobileNavMode` key (`skills/objectui/guides/mobile.md`); - * - the plugin-detail README's `detail-view` document, whose first tab's - * content is a `detail-section` node. + * - the plugin-detail package's own `detail-view` example, whose first tab's + * content is a `detail-section` node (its "With Tabs" section). * - * The README document is TSX, and its second tab hands `record:activity` a + * That example is TSX, and its second tab hands `record:activity` a * host feed (`items: activityData`), which objectui#11321 refuses by name in a - * JSON document — that refusal names this README as the TSX composition it - * stays legal in. So the row below validates the README's first tab as - * written, and the second row holds the host-feed refusal as the ONLY issue + * JSON document — that refusal names this example as the TSX composition it + * stays legal in. So the row below validates the example's first tab as + * written (restated here, not read from the page, so this file reads no + * markdown), and the second row holds the host-feed refusal as the ONLY issue * left on the full document. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { fileURLToPath } from 'node:url'; import { validate } from '../commands/validate.js'; -/** Rooted on this file, never on `process.cwd()`. */ -const HERE = dirname(fileURLToPath(import.meta.url)); -const PLUGIN_DETAIL_README = join(HERE, '..', '..', '..', 'plugin-detail', 'README.md'); - /** See `validate-root-path-line.test.ts` — the escape byte is never spelled. */ const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); @@ -79,7 +75,7 @@ async function run(name: string, document: unknown): Promise { return out.join('\n').replace(ANSI, ''); } -const README_DETAIL_SECTION = { +const EXAMPLE_DETAIL_SECTION = { type: 'detail-section', fields: [ { name: 'description', label: 'Description' }, @@ -118,18 +114,14 @@ describe('objectui validate — the documents objectui#11440 arms', () => { expect(exitCodes).toEqual([1]); }); - it('validates the plugin-detail README\'s `detail-view` with its `detail-section` tab', async () => { - // Lit control on the source: the README still teaches this node, in this shape. - const readme = readFileSync(PLUGIN_DETAIL_README, 'utf8'); - expect(readme).toContain("type: 'detail-section'"); - expect(readme).toContain("{ name: 'employees', label: 'Employee Count' }"); + it('validates the plugin-detail example\'s `detail-view` with its `detail-section` tab', async () => { const text = await run('detail-view-section', { type: 'detail-view', title: 'Account: Acme Corp', objectName: 'accounts', resourceId: '12345', fields: [{ name: 'name', label: 'Account Name' }, { name: 'industry', label: 'Industry' }], - tabs: [{ key: 'details', label: 'Details', content: README_DETAIL_SECTION }], + tabs: [{ key: 'details', label: 'Details', content: EXAMPLE_DETAIL_SECTION }], showEdit: true, showDelete: true, }); @@ -138,14 +130,14 @@ describe('objectui validate — the documents objectui#11440 arms', () => { expect(exitCodes).toEqual([0]); }); - it('on the README\'s full document, the only issue left is the host feed on `record:activity` (objectui#11321)', async () => { + it('on the example\'s full document, the only issue left is the host feed on `record:activity` (objectui#11321)', async () => { const text = await run('detail-view-full', { type: 'detail-view', title: 'Account: Acme Corp', objectName: 'accounts', resourceId: '12345', tabs: [ - { key: 'details', label: 'Details', content: README_DETAIL_SECTION }, + { key: 'details', label: 'Details', content: EXAMPLE_DETAIL_SECTION }, { key: 'activity', label: 'Activity', badge: '12', content: { type: 'record:activity', items: [] } }, ], }); From 30614b964774341e9875df039a304488ae931b79 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:19:47 +0000 Subject: [PATCH 3/5] fix(types): the page-kind arm joins the root union directly, not LayoutSchema (objectui#11440) `LayoutSchema`'s enum-keyed arms are the two html-tag families that `@object-ui/core`'s html-tier roster pin finds by shape; a third enum-keyed arm there turned that pin red. The zod README stops naming the app document's arm by symbol, which the objectui#7469 census reads. Claude-Session: https://claude.ai/code/session_01YLg8XqWGJ785fwQ5v4pH37 Co-authored-by: Claude --- packages/types/src/zod/README.md | 2 +- packages/types/src/zod/index.zod.ts | 7 ++++++- packages/types/src/zod/layout.zod.ts | 2 -- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md index c3029291c0..5bfbdd76cd 100644 --- a/packages/types/src/zod/README.md +++ b/packages/types/src/zod/README.md @@ -194,7 +194,7 @@ function validateComponent(config: unknown) { - `ScrollAreaSchema`, `ResizableSchema` - `AspectRatioSchema`, `PageSchema`, `PageKindNodeSchema` -`PageKindNodeSchema` (objectui#11440) is a stored `@objectstack/spec` page whose `type` is its page kind, `record`, `home` or `utility`: the spelling the spec's `PageSchema` declares, and the node `PageView` hands to the renderer. `@object-ui/components` registers the three kinds on the `page` node's renderer, so every member is `PageNodeSchema`'s own, by reference, and `type` is the spec's `PageTypeSchema` narrowed to those three. The spec's other two kinds are claimed elsewhere: `app` by `AppComponentSchema`, the app document, and `list` by the `list` component. Which spelling of a page is canonical is not decided here; `{ type: 'page', pageType }` validates as before. +`PageKindNodeSchema` (objectui#11440) is a stored `@objectstack/spec` page whose `type` is its page kind, `record`, `home` or `utility`: the spelling the spec's `PageSchema` declares, and the node `PageView` hands to the renderer. `@object-ui/components` registers the three kinds on the `page` node's renderer, so every member is `PageNodeSchema`'s own, by reference, and `type` is the spec's `PageTypeSchema` narrowed to those three. The spec's other two kinds are claimed elsewhere: `app` by the app document's arm, and `list` by the `list` component. Which spelling of a page is canonical is not decided here; `{ type: 'page', pageType }` validates as before. `FlexBlockSchema` is the authored `flex` node (objectui#11276's `flex` batch, the maintainer's ruling A on objectui#11300), built the way `ObjectChartBlockSchema` is: `@objectstack/spec` has no `ComponentPropsMap['flex']` row, so the bag's `direction`, `justify`, `align`, `gap` and `wrap` are the flat `FlexSchema` mirror's own members, by reference, and no row is invented. The child list is in the bag too, as `properties.children`: one node or a list, the list's entries judged by the spec's page walk like a `page:` container's. Each bag member written flat on the node is refused by name, naming `properties.KEY`, and `body` is refused toward `properties.children`. `FlexSchema` stays exported as the node as the `flex` renderer reads it after the hoist, and as code composes it; it is no longer an arm of `AnyComponentSchema`. `stack` and the other layout arms keep their props on the node. diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index eea82f1d03..112602b249 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -519,7 +519,7 @@ export { CloudPlanStatusSchema } from './cloud.zod.js'; import { z } from 'zod'; import { defineNodeComponentUnion } from './base.zod.js'; import { AppComponentSchema, AppSchemaRendererNodeSchema } from './app.zod.js'; -import { LayoutSchema } from './layout.zod.js'; +import { LayoutSchema, PageKindNodeSchema } from './layout.zod.js'; import { FormComponentSchema } from './form.zod.js'; import { DataDisplaySchema } from './data-display.zod.js'; import { FeedbackSchema } from './feedback.zod.js'; @@ -591,6 +591,11 @@ export const AnyComponentSchema = defineNodeComponentUnion(z.discriminatedUnion( AppComponentSchema, AppSchemaRendererNodeSchema, LayoutSchema, + // objectui#11440 — the spec page kinds `record` / `home` / `utility`. An arm + // of its own rather than a member of `LayoutSchema`, whose enum-keyed arms are + // the two html-tag families that `@object-ui/core`'s html-tier roster pin + // (`html-tier-intrinsics.test.ts`) finds by that shape. + PageKindNodeSchema, FormComponentSchema, DataDisplaySchema, FeedbackSchema, diff --git a/packages/types/src/zod/layout.zod.ts b/packages/types/src/zod/layout.zod.ts index 47f50b0981..56e4685c8a 100644 --- a/packages/types/src/zod/layout.zod.ts +++ b/packages/types/src/zod/layout.zod.ts @@ -1331,8 +1331,6 @@ export const LayoutSchema = z.discriminatedUnion('type', [ ResizableSchema, AspectRatioSchema, PageNodeSchema, - // objectui#11440 — the spec page kinds `record` / `home` / `utility`. - PageKindNodeSchema, SemanticElementSchema, HtmlElementSchema, ]); From b587b59fdd9b67a38aba60f9b7b87c41554baa68 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:50:37 +0000 Subject: [PATCH 4/5] feat(types): object-pivot and embeddable-form get named TypeScript node types, their required bags closed (objectui#11440) objectui#11468 (landed on main) derives `ObjectQLPublicBlockNode` from the union's arms, so the two new arms joined it. `ClosedBag` closed only an optional bag; the two required bags are closed now too, so no `AuthoringNode` member carries an open bag. `ObjectPivotBlockNode` and `EmbeddableFormBlockNode` are named and exported beside the other eight, and the objectui#11468 pin lists them. Claude-Session: https://claude.ai/code/session_01YLg8XqWGJ785fwQ5v4pH37 Co-authored-by: Claude --- ...authoring-nodes-bag-carriers-11468.test.ts | 16 +++++++++--- .../__tests__/passing-keys-arms-11440.test.ts | 17 +++++++++++++ packages/types/src/authoring-nodes.ts | 25 ++++++++++++++----- packages/types/src/index.ts | 2 ++ 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/packages/types/src/__tests__/authoring-nodes-bag-carriers-11468.test.ts b/packages/types/src/__tests__/authoring-nodes-bag-carriers-11468.test.ts index f3b5328330..94b9eb9201 100644 --- a/packages/types/src/__tests__/authoring-nodes-bag-carriers-11468.test.ts +++ b/packages/types/src/__tests__/authoring-nodes-bag-carriers-11468.test.ts @@ -33,6 +33,8 @@ import type { ObjectMapBlockNode, ObjectMasterDetailFormBlockNode, ObjectMetricBlockNode, + ObjectPivotBlockNode, + EmbeddableFormBlockNode, ObjectQLPublicBlockNode, ObjectTimelineBlockNode, PublicBlockNode, @@ -89,6 +91,8 @@ type _EachAliasIsItsArm = Assert< ObjectChartBlockNode['type'], ObjectGanttBlockNode['type'], ObjectGridBlockNode['type'], + ObjectPivotBlockNode['type'], + EmbeddableFormBlockNode['type'], ], [ 'object-metric', @@ -99,6 +103,8 @@ type _EachAliasIsItsArm = Assert< 'object-chart', 'object-gantt', 'object-grid', + 'object-pivot', + 'embeddable-form', ] > >; @@ -109,13 +115,15 @@ type _JoinedIntoAuthoringNode = Assert< // 3. No member of `AuthoringNode` carries an open bag. `flex` and `object-chart` // have no spec row: their bags are the flat mirrors' own members, kept // `.passthrough()` for the tolerant face, so reading the arm's shape alone -// leaves each bag with `[k: string]: unknown`. The control shows that, which -// is why the derivation reads the bag's shape too. +// leaves each bag with `[k: string]: unknown`. `object-pivot` and +// `embeddable-form` (objectui#11440) have no row either: their required bags +// are built from the registration inputs, kept `.passthrough()` the same way. +// The control shows that, which is why the derivation reads the bag's shape too. type _NoAuthoringNodeBagIsOpen = Assert, never>>; -type _ControlShapeOnlyLeavesTwoBagsOpen = Assert< +type _ControlShapeOnlyLeavesTheOwnMemberBagsOpen = Assert< Equal< OpenBagTypes>, - 'object-chart' | 'flex' + 'object-chart' | 'flex' | 'object-pivot' | 'embeddable-form' > >; diff --git a/packages/types/src/__tests__/passing-keys-arms-11440.test.ts b/packages/types/src/__tests__/passing-keys-arms-11440.test.ts index 9d47223118..18d71fdbad 100644 --- a/packages/types/src/__tests__/passing-keys-arms-11440.test.ts +++ b/packages/types/src/__tests__/passing-keys-arms-11440.test.ts @@ -40,6 +40,7 @@ import { StrictAnyComponentSchema, safeValidateSchema, } from '../zod/index.zod'; +import type { EmbeddableFormBlockNode, ObjectPivotBlockNode } from '../authoring-nodes'; type Issue = { code: string; path: PropertyKey[]; message: string; keys?: string[]; errors?: Issue[][] }; type Result = { success: boolean; error?: { issues: unknown[] } }; @@ -257,6 +258,22 @@ describe('`embeddable-form` — its required member (objectui#11440)', () => { }); }); +describe('`object-pivot` and `embeddable-form` — the TypeScript authoring face (objectui#11440)', () => { + // TYPE-level rows: judged by this package's `type-check` (`tsc -p + // tsconfig.test.json`), never by vitest, which strips types. + it('the bag is closed and required, and a flat prop is refused', () => { + const pivot: ObjectPivotBlockNode = { type: 'object-pivot', properties: { rowField: 'stage', columnField: 'owner', valueField: 'amount' } }; + // @ts-expect-error `rowFeild` is not a member of the object-pivot bag (the registration inputs, closed) + const misspelled: ObjectPivotBlockNode = { type: 'object-pivot', properties: { rowFeild: 'stage', columnField: 'owner', valueField: 'amount' } }; + // @ts-expect-error the bag is required: `rowField`, `columnField` and `valueField` live in it + const bagless: ObjectPivotBlockNode = { type: 'object-pivot' }; + // @ts-expect-error `formId` is a member of the bag, refused flat on the node by name + const flat: EmbeddableFormBlockNode = { type: 'embeddable-form', formId: 'contact-us', properties: { formId: 'contact-us' } }; + const form: EmbeddableFormBlockNode = { type: 'embeddable-form', properties: { formId: 'contact-us', objectName: 'lead' } }; + expect([pivot, misspelled, bagless, flat, form]).toHaveLength(5); + }); +}); + /* ── detail-section ──────────────────────────────────────────────────────── */ /** The `detail-section` node the plugin-detail README teaches inside a `detail-view` tab. */ diff --git a/packages/types/src/authoring-nodes.ts b/packages/types/src/authoring-nodes.ts index de2c2fa752..ce6c0110ff 100644 --- a/packages/types/src/authoring-nodes.ts +++ b/packages/types/src/authoring-nodes.ts @@ -106,11 +106,16 @@ import type { retirementTombstone } from './zod/tombstone.zod.js'; * for it. A bag built from a flat `.passthrough()` mirror (`flex`'s * `FlexPropsBag`, `object-chart`'s `ObjectChartPropsBag`, objectui#11276) keeps * the mirror's tolerant posture, which the strict authoring face closes, and - * this type closes it the same way. Any other member is left as it is. + * this type closes it the same way. So does a REQUIRED bag built from a + * block's registration inputs (`object-pivot`, `embeddable-form`, + * objectui#11440), which is a zod object rather than an optional one. Any + * other member is left as it is. */ type ClosedBag = Member extends z.ZodOptional ? z.ZodOptional> - : Member; + : Member extends z.ZodObject + ? z.ZodObject + : Member; /** * A zod object arm's authoring input, read off its shape with the object @@ -179,10 +184,12 @@ export type PublicBlockNodeOf = Extract; /** An authored `object-grid` node: the `ObjectGridBlockSchema` arm's input. */ export type ObjectGridBlockNode = ObjectQLPublicBlockNodeOf<'object-grid'>; +/** An authored `object-pivot` node: the `ObjectPivotBlockSchema` arm's input (objectui#11440). */ +export type ObjectPivotBlockNode = ObjectQLPublicBlockNodeOf<'object-pivot'>; + +/** An authored `embeddable-form` node: the `EmbeddableFormBlockSchema` arm's input (objectui#11440). */ +export type EmbeddableFormBlockNode = ObjectQLPublicBlockNodeOf<'embeddable-form'>; + /** * An authored `flex` node: the `FlexBlockSchema` arm's input (objectui#11468), * derived as {@link PublicBlockNode} is. Its props, the child list included, diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 13512ec12a..efdd46c442 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -147,6 +147,8 @@ export type { ObjectChartBlockNode, ObjectGanttBlockNode, ObjectGridBlockNode, + ObjectPivotBlockNode, + EmbeddableFormBlockNode, FlexBlockNode, ElementTextInputNode, ElementRecordPickerNode, From 34d127803178ca861c62c199df45c8c06ddc033c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:51:10 +0000 Subject: [PATCH 5/5] docs(changeset): the 11440 entry names the two new node types; date-note the 11468 entry's member count (objectui#11440) Claude-Session: https://claude.ai/code/session_01YLg8XqWGJ785fwQ5v4pH37 Co-authored-by: Claude --- .changeset/11440-arm-passing-types.md | 2 ++ .changeset/11468-bag-carrier-node-types.md | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/.changeset/11440-arm-passing-types.md b/.changeset/11440-arm-passing-types.md index 300970ba3b..e7690bc29a 100644 --- a/.changeset/11440-arm-passing-types.md +++ b/.changeset/11440-arm-passing-types.md @@ -16,3 +16,5 @@ - **`{ type: 'detail-section', fields, … }`** — `DetailSectionNodeSchema`, one field section of `@object-ui/plugin-detail` as a node: the ten `DetailViewSectionSchema` members its registration publishes (`title`, `description`, `icon`, `fields`, `collapsible`, `defaultCollapsed`, `columns`, `showBorder`, `headerColor`, `hideEmpty`), flat on the node, `fields` required. The plugin-detail README's `detail-view` tab with a `detail-section` content validates. `children` and `body` are refused by name. The strict authoring face (`StrictAnyComponentSchema`) derives from these arms and accepts the same documents, closed to undeclared keys. + +**The TypeScript face.** `@object-ui/types` exports `ObjectPivotBlockNode` and `EmbeddableFormBlockNode`, the two arms' authoring inputs, derived by reference as the other `ObjectQLPublicBlockNode` members are, so they are in `AuthoringNode` and `SchemaRenderer`'s `schema` prop accepts them. Each bag is required and closed: a misspelled bag key, a missing bag and a prop written flat do not compile. The page kinds were already typed (`PageDocumentNode`, the spec's own page input). diff --git a/.changeset/11468-bag-carrier-node-types.md b/.changeset/11468-bag-carrier-node-types.md index bed87507ac..a51ebedd5e 100644 --- a/.changeset/11468-bag-carrier-node-types.md +++ b/.changeset/11468-bag-carrier-node-types.md @@ -35,3 +35,12 @@ accepted before is still accepted. Reading is a different matter: on a union, a key that one closed member does not declare is TS2339, so code that reads a key off `SchemaRendererProps['schema']` or off `toRenderableSchema`'s return needs to narrow first. + +⚠️ **Dated note, 2026-10-02 — two more members — objectui#11440.** +Later in this same release `ObjectQLPublicBlockComponentSchema` gained the `object-pivot` and +`embeddable-form` arms, so `ObjectQLPublicBlockNode` derives ten members, and "The eight members +are also exported by name" above reads ten: `ObjectPivotBlockNode` and `EmbeddableFormBlockNode` +join the list. Their bags have no row either (each is built from the block's registration +inputs) and are REQUIRED, and the derivation closes a required bag as it closes an optional one. +`.changeset/11440-arm-passing-types.md` states what ships. The rest of this entry is kept as the +reading of this change.