diff --git a/.changeset/10872-held-public-block-arms.md b/.changeset/10872-held-public-block-arms.md new file mode 100644 index 0000000000..fda451cad1 --- /dev/null +++ b/.changeset/10872-held-public-block-arms.md @@ -0,0 +1,24 @@ +--- +'@object-ui/types': minor +--- + +`safeValidateSchema`, and so `objectui validate`, accepts the six ADR-0080 public blocks held back until `@objectstack/spec` carried a `ComponentPropsMap` row for each: `action:button`, `action:icon`, `action:group`, `action:menu`, `element:definition-list` and `element:repeater` (objectui#10872, batch 4). Their rows arrived in `@objectstack/spec` 17.5.0, measured at the renderers' read points (objectstack-ai/objectstack#20371). + +**Clause-②: yes.** The accept set of `AnyComponentSchema` widens by six `type` literals, and `@object-ui/types/zod` exports six new schemas. Nothing that parsed before is refused now. + +**What it was.** Each of these types is registered and curated as platform contract by ADR-0080, and `AnyComponentSchema` carried no arm for any of them. So every document naming one was refused with `invalid_union` at `type`. That included `action:button`, the node the validator's own handler-key refusals tell an author to write instead of an `onClick`. + +**What changed, in observable terms.** + +- `@object-ui/types/zod` exports `ActionButtonBlockSchema`, `ActionIconBlockSchema`, `ActionGroupBlockSchema`, `ActionMenuBlockSchema`, `ElementDefinitionListBlockSchema` and `ElementRepeaterBlockSchema`, each a member of `PublicBlockComponentSchema`. The strict authoring face (`StrictAnyComponentSchema`) accepts the same documents, closed to undeclared keys like every other arm. +- Each arm's `properties` bag is the block's spec row, read by reference: its members, value types and strictness are the spec's. The rows were measured at what the renderers read, not at what the registrations publish, so the validator refuses what no renderer reads: + - a group-level `name` on `action:group`, with the spec's own prescription; + - `size: "md"` on `action:group` and `action:menu`, whose renderers hand it to the Button primitive unmapped; + - the strings `"1"` / `"2"` for `element:definition-list`'s `columns`, where the renderer compares the number `2`: write `columns: 2`; + - a `label` on an `element:repeater` `fields` entry, which the list never prints. +- `element:repeater` requires `properties.object`: its renderer never queries without one, and it does not read the node's `dataSource`. +- `action:button` and `action:icon` also refuse two keys their renderers read off the node, each by name: + - an authored `onClick`, a runtime slot for a host-supplied function (objectui#6124), as `ButtonSchema.onClick` is; + - a flat `onSuccess`, with a message naming `properties.onSuccess`, where the spec row declares the post-success `{ navigate, openIn }` block. The spec's own page component refuses the flat spelling too. +- A prop written directly on the node rather than in `properties` is treated as on every other public block. The tolerant face passes a key `BaseSchema` does not declare, unjudged; the strict face refuses it. So the taught `{ "type": "action:button", "label": "Open details", "actionType": "url", "target": "/users/ada" }` validates through `objectui validate` as it runs today, while `StrictAnyComponentSchema` refuses its flat `actionType` and `target`, as `@objectstack/spec`'s `PageComponentSchema` does. The same node with its props in `properties` passes both faces. +- `record:line_items` is still refused at `type`: the spec has no row for it yet. diff --git a/.changeset/10872-public-block-zod-arms.md b/.changeset/10872-public-block-zod-arms.md index 442d48f65c..3383e6ffab 100644 --- a/.changeset/10872-public-block-zod-arms.md +++ b/.changeset/10872-public-block-zod-arms.md @@ -19,3 +19,5 @@ ⚠️ **Dated note, 2026-09-28 — `element:number` armed — objectui#10872 batch 2.** Later in this same release `element:number` is armed as well (`ElementNumberBlockSchema`, with the spec's `dataSource` waiver on its required `object`), so the lead sentence's "less `element:number`" and the first item of the "Not armed in this release" bullet no longer describe the release as a whole; the other held blocks are still refused at `type`. The rest of this entry is kept as the reading of this change. ⚠️ **Dated note, 2026-09-29 — `record:alert`'s flat `body` — objectui#10872 batch 3.** Later in this same release `record:alert`'s arm restates `body` with its own refusal, which names `properties.body`. So the bullet on a prop written directly on the node, where it says "`body` (`page:card`, `record:alert`) is refused on both faces by the base's objectui#6771 retirement", no longer describes the release as a whole: that holds for `page:card` only, and a flat `body` on `record:alert` is still refused on both faces, by the arm's own refusal instead. The bullet's advice, to write a `record:alert` banner's `body` inside `properties`, is what that refusal now says. The rest of this entry is kept as the reading of this change. + +⚠️ **Dated note, 2026-09-30 — the six held blocks armed — objectui#10872 batch 4.** Later in this same release `@objectstack/spec` 17.5.0 carries a `ComponentPropsMap` row for `element:definition-list`, `element:repeater`, `action:button`, `action:group`, `action:menu` and `action:icon`, and each is armed from its row (`.changeset/10872-held-public-block-arms.md`). So the last two blocks of the "Not armed in this release" bullet no longer describe the release as a whole: of the held blocks, only `record:line_items` is still refused at `type`. The rest of this entry is kept as the reading of this change. 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 e3eef9eddd..c761845423 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 @@ -75,10 +75,11 @@ const REFUSED_AT_TYPE = 73; * The head's refused count over the NAMESPACED keys (objectui#10872 batch 1: * 418 on `main` before it, minus the twenty ADR-0080 public blocks armed from * their `@objectstack/spec` `ComponentPropsMap` rows; batch 2: minus - * `element:number`, armed with the spec's `dataSource` waiver). LOWER it when - * a batch arms more keys; never raise it. + * `element:number`, armed with the spec's `dataSource` waiver; batch 4: minus + * the six blocks `@objectstack/spec` 17.5.0 gave a row). LOWER it when a batch + * arms more keys; never raise it. */ -const NAMESPACED_REFUSED_AT_TYPE = 397; +const NAMESPACED_REFUSED_AT_TYPE = 391; /** The bare registry keys — the population the card measured. */ const BARE_KEYS = KNOWN_SCHEMA_TYPES.filter((key) => !key.includes(':')); @@ -100,6 +101,11 @@ const ARMED_PUBLIC_BLOCKS_10872 = [ /** The public block objectui#10872 batch 2 armed. */ const ARMED_PUBLIC_BLOCKS_10872_BATCH_2 = ['element:number'] as const; +/** The six public blocks objectui#10872 batch 4 armed, held until `@objectstack/spec` 17.5.0 carried their rows. */ +const ARMED_PUBLIC_BLOCKS_10872_BATCH_4 = [ + 'action:button', 'action:icon', 'action:group', 'action:menu', 'element:definition-list', 'element:repeater', +] as const; + /** Is `type` unclaimed by every arm of the validator's root union? */ function refusedAtType(type: string): boolean { const result = safeValidateSchema({ type }); @@ -197,6 +203,13 @@ describe('registered NAMESPACED component types refused at `type` — a ratchet } }); + it('counts the six public blocks objectui#10872 batch 4 armed', () => { + for (const key of ARMED_PUBLIC_BLOCKS_10872_BATCH_4) { + expect(NAMESPACED_KEYS, key).toContain(key); + expect(refusedAtType(key), key).toBe(false); + } + }); + it('counts `cloud:plan-status` armed — it registered WITH its arm (objectui#10919)', () => { // One registry key (`skipFallback: true`, so no bare `plan-status`), armed in // `@object-ui/types/zod` in the same change, so the pin above did not move. @@ -211,6 +224,7 @@ describe('registered NAMESPACED component types refused at `type` — a ratchet /** Rooted on this file, never on `process.cwd()`. */ const HERE = dirname(fileURLToPath(import.meta.url)); const PLUGIN_AI_README = join(HERE, '..', '..', '..', 'plugin-ai', 'README.md'); +const QUICK_START = join(HERE, '..', '..', '..', '..', 'content', 'docs', 'guide', 'quick-start.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'); @@ -347,4 +361,70 @@ describe('objectui validate — a page built from ADR-0080 public blocks (object expect(text).toContain('dataSource.object'); expect(exitCodes).toEqual([1]); }); + + it('validates a page with the taught `action:button` node (objectui#10872 batch 4)', async () => { + // The node the quick-start's "Add Actions" section teaches — the one + // AGENTS.md #4 and the handler-key refusals' own remedy point at — read + // from the page that teaches it, beside a `page:header`. + const quickStart = readFileSync(QUICK_START, 'utf8'); + const fence = /```json\n([\s\S]*?)\n```/.exec(quickStart.slice(quickStart.indexOf('### Add Actions'))); + expect(fence, 'no ```json fence under "### Add Actions" in the quick-start').not.toBeNull(); + const taught = JSON.parse((fence as RegExpExecArray)[1]); + // Lit control on the extraction: it is the taught node. + expect(taught.type).toBe('action:button'); + const page = { + type: 'page', + title: 'Users', + children: [{ type: 'page:header', properties: { title: 'Users' } }, taught], + }; + const file = join(dir, 'action-button-page.json'); + writeFileSync(file, JSON.stringify(page, null, 2), 'utf-8'); + + await validate(file); + + const text = out.join('\n').replace(ANSI, ''); + expect(text).not.toContain('Schema validation failed'); + expect(text).toContain('Schema is valid'); + expect(exitCodes).toEqual([0]); + }); + + it('validates a page of the six batch-4 blocks in the spec\'s `properties` spelling', async () => { + const page = { + type: 'page', + children: [ + { type: 'action:button', properties: { label: 'Open details', actionType: 'url', target: '/users/ada' } }, + { type: 'action:icon', properties: { icon: 'pencil', label: 'Edit', actionType: 'url', target: '/users/ada/edit' } }, + { type: 'action:group', properties: { display: 'dropdown', actions: [{ name: 'archive', label: 'Archive' }] } }, + { type: 'action:menu', properties: { actions: [{ name: 'delete', label: 'Delete' }] } }, + { type: 'element:definition-list', properties: { columns: 2, items: [{ term: 'Owner', description: 'Ada' }] } }, + { type: 'element:repeater', properties: { object: 'task', fields: ['subject'], limit: 5 } }, + ], + }; + const file = join(dir, 'held-blocks-page.json'); + writeFileSync(file, JSON.stringify(page, null, 2), 'utf-8'); + + await validate(file); + + const text = out.join('\n').replace(ANSI, ''); + expect(text).not.toContain('Schema validation failed'); + expect(text).toContain('Schema is valid'); + expect(exitCodes).toEqual([0]); + }); + + it('still judges an `action:button` bag — an undeclared prop is refused and named (batch 4)', async () => { + // The control that keeps the two rows above from passing for the wrong reason. + const page = { + type: 'page', + children: [{ type: 'action:button', properties: { label: 'Go', inventedProp10872b4: true } }], + }; + const file = join(dir, 'action-button-page-refused.json'); + writeFileSync(file, JSON.stringify(page, null, 2), 'utf-8'); + + await validate(file); + + const text = out.join('\n').replace(ANSI, ''); + expect(text).toContain('Schema validation failed'); + expect(text).toContain('inventedProp10872b4'); + expect(exitCodes).toEqual([1]); + }); }); diff --git a/packages/types/src/__tests__/held-public-block-arms-10872.test.ts b/packages/types/src/__tests__/held-public-block-arms-10872.test.ts new file mode 100644 index 0000000000..409caf98ef --- /dev/null +++ b/packages/types/src/__tests__/held-public-block-arms-10872.test.ts @@ -0,0 +1,267 @@ +/** + * 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. + */ + +/** + * The six public blocks held back from batch 1 validate by their spec + * `ComponentPropsMap` rows (objectui#10872, batch 4). + * + * ## The defect these pin + * + * `action:button`, `action:group`, `action:menu`, `action:icon`, + * `element:definition-list` and `element:repeater` are registered and curated + * by ADR-0080, and `AnyComponentSchema` carried no arm for any of them — so + * `safeValidateSchema` and `objectui validate` refused every document naming + * one with `invalid_union` at `type`. That included the node the validator's + * own handler-key remedy teaches (`action:button`). They were held because + * `@objectstack/spec` had no row for them; 17.5.0 carries one for each, + * measured at the renderers' read points (objectstack-ai/objectstack#20371). + * + * ## What is read, and against what + * + * Every reading below is taken against the INSTALLED spec's row, so a row the + * spec moves moves these readings with it. The "read point" block pins where + * the rows disagree with the blocks' registrations — each a key a registration + * publishes that no renderer reads — at the validator: the arm follows the + * row, so it refuses what the row refuses. The registrations themselves are + * not this file's (see `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`). + */ + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; +import { ComponentPropsMap, PageComponentSchema } from '@objectstack/spec/ui'; + +import { + ActionButtonBlockSchema, + ActionGroupBlockSchema, + ActionIconBlockSchema, + ActionMenuBlockSchema, + ElementDefinitionListBlockSchema, + ElementRepeaterBlockSchema, + PublicBlockComponentSchema, + StrictAnyComponentSchema, + safeValidateSchema, +} from '../zod/index.zod.js'; +import { stripImportedDefaults } from '../zod/imported-defaults.js'; + +type Issue = { code: string; path: PropertyKey[]; message: string; keys?: string[]; errors?: Issue[][] }; +type Result = { success: boolean; error?: { issues: z.core.$ZodIssue[] } }; + +const FACES: ReadonlyArray Result]> = [ + ['tolerant', (document) => safeValidateSchema(document)], + ['strict', (document) => StrictAnyComponentSchema.safeParse(document)], +]; + +/** The six batch-4 blocks and the arm each one exports. */ +const ARMS: ReadonlyArray = [ + ['action:button', ActionButtonBlockSchema as unknown as z.ZodObject], + ['action:icon', ActionIconBlockSchema as unknown as z.ZodObject], + ['action:group', ActionGroupBlockSchema as unknown as z.ZodObject], + ['action:menu', ActionMenuBlockSchema as unknown as z.ZodObject], + ['element:definition-list', ElementDefinitionListBlockSchema as unknown as z.ZodObject], + ['element:repeater', ElementRepeaterBlockSchema as unknown as z.ZodObject], +]; +const TYPES = ARMS.map(([type]) => type); + +const rowOf = (type: string): z.ZodType => (ComponentPropsMap as unknown as Record)[type]; +const bagOf = (arm: z.ZodObject): z.ZodType => (arm.shape.properties as z.ZodOptional).unwrap() as z.ZodType; +const keysOf = (schema: z.ZodType): string[] => + Object.keys((schema as unknown as { shape?: Record }).shape ?? {}).sort(); + +/** 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 unknown as Issue[] | undefined); +const at = (result: Result, path: string): Issue[] => issuesOf(result).filter((i) => i.path.join('.') === path); +const refusedKeys = (result: Result): string[] => + issuesOf(result).flatMap((i) => (i.code === 'unrecognized_keys' ? i.keys ?? [] : [])); + +describe('objectui#10872 batch 4 — the six held blocks are armed, by reference', () => { + it('each arm is a member of `PublicBlockComponentSchema`, under its own literal', () => { + const options = (PublicBlockComponentSchema as unknown as { options: z.ZodObject[] }).options; + for (const [type, arm] of ARMS) { + expect(options, type).toContain(arm); + expect((arm.shape.type as z.ZodLiteral).value, type).toBe(type); + } + }); + + it.each(TYPES)('%s: the spec carries a row, and it is in the page vocabulary', (type) => { + expect(rowOf(type), `${type} has no ComponentPropsMap row on the installed spec`).toBeDefined(); + expect(PageComponentSchema.safeParse({ type }).success).toBe(true); + }); + + it.each(ARMS)('%s: the bag IS the spec row — nothing restated, nothing stripped', (type, arm) => { + const row = rowOf(type); + // All six rows carry no default and no `z.lazy`, so the import boundary is + // the identity function on them (objectui#8317)… + expect(stripImportedDefaults(row)).toBe(row); + // …and the arm holds the spec's own schema: its DEFINITION is the row's. + // Not `toBe(row)`: 17.5.0 publishes these rows as lazy proxies whose + // methods run on the real schema, so `.optional()` wraps the object behind + // the proxy, and the definition is the identity that survives. + expect((bagOf(arm) as unknown as { _zod: { def: unknown } })._zod.def) + .toBe((row as unknown as { _zod: { def: unknown } })._zod.def); + expect(keysOf(bagOf(arm))).toEqual(keysOf(row)); + }); + + it.each(TYPES)('%s: no longer refused at `type` — the minimal node parses on both faces', (type) => { + for (const [face, judge] of FACES) { + const result = judge({ type }); + expect(result.success, `${face}: ${JSON.stringify(result.error?.issues)}`).toBe(true); + } + }); + + it('CONTROL — an unarmed namespaced literal is still refused at `type`', () => { + const result = safeValidateSchema({ type: 'action:no-such-control-10872' }); + expect(result.success).toBe(false); + expect(at(result, 'type').map((i) => i.code)).toContain('invalid_union'); + }); +}); + +describe('objectui#10872 batch 4 — the arm follows the row, which follows the read points', () => { + it.each(FACES)('%s face: `action:group` refuses a group-level `name`, which no renderer reads', (_face, judge) => { + const result = judge({ type: 'action:group', properties: { name: 'row_actions', actions: [] } }); + expect(result.success).toBe(false); + expect(refusedKeys(result)).toContain('name'); + // Control: the same bag without it parses. + expect(judge({ type: 'action:group', properties: { actions: [] } }).success).toBe(true); + }); + + it.each(FACES)('%s face: `action:group` refuses `size: "md"`, which inline mode hands the primitive raw', (_face, judge) => { + const result = judge({ type: 'action:group', properties: { size: 'md' } }); + expect(result.success).toBe(false); + expect(at(result, 'properties.size').length).toBeGreaterThan(0); + expect(judge({ type: 'action:group', properties: { size: 'sm' } }).success).toBe(true); + // `action:menu` hands its `size` to the primitive unmapped on every path. + expect(at(judge({ type: 'action:menu', properties: { size: 'md' } }), 'properties.size').length).toBeGreaterThan(0); + expect(judge({ type: 'action:menu', properties: { size: 'icon' } }).success).toBe(true); + }); + + it.each(FACES)('%s face: `element:definition-list` takes the NUMBER `columns` the renderer compares', (_face, judge) => { + const result = judge({ type: 'element:definition-list', properties: { columns: '2' } }); + expect(result.success).toBe(false); + expect(at(result, 'properties.columns').length).toBeGreaterThan(0); + expect(judge({ type: 'element:definition-list', properties: { columns: 2 } }).success).toBe(true); + }); + + it.each(FACES)('%s face: `element:definition-list` items are strict `{ term, description }`', (_face, judge) => { + // The `label` / `value` items the designer wrote until objectui#8279, every row blank. + const result = judge({ type: 'element:definition-list', properties: { items: [{ label: 'Owner', value: 'Ada' }] } }); + expect(result.success).toBe(false); + expect(refusedKeys(result)).toEqual(expect.arrayContaining(['label', 'value'])); + }); + + it.each(FACES)('%s face: `element:repeater` refuses a `fields[].label`, which the list never prints', (_face, judge) => { + const result = judge({ type: 'element:repeater', properties: { object: 'task', fields: [{ field: 'subject', label: 'Subject' }] } }); + expect(result.success).toBe(false); + expect(refusedKeys(result)).toContain('label'); + expect(judge({ type: 'element:repeater', properties: { object: 'task', fields: [{ field: 'subject' }, 'status'] } }).success) + .toBe(true); + }); + + it.each(FACES)('%s face: `element:repeater` requires `object` — no `dataSource` waiver, it never reads one', (_face, judge) => { + const result = judge({ type: 'element:repeater', properties: { limit: 5 } }); + expect(result.success).toBe(false); + expect(at(result, 'properties.object').length).toBeGreaterThan(0); + }); + + it.each(FACES)('%s face: `objectName` is the two single actions\' key, and each MEMBER\'s on the containers', (_face, judge) => { + expect(judge({ type: 'action:button', properties: { label: 'Log call', objectName: 'task' } }).success).toBe(true); + expect(judge({ type: 'action:icon', properties: { icon: 'phone', objectName: 'task' } }).success).toBe(true); + // The containers forward it per member, so it rides the member object… + expect(judge({ type: 'action:group', properties: { actions: [{ name: 'log', objectName: 'task' }] } }).success).toBe(true); + expect(judge({ type: 'action:menu', properties: { actions: [{ name: 'log', objectName: 'task' }] } }).success).toBe(true); + // …and the container rows refuse it at container level. + expect(refusedKeys(judge({ type: 'action:group', properties: { objectName: 'task' } }))).toContain('objectName'); + expect(refusedKeys(judge({ type: 'action:menu', properties: { objectName: 'task' } }))).toContain('objectName'); + }); +}); + +describe('objectui#10872 batch 4 — the two handler keys the action renderers read off the node', () => { + it.each([['action:button'], ['action:icon']])('%s refuses an authored `onClick` by name — a runtime slot (objectui#6124)', (type) => { + for (const [face, judge] of FACES) { + const result = judge({ type, onClick: { action: 'toast' } }); + expect(result.success, face).toBe(false); + const issue = at(result, 'onClick')[0]; + expect(issue?.code, face).toBe('custom'); + expect(issue?.message, face).toContain('RUNTIME SLOT'); + // Control: the node without it parses. + expect(judge({ type }).success, face).toBe(true); + } + // The row does not take it in the bag either. + expect(refusedKeys(safeValidateSchema({ type, properties: { onClick: { action: 'toast' } } }))).toContain('onClick'); + }); + + it.each([['action:button'], ['action:icon']])('%s refuses a flat `onSuccess`, naming `properties.onSuccess`', (type) => { + for (const [face, judge] of FACES) { + const result = judge({ type, onSuccess: { navigate: '/tasks' } }); + expect(result.success, face).toBe(false); + const issue = at(result, 'onSuccess')[0]; + expect(issue?.code, face).toBe('invalid_type'); + expect(issue?.message, face).toContain('Did you mean `onSuccess` → `properties.onSuccess`?'); + // What the remedy sends the author to is accepted. + expect(judge({ type, properties: { onSuccess: { navigate: '/tasks' } } }).success, face).toBe(true); + } + // The spec's own page component refuses the flat spelling as well. + expect(PageComponentSchema.safeParse({ type, onSuccess: { navigate: '/tasks' } }).success).toBe(false); + }); +}); + +/* ── The taught `action:button` node ──────────────────────────────────────── */ + +/** Rooted on this file, never on `process.cwd()`. */ +const HERE = dirname(fileURLToPath(import.meta.url)); +const QUICK_START = join(HERE, '..', '..', '..', '..', 'content', 'docs', 'guide', 'quick-start.md'); + +/** The quick-start's "Add Actions" node — the one AGENTS.md #4 teaches, read from the page that teaches it. */ +function taughtActionButton(): Record { + const doc = readFileSync(QUICK_START, 'utf8'); + const section = doc.slice(doc.indexOf('### Add Actions')); + const fence = /```json\n([\s\S]*?)\n```/.exec(section); + if (!fence) throw new Error('no ```json fence under "### Add Actions" in the quick-start'); + return JSON.parse(fence[1]) as Record; +} + +describe('objectui#10872 batch 4 — a page with an `action:button` validates', () => { + it('reads the taught node (non-vacuity)', () => { + const node = taughtActionButton(); + expect(node.type).toBe('action:button'); + expect(node.actionType).toBe('url'); + }); + + it('the taught node, in a page, passes the tolerant face `objectui validate` runs', () => { + const page = { type: 'page', title: 'Users', children: [{ type: 'page:header', properties: { title: 'Users' } }, taughtActionButton()] }; + const result = safeValidateSchema(page); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); + + it.each(FACES)('%s face: the same node in the spec\'s spelling — its props in `properties` — validates', (_face, judge) => { + const { type, ...props } = taughtActionButton(); + const page = { type: 'page', children: [{ type: 'page:header', properties: { title: 'Users' } }, { type, properties: props }] }; + const result = judge(page); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); + + it('⚠️ the strict face refuses the taught node\'s FLAT props — the flat-props question objectui#10872 holds', () => { + // A prop written flat on the node is not judged against the row: `label` is + // a `BaseSchema` key, and `actionType` / `target` are undeclared there, so + // the strict face refuses exactly those two, as the spec's own + // `PageComponentSchema` does. Whether the flat spelling becomes an + // authoring channel for `action:*` is that card's call; this reading moves + // with it. + const result = StrictAnyComponentSchema.safeParse(taughtActionButton()); + expect(result.success).toBe(false); + expect(refusedKeys(result).sort()).toEqual(['actionType', 'target']); + expect(PageComponentSchema.safeParse(taughtActionButton()).success).toBe(false); + }); +}); diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index 884180b00f..9655e69ae7 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -102,6 +102,13 @@ import { RecordAlertProps as SpecRecordAlertProps, ElementTextPropsSchema as SpecElementTextPropsSchema, ElementButtonPropsSchema as SpecElementButtonPropsSchema, + // objectui#10872 batch 4 — the six rows `@objectstack/spec` 17.5.0 carries. + ElementDefinitionListPropsSchema as SpecElementDefinitionListPropsSchema, + ElementRepeaterPropsSchema as SpecElementRepeaterPropsSchema, + ActionButtonPropsSchema as SpecActionButtonPropsSchema, + ActionIconPropsSchema as SpecActionIconPropsSchema, + ActionGroupPropsSchema as SpecActionGroupPropsSchema, + ActionMenuPropsSchema as SpecActionMenuPropsSchema, // objectui#10872 batch 2 — `element:number`'s row, and the node's `dataSource`. ElementNumberPropsSchema as SpecElementNumberPropsSchema, ElementDataSourceSchema as SpecElementDataSourceSchema, @@ -264,6 +271,16 @@ const IMPORTED: Array = [ ['RecordAlertProps', SpecRecordAlertProps], ['ElementTextPropsSchema', SpecElementTextPropsSchema], ['ElementButtonPropsSchema', SpecElementButtonPropsSchema], + // objectui#10872 batch 4: the six held blocks' arms read their rows as their + // `properties` bags, each crossed through this boundary like every other + // row. None carries a default or reaches a `z.lazy`, so each strip is the + // identity function. + ['ElementDefinitionListPropsSchema', SpecElementDefinitionListPropsSchema], + ['ElementRepeaterPropsSchema', SpecElementRepeaterPropsSchema], + ['ActionButtonPropsSchema', SpecActionButtonPropsSchema], + ['ActionIconPropsSchema', SpecActionIconPropsSchema], + ['ActionGroupPropsSchema', SpecActionGroupPropsSchema], + ['ActionMenuPropsSchema', SpecActionMenuPropsSchema], // objectui#10872 batch 2: the `element:number` arm's bag is its row (with // `object` made optional after the strip), and its node declares // `dataSource` as the spec's element binding — two crossings, measured here diff --git a/packages/types/src/__tests__/public-block-arms-10872.test.ts b/packages/types/src/__tests__/public-block-arms-10872.test.ts index 90cdb4c664..003ebd8c51 100644 --- a/packages/types/src/__tests__/public-block-arms-10872.test.ts +++ b/packages/types/src/__tests__/public-block-arms-10872.test.ts @@ -71,6 +71,14 @@ const VALID_BAG: Readonly>> = { 'element:number': { object: 'order', aggregate: 'count' }, 'element:button': { label: 'Go' }, 'element:divider': {}, + // objectui#10872 batch 4 — the six rows `@objectstack/spec` 17.5.0 carries; + // their own readings are pinned in `./held-public-block-arms-10872.test.ts`. + 'element:definition-list': { items: [{ term: 'Owner', description: 'Ada' }], columns: 2 }, + 'element:repeater': { object: 'task', fields: ['subject', { field: 'status' }], limit: 5 }, + 'action:button': { label: 'Open details', actionType: 'url', target: '/users/ada' }, + 'action:icon': { icon: 'pencil', label: 'Edit', actionType: 'url', target: '/users/ada/edit' }, + 'action:group': { actions: [{ name: 'edit', label: 'Edit' }], display: 'dropdown' }, + 'action:menu': { actions: [{ name: 'archive', label: 'Archive' }] }, }; /** The armed literals, in the order `VALID_BAG` lists them. */ @@ -173,7 +181,13 @@ describe('the bag is the spec row, read by reference (objectui#10872)', () => { (type) => { const row = rowOf(type); if (stripImportedDefaults(row) === row) { - expect(bagOf(type)).toBe(row); + // The same schema, compared by DEFINITION (objectui#10872 batch 4): + // `@objectstack/spec` 17.5.0 publishes some rows as lazy proxies whose + // methods run on the real schema, so the bag's `.optional()` wraps the + // object behind the proxy, never the proxy itself. A non-proxy row + // answers the same way: one object, one definition. + expect((bagOf(type) as unknown as { _zod: { def: unknown } })._zod.def) + .toBe((row as unknown as { _zod: { def: unknown } })._zod.def); } else { // The row carries a spec default the boundary removes (objectui#8317): // the bag is the stripped clone, which answers the fixture as the row does. diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 746abdcd2f..68625b6bec 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4043,6 +4043,24 @@ const EXCLUSIONS: Readonly> = { "spec-owned BY REFERENCE — `BaseSchema` + the `element:button` literal + `properties`, which IS `ComponentPropsMap['element:button']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#ElementDividerBlockSchema': "spec-owned, RESTATED — `BaseSchema` + the `element:divider` literal + a member-less strict `properties`, because `@objectstack/spec/ui` exports `ComponentPropsMap['element:divider']` under no name to read by reference; its key set and accept set are pinned to the row's in `./public-block-arms-10872.test.ts`, and no TS declaration in this package restates the node", + // objectui#10872 batch 4 — the six blocks held until `@objectstack/spec` + // 17.5.0 carried their rows. Same shape as the rows above, minus the + // objectui#9256 channel refusals (that measurement never covered them); + // `action:button` / `action:icon` add the two handler keys their renderers + // read off the node. `action:group` / `action:menu` have renderer-side + // interfaces in `@object-ui/components`, outside this package. + 'public-blocks.zod.ts#ElementDefinitionListBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `element:definition-list` literal + `properties`, which IS `ComponentPropsMap['element:definition-list']`; no TS declaration in this package restates the node", + 'public-blocks.zod.ts#ElementRepeaterBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `element:repeater` literal + `properties`, which IS `ComponentPropsMap['element:repeater']`; no TS declaration in this package restates the node", + 'public-blocks.zod.ts#ActionButtonBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `action:button` literal + `properties`, which IS `ComponentPropsMap['action:button']`, + the `onClick` runtime-slot refusal (objectui#6124) and the flat-`onSuccess` alias refusal naming `properties.onSuccess`, the two handler keys its renderer reads off the node; no TS declaration in this package restates the node", + 'public-blocks.zod.ts#ActionIconBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `action:icon` literal + `properties`, which IS `ComponentPropsMap['action:icon']`, + the `onClick` runtime-slot refusal (objectui#6124) and the flat-`onSuccess` alias refusal naming `properties.onSuccess`, the two handler keys its renderer reads off the node; no TS declaration in this package restates the node", + 'public-blocks.zod.ts#ActionGroupBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `action:group` literal + `properties`, which IS `ComponentPropsMap['action:group']`; no TS declaration in this package restates the node", + 'public-blocks.zod.ts#ActionMenuBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `action:menu` literal + `properties`, which IS `ComponentPropsMap['action:menu']`; no TS declaration in this package restates the node", 'public-blocks.zod.ts#PublicBlockComponentSchema': "a union OVER the public-block arms, not an object of its own — its members are accounted for individually above", // objectui#10919 — the `cloud:plan-status` widget's arm, declared locally from diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md index f51d465978..396d1eb89b 100644 --- a/packages/types/src/zod/README.md +++ b/packages/types/src/zod/README.md @@ -238,15 +238,19 @@ The ADR-0080 public blocks `object-metric` and `object-master-detail-form`: each ### AI Components (3) - `AIFormAssistSchema`, `AIRecommendationsSchema`, `NLQuerySchema` -### ADR-0080 Public Blocks (21) +### 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. -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). 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. +Every arm before the six below, 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). 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` - `PageSectionBlockSchema`, `PageFooterBlockSchema`, `PageSidebarBlockSchema` - `RecordDetailsBlockSchema`, `RecordHighlightsBlockSchema`, `RecordRelatedListBlockSchema` - `RecordPathBlockSchema`, `RecordActivityBlockSchema`, `RecordDiscussionBlockSchema` - `RecordHistoryBlockSchema`, `RecordQuickActionsBlockSchema`, `RecordReferenceRailBlockSchema` - `RecordAlertBlockSchema`, `ElementTextBlockSchema`, `ElementNumberBlockSchema`, `ElementButtonBlockSchema`, `ElementDividerBlockSchema` +- `ElementDefinitionListBlockSchema`, `ElementRepeaterBlockSchema` +- `ActionButtonBlockSchema`, `ActionIconBlockSchema`, `ActionGroupBlockSchema`, `ActionMenuBlockSchema` + +The last six arrived with `@objectstack/spec` 17.5.0, whose rows for them were measured at their renderers' reads (objectui#10872 batch 4). They carry no content-channel refusal: objectui#9256 never measured them, so they take `BaseSchema`'s `children` and `body` as they are. `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. ### 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. diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index 268cf107b7..8b8275848f 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -414,8 +414,8 @@ export { } from './ai.zod.js'; // ============================================================================ -// ADR-0080 Public Blocks - the spec-row `page:` / `record:` / `element:` -// blocks (objectui#10872) +// ADR-0080 Public Blocks - the spec-row `page:` / `record:` / `element:` / +// `action:` blocks (objectui#10872) // ============================================================================ export { PageHeaderBlockSchema, @@ -439,6 +439,12 @@ export { ElementNumberBlockSchema, ElementButtonBlockSchema, ElementDividerBlockSchema, + ElementDefinitionListBlockSchema, + ElementRepeaterBlockSchema, + ActionButtonBlockSchema, + ActionIconBlockSchema, + ActionGroupBlockSchema, + ActionMenuBlockSchema, PublicBlockComponentSchema, } from './public-blocks.zod.js'; diff --git a/packages/types/src/zod/public-blocks.zod.ts b/packages/types/src/zod/public-blocks.zod.ts index 68c831076b..7dd7851974 100644 --- a/packages/types/src/zod/public-blocks.zod.ts +++ b/packages/types/src/zod/public-blocks.zod.ts @@ -13,8 +13,10 @@ * vocabulary `PUBLIC_BLOCKS` in `@object-ui/core` * (`packages/core/src/registry/public-blocks.ts`) — whose props * `@objectstack/spec` declares as a `ComponentPropsMap` row: the `page:` - * structure blocks, the `record:` blocks that carry a row, and `element:text`, - * `element:number`, `element:button` and `element:divider`. + * structure blocks, the `record:` blocks that carry a row, `element:text`, + * `element:number`, `element:button`, `element:divider`, + * `element:definition-list` and `element:repeater`, and the four `action:` + * controls (`action:button`, `action:icon`, `action:group`, `action:menu`). * * ## Why this module exists (objectui#10872) * @@ -39,18 +41,21 @@ * the spec changes changes the arm the same day. No member is restated here, * so none can drift — except the one row the spec does not export by name * (`element:divider`, below). The node-level members an arm adds are three - * kinds: an `on*` key a renderer reads off the node (`page:tabs`'s - * `onTabChange`), refused by name with `handlerKeyRefusal` as - * `check:handler-key-reads` requires of every such read; `element:number`'s - * `dataSource`, the spec's own binding schema by reference (below); and the - * content-channel refusals (next section) — the tombstones, and - * `record:alert`'s flat-`body` alias refusal. + * kinds: an `on*` key a renderer reads off the node, declared as + * `check:handler-key-reads` requires of every such read — refused by name with + * `handlerKeyRefusal` where it is a runtime slot (`page:tabs`'s `onTabChange`, + * the `action:button` / `action:icon` `onClick`), or refused with an alias + * refusal naming its bag member where the row declares it (the same two + * blocks' `onSuccess`, below); `element:number`'s `dataSource`, the spec's own + * binding schema by reference (below); and the content-channel refusals (next + * section) — the tombstones, and `record:alert`'s flat-`body` alias refusal. * * ## The content channels (objectui#9256) * - * Every block armed here except the four `page:` containers (`page:card`, + * Every block objectui#9256 measured here — each block armed before + * objectui#10872 batch 4 except the four `page:` containers (`page:card`, * `page:section`, `page:footer`, `page:sidebar`, which render the node's child - * list) reads NEITHER content channel: no read of the node's `children` or + * list) — reads NEITHER content channel: no read of the node's `children` or * `body` reaches the renderer, and `SchemaRenderer` strips both out of the * props bag it spreads. So each of those arms declares `children` as a * by-name refusal, and restates `body` with the same guidance — `BaseSchema` @@ -58,6 +63,15 @@ * the remedy, which these blocks do not read either. Both stay MEMBERS * (`retirementTombstone`), so the refusal sits at the key's own path. * + * ⚠️ The six batch-4 arms (the four `action:` controls, + * `element:definition-list`, `element:repeater`) carry NO channel refusal: + * objectui#9256's measurement never covered them, because they had no arm + * when it ran, and its guidance text states that measurement. They take + * `BaseSchema`'s channels as they are — `children` judged by the base's own + * type, a flat `body` refused by the objectui#6771 retirement. Measuring their + * channels and narrowing them is objectui#9256's method, left open on + * objectui#10872 rather than decided by this module. + * * Two carve-outs, each stated on its arm: `page:tabs` and `page:accordion` * render the `children` of each ITEM in their `items` bag member, which is * the spec row's and stays live — only the node's own `children` is refused; @@ -87,10 +101,14 @@ * tombstones; on `record:alert`, by its alias refusal naming `properties.body`; * and on the four containers, by the base's objectui#6771 retirement, whose * `children` remedy they do render. Whether the flat spelling is ALSO an authoring channel for - * the `page:` / `record:` families (their renderers read the hoisted node + * the `page:` / `record:` / `action:` families (their renderers read the hoisted node * keys) is left open on objectui#10872 rather than decided by this module — * declaring it later is additive, and it must never extend to `element:*`, - * whose renderers do not read a flat key at all. + * whose renderers do not read a flat key at all. ⚠️ For `action:button` it is + * the TAUGHT spelling: AGENTS.md #4 and the guides write `label`, `actionType` + * and `target` flat on the node, which the tolerant face passes (`label` judged + * by the base, the other two unjudged) and the strict face refuses, as the + * spec's own `PageComponentSchema` does. * * `properties` is optional on every arm, as it is on `PageComponentSchema`: * the spec's own props gate (`validateComponentProps`, `@objectstack/lint`) @@ -122,12 +140,16 @@ * - `record:line_items` — the spec carries no row, on purpose: its * `STRING_ARM_REGISTERED_TYPES` ledger records the row as still to be * measured from the renderer's read points. - * - `element:definition-list`, `element:repeater` — the spec's `element:` - * namespace is a closed vocabulary at author time and does not declare - * either type. - * - `action:button`, `action:group`, `action:menu`, `action:icon` — no spec - * row yet; one is being measured from the renderers' read points upstream - * (objectstack-ai/objectstack#20371). + * + * The six held until `@objectstack/spec` 17.5.0 — `action:button`, + * `action:group`, `action:menu`, `action:icon`, `element:definition-list` and + * `element:repeater` — are armed below (objectui#10872 batch 4), from the rows + * objectstack-ai/objectstack#20371 measured at the renderers' read points. + * Those rows follow the READS, not the registrations: a key a registration + * publishes and no renderer reads is refused by the row, and so by the arm. + * Where a registration's `inputs` and its row disagree, the difference is + * booked and re-derived by `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`, + * not restated here. * * ⛔ No `.default()` anywhere in this module — see the "authors no default" * note in `index.zod.ts`. @@ -157,6 +179,12 @@ import { ElementNumberPropsSchema as SpecElementNumberPropsSchema, ElementButtonPropsSchema as SpecElementButtonPropsSchema, ElementDataSourceSchema as SpecElementDataSourceSchema, + ElementDefinitionListPropsSchema as SpecElementDefinitionListPropsSchema, + ElementRepeaterPropsSchema as SpecElementRepeaterPropsSchema, + ActionButtonPropsSchema as SpecActionButtonPropsSchema, + ActionIconPropsSchema as SpecActionIconPropsSchema, + ActionGroupPropsSchema as SpecActionGroupPropsSchema, + ActionMenuPropsSchema as SpecActionMenuPropsSchema, } from '@objectstack/spec/ui'; import { BaseSchema } from './base.zod.js'; import { stripImportedDefaults } from './imported-defaults.js'; @@ -695,6 +723,125 @@ export const ElementDividerBlockSchema = BaseSchema.extend({ children: retirementTombstone(ELEMENT_DIVIDER_NEITHER_CHANNEL), }); +/** + * `element:definition-list` — `ComponentPropsMap['element:definition-list']` + * (objectui#10872 batch 4). + * + * Its renderer reads the props bag only (`readProps`), so the row is the whole + * of what an author configures. The row was measured at those reads: `columns` + * is the NUMBER the renderer compares against, so a string `'2'` is refused + * with the row's own prescription, and each item is a strict `{ term, + * description }`, so the `label` / `value` items the designer once wrote + * (objectui#8279) are refused by name rather than rendered blank. + */ +export const ElementDefinitionListBlockSchema = BaseSchema.extend({ + type: z.literal('element:definition-list'), + properties: propsBag('element:definition-list', stripImportedDefaults(SpecElementDefinitionListPropsSchema)), +}); + +/** + * `element:repeater` — `ComponentPropsMap['element:repeater']` (objectui#10872 + * batch 4). + * + * Its renderer reads the props bag only (`readProps`), and never the node's + * `dataSource` binding, so the row's query keys are the only way to aim it. The + * row requires `object`, as `element:number`'s does, but with NO waiver: a + * repeater with no `object` never queries, so a bag without one is refused at + * `properties.object`. A `fields` entry is a bare name or a strict `{ field }`; + * a `label` there is refused by name, because the list has no header row to + * print it in. + */ +export const ElementRepeaterBlockSchema = BaseSchema.extend({ + type: z.literal('element:repeater'), + properties: propsBag('element:repeater', stripImportedDefaults(SpecElementRepeaterPropsSchema)), +}); + +/* ── action: — action controls ──────────────────────────────────────────── */ + +/** + * The refusal of a flat `onSuccess` on `action:button`, and its reason. ONE + * string per block, as the tombstones above keep theirs. + */ +const ACTION_BUTTON_FLAT_ON_SUCCESS = + 'An `action:button`\'s post-success block (`{ navigate, openIn }`) is the `onSuccess` member of its ' + + '`properties` bag, where `@objectstack/spec`\'s `ComponentPropsMap[\'action:button\']` row declares it: write ' + + '`{ "type": "action:button", "properties": { "onSuccess": { "navigate": "…" } } }` (objectui#10872). ' + + 'The spec\'s own page component refuses the key on the node as mis-layered.'; + +/** The same refusal, for `action:icon`. */ +const ACTION_ICON_FLAT_ON_SUCCESS = + 'An `action:icon`\'s post-success block (`{ navigate, openIn }`) is the `onSuccess` member of its ' + + '`properties` bag, where `@objectstack/spec`\'s `ComponentPropsMap[\'action:icon\']` row declares it: write ' + + '`{ "type": "action:icon", "properties": { "onSuccess": { "navigate": "…" } } }` (objectui#10872). ' + + 'The spec\'s own page component refuses the key on the node as mis-layered.'; + +/** + * `action:button` — `ComponentPropsMap['action:button']` (objectui#10872 batch + * 4), plus the two handler keys its renderer reads off the node. + * + * The row is the spec's page-node declaration of the button, measured at the + * renderer's reads (objectstack-ai/objectstack#20371) — ⛔ not the spec's + * object-metadata `Action` declaration, which requires `name`. The renderer + * reads `name ?? label`, so `name` is optional here, as it is read, and + * AGENTS.md #4's taught node, which carries none, is not refused for lacking + * one. + * + * The renderer reads `schema.X` — the node with its `properties` hoisted onto + * it by `SchemaRenderer` — so it also reads two `on*` keys there, and + * `check:handler-key-reads` requires each to be a declared member: + * + * - `onClick` is a RUNTIME SLOT (objectui#6124): the renderer calls it only + * when it is a function, which reaches it from a code-composed schema (an + * `action:bar` member spread onto the node), never from JSON. Refused by + * name, as `ButtonSchema.onClick` is. The row does not declare it either. + * - `onSuccess` is DATA, the spec's post-success `{ navigate, openIn }` block, + * and the row declares it in the bag. On the node it is the flat spelling + * of that member — the case `aliasKeyRefusal` exists for, as `record:alert`'s + * flat `body` is — so it is refused with a message naming + * `properties.onSuccess`. The strict face and the spec's own + * `PageComponentSchema` refuse it there as well. + */ +export const ActionButtonBlockSchema = BaseSchema.extend({ + type: z.literal('action:button'), + properties: propsBag('action:button', stripImportedDefaults(SpecActionButtonPropsSchema)), + onClick: handlerKeyRefusal('onClick', 'runtime-slot', 'Click handler'), + onSuccess: aliasKeyRefusal('onSuccess', 'properties.onSuccess', 'this `action:button` node', ACTION_BUTTON_FLAT_ON_SUCCESS), +}); + +/** + * `action:icon` — `ComponentPropsMap['action:icon']` (objectui#10872 batch 4), + * plus the same two handler keys as `action:button`, read the same way by its + * renderer. Its own row, measured separately: it declares no `size`, and + * forwards neither `undoable` nor `recordIdField`. + */ +export const ActionIconBlockSchema = BaseSchema.extend({ + type: z.literal('action:icon'), + properties: propsBag('action:icon', stripImportedDefaults(SpecActionIconPropsSchema)), + onClick: handlerKeyRefusal('onClick', 'runtime-slot', 'Click handler'), + onSuccess: aliasKeyRefusal('onSuccess', 'properties.onSuccess', 'this `action:icon` node', ACTION_ICON_FLAT_ON_SUCCESS), +}); + +/** + * `action:group` — `ComponentPropsMap['action:group']` (objectui#10872 batch + * 4). The row declares `actions` as a list of member objects, each one read + * and forwarded by the renderer, and refuses a group-level `name` with its own + * prescription: the renderer never reads it. + */ +export const ActionGroupBlockSchema = BaseSchema.extend({ + type: z.literal('action:group'), + properties: propsBag('action:group', stripImportedDefaults(SpecActionGroupPropsSchema)), +}); + +/** + * `action:menu` — `ComponentPropsMap['action:menu']` (objectui#10872 batch 4). + * Its `variant` and `size` reach the Button primitive unmapped, so the row + * declares neither `primary` nor `md`. + */ +export const ActionMenuBlockSchema = BaseSchema.extend({ + type: z.literal('action:menu'), + properties: propsBag('action:menu', stripImportedDefaults(SpecActionMenuPropsSchema)), +}); + /** * Union of the public-block arms — the category member `AnyComponentSchema` * lists (objectui#10872). @@ -721,4 +868,10 @@ export const PublicBlockComponentSchema = z.discriminatedUnion('type', [ ElementNumberBlockSchema, ElementButtonBlockSchema, ElementDividerBlockSchema, + ElementDefinitionListBlockSchema, + ElementRepeaterBlockSchema, + ActionButtonBlockSchema, + ActionIconBlockSchema, + ActionGroupBlockSchema, + ActionMenuBlockSchema, ]); diff --git a/scripts/__tests__/check-handler-key-read-sites.test.ts b/scripts/__tests__/check-handler-key-read-sites.test.ts index 2f229824c9..6e71319460 100644 --- a/scripts/__tests__/check-handler-key-read-sites.test.ts +++ b/scripts/__tests__/check-handler-key-read-sites.test.ts @@ -1045,13 +1045,15 @@ describe('check-handler-key-read-sites — this repository', () => { * `page:tabs` was the sixth until objectui#10872 gave it its own arm, which * takes it out of this population by construction — an armed type is not an * alias. Where its read is judged now is pinned in the objectui#9344 leg above. + * `action:button` and `action:icon` left the same way in objectui#10872 batch + * 4; where their reads are judged now is pinned in the leg below. */ - it('keys the five namespaced-only aliases on what they claim, and scores their reads once', () => { + it('keys the three namespaced-only aliases on what they claim, and scores their reads once', () => { const aliasRows = result.census.filter((c) => c.unmirroredAlias); expect( [...new Set(aliasRows.map((c) => c.type))].sort(), 'the alias census is the objectui#9573 population — an empty one is a collapsed scan, not a clean tree', - ).toEqual(['action:button', 'action:icon', 'view:form', 'view:grid', 'view:list']); + ).toEqual(['view:form', 'view:grid', 'view:list']); expect(result.counters.aliasReads).toBe(aliasRows.length); expect(aliasRows.length).toBeGreaterThan(10); @@ -1081,6 +1083,36 @@ describe('check-handler-key-read-sites — this repository', () => { expect(result.unkeyable).toEqual([]); }); + /** + * objectui#10872 batch 4 — `action:button` and `action:icon` got their own + * arms (`ActionButtonBlockSchema`, `ActionIconBlockSchema`), so the four reads + * this census used to report as UNMIRRORED-ALIAS rows are judged there now, + * each a DECLARED member: `onClick` as an objectui#6124 runtime slot, and + * `onSuccess` as the alias refusal naming `properties.onSuccess`, the row's + * member. ⛔ Neither was waived into the ledger. + */ + it('judges the `action:button` / `action:icon` handler reads on their own arms (objectui#10872 batch 4)', () => { + const census = (type: string, key: string) => result.census.find((c) => c.type === type && c.key === key); + for (const [type, schema] of [['action:button', 'ActionButtonBlockSchema'], ['action:icon', 'ActionIconBlockSchema']]) { + for (const key of ['onClick', 'onSuccess']) { + const row = census(type, key); + expect(row, `${type}.${key} left the census`).toBeDefined(); + expect(row?.unmirroredAlias, `${type} is an armed type, not an alias`).toBeUndefined(); + expect(row?.schema).toBe(schema); + expect(row?.declared, `${type}.${key}`).toBe(true); + expect(KNOWN_UNDECLARED_READS.has(`${type}::${schema}.${key}`)).toBe(false); + } + expect(census(type, 'onClick')?.disposition).toBe('runtime-slot'); + // Un-vacuous from the arm side: the resolver READ both members off the arm. + const members = collectArms(repoRoot).arms.get(type)?.members; + expect(members?.get('onClick')).toBe('runtime-slot'); + expect(members?.has('onSuccess')).toBe(true); + } + // …and nothing re-keys them onto the bare `button` / `icon` arms, which are other components'. + expect(census('button', 'onClick')).toBeUndefined(); + expect(census('icon', 'onSuccess')).toBeUndefined(); + }); + /** * The ledger is an EXEMPTION list, never the population, and it only shrinks. * Both directions are pinned: a row whose defect is gone reads as a live waiver diff --git a/scripts/check-handler-key-read-sites.mjs b/scripts/check-handler-key-read-sites.mjs index 45ec5ebf98..3914596455 100644 --- a/scripts/check-handler-key-read-sites.mjs +++ b/scripts/check-handler-key-read-sites.mjs @@ -112,8 +112,8 @@ * are registered without a mirror. An arm that does not exist cannot have * lost a member, and inventing an obligation there would be a different * card. ⚠️ A namespaced-only alias over a type whose BARE key does have an - * arm falls here too — `view:list`, `action:button` (and `page:tabs` until - * objectui#10872 gave it its own arm) — and + * arm falls here too — `view:list` (and `page:tabs`, `action:button` and + * `action:icon` until objectui#10872 gave each its own arm) — and * because that is the shape objectui#9573 was mis-judging, its reads are * still REPORTED, as UNMIRRORED-ALIAS census rows carrying the bare arm * they are not. They are countable and `--list`-able; they are ⛔ not @@ -220,7 +220,11 @@ export const KNOWN_UNDECLARED_READS = new Map([ // boundary 3 above says an alias should: the namespaced key was mirrored. // `PageTabsBlockSchema` (`public-blocks.zod.ts`) is its own arm and declares // `onTabChange` as an objectui#6124 runtime slot, so its read is judged there - // like any armed read. `action:button` and `action:icon` are still aliases. + // like any armed read. `action:button` and `action:icon` left the same way in + // objectui#10872 batch 4: `ActionButtonBlockSchema` / `ActionIconBlockSchema` + // declare `onClick` as a runtime slot and refuse a flat `onSuccess` by name, + // pointing at the row's `properties.onSuccess`, so their reads are judged + // there. The alias population left is `view:form`, `view:grid`, `view:list`. // // ⚠️ And the same re-keying removed a FALSE GREEN the ledger could never have // shown, because a wrongly-passing read leaves no row: `'form' diff --git a/scripts/markdown-test-inputs.mjs b/scripts/markdown-test-inputs.mjs index 2d4638741c..e8c3e16f24 100644 --- a/scripts/markdown-test-inputs.mjs +++ b/scripts/markdown-test-inputs.mjs @@ -221,11 +221,15 @@ export const ADJUDICATED = new Map([ // Usage" block and runs it through `objectui validate` end to end -- so an edit // to that README IS an edit to this test's input. The scanner also resolves the // bare `'README.md'` segment of that path against this package and the root; - // an `fs` trace of the file's run opened the plugin-ai README and nothing else. + // an `fs` trace of the file's run at objectui#10859 opened the plugin-ai README + // and nothing else. objectui#10872 batch 4 added the second read (its + // `QUICK_START` path): the first json fence under the quick-start's + // "### Add Actions" heading -- the taught `action:button` node -- which the + // page pin runs through `objectui validate` beside a `page:header`. [ 'packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts', { - reads: ['packages/plugin-ai/README.md'], + reads: ['packages/plugin-ai/README.md', 'content/docs/guide/quick-start.md'], notRead: ['README.md', 'packages/cli/README.md'], }, ], @@ -468,6 +472,13 @@ export const ADJUDICATED = new Map([ 'packages/types/src/__tests__/filter-builder-mirror-6939.test.ts', { reads: ['content/docs/components/complex/filter-builder.mdx'] }, ], + // objectui#10872 batch 4. `taughtActionButton()` extracts the first json fence + // under the quick-start's "### Add Actions" heading -- the taught + // `action:button` node -- and judges it on both zod faces, in a page and alone. + [ + 'packages/types/src/__tests__/held-public-block-arms-10872.test.ts', + { reads: ['content/docs/guide/quick-start.md'] }, + ], [ 'packages/types/src/__tests__/object-calendar-record-source-7313.test.ts', { reads: ['content/docs/plugins/plugin-calendar.mdx'] },