diff --git a/.changeset/11170-node-slot-keys.md b/.changeset/11170-node-slot-keys.md new file mode 100644 index 0000000000..7b858f2bd1 --- /dev/null +++ b/.changeset/11170-node-slot-keys.md @@ -0,0 +1,22 @@ +--- +"@object-ui/types": minor +"@object-ui/cli": minor +"@object-ui/core": minor +"@object-ui/sdui-parser": minor +"@object-ui/components": minor +--- + +**Clause-②: yes (narrowing)** + +One declaration of the per-type NODE SLOTS — the keys other than `children` through which a renderer hands authored nodes back to `SchemaRenderer` — and three readers that walk it instead of stopping at `children` (objectui#11170, the follow-up PR #11126's Acceptance notes filed). + +New on `@object-ui/types`, beside `BaseSchema.children`: `NODE_SLOT_DECLARATIONS` (one row per renderer, under every registry spelling that resolves to it), `nodeSlotsFor(type)`, `nodeSlotPathSegments(path)` and `nodeSlotValues(node, path)`, with the types `NodeSlotDeclaration`, `NodeSlotRow`, `NodeSlotSegment` and `NodeSlotValue`. A position is spelled as a key path — `trigger`, `items[].content`, `regions[].components`, `items[]`, `report.sections[].content` — and the value at its end is one node or a list of nodes. The `page:*` rows are `@objectstack/spec`'s `pageComponentSlotPositions()` placed on the type whose renderer reads each position, pinned against that export in both directions; every other row is objectui's own, pinned against the live renderer. `body` stays retired as the generic child-list key (objectui#6771): it appears only on the four `page:*` types whose renderer still paints it for stored documents, marked `retired`. + +Accept sets that narrow, each reader FROM → TO: + +- `@object-ui/cli` — `objectui check`'s unevaluated-expression refusal (`findUnbindableTextExpressions`). FROM: the document root and every node its `children` hold. TO: those, and every node under a slot its type declares — so a `${…}` on `title` / `label` / `value` / `description` of a node under a dialog's `content`, a tab item's `content`, a page's `regions[].components`, a carousel item, a detail view's `tabs[].content` is now refused with the slot path (`items → 0 → content → value`). The false-refusal rows of PR #11126's ablation 2 stay green: a form's `fields[]`, a grid's `columns[]` and `{ "type": "multiple" }` are not slots. Measured over this repository's own JSON corpus and docs fences: no new finding. +- `@object-ui/core` — `validateSchema`. FROM: `validateChildren` recursed through `children` only. TO: it also recurses through the declared slots, so an invalid node under one (a retired `crud` spelling under `dialog.content`, an `INVALID_SCHEMA` member) is reported with its own path, spelled as `schema.items[0].content`. Measured over the same corpus: no new finding. +- `@object-ui/sdui-parser` — `validateTree`. FROM: the walk descended `children` alone, and a manifest entry carried no slot. TO: `ManifestComponent` gains `slots?: readonly string[]`, `manifestFromConfigs` gains `opts.slotsFor` (hand it `nodeSlotsFor`) and projects each entry's non-retired positions, and `validateTree` descends them — an unknown component, an unknown or mis-typed prop or an illegal enum under a slot now draws its diagnostic. A manifest built without the option serialises byte-identically and keeps the `children`-only reach. The `RETIRED_CHILD_LIST_KEY` refusals are unchanged. +- `@object-ui/components` — the `kind:'html'` page's compile manifest (`getJsxManifest`) is built with `slotsFor`, so an html-tier page whose slot-held node fails validation now fails to compile the way one under `children` does. Narrowing: a page that compiled with an unknown tag under a `dialog`'s `content` no longer does. + +Docs: `content/docs/utilities/cli.mdx`'s "Component nodes only" rule, the gate's own docblock, `validateChildren`'s comment and the parser's header now say the walk follows `children` and the declared slots; the declaration's header is where the slot list is explained. diff --git a/apps/console/dev/manifest-dump.tsx b/apps/console/dev/manifest-dump.tsx index 90a8c4fe8c..07d6e299cf 100644 --- a/apps/console/dev/manifest-dump.tsx +++ b/apps/console/dev/manifest-dump.tsx @@ -8,6 +8,7 @@ import './manifest-registry'; import { ComponentRegistry } from '@object-ui/core'; import { assertFullyLoaded, manifestFromConfigs } from '@object-ui/sdui-parser'; +import { nodeSlotsFor } from '@object-ui/types'; const out = document.getElementById('out')!; const win = window as unknown as { __MANIFEST?: string; __MANIFEST_ERROR?: string }; @@ -15,7 +16,10 @@ const win = window as unknown as { __MANIFEST?: string; __MANIFEST_ERROR?: strin try { const configs = ComponentRegistry.getPublicConfigs() as never; assertFullyLoaded(configs); - const json = JSON.stringify(manifestFromConfigs(configs), null, 2); + // `slotsFor` (objectui#11170): the dumped manifest carries each entry's node + // slots from the one declaration in `@object-ui/types`, as the shipped + // `sdui.manifest.json` does. + const json = JSON.stringify(manifestFromConfigs(configs, { slotsFor: nodeSlotsFor }), null, 2); out.textContent = json; win.__MANIFEST = json; } catch (err) { diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index a42fe178e1..45acfedf1f 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -94,7 +94,7 @@ One row per declared member, in declaration order, so the list can be checked ag | `data` | `any` | Arbitrary data attached to the node. `any` because the shape is defined by the consuming component rather than by `BaseSchema`. | | `bind` | `string` | Data-scope path this node draws its rows or value from, resolved by `useDataScope()`. Honoured only by components that call it. | | `body` | *retired* | ⛔ Refused by name (objectui#6771). `body` was a second child-list spelling `BaseSchema` declared beside `children`; it is now `never` on the TypeScript face and an alias refusal on the Zod mirror, and the refusal names `children`. | -| `children` | `SchemaNode \| SchemaNode[]` | Child components rendered inside this component — the child-list key, and since objectui#6771 the only one. Whether a given node type renders a child list at all is still per component; see the note below. | +| `children` | `SchemaNode \| SchemaNode[]` | Child components rendered inside this component — the child-list key, and since objectui#6771 the only one. Whether a given node type renders a child list at all is still per component; see the note below. The keys OTHER than this one through which a renderer hands nodes to `SchemaRenderer` (a dialog's `trigger`, a tab item's `content`, a page's `regions[].components`) are per type and declared once as `NODE_SLOT_DECLARATIONS` / `nodeSlotsFor` (objectui#11170); `objectui check`, the core schema validator and the SDUI parser walk those positions as they walk `children`. | | `visible` | `boolean \| string \| { dialect?: string; source: string }` | Visibility control. Accepts a boolean, a predicate expression string, **or** the CEL envelope object (`{ dialect: 'cel', source }` — what `objectstack build` emits for every authored predicate) — the renderer evaluates this key rather than reading it as a boolean. The string-or-envelope half is `ExpressionWire`, the one wire type `visibleWhen` on form fields already carries. | | `visibleWhen` | `string \| { dialect, source }` | Canonical conditional-visibility predicate (ADR-0089); the element is shown when it evaluates truthy. Typed as `@objectstack/spec`'s `EvaluatedExpressionInput`: a predicate string, or the envelope the spec's parse writes (`dialect` is `cel`, `cron` or `template`, and `source` is not blank). Evaluated **before** `visible` and `visibleOn`, and outranks both. | | `visibleOn` | `string` | Expression for conditional visibility. **Deprecated** (ADR-0089) — use `visibleWhen`. | diff --git a/content/docs/utilities/cli.mdx b/content/docs/utilities/cli.mdx index 6fd3c2529a..9303900e4d 100644 --- a/content/docs/utilities/cli.mdx +++ b/content/docs/utilities/cli.mdx @@ -217,11 +217,17 @@ distinction matters: other type, the expression is never resolved: the user sees its literal text, or nothing. `objectui check` refuses it in every recognised file: -- **Component nodes only.** The file's root node and every node its `children` - hold, at any depth. Objects under other keys (a form's `fields`, a grid's - `columns`) are definitions, not nodes, and are not judged. A root whose `type` - is `page` keeps its own `title`: that is a page key, while its `children` are - still judged. +- **Component nodes only.** The file's root node, every node its `children` + hold, and every node under a slot the node's type declares — a dialog's + `trigger` and `content`, a tab item's `content`, a page's + `regions[].components` — at any depth. Which keys are slots is per type and + declared once, as `NODE_SLOT_DECLARATIONS` / `nodeSlotsFor` in + `@object-ui/types`, the same declaration core's schema validator and the + SDUI parser walk; the check keeps no list of its own. Objects under any other + key (a form's `fields`, a grid's `columns`) are definitions, not nodes, and + are not judged. A root whose `type` is `page` keeps its own `title`: that is + a page key, while its `children` and its regions' components are still + judged. - **The type is matched as written.** `ui:card` is not `card`, exactly as at render time. - **A type no registered component answers to** is warned about, not refused: a diff --git a/packages/cli/src/__tests__/check-unbindable-text-expression-4795.test.ts b/packages/cli/src/__tests__/check-unbindable-text-expression-4795.test.ts index 1ab2789d61..921f4a3638 100644 --- a/packages/cli/src/__tests__/check-unbindable-text-expression-4795.test.ts +++ b/packages/cli/src/__tests__/check-unbindable-text-expression-4795.test.ts @@ -33,6 +33,7 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { EXPRESSION_BINDABLE_TEXT_KEYS, expressionBindableTextKeysFor } from '@objectstack/spec/ui'; +import { nodeSlotsFor } from '@object-ui/types'; import { check } from '../commands/check.js'; import { formatIssuePath } from '../utils/issue-path.js'; @@ -250,6 +251,79 @@ describe('sub-rule (i): component nodes only', () => { }); }); +/** + * The walk reaches the node slots the node's type declares (objectui#11170): + * `nodeSlotsFor` in `@object-ui/types`, the declaration core's + * `validateChildren` and the SDUI parser read too. Each case pins the path the + * way this command prints it, and the false-refusal rows of PR #11126's + * ablation 2 — `fields[]`, `columns[]`, `{ "type": "multiple" }` — stay green + * beside them: reach is decided by the declaration, not by the shape of a value. + */ +describe('component nodes under a declared node slot are judged (objectui#11170)', () => { + it('refuses under a direct slot (`dialog.content`), printing the slot path', async () => { + expect(nodeSlotsFor('dialog').map((s) => s.path)).toContain('content'); + const document = { type: 'dialog', trigger: { type: 'button', label: 'Open' }, content: [{ type: 'text', value: EXPR }] }; + await checkOne(document); + expect(findUnbindableTextExpressions(document).map((f) => f.path)).toEqual([['content', 0, 'value']]); + expect(refusalLines()).toHaveLength(1); + expect(refusalLines()[0]).toContain(`at ${formatIssuePath(['content', 0, 'value'])}:`); + expect(exitCodes).toEqual([1]); + }); + + it('refuses under a panel list (`tabs.items[].content`) and under a page’s `regions[].components`', async () => { + const tabs = { type: 'tabs', items: [{ value: 'a', label: 'A', content: { type: 'text', value: EXPR } }] }; + expect(findUnbindableTextExpressions(tabs).map((f) => f.path)).toEqual([['items', 0, 'content', 'value']]); + await checkOne(tabs); + expect(refusalLines()[0]).toContain(`at ${formatIssuePath(['items', 0, 'content', 'value'])}:`); + + lines = []; + exitCodes = []; + const page = { type: 'page', title: EXPR, regions: [{ name: 'main', components: [{ type: 'action:button', label: EXPR }] }] }; + await checkOne(page); + // The page root's own `title` is still a page key (sub-rule i); the region node is judged. + expect(findUnbindableTextExpressions(page).map((f) => f.path)).toEqual([['regions', 0, 'components', 0, 'label']]); + expect(refusalLines()).toHaveLength(1); + expect(exitCodes).toEqual([1]); + }); + + it('walks a slot under a child under a slot, every hop in the path', () => { + const document = { + type: 'flex', + children: [{ type: 'sheet', content: { type: 'card', footer: [{ type: 'text', value: EXPR }] } }], + }; + expect(findUnbindableTextExpressions(document).map((f) => f.path)).toEqual([ + ['children', 0, 'content', 'footer', 0, 'value'], + ]); + }); + + it('walks the retired `body` only where the renderer still paints it (`page:card`), never as a generic key', () => { + expect(nodeSlotsFor('page:card').find((s) => s.path === 'body')?.retired).toBe(true); + expect(findUnbindableTextExpressions({ type: 'page:card', body: [{ type: 'text', value: EXPR }] }).map((f) => f.path)).toEqual([ + ['body', 0, 'value'], + ]); + expect(nodeSlotsFor('badge')).toEqual([]); + expect(findUnbindableTextExpressions({ type: 'badge', body: [{ type: 'text', value: EXPR }] })).toEqual([]); + }); + + it('does not walk a key that is a slot of another type, nor the ablation-2 definition lists', async () => { + // `content` is `dialog`'s slot and nothing of `text`'s. + expect(findUnbindableTextExpressions({ type: 'text', content: { type: 'text', value: EXPR } })).toEqual([]); + // PR #11126's ablation 2, verbatim: these must stay green. + const field = { name: 'total', type: 'text', label: EXPR }; + await checkOne({ type: 'form', fields: [field] }); + expect(refusalLines()).toEqual([]); + expect(findUnbindableTextExpressions({ type: 'data-table', columns: [{ type: 'text', label: EXPR }] })).toEqual([]); + expect(findUnbindableTextExpressions({ type: 'data-table', selection: { type: 'multiple', label: EXPR } })).toEqual([]); + expect(exitCodes).toEqual([]); + }); + + it('a type with no row — unknown types included — has only its `children` walked', () => { + expect(nodeSlotsFor('stat-card')).toEqual([]); + const document = { type: 'stat-card', content: { type: 'text', value: EXPR }, children: [{ type: 'text', value: EXPR }] }; + expect(findUnbindableTextExpressions(document).map((f) => f.path)).toEqual([['children', 0, 'value']]); + }); +}); + describe('sub-rule (ii): a type no registered component answers to warns, never refuses', () => { it('warns on `stat-card`, and the run still passes', async () => { expect(isKnownSchemaType('stat-card')).toBe(false); diff --git a/packages/cli/src/utils/unbindable-text-expressions.ts b/packages/cli/src/utils/unbindable-text-expressions.ts index f076523620..a306f40712 100644 --- a/packages/cli/src/utils/unbindable-text-expressions.ts +++ b/packages/cli/src/utils/unbindable-text-expressions.ts @@ -10,6 +10,7 @@ import { EXPRESSION_BINDABLE_TEXT_KEYS, expressionBindableTextKeysFor, } from '@objectstack/spec/ui'; +import { nodeSlotValues, nodeSlotsFor } from '@object-ui/types'; import { formatIssuePath } from './issue-path.js'; import { isKnownSchemaType } from './known-schema-types.js'; @@ -49,7 +50,8 @@ import { isKnownSchemaType } from './known-schema-types.js'; * suite `SchemaRenderer.bindableTextKeys.test.tsx`, this gate's over every * registered type by `check-unbindable-text-expression-4795.test.ts`. * - * ## What a component node is: the root, and what `children` holds + * ## What a component node is: the root, what `children` holds, and the + * ## node slots the node's type declares * * `SchemaRenderer` does not recurse on its own. Each renderer decides which of * its keys it hands back to `SchemaRenderer`, and many keys that hold objects @@ -60,13 +62,17 @@ import { isKnownSchemaType } from './known-schema-types.js'; * `{ "type": "text", "label": "${…}" }` inside `fields[]` — a false refusal * on a key this gate has no business judging. * - * So the walk follows the protocol's ONE composition key, - * `BaseSchema.children`, from the document root: the same single spelling the - * core validator's recursive walk and the SDUI parser's child-list key follow. - * Nodes that a renderer reaches through a key of its own (`trigger`, `footer`, - * a page's `regions`, a tab's `content`) are not walked. That is a stated - * boundary, and it fails quiet in the safe direction: a node the walk does not - * reach is not refused, and nothing outside a component node is refused. + * So the walk follows two things and nothing else, from the document root: + * the protocol's ONE composition key, `BaseSchema.children`, on every node; + * and the NODE SLOTS declared for the node's type — `nodeSlotsFor(type)` in + * `@object-ui/types` (objectui#11170), the one declaration of where a + * renderer hands nodes back through a key of its own (`trigger`, `footer`, a + * page's `regions[].components`, a tab's `items[].content`). The core + * validator's recursive walk and the SDUI parser read the same declaration; + * ⛔ this gate keeps no slot list of its own. The declaration's header says + * what a slot is, how a position is spelled and which test holds each row + * against the live renderer. A type with no row — an unknown or custom type + * included — has its `children` walked and nothing else. * * ## What an expression is * @@ -78,7 +84,10 @@ import { isKnownSchemaType } from './known-schema-types.js'; /** The evaluator's interpolation pattern, as `ExpressionEvaluator.evaluate` matches it. */ const EXPRESSION_PATTERN = /\$\{[^}]+\}/; -/** The protocol's one composition key: `BaseSchema.children`. */ +/** + * The protocol's one composition key: `BaseSchema.children`. Walked on every + * node; the per-type node slots beside it come from `nodeSlotsFor`. + */ const COMPOSITION_KEY = 'children'; /** @@ -152,6 +161,15 @@ function visit( } else if (isComponentNode(children)) { visit(children, [...path, COMPOSITION_KEY], false, into); } + // The node slots this type's renderer reads (objectui#11170): the same + // declaration core's `validateChildren` and the SDUI parser walk. Retired + // positions are walked too — the renderer still paints them, so a `${…}` + // under one still reaches the user. + for (const slot of nodeSlotsFor(node.type)) { + for (const { segments, value } of nodeSlotValues(node, slot.path)) { + if (isComponentNode(value)) visit(value, [...path, ...segments], false, into); + } + } } /** diff --git a/packages/components/src/renderers/__tests__/node-slot-declaration-11170.test.tsx b/packages/components/src/renderers/__tests__/node-slot-declaration-11170.test.tsx new file mode 100644 index 0000000000..1b0e3bfa8d --- /dev/null +++ b/packages/components/src/renderers/__tests__/node-slot-declaration-11170.test.tsx @@ -0,0 +1,263 @@ +/** + * 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 node-slot declaration ⇔ the renderers, over the live registry, in both + * directions (objectui#11170). + * + * `NODE_SLOT_DECLARATIONS` in `@object-ui/types` says, per registry type, + * through which keys other than `children` the renderer hands authored nodes + * back to `SchemaRenderer`. Three walks read it — the `objectui check` gate, + * core's `validateChildren` and the SDUI parser's manifest — so a row that + * does not match the renderer is a false refusal (a row the renderer never + * paints) or a silent under-reach (a read the row misses). This file holds the + * rows registered by `@object-ui/components` against the renderers themselves: + * + * 1. DECLARED ⇒ RENDERED. A node authored at every declared position of + * every known key reaches the DOM, rendered through the real + * `SchemaRenderer`, in the context the renderer needs (an overlay held + * open, a tab selected). The predicate is behavioural for the reason + * `container-declaration-ratchet.test.tsx` gives: a source-side spelling + * of "does this renderer read `schema.X`" cannot be built here. + * 2. REGISTERED ⇒ DECLARED. Every `type: 'slot'` input a registration + * declares (other than `children`) names a position the declaration + * carries for that key — the designer's authoring face and the walks + * agree about where nodes go. + * 3. TWINS AGREE. Every registry key that resolves to the same renderer + * answers the same slots, and a row lists only keys the registry stores. + * 4. THE TIER READS THE PROJECTION. `manifestFromConfigs` handed + * `nodeSlotsFor` carries the non-retired positions, `validateTree` + * reaches a node under each of them, and its reach equals + * `nodeSlotValues`' over the same fixtures — the two walks, one grammar. + * + * A reading needs controls that can fail: a key with no row renders its + * `children` and does NOT render a node under a key the row would have to + * name, so the probe distinguishes "declared" from "any object anywhere". + * + * The plugin-registered rows (`detail`, `report-viewer`, `plugin-timeline:timeline`, + * `view-switcher`, `dashboard`) are not registered in this package; each + * plugin's `nodeSlotDeclaration-11170` suite holds them the same way. + */ + +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { AdapterCtx, SchemaRenderer } from '@object-ui/react'; +import { inputTypeArms, manifestFromConfigs, validateTree } from '@object-ui/sdui-parser'; +import type { SchemaElement } from '@object-ui/sdui-parser'; +import { + NODE_SLOT_DECLARATIONS, + nodeSlotPathSegments, + nodeSlotValues, + nodeSlotsFor, +} from '@object-ui/types'; + +// Module scope, not a hook: this import IS the registration (AGENTS.md +// §测试纪律 — an unbounded module load must not be billed to a bounded window). +import '../index'; +import { SidebarProvider } from '../../ui'; + +const MARK = 'slot-census-11170'; +const MARK_NODE = { type: 'text', content: MARK }; +const UNKNOWN_TAG = 'nope-11170'; + +/** Long enough for the slot probes under a loaded CI box. */ +const CENSUS_TIMEOUT = 180_000; + +/** + * The context a renderer needs before a slot's content is on the page: + * `extra` goes on the node, `panel` on each panel element of an `[]` step, + * `readBody` reads `document.body` for content a portal mounts outside the + * container. Keyed by BARE type; a namespaced twin shares its entry. + */ +interface Context { + extra?: Record; + panel?: Record; + readBody?: boolean; + /** A provider the renderer mounts under (`header-bar` reads the sidebar context). */ + wrap?: (node: React.ReactNode) => React.ReactElement; +} + +const CONTEXTS: Record = { + 'header-bar': { wrap: (node) => {node} }, + tabs: { extra: { defaultValue: 'p' }, panel: { value: 'p', label: 'P' } }, + accordion: { extra: { defaultValue: 'p' }, panel: { value: 'p', title: 'P' } }, + collapsible: { extra: { defaultOpen: true } }, + dialog: { extra: { defaultOpen: true }, readBody: true }, + sheet: { extra: { defaultOpen: true }, readBody: true }, + drawer: { extra: { defaultOpen: true }, readBody: true }, + 'alert-dialog': { extra: { defaultOpen: true }, readBody: true }, + popover: { extra: { defaultOpen: true }, readBody: true }, + 'hover-card': { extra: { open: true }, readBody: true }, + tooltip: { extra: { open: true }, readBody: true }, + 'data-table': { extra: { data: [], columns: [{ key: 'a', title: 'A' }] } }, + 'page:tabs': { panel: { value: 'p', label: 'P' } }, + 'page:accordion': { panel: { label: 'P', collapsed: false } }, +}; + +const bareName = (type: string): string => type.slice(type.lastIndexOf(':') + 1); +const contextFor = (type: string): Context => CONTEXTS[type] ?? CONTEXTS[bareName(type)] ?? {}; + +/** + * A node of `type` with `terminal` placed at `path`, and nothing else at + * that position: built from the path's own segments, so the probe cannot + * drift from the grammar the readers walk. + */ +function placeAt(type: string, path: string, terminal: unknown): Record { + const { extra = {}, panel = {} } = contextFor(type); + const segments = nodeSlotPathSegments(path); + let value: unknown = terminal; + for (let i = segments.length - 1; i >= 0; i -= 1) { + const { key, each } = segments[i]!; + if (!each) value = { [key]: value }; + else if (i === segments.length - 1) value = { [key]: [value] }; + else value = { [key]: [{ ...panel, ...(value as Record) }] }; + } + return { type, ...extra, ...(value as Record) }; +} + +const rendersMark = async (schema: unknown, readBody = false): Promise => { + const type = (schema as { type: string }).type; + const wrap = contextFor(type).wrap ?? ((node: React.ReactNode) => <>{node}); + const { container, unmount } = render( + {wrap()}, + ); + try { + await waitFor(() => expect(container.textContent).toBeDefined()); + const text = readBody ? document.body.textContent || '' : container.textContent || ''; + return text.includes(MARK); + } finally { + unmount(); + } +}; + +const knownTypes = (): string[] => ComponentRegistry.getKnownTypes().slice().sort(); + +/** The rows whose keys this package registers — the population this file holds. */ +const rowsHere = () => NODE_SLOT_DECLARATIONS.filter((row) => row.types.some((t) => ComponentRegistry.has(t))); + +describe('direction 1 — every declared slot renders an authored node (objectui#11170)', () => { + it( + 'a node placed at each declared position of each key registered here reaches the DOM', + async () => { + const rows = rowsHere(); + expect(rows.length, 'no declared row is registered here — the import above stopped registering').toBeGreaterThan(20); + const missing: string[] = []; + for (const row of rows) { + for (const type of row.types) { + for (const slot of row.slots) { + const terminal = slot.path.endsWith('[]') ? MARK_NODE : [MARK_NODE]; + const ok = await rendersMark(placeAt(type, slot.path, terminal), contextFor(type).readBody); + if (!ok) missing.push(`${type} → ${slot.path}`); + } + } + } + expect( + missing, + 'declared slot(s) whose renderer never put the authored node on the page — fix the declaration, or add the context the renderer needs to CONTEXTS', + ).toEqual([]); + }, + CENSUS_TIMEOUT, + ); + + it('the probe can fail: a key with no row renders `children` and not a node under an undeclared key', async () => { + expect(nodeSlotsFor('button')).toEqual([]); + expect(await rendersMark({ type: 'button', children: [MARK_NODE] })).toBe(true); + expect(await rendersMark({ type: 'button', footer: [MARK_NODE] })).toBe(false); + // …and a declared slot is decided by the type, not by the key's name: + // `footer` is `card`'s slot and `tabs`' nothing. + expect(await rendersMark({ type: 'card', footer: [MARK_NODE] })).toBe(true); + expect(await rendersMark({ type: 'tabs', items: [], footer: [MARK_NODE] })).toBe(false); + }); +}); + +describe('direction 2 — every registered `slot` input other than `children` is a declared position', () => { + it('no registration declares a slot input the declaration does not carry for that key', () => { + const undeclared: string[] = []; + let slotInputs = 0; + for (const type of knownTypes()) { + for (const input of ComponentRegistry.getMeta(type)?.inputs ?? []) { + if (input.name === 'children' || !inputTypeArms(input.type).includes('slot')) continue; + slotInputs += 1; + if (!nodeSlotsFor(type).some((s) => s.path === input.name)) undeclared.push(`${type}.${input.name}`); + } + } + // Population control: the overlays alone declare more than a dozen. + expect(slotInputs).toBeGreaterThan(12); + expect(undeclared, 'slot input(s) the declaration does not carry — add the row, the walks are blind to the key').toEqual([]); + }); +}); + +describe('direction 3 — twins agree, and a row lists only keys the registry stores', () => { + it('every key that resolves to one renderer answers one slot list', () => { + const byRenderer = new Map(); + for (const type of knownTypes()) { + const renderer = ComponentRegistry.get(type); + if (!renderer) continue; + byRenderer.set(renderer, [...(byRenderer.get(renderer) ?? []), type]); + } + const disagreements: string[] = []; + for (const types of byRenderer.values()) { + const answers = new Set(types.map((t) => JSON.stringify(nodeSlotsFor(t)))); + if (answers.size > 1) disagreements.push(types.join(' / ')); + } + expect(disagreements, 'registry twins of one renderer answer different slots').toEqual([]); + // The reading has twins in it: `dialog` and `ui:dialog` are one renderer. + expect(ComponentRegistry.get('dialog')).toBe(ComponentRegistry.get('ui:dialog')); + }); + + it('a row registered here lists every one of its keys here, and each is a key the registry stores', () => { + for (const row of rowsHere()) { + for (const type of row.types) { + expect(ComponentRegistry.has(type), `\`${type}\` is declared but not registered`).toBe(true); + } + } + }); +}); + +describe('direction 4 — the tier reads the projection, and the two walks agree (objectui#11170)', () => { + const manifest = () => { + const configs = ComponentRegistry.getKnownTypes().map((t) => { + const meta = ComponentRegistry.getMeta(t); + return { type: t, namespace: meta?.namespace, isContainer: meta?.isContainer, inputs: meta?.inputs }; + }); + return manifestFromConfigs(configs as unknown as Parameters[0], { slotsFor: nodeSlotsFor }); + }; + + it('the projected manifest carries each key’s non-retired positions', () => { + const m = manifest(); + expect(m.components['dialog']!.slots).toEqual(['trigger', 'content', 'footer']); + expect(m.components['page:card']!.slots).toEqual(['footer']); + expect(m.components['button']!.slots).toBeUndefined(); + }); + + it( + '`validateTree` reaches exactly the nodes `nodeSlotValues` finds, at every declared position registered here', + () => { + const m = manifest(); + const disagreements: string[] = []; + for (const row of rowsHere()) { + for (const type of row.types) { + for (const slot of row.slots) { + const unknown = { type: UNKNOWN_TAG }; + const doc = placeAt(type, slot.path, slot.path.endsWith('[]') ? unknown : [unknown, unknown]); + const found = nodeSlotValues(doc, slot.path).filter((v) => (v.value as { type?: unknown })?.type === UNKNOWN_TAG).length; + const reached = validateTree(doc as unknown as SchemaElement, m).diagnostics.filter( + (d) => d.code === 'unknown-component' && d.tag === UNKNOWN_TAG, + ).length; + const expected = slot.retired ? 0 : found; + if (reached !== expected) disagreements.push(`${type} → ${slot.path}: types walk ${found}, tier reached ${reached}`); + } + } + } + expect(disagreements).toEqual([]); + }, + CENSUS_TIMEOUT, + ); +}); diff --git a/packages/components/src/renderers/layout/page.tsx b/packages/components/src/renderers/layout/page.tsx index 2f2121d350..4ddd9bb889 100644 --- a/packages/components/src/renderers/layout/page.tsx +++ b/packages/components/src/renderers/layout/page.tsx @@ -14,6 +14,7 @@ import React, { useMemo } from 'react'; import type { DeclaredNode, PageNodeSchema, PageNodeRegion, SchemaNode } from '@object-ui/types'; +import { nodeSlotsFor } from '@object-ui/types'; import { SchemaRenderer, toRenderableSchema, @@ -497,7 +498,13 @@ function getJsxManifest() { const meta = ComponentRegistry.getMeta(t); return { type: t, namespace: meta?.namespace, isContainer: meta?.isContainer, inputs: meta?.inputs }; }); - _jsxManifest = manifestFromConfigs(configs as unknown as Parameters[0]); + // `slotsFor` (objectui#11170): each entry carries the node slots its + // renderer reads besides `children`, from the one declaration in + // `@object-ui/types`, so the tier judges a node authored under a dialog's + // `content` or a tab item's `content` exactly as one under `children`. + _jsxManifest = manifestFromConfigs(configs as unknown as Parameters[0], { + slotsFor: nodeSlotsFor, + }); _jsxManifestSig = version; } return _jsxManifest; diff --git a/packages/core/src/validation/__tests__/node-slot-walk-11170.test.ts b/packages/core/src/validation/__tests__/node-slot-walk-11170.test.ts new file mode 100644 index 0000000000..bcc04015eb --- /dev/null +++ b/packages/core/src/validation/__tests__/node-slot-walk-11170.test.ts @@ -0,0 +1,83 @@ +/** + * 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. + */ + +/** + * `validateChildren` descends the node slots declared for the node's type + * (objectui#11170), not `children` alone. + * + * The subject under every slot is a node the validator already refuses when it + * sits under `children` — the retired `crud` spelling, `RETIRED_TYPE` — so + * what each case measures is REACH: the same node, judged or not judged, + * decided by its position. The path is pinned the way this validator spells + * paths (`schema.children[0]` → `schema.items[0].content`). + */ + +import { describe, expect, it } from 'vitest'; +import { nodeSlotsFor } from '@object-ui/types'; +import { validateSchema } from '../schema-validator'; + +const RETIRED = { type: 'crud', columns: [] }; +const errorPaths = (schema: unknown) => validateSchema(schema).errors.map((e) => `${e.code} @ ${e.path}`); + +describe('core: slot-held nodes are validated, with the slot path (objectui#11170)', () => { + it('a direct slot: `dialog.content`', () => { + expect(nodeSlotsFor('dialog').map((s) => s.path)).toContain('content'); + expect(errorPaths({ type: 'dialog', content: RETIRED })).toEqual(['RETIRED_TYPE @ schema.content.type']); + expect(errorPaths({ type: 'dialog', content: [{ type: 'text' }, RETIRED] })).toEqual([ + 'RETIRED_TYPE @ schema.content[1].type', + ]); + }); + + it('a panel list: `tabs.items[].content`', () => { + expect(errorPaths({ type: 'tabs', items: [{ value: 'a', content: [{ type: 'text' }] }, { value: 'b', content: RETIRED }] })).toEqual([ + 'RETIRED_TYPE @ schema.items[1].content.type', + ]); + }); + + it('a page’s `regions[].components`, and the retired `body` on `page:card` that its renderer still paints', () => { + expect(errorPaths({ type: 'page', regions: [{ name: 'main', components: [RETIRED] }] })).toEqual([ + 'RETIRED_TYPE @ schema.regions[0].components[0].type', + ]); + expect(nodeSlotsFor('page:card').find((s) => s.path === 'body')?.retired).toBe(true); + expect(errorPaths({ type: 'page:card', body: [RETIRED] })).toEqual(['RETIRED_TYPE @ schema.body[0].type']); + }); + + it('nested: a slot under a child under a slot, every hop in the path', () => { + const doc = { + type: 'flex', + children: [{ type: 'dialog', content: { type: 'tabs', items: [{ value: 'a', content: RETIRED }] } }], + }; + expect(errorPaths(doc)).toEqual(['RETIRED_TYPE @ schema.children[0].content.items[0].content.type']); + }); + + describe('reach is decided by the declaration, never by the shape of the value', () => { + it('`body` on a type whose renderer does not read it is NOT descended (objectui#6771 stands)', () => { + expect(nodeSlotsFor('badge')).toEqual([]); + expect(errorPaths({ type: 'badge', body: [RETIRED] })).toEqual([]); + // The control: the same list under `children` on the same type is judged. + expect(errorPaths({ type: 'badge', children: [RETIRED] })).toEqual(['RETIRED_TYPE @ schema.children[0].type']); + }); + + it('a key that is not a slot of this type is not descended, even when it is one elsewhere', () => { + // `content` is `dialog`'s slot and `button`'s nothing. + expect(errorPaths({ type: 'button', content: RETIRED })).toEqual([]); + // `footer` is `card`'s slot; a `tabs` node has no such position. + expect(errorPaths({ type: 'tabs', footer: RETIRED })).toEqual([]); + }); + + it('a form’s `fields[]` and a grid’s `columns[]` are definitions, not nodes (PR #11126, ablation 2)', () => { + expect(errorPaths({ type: 'form', fields: [{ name: 'x', type: 'crud' }] })).toEqual([]); + expect(errorPaths({ type: 'grid', columns: [{ type: 'crud' }] })).toEqual([]); + }); + + it('a primitive or an absent slot is skipped, not an error', () => { + expect(validateSchema({ type: 'dialog', trigger: 'Open', content: 0, footer: null }).valid).toBe(true); + expect(validateSchema({ type: 'tabs', items: ['not-a-panel', { value: 'a' }] }).valid).toBe(true); + }); + }); +}); diff --git a/packages/core/src/validation/schema-validator.ts b/packages/core/src/validation/schema-validator.ts index 3621d5300e..35b0c9a988 100644 --- a/packages/core/src/validation/schema-validator.ts +++ b/packages/core/src/validation/schema-validator.ts @@ -17,6 +17,7 @@ */ import type { BaseSchema } from '@object-ui/types'; +import { nodeSlotValues, nodeSlotsFor } from '@object-ui/types'; import { hasDeclaredPredicate } from '../evaluator/declaredPredicate.js'; import { isUnevaluablePredicate } from '../evaluator/unevaluablePredicate.js'; @@ -470,8 +471,17 @@ function validateFormSchema( return errors; } +/** `schema.items[0].content[1]`: the child's path, spelled the way `children[0]` already is. */ +function spellSlotPath(path: string, segments: readonly (string | number)[]): string { + return segments.reduce( + (spelled, segment) => (typeof segment === 'number' ? `${spelled}[${segment}]` : `${spelled}.${segment}`), + path, + ); +} + /** - * Validate child schemas recursively + * Validate child schemas recursively: what `children` holds on every node, + * and what the node slots declared for this node's type hold. */ function validateChildren( schema: SchemaNodeUnderValidation, @@ -479,10 +489,11 @@ function validateChildren( ): SchemaNodeValidationError[] { const errors: SchemaNodeValidationError[] = []; - // One spelling. This walker resolved `children || body` for ANY node type, - // so it outlived every per-registration read of the dialect — objectui#6771 - // retired it, and a recursive validator that still descended `body` would - // keep validating a child list no renderer puts on the page. + // One child-list spelling. This walker resolved `children || body` for ANY + // node type, so it outlived every per-registration read of the dialect — + // objectui#6771 retired it, and a recursive validator that still descended + // `body` on every node would keep validating a child list no renderer puts + // on the page. const children = schema.children; if (children) { if (Array.isArray(children)) { @@ -498,6 +509,23 @@ function validateChildren( } } + // The node slots THIS type's renderer reads — a dialog's `trigger`, a tab + // item's `content`, a page's `regions[].components` — from the one + // declaration in `@object-ui/types` (`nodeSlotsFor`, objectui#11170), which + // the `objectui check` gate and the SDUI parser read too. Per type, never a + // second generic spelling: `page:card`'s retired `body` is walked because + // that renderer is measured still painting it; `body` on any other type is + // not, which is the objectui#6771 line above, kept. + if (typeof schema.type === 'string') { + for (const slot of nodeSlotsFor(schema.type)) { + for (const { segments, value } of nodeSlotValues(schema, slot.path)) { + if (!isSchemaNodeShape(value)) continue; + const childResult = validateSchema(value, spellSlotPath(path, segments)); + errors.push(...childResult.errors, ...childResult.warnings); + } + } + } + return errors; } diff --git a/packages/plugin-dashboard/src/__tests__/nodeSlotDeclaration-11170.test.tsx b/packages/plugin-dashboard/src/__tests__/nodeSlotDeclaration-11170.test.tsx new file mode 100644 index 0000000000..c6c22d6d10 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/nodeSlotDeclaration-11170.test.tsx @@ -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. + */ + +/** + * The node-slot declaration ⇔ this plugin's renderers (objectui#11170). See + * `packages/components/src/renderers/__tests__/node-slot-declaration-11170.test.tsx` + * for the census this file is the plugin half of. A dashboard's widget slot + * entry on the widget arm carries an authored `component` node that the + * renderer hands to `SchemaRenderer` (`entryComponent` in `widgetDispatch.ts`). + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { AdapterCtx, SchemaRenderer } from '@object-ui/react'; +import { nodeSlotsFor } from '@object-ui/types'; + +// Module scope, not a hook: these imports ARE the registrations. +import '@object-ui/components'; +import '../index'; + +afterEach(() => cleanup()); + +const MARK = 'slot-census-11170'; +const MARK_NODE = { type: 'text', content: MARK }; + +const rendersMark = (schema: unknown): boolean => { + render( + + + , + ); + return (document.body.textContent ?? '').includes(MARK); +}; + +const FIXTURES: Record> = { + 'widgets[].component': { widgets: [{ id: 'w1', component: MARK_NODE }] }, +}; + +describe.each(['dashboard', 'plugin-dashboard:dashboard'])('`%s` — declared ⇔ rendered (objectui#11170)', (type) => { + it('is registered, and the declaration lists exactly the positions this file exercises', () => { + expect(ComponentRegistry.has(type)).toBe(true); + expect(nodeSlotsFor(type).map((s) => s.path).sort()).toEqual(Object.keys(FIXTURES).sort()); + }); + + it.each(Object.keys(FIXTURES))('renders an authored node at `%s`', (path) => { + expect(rendersMark({ type, ...FIXTURES[path] })).toBe(true); + }); + + it('the probe can fail: a node under a key the row does not name stays off the page', () => { + expect(rendersMark({ type, widgets: [], footer: [MARK_NODE] })).toBe(false); + }); +}); diff --git a/packages/plugin-detail/src/__tests__/nodeSlotDeclaration-11170.test.tsx b/packages/plugin-detail/src/__tests__/nodeSlotDeclaration-11170.test.tsx new file mode 100644 index 0000000000..339bf77065 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/nodeSlotDeclaration-11170.test.tsx @@ -0,0 +1,84 @@ +/** + * 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 node-slot declaration ⇔ this plugin's renderers (objectui#11170). + * + * `NODE_SLOT_DECLARATIONS` (`@object-ui/types`) says through which keys other + * than `children` a renderer hands authored nodes back to `SchemaRenderer`, + * and the `objectui check` gate, core's `validateChildren` and the SDUI + * parser walk those positions. The components-side census holds the rows + * registered by `@object-ui/components`; this file holds the rows registered + * HERE, the same way: a node authored at every declared position reaches the + * DOM through the real `SchemaRenderer`, under every registry key of the row, + * and the row lists exactly the positions this file exercises — so a position + * added to the declaration without a renderer read, or a read the declaration + * drops, is red here. + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { AdapterCtx, SchemaRenderer } from '@object-ui/react'; +import { nodeSlotsFor } from '@object-ui/types'; + +// Module scope, not a hook: these imports ARE the registrations. +import '@object-ui/components'; +import '../index'; + +afterEach(() => cleanup()); + +const MARK = 'slot-census-11170'; +const MARK_NODE = { type: 'text', content: MARK }; + +const rendersMark = (schema: unknown): boolean => { + render( + + + , + ); + return (document.body.textContent ?? '').includes(MARK); +}; + +/** The detail view's slots: a fixture per declared position, nothing else at it. */ +const DETAIL_BASE = { objectName: 'thing', title: 'Fixture', data: { id: 'X1', qty: 3 } }; +const DETAIL_FIXTURES: Record> = { + header: { header: [MARK_NODE] }, + footer: { footer: [MARK_NODE] }, + actions: { actions: [MARK_NODE] }, + 'tabs[].content': { tabs: [{ key: 't', label: 'T', content: [MARK_NODE] }] }, + 'sections[].fields[].render': { sections: [{ title: 'S', fields: [{ name: 'qty', render: MARK_NODE }] }] }, + 'fields[].render': { fields: [{ name: 'qty', render: MARK_NODE }] }, +}; + +const SECTION_FIXTURES: Record> = { + 'fields[].render': { title: 'S', fields: [{ name: 'qty', render: MARK_NODE }], data: { qty: 3 } }, +}; + +describe.each([ + ['detail-view', DETAIL_BASE, DETAIL_FIXTURES], + ['plugin-detail:detail-view', DETAIL_BASE, DETAIL_FIXTURES], + ['detail', DETAIL_BASE, DETAIL_FIXTURES], + ['view:detail', DETAIL_BASE, DETAIL_FIXTURES], + ['detail-section', {}, SECTION_FIXTURES], + ['plugin-detail:detail-section', {}, SECTION_FIXTURES], +] as const)('`%s` — declared ⇔ rendered (objectui#11170)', (type, base, fixtures) => { + it('is registered, and the declaration lists exactly the positions this file exercises', () => { + expect(ComponentRegistry.has(type)).toBe(true); + expect(nodeSlotsFor(type).map((s) => s.path).sort()).toEqual(Object.keys(fixtures).sort()); + }); + + it.each(Object.keys(fixtures))('renders an authored node at `%s`', (path) => { + expect(rendersMark({ type, ...base, ...fixtures[path] })).toBe(true); + }); + + it('the probe can fail: a node under a key the row does not name stays off the page', () => { + expect(nodeSlotsFor(type).some((s) => s.path === 'trigger')).toBe(false); + expect(rendersMark({ type, ...base, trigger: [MARK_NODE] })).toBe(false); + }); +}); diff --git a/packages/plugin-report/src/__tests__/nodeSlotDeclaration-11170.test.tsx b/packages/plugin-report/src/__tests__/nodeSlotDeclaration-11170.test.tsx new file mode 100644 index 0000000000..318eb87e7b --- /dev/null +++ b/packages/plugin-report/src/__tests__/nodeSlotDeclaration-11170.test.tsx @@ -0,0 +1,77 @@ +/** + * 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 node-slot declaration ⇔ this plugin's renderers (objectui#11170). See + * `packages/components/src/renderers/__tests__/node-slot-declaration-11170.test.tsx` + * for the census this file is the plugin half of: a node authored at every + * declared position reaches the DOM through the real `SchemaRenderer`, under + * every registry key of the row, and the row lists exactly the positions this + * file exercises. + * + * `report-viewer` is the one registration here that hands authored nodes + * back: `report.sections[].content`. The bare `report` node unwraps its + * `report` key and routes to a dataset renderer, a spec-report presentation + * or the legacy renderer, none of which reads an authored node, so it has no + * row — asserted below so a later read is a measured change, not a drift. + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { AdapterCtx, SchemaRenderer } from '@object-ui/react'; +import { nodeSlotsFor } from '@object-ui/types'; + +// Module scope, not a hook: these imports ARE the registrations. +import '@object-ui/components'; +import '../index'; + +afterEach(() => cleanup()); + +const MARK = 'slot-census-11170'; +const MARK_NODE = { type: 'text', content: MARK }; + +const rendersMark = (schema: unknown): boolean => { + render( + + + , + ); + return (document.body.textContent ?? '').includes(MARK); +}; + +const FIXTURES: Record> = { + 'report.sections[].content': { + report: { title: 'R', sections: [{ type: 'text', title: 'S', content: [MARK_NODE] }] }, + data: [], + showToolbar: false, + }, +}; + +describe.each(['report-viewer', 'plugin-report:report-viewer'])('`%s` — declared ⇔ rendered (objectui#11170)', (type) => { + it('is registered, and the declaration lists exactly the positions this file exercises', () => { + expect(ComponentRegistry.has(type)).toBe(true); + expect(nodeSlotsFor(type).map((s) => s.path).sort()).toEqual(Object.keys(FIXTURES).sort()); + }); + + it.each(Object.keys(FIXTURES))('renders an authored node at `%s`', (path) => { + expect(rendersMark({ type, ...FIXTURES[path] })).toBe(true); + }); + + it('the probe can fail: a node under a key the row does not name stays off the page', () => { + expect(rendersMark({ type, report: { title: 'R', sections: [] }, data: [], footer: [MARK_NODE] })).toBe(false); + }); +}); + +describe('`report` has no node slot (objectui#11170)', () => { + it('declares none, and an authored node under `report.sections[].content` is not what it renders', () => { + expect(ComponentRegistry.has('report')).toBe(true); + expect(nodeSlotsFor('report')).toEqual([]); + expect(nodeSlotsFor('plugin-report:report')).toEqual([]); + }); +}); diff --git a/packages/plugin-timeline/src/__tests__/nodeSlotDeclaration-11170.test.tsx b/packages/plugin-timeline/src/__tests__/nodeSlotDeclaration-11170.test.tsx new file mode 100644 index 0000000000..1c2b2dfe06 --- /dev/null +++ b/packages/plugin-timeline/src/__tests__/nodeSlotDeclaration-11170.test.tsx @@ -0,0 +1,68 @@ +/** + * 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 node-slot declaration ⇔ this plugin's renderers (objectui#11170). See + * `packages/components/src/renderers/__tests__/node-slot-declaration-11170.test.tsx` + * for the census this file is the plugin half of. + * + * Two renderers answer to `timeline` here: `view:timeline` (and the bare key + * it claims) is the object-bound view and reads no authored node; + * `plugin-timeline:timeline`, the presentational feed, hands each item's + * `content` to `renderChildren`. Only the latter has a row. + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { AdapterCtx, SchemaRenderer } from '@object-ui/react'; +import { nodeSlotsFor } from '@object-ui/types'; + +// Module scope, not a hook: these imports ARE the registrations. +import '@object-ui/components'; +import '../index'; + +afterEach(() => cleanup()); + +const MARK = 'slot-census-11170'; +const MARK_NODE = { type: 'text', content: MARK }; +const TYPE = 'plugin-timeline:timeline'; + +const rendersMark = (schema: unknown): boolean => { + render( + + + , + ); + return (document.body.textContent ?? '').includes(MARK); +}; + +const FIXTURES: Record> = { + 'items[].content': { items: [{ title: 'T', content: [MARK_NODE] }] }, +}; + +describe(`\`${TYPE}\` — declared ⇔ rendered (objectui#11170)`, () => { + it('is registered, and the declaration lists exactly the positions this file exercises', () => { + expect(ComponentRegistry.has(TYPE)).toBe(true); + expect(nodeSlotsFor(TYPE).map((s) => s.path).sort()).toEqual(Object.keys(FIXTURES).sort()); + }); + + it.each(Object.keys(FIXTURES))('renders an authored node at `%s`', (path) => { + expect(rendersMark({ type: TYPE, ...FIXTURES[path] })).toBe(true); + }); + + it('the probe can fail: a node under a key the row does not name stays off the page', () => { + expect(rendersMark({ type: TYPE, items: [{ title: 'T' }], footer: [MARK_NODE] })).toBe(false); + }); + + it('the object-bound `timeline` / `view:timeline` has no row', () => { + expect(ComponentRegistry.get('timeline')).not.toBe(ComponentRegistry.get(TYPE)); + expect(nodeSlotsFor('timeline')).toEqual([]); + expect(nodeSlotsFor('view:timeline')).toEqual([]); + }); +}); diff --git a/packages/plugin-view/src/__tests__/nodeSlotDeclaration-11170.test.tsx b/packages/plugin-view/src/__tests__/nodeSlotDeclaration-11170.test.tsx new file mode 100644 index 0000000000..95a4de7457 --- /dev/null +++ b/packages/plugin-view/src/__tests__/nodeSlotDeclaration-11170.test.tsx @@ -0,0 +1,57 @@ +/** + * 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 node-slot declaration ⇔ this plugin's renderers (objectui#11170). See + * `packages/components/src/renderers/__tests__/node-slot-declaration-11170.test.tsx` + * for the census this file is the plugin half of. `view-switcher` renders the + * active view's `schema` — one node or a list — through `SchemaRenderer`. + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { AdapterCtx, SchemaRenderer } from '@object-ui/react'; +import { nodeSlotsFor } from '@object-ui/types'; + +// Module scope, not a hook: these imports ARE the registrations. +import '@object-ui/components'; +import '../index'; + +afterEach(() => cleanup()); + +const MARK = 'slot-census-11170'; +const MARK_NODE = { type: 'text', content: MARK }; + +const rendersMark = (schema: unknown): boolean => { + render( + + + , + ); + return (document.body.textContent ?? '').includes(MARK); +}; + +const FIXTURES: Record> = { + 'views[].schema': { views: [{ type: 'grid', label: 'G', schema: [MARK_NODE] }] }, +}; + +describe.each(['view-switcher', 'view:view-switcher'])('`%s` — declared ⇔ rendered (objectui#11170)', (type) => { + it('is registered, and the declaration lists exactly the positions this file exercises', () => { + expect(ComponentRegistry.has(type)).toBe(true); + expect(nodeSlotsFor(type).map((s) => s.path).sort()).toEqual(Object.keys(FIXTURES).sort()); + }); + + it.each(Object.keys(FIXTURES))('renders an authored node at `%s`', (path) => { + expect(rendersMark({ type, ...FIXTURES[path] })).toBe(true); + }); + + it('the probe can fail: a node under a key the row does not name stays off the page', () => { + expect(rendersMark({ type, views: [{ type: 'grid', label: 'G' }], footer: [MARK_NODE] })).toBe(false); + }); +}); diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json index 6224a0cb59..d27994f72b 100644 --- a/packages/sdui-parser/package.json +++ b/packages/sdui-parser/package.json @@ -33,6 +33,7 @@ "devDependencies": { "@object-ui/core": "workspace:*", "@object-ui/react": "workspace:*", + "@object-ui/types": "workspace:*", "@objectstack/spec": "^17.0.0" } } diff --git a/packages/sdui-parser/scripts/gen-manifest.ts b/packages/sdui-parser/scripts/gen-manifest.ts index 93dcd13237..a74fd7ce0c 100644 --- a/packages/sdui-parser/scripts/gen-manifest.ts +++ b/packages/sdui-parser/scripts/gen-manifest.ts @@ -22,12 +22,16 @@ */ import { writeFileSync } from 'node:fs'; import { ComponentRegistry } from '@object-ui/core'; +import { nodeSlotsFor } from '@object-ui/types'; import { assertFullyLoaded, generateBlockList, generateDts, manifestFromConfigs, type RegistryConfigLike } from '../src/index.js'; export function buildArtifacts(outDir: string): void { const configs = ComponentRegistry.getPublicConfigs() as unknown as RegistryConfigLike[]; assertFullyLoaded(configs); - const manifest = manifestFromConfigs(configs); + // `slotsFor` (objectui#11170): every entry carries the node slots its renderer + // reads besides `children`, from the one declaration in `@object-ui/types`, so + // the shipped manifest lets `validateTree` judge slot-held nodes. + const manifest = manifestFromConfigs(configs, { slotsFor: nodeSlotsFor }); writeFileSync(`${outDir}/sdui.manifest.json`, JSON.stringify(manifest, null, 2)); writeFileSync(`${outDir}/sdui-intrinsics.d.ts`, generateDts(manifest)); writeFileSync(`${outDir}/sdui-blocks.md`, generateBlockList(manifest)); diff --git a/packages/sdui-parser/src/__tests__/node-slot-walk-11170.test.ts b/packages/sdui-parser/src/__tests__/node-slot-walk-11170.test.ts new file mode 100644 index 0000000000..8e71e8f11f --- /dev/null +++ b/packages/sdui-parser/src/__tests__/node-slot-walk-11170.test.ts @@ -0,0 +1,106 @@ +/** + * 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. + */ + +/** + * `validateTree` descends the node slots a component's manifest entry carries + * (objectui#11170), and `manifestFromConfigs` projects them from the one + * declaration it is handed through `slotsFor`. + * + * The subject under every slot is a tag the manifest does not know, so what + * each case measures is REACH: `unknown-component` on the same node, drawn or + * not drawn, decided by whether its position is a declared slot. The + * declaration used here is a stand-in with the real grammar — this package + * imports nothing at runtime — and the components-side census for the same + * card holds this walk and `@object-ui/types`' `nodeSlotValues` to one answer + * over the live declaration. + */ +import { describe, expect, it } from 'vitest'; +import { manifestFromConfigs, validateTree } from '../index.js'; +import type { Diagnostic, SchemaElement } from '../types.js'; + +const SLOTS: Record = { + dialog: [{ path: 'trigger' }, { path: 'content' }, { path: 'footer' }], + tabs: [{ path: 'items[].content' }], + carousel: [{ path: 'items[]' }], + 'report-viewer': [{ path: 'report.sections[].content' }], + 'page:card': [{ path: 'footer' }, { path: 'body', retired: true }], +}; +const slotsFor = (type: string) => SLOTS[type] ?? []; + +const CONFIGS = [ + { type: 'dialog', namespace: 'ui', inputs: [{ name: 'trigger', type: 'slot' }, { name: 'content', type: 'slot' }, { name: 'footer', type: 'slot' }] }, + { type: 'tabs', namespace: 'ui', inputs: [{ name: 'items', type: 'array' }] }, + { type: 'carousel', namespace: 'ui', inputs: [{ name: 'items', type: 'array' }] }, + { type: 'report-viewer', namespace: 'plugin-report', inputs: [{ name: 'report', type: 'code' }] }, + { type: 'page:card', namespace: 'page', inputs: [{ name: 'children', type: 'slot' }, { name: 'footer', type: 'slot' }] }, + { type: 'text', namespace: 'ui', inputs: [{ name: 'content', type: 'string' }] }, +] as unknown as Parameters[0]; + +const withSlots = manifestFromConfigs(CONFIGS, { slotsFor }); +const withoutSlots = manifestFromConfigs(CONFIGS); + +const UNKNOWN = { type: 'nope-11170' }; +const codes = (node: unknown, manifest = withSlots): string[] => + validateTree(node as SchemaElement, manifest).diagnostics.map((d: Diagnostic) => d.code); +const unknownCount = (node: unknown, manifest = withSlots) => + codes(node, manifest).filter((c) => c === 'unknown-component').length; + +describe('manifestFromConfigs projects the declaration it is handed (objectui#11170)', () => { + it('carries each entry’s non-retired slot paths, and no key at all without the option', () => { + expect(withSlots.components['dialog']!.slots).toEqual(['trigger', 'content', 'footer']); + expect(withSlots.components['tabs']!.slots).toEqual(['items[].content']); + // The retired `body` is left out of the authoring tier's projection. + expect(withSlots.components['page:card']!.slots).toEqual(['footer']); + // An entry with no slots publishes none (`undefined`, which `JSON.stringify` + // drops — the same contract `tier` and `of` keep), so the serialised entry + // is byte-identical to one built before the key existed. + expect(withSlots.components['text']!.slots).toBeUndefined(); + expect(JSON.stringify(withSlots.components['text'])).toBe(JSON.stringify(withoutSlots.components['text'])); + expect(JSON.stringify(withoutSlots)).not.toContain('"slots"'); + }); +}); + +describe('validateTree reaches slot-held nodes exactly where the entry declares a slot', () => { + it('a direct slot, one node or a list', () => { + expect(unknownCount({ type: 'dialog', content: UNKNOWN })).toBe(1); + expect(unknownCount({ type: 'dialog', content: [{ type: 'text' }, UNKNOWN], footer: UNKNOWN })).toBe(2); + // The same node under an undeclared key is not reached. + expect(unknownCount({ type: 'dialog', header: UNKNOWN })).toBe(0); + }); + + it('a panel list and a trailing `[]`', () => { + expect(unknownCount({ type: 'tabs', items: [{ value: 'a', content: UNKNOWN }, { value: 'b', content: [UNKNOWN, UNKNOWN] }] })).toBe(3); + expect(unknownCount({ type: 'carousel', items: [UNKNOWN, [UNKNOWN]] })).toBe(2); + expect(unknownCount({ type: 'report-viewer', report: { sections: [{ content: UNKNOWN }] } })).toBe(1); + }); + + it('nested: a slot under a child under a slot', () => { + const doc = { type: 'dialog', content: { type: 'tabs', items: [{ value: 'a', content: UNKNOWN }] } }; + expect(unknownCount(doc)).toBe(1); + }); + + it('a retired position is not walked by this tier; its refusal by name stands (objectui#6771)', () => { + const diagnostics = validateTree({ type: 'page:card', body: [UNKNOWN] } as unknown as SchemaElement, withSlots).diagnostics; + expect(diagnostics.filter((d) => d.code === 'unknown-component')).toEqual([]); + expect(diagnostics.map((d) => d.code)).toContain('unknown-prop'); + expect(diagnostics.find((d) => d.code === 'unknown-prop')?.message).toContain('retired by objectui#6771'); + }); + + it('a manifest without slots walks `children` alone — the pre-11170 reach, unchanged', () => { + expect(unknownCount({ type: 'dialog', content: UNKNOWN }, withoutSlots)).toBe(0); + expect(unknownCount({ type: 'dialog', children: [UNKNOWN] }, withoutSlots)).toBe(1); + }); + + it('an unknown component’s own slots are not consulted: one diagnostic, on it', () => { + expect(codes({ type: 'nope-outer', content: UNKNOWN })).toEqual(['unknown-component']); + }); + + it('a string in a slot is legal and draws nothing', () => { + expect(codes({ type: 'dialog', trigger: 'Open', content: ['plain', { type: 'text' }] })).toEqual([]); + }); +}); diff --git a/packages/sdui-parser/src/index.ts b/packages/sdui-parser/src/index.ts index 9a7aef3608..021af95665 100644 --- a/packages/sdui-parser/src/index.ts +++ b/packages/sdui-parser/src/index.ts @@ -61,6 +61,16 @@ export function compile(source: string, manifest: Manifest): CompileResult { * the `tier:'public'` set). * ------------------------------------------------------------------ */ +/** + * The shape of one `@object-ui/types` `NodeSlotDeclaration`, restated + * structurally because this package imports nothing at runtime: a position in + * that package's key-path grammar, and whether its spelling is retired. + */ +export interface NodeSlotDeclarationLike { + readonly path: string; + readonly retired?: boolean; +} + export interface RegistryConfigLike { type: string; namespace?: string; @@ -184,19 +194,42 @@ export function assertFullyLoaded(configs: RegistryConfigLike[]): void { * no renderer read and sailed through it green (objectstack#4413; corrected in * objectstack#4472). Evidence about the render path has to come from the render * path — see `apps/console/src/__tests__/public-block-binding-reach.test.tsx`. + * + * `opts.slotsFor` is the one exception to "copied from the registration", and + * it is a DECLARATION too (objectui#11170): hand it `nodeSlotsFor` from + * `@object-ui/types` and every entry carries `slots`, the node-slot positions + * that type's renderer reads besides `children`, so `validateTree` judges the + * nodes under them. This package has no runtime dependency and so cannot + * import the declaration itself; the producers that build a live manifest + * (`getJsxManifest` in `@object-ui/components`' page renderer, the console's + * manifest generator) pass it. A RETIRED position is left out here: this is + * the authoring tier, and the spec's own authoring walks skip the tombstoned + * spellings for the same reason. Without the option no `slots` key is written, + * so every manifest built before the option existed serialises byte-identically. */ export function manifestFromConfigs( configs: RegistryConfigLike[], - opts: { only?: Set; publicOnly?: boolean } = {}, + opts: { + only?: Set; + publicOnly?: boolean; + slotsFor?: (type: string) => readonly NodeSlotDeclarationLike[]; + } = {}, ): Manifest { const components: Manifest['components'] = {}; for (const c of configs) { if (opts.only && !opts.only.has(c.type)) continue; if (opts.publicOnly && c.tier !== 'public') continue; + const slots = opts.slotsFor + ? opts.slotsFor(c.type).filter((s) => !s.retired).map((s) => s.path) + : undefined; components[c.type] = { type: c.type, namespace: c.namespace, isContainer: c.isContainer, + // `undefined` is dropped by `JSON.stringify`, so a manifest built with + // no resolver — or an entry with no slots — serialises exactly as before + // this key existed. + slots: slots && slots.length > 0 ? slots : undefined, // The html tier's stamp, and ONLY that stamp (objectui#10735): a // registration's `'public'` / `'internal'` is registry mechanics the // manifest never carried, and `undefined` is dropped by `JSON.stringify`, diff --git a/packages/sdui-parser/src/types.ts b/packages/sdui-parser/src/types.ts index 46ec8cfb90..461d0970aa 100644 --- a/packages/sdui-parser/src/types.ts +++ b/packages/sdui-parser/src/types.ts @@ -155,6 +155,19 @@ export interface ManifestComponent { * carry no such entry (objectui#9910; `acceptsChildren` in `validate.ts`). */ inputs: ManifestInput[]; + /** + * The NODE SLOTS this component's renderer reads besides `children` + * (objectui#11170): positions in the key-path grammar `@object-ui/types`' + * `NODE_SLOT_DECLARATIONS` states — `trigger`, `items[].content`, + * `regions[].components`. `validateTree` descends each one the way it + * descends `children`, so a slot-held node is judged too. Projected from + * that one declaration by `manifestFromConfigs`'s `slotsFor` option; the + * authoring tier leaves the declaration's RETIRED positions out, as the + * spec's own authoring walks do. ⛔ This tier keeps no slot list of its own: + * a manifest built without the option carries none, and the walk then + * follows `children` alone. + */ + slots?: readonly string[]; /** * LAYOUT containment (objectui#6804, objectui#9910 Q2-A): the flag the * react-page JSX scope builder skips and the public layout ledger lists. diff --git a/packages/sdui-parser/src/validate.ts b/packages/sdui-parser/src/validate.ts index 146b9081b6..72a08ad14b 100644 --- a/packages/sdui-parser/src/validate.ts +++ b/packages/sdui-parser/src/validate.ts @@ -8,6 +8,13 @@ * `requires` (plugin provenance) and binding sites the SERVER must resolve * against object schema (we cannot resolve objects/fields here — that check is * framework-side by design). + * + * The walk descends `children` on every node and, for a component the + * manifest knows, the NODE SLOTS its entry carries (`ManifestComponent.slots`, + * objectui#11170) — the positions besides `children` through which that + * renderer hands nodes back to `SchemaRenderer`, projected from the one + * declaration in `@object-ui/types` by `manifestFromConfigs`. ⛔ No slot list + * lives here. */ import type { @@ -172,6 +179,47 @@ const WHERE_UNDECLARED_BASE_PROPS = basePropNames('where-undeclared'); const isExpr = (v: unknown): boolean => typeof v === 'object' && v !== null && '$expr' in (v as Record); +const isPlainObject = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v); + +const isElement = (v: unknown): v is SchemaElement => + isPlainObject(v) && typeof v.type === 'string'; + +/** + * The element nodes a slot position holds on `node`, in document order. + * + * The position is spelled in `@object-ui/types`' key-path grammar, which that + * package's `nodeSlotValues` implements for the other two readers and whose + * header states it: keys separated by `.`, a key followed by `[]` meaning each + * element of the array under it, and the value at the end being one node or a + * list of nodes. Restated here because this package imports nothing at + * runtime; the components-side census for objectui#11170 holds the two walks + * to the same answer over the same fixtures. Only element-shaped values are + * returned — a string child is legal in a slot and has nothing to validate. + */ +function slotElements(node: SchemaElement, path: string): SchemaElement[] { + let holders: unknown[] = [node]; + for (const segment of path.split('.')) { + const each = segment.endsWith('[]'); + const key = each ? segment.slice(0, -2) : segment; + const next: unknown[] = []; + for (const holder of holders) { + if (!isPlainObject(holder)) continue; + const value = holder[key]; + if (!each) next.push(value); + else if (Array.isArray(value)) next.push(...value); + } + holders = next; + } + const found: SchemaElement[] = []; + for (const holder of holders) { + for (const value of Array.isArray(holder) ? holder : [holder]) { + if (isElement(value)) found.push(value); + } + } + return found; +} + export function validateTree(tree: SchemaElement | null, manifest: Manifest): ManifestValidationResult { const diagnostics: Diagnostic[] = []; const requires = new Set(); @@ -309,6 +357,13 @@ export function validateTree(tree: SchemaElement | null, manifest: Manifest): Ma } if (node.children) node.children.forEach(visit); + // The node slots this component's renderer reads besides `children` + // (objectui#11170), as its manifest entry declares them. An unknown + // component has no entry and so no slots: its `unknown-component` above is + // the answer, and nothing under it is judged. + for (const path of comp?.slots ?? []) { + for (const element of slotElements(node, path)) visit(element); + } }; if (tree) visit(tree); diff --git a/packages/types/README.md b/packages/types/README.md index 0f1afa641e..68d40c9057 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -274,6 +274,7 @@ Foundation types that all components build upon: - `DeclaredNode` - The discriminated union, keyed by `type`, of every declared node type; every node slot and `SchemaRenderer`'s `schema` prop take it, so an inline child is checked against its own type and an undeclared `type` is refused - `CustomNodeRegistry` - The interface an application augments (`declare module '@object-ui/types'`) to declare a node type it registers, which then joins `DeclaredNode` - `AuthoringNode` - The spec-declared nodes with a typed `properties` bag (see "Authoring the spec's blocks in TypeScript") +- `NODE_SLOT_DECLARATIONS` / `nodeSlotsFor(type)` - The per-type node slots beside `children`: where a renderer hands authored nodes back to `SchemaRenderer` through a key of its own (a dialog's `trigger`, a tab item's `content`, a page's `regions[].components`), spelled as key paths (`items[].content`). One declaration, read by `objectui check`, the core schema validator and the SDUI parser; `nodeSlotValues(node, path)` walks a node by it - `ComponentMeta` - Metadata for component registration - `ComponentInput` - Input field definitions for designers/editors diff --git a/packages/types/src/__tests__/node-slots-11170.test.ts b/packages/types/src/__tests__/node-slots-11170.test.ts new file mode 100644 index 0000000000..2a114aaa9e --- /dev/null +++ b/packages/types/src/__tests__/node-slots-11170.test.ts @@ -0,0 +1,198 @@ +/** + * 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 per-type node-slot declaration (objectui#11170): its shape, its walk, + * and the tie between its `page:*` rows and the `@objectstack/spec` export + * they are read from. + * + * What is NOT here: whether each row matches the renderer. That is a runtime + * question answered against the live registry by + * `packages/components/src/renderers/__tests__/node-slot-declaration-11170.test.tsx` + * and the `nodeSlotDeclaration-11170` suite in each plugin package. + */ + +import { describe, expect, it } from 'vitest'; +import { pageComponentSlotPositions } from '@objectstack/spec/ui'; + +import { + NODE_SLOT_DECLARATIONS, + nodeSlotPathSegments, + nodeSlotValues, + nodeSlotsFor, +} from '../node-slots.js'; + +const PAGE_FAMILY_PREFIX = 'page:'; + +describe('the declaration is one row per renderer, keyed by registry spelling (objectui#11170)', () => { + it('lists every type once, never `children`, and every path parses', () => { + const seen = new Set(); + for (const row of NODE_SLOT_DECLARATIONS) { + expect(row.types.length, `a row with no types`).toBeGreaterThan(0); + expect(row.slots.length, `\`${row.types[0]}\` declares no slot — delete the row`).toBeGreaterThan(0); + for (const type of row.types) { + expect(seen.has(type), `\`${type}\` is listed twice`).toBe(false); + seen.add(type); + } + const paths = row.slots.map((s) => s.path); + expect(new Set(paths).size, `\`${row.types[0]}\` repeats a path`).toBe(paths.length); + for (const path of paths) { + expect(path).not.toBe('children'); + expect(nodeSlotPathSegments(path).every((s) => s.key.length > 0)).toBe(true); + } + } + // The population control: a reading over an empty table proves nothing. + expect(seen.size).toBeGreaterThan(40); + }); + + it('`nodeSlotsFor` answers the row verbatim, and the one frozen empty list otherwise', () => { + expect(nodeSlotsFor('dialog').map((s) => s.path)).toEqual(['trigger', 'content', 'footer']); + expect(nodeSlotsFor('ui:dialog')).toBe(nodeSlotsFor('dialog')); + // Verbatim: a bare `card` and the page family's `page:card` are different renderers. + expect(nodeSlotsFor('card').map((s) => s.path)).toEqual(['header', 'footer']); + expect(nodeSlotsFor('page:card').map((s) => s.path)).toEqual(['footer', 'body']); + const none = nodeSlotsFor('stat-card'); + expect(none).toEqual([]); + expect(Object.isFrozen(none)).toBe(true); + expect(nodeSlotsFor('button')).toBe(none); + // Prototype keys are not rows. + expect(nodeSlotsFor('constructor')).toBe(none); + expect(nodeSlotsFor('toString')).toBe(none); + }); + + it('`body` is retired wherever it appears, and appears only on the page family', () => { + const bodyRows = NODE_SLOT_DECLARATIONS.filter((row) => row.slots.some((s) => s.path === 'body')); + expect(bodyRows.length).toBeGreaterThan(0); + for (const row of bodyRows) { + for (const type of row.types) expect(type.startsWith(PAGE_FAMILY_PREFIX), type).toBe(true); + expect(row.slots.find((s) => s.path === 'body')?.retired).toBe(true); + } + // …and nothing else is retired: `retired` is the tombstone's flag, not a mood. + for (const row of NODE_SLOT_DECLARATIONS) { + for (const s of row.slots) if (s.retired) expect(s.path).toBe('body'); + } + }); +}); + +describe('the path grammar and the walk (objectui#11170)', () => { + it('splits a path into keys, each marked when `[]` followed it', () => { + expect(nodeSlotPathSegments('trigger')).toEqual([{ key: 'trigger', each: false }]); + expect(nodeSlotPathSegments('items[].content')).toEqual([ + { key: 'items', each: true }, + { key: 'content', each: false }, + ]); + expect(nodeSlotPathSegments('items[]')).toEqual([{ key: 'items', each: true }]); + expect(nodeSlotPathSegments('report.sections[].content')).toEqual([ + { key: 'report', each: false }, + { key: 'sections', each: true }, + { key: 'content', each: false }, + ]); + expect(() => nodeSlotPathSegments('[]')).toThrow(/Malformed/); + expect(() => nodeSlotPathSegments('items..content')).toThrow(/Malformed/); + }); + + const node = (content: string) => ({ type: 'text', content }); + + it('a direct slot: one node, or a list expanded to its elements', () => { + expect(nodeSlotValues({ type: 'dialog', trigger: node('a') }, 'trigger')).toEqual([ + { segments: ['trigger'], value: node('a') }, + ]); + expect(nodeSlotValues({ type: 'dialog', content: [node('a'), 'plain', node('b')] }, 'content')).toEqual([ + { segments: ['content', 0], value: node('a') }, + { segments: ['content', 1], value: 'plain' }, + { segments: ['content', 2], value: node('b') }, + ]); + // Absent is absent; `null` and a primitive are returned for the reader to skip. + expect(nodeSlotValues({ type: 'dialog' }, 'trigger')).toEqual([]); + expect(nodeSlotValues({ type: 'dialog', trigger: null }, 'trigger')).toEqual([{ segments: ['trigger'], value: null }]); + expect(nodeSlotValues({ type: 'dialog', trigger: 0 }, 'trigger')).toEqual([{ segments: ['trigger'], value: 0 }]); + }); + + it('a panel list: each element’s slot, with the element index in the path', () => { + const tabs = { + type: 'tabs', + items: [ + { value: 'a', content: node('a') }, + 'not-a-panel', + { value: 'c', content: [node('c1'), node('c2')] }, + { value: 'd' }, + ], + }; + expect(nodeSlotValues(tabs, 'items[].content')).toEqual([ + { segments: ['items', 0, 'content'], value: node('a') }, + { segments: ['items', 2, 'content', 0], value: node('c1') }, + { segments: ['items', 2, 'content', 1], value: node('c2') }, + ]); + // A non-array under an `[]` key holds no panels. + expect(nodeSlotValues({ type: 'tabs', items: { content: node('x') } }, 'items[].content')).toEqual([]); + }); + + it('a trailing `[]`: each element is itself a node or a list (`carousel.items[]`)', () => { + const carousel = { type: 'carousel', items: [node('a'), [node('b1'), node('b2')]] }; + expect(nodeSlotValues(carousel, 'items[]')).toEqual([ + { segments: ['items', 0], value: node('a') }, + { segments: ['items', 1, 0], value: node('b1') }, + { segments: ['items', 1, 1], value: node('b2') }, + ]); + }); + + it('an object step and two list steps: `report.sections[].content`, `sections[].fields[].render`', () => { + const viewer = { + type: 'report-viewer', + report: { sections: [{ content: node('s0') }, { content: [node('s1a')] }] }, + }; + expect(nodeSlotValues(viewer, 'report.sections[].content')).toEqual([ + { segments: ['report', 'sections', 0, 'content'], value: node('s0') }, + { segments: ['report', 'sections', 1, 'content', 0], value: node('s1a') }, + ]); + const detail = { + type: 'detail', + sections: [{ fields: [{ name: 'a' }, { name: 'b', render: node('b') }] }], + }; + expect(nodeSlotValues(detail, 'sections[].fields[].render')).toEqual([ + { segments: ['sections', 0, 'fields', 1, 'render'], value: node('b') }, + ]); + }); +}); + +/** + * Route (a) for the page family: the rows are the spec's positions, read by + * reference. `pageComponentSlotPositions()` is derived in `@objectstack/spec` + * from the `ComponentPropsMap` rows that declare a slot, by SHAPE over the + * whole family; the rows here place each position on the `page:*` type whose + * renderer reads it. Held both ways, so a position the spec adds, drops or + * re-flags is a red here rather than a silent drift. + */ +describe('the `page:*` rows are the spec’s positions, by reference (objectui#11170)', () => { + const specPositions = pageComponentSlotPositions(); + const asPath = (p: { key: string; panelKey?: string }) => (p.panelKey ? `${p.key}[].${p.panelKey}` : p.key); + const pageRows = NODE_SLOT_DECLARATIONS.filter((row) => row.types.some((t) => t.startsWith(PAGE_FAMILY_PREFIX))); + + it('every `page:*` row position is one the spec declares, with the spec’s retirement flag', () => { + expect(pageRows.length).toBeGreaterThan(0); + const bySpecPath = new Map(specPositions.map((p) => [asPath(p), p])); + for (const row of pageRows) { + // A page-family row never mixes in a non-family key: the renderer is the + // page family's and its positions are the spec's. + for (const type of row.types) expect(type.startsWith(PAGE_FAMILY_PREFIX), type).toBe(true); + for (const s of row.slots) { + const spec = bySpecPath.get(s.path); + expect(spec, `\`${row.types[0]}\` declares \`${s.path}\`, which the installed spec does not`).toBeDefined(); + expect(s.retired === true, `\`${row.types[0]}.${s.path}\`: retirement disagrees with the spec`).toBe(spec!.retired); + } + } + }); + + it('every position the spec declares — `children` aside — is read by at least one `page:*` type', () => { + const declared = new Set(pageRows.flatMap((row) => row.slots.map((s) => s.path))); + const unplaced = specPositions.map(asPath).filter((p) => p !== 'children' && !declared.has(p)); + expect(unplaced, 'the spec declares a page slot no `page:*` row places — measure which renderer reads it').toEqual([]); + // The reading has a population: the spec's list carries the known four. + expect(specPositions.map(asPath)).toEqual(expect.arrayContaining(['children', 'body', 'footer', 'items[].children'])); + }); +}); diff --git a/packages/types/src/base.ts b/packages/types/src/base.ts index 83278ba92c..21d801568a 100644 --- a/packages/types/src/base.ts +++ b/packages/types/src/base.ts @@ -365,6 +365,13 @@ export interface BaseSchema { * which — a sentence that was itself load-bearing evidence on three * separate cards, because it told an author both spellings were live and * left them to guess per component. + * + * The keys OTHER than this one through which a renderer hands nodes back + * to `SchemaRenderer` — a dialog's `trigger`, a tab item's `content`, a + * page's `regions[].components` — are per-type and declared once, in + * `NODE_SLOT_DECLARATIONS` / `nodeSlotsFor` (`./node-slots.ts`, + * objectui#11170); every walk that judges a document follows `children` + * unconditionally and those positions by the node's type. */ children?: SchemaNode | SchemaNode[]; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index b1388993e0..6fcb41f349 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -129,6 +129,12 @@ export type { EventHandlers, StyleProps, } from './base.js'; +// Per-type NODE SLOTS beside `BaseSchema.children` (objectui#11170): where a +// renderer hands authored nodes back to `SchemaRenderer` through a key other +// than `children`, read by the `objectui check` gate, core's `validateChildren` +// and the SDUI parser's manifest projection so that none keeps a list of its own. +export { NODE_SLOT_DECLARATIONS, nodeSlotsFor, nodeSlotPathSegments, nodeSlotValues } from './node-slots.js'; +export type { NodeSlotDeclaration, NodeSlotRow, NodeSlotSegment, NodeSlotValue } from './node-slots.js'; // The predicate WIRE shape `BaseSchema.visible` / `.hidden` / `.disabled` and // the form predicate keys share (objectui#7530): a bare string or the CEL // envelope `{ dialect?, source }`. Its zod twin is `ExpressionWireSchema` on diff --git a/packages/types/src/node-slots.ts b/packages/types/src/node-slots.ts new file mode 100644 index 0000000000..6798a9abf0 --- /dev/null +++ b/packages/types/src/node-slots.ts @@ -0,0 +1,276 @@ +/** + * 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. + */ + +/** + * Per-type NODE SLOTS — where a renderer hands authored nodes back to + * `SchemaRenderer` through a key other than `children` (objectui#11170). + * + * ## What this declares, and what it does not + * + * `SchemaRenderer` does not recurse on its own. `BaseSchema.children` is the + * protocol's one COMPOSITION key, legal on every node, and every walk over a + * document follows it unconditionally. Beyond it, each renderer decides which + * of its keys hold nodes it puts on the page: a dialog's `trigger` and + * `content`, a tab item's `content`, a page's `regions[].components`. Those + * are per-type facts about the RENDERER, and before this module nothing + * declared them, so the three walks that judge a document — the `objectui + * check` unevaluated-expression gate (`findUnbindableTextExpressions` in + * `@object-ui/cli`), `validateChildren` in `@object-ui/core`'s schema + * validator and `validateTree` in `@object-ui/sdui-parser` — stopped at + * `children` and never reached a node under a slot: a `${…}` on such a node's + * `title` reached the user as literal text, unrefused. + * + * ⛔ This is not a list of every key whose VALUE happens to be an object with a + * string `type`. A form's `fields[]` entries are field definitions rendered by + * the form's field widgets, a grid's `columns[]` entries are column + * definitions and `{ "type": "multiple" }` can be a selection mode; walking + * them refuses things that are not nodes (measured as PR #11126's ablation 2). + * A key is here because its renderer was measured handing the value to + * `SchemaRenderer` — through `renderChildren`, `renderNodeSlot`, + * `renderTriggerSlot` or a direct `SchemaRenderer schema={…}` of it. + * + * ⛔ Not a second child-list key. `body` was retired as the generic child-list + * spelling (objectui#6771) and stays retired: it appears below ONLY on the + * types whose renderer was measured still reading it for stored documents, + * marked `retired`, and the parser tier's refusal of the spelling by name + * (`RETIRED_CHILD_LIST_KEY` in `@object-ui/sdui-parser`) is unchanged. + * + * ## Where each row comes from + * + * - The `page:*` family's positions are `@objectstack/spec`'s, read by + * reference rather than copied: the spec derives `pageComponentSlotPositions()` + * from the `ComponentPropsMap` rows that declare a slot, and + * `__tests__/node-slots-11170.test.ts` holds this module's `page:*` rows + * against that export in both directions, so a spec row that moves is a red + * test here. The spec's list is by SHAPE over the whole family; the rows + * below are the same positions placed on the types whose renderer reads + * them (`page:tabs` and `page:accordion` read `items[].children`, + * `page:card` reads `footer` and the retired `body`). + * - Every other row is objectui's own: the installed spec declares no slot + * for these types, and the registry's `inputs` cannot spell a nested + * position (`items[].content` is not a `ComponentInput`). Each is held + * against the live renderer by + * `packages/components/src/renderers/__tests__/node-slot-declaration-11170.test.tsx` + * (a node authored at every declared position reaches the DOM; every + * `type: 'slot'` input a registration declares has a row here) and, for + * the plugin-registered types, by the `nodeSlotDeclaration-11170` suite in + * each plugin package. + * + * ## The spelling of a position + * + * A path of keys separated by `.`; a key followed by `[]` means "each element + * of the array under that key". The value at the end of the path is ONE node + * or a LIST of nodes, which is what every slot renderer accepts + * (`renderChildren` in `@object-ui/components`). So: + * + * - `trigger` — the node(s) under `node.trigger`; + * - `items[].content` — for each element of `node.items`, the node(s) under + * its `content`; + * - `regions[].components` — for each region, the list under `components`; + * - `items[]` — each element of `node.items` is itself a node or a list + * (the `carousel` renderer hands every item to `renderChildren`); + * - `report.sections[].content` — through the `report` object, then each + * section's `content`. + * + * {@link nodeSlotValues} walks a node by this grammar and is what the three + * readers call, so the grammar is implemented once. Readers spell the + * resulting path their own way (`children → 0 → title` in the CLI, + * `schema.items[0].content` in core). + * + * ## Namespaced spellings + * + * The registry stores a registration under its namespaced key AND, unless + * `skipFallback` is set, its bare name; both resolve to the same renderer, so + * both are listed and the census above pins that twins agree. A key the + * registry stores only namespaced (`page:card`) is listed once. + */ + +/** One node slot a type's renderer reads. */ +export interface NodeSlotDeclaration { + /** The position, in the key-path grammar this module's header states. */ + readonly path: string; + /** + * The spelling is RETIRED: not authorable, refused by name on the authoring + * faces, but still read by the renderer for stored documents (`page:card`'s + * `body`, objectstack#5775 / objectui#6771). A walk that judges what the + * renderer paints descends it; the authoring tier's manifest projection + * leaves it out, as the spec's own authoring walks do. + */ + readonly retired?: true; +} + +/** One renderer's slots, under every registry key that resolves to it. */ +export interface NodeSlotRow { + readonly types: readonly string[]; + readonly slots: readonly NodeSlotDeclaration[]; +} + +const slot = (path: string): NodeSlotDeclaration => Object.freeze({ path }); +const retired = (path: string): NodeSlotDeclaration => Object.freeze({ path, retired: true }); +const row = (types: readonly string[], slots: readonly NodeSlotDeclaration[]): NodeSlotRow => + Object.freeze({ types: Object.freeze([...types]), slots: Object.freeze([...slots]) }); + +/** The overlay primitives that wrap a Radix `*Trigger` around `trigger` and render `content` in the portal. */ +const TRIGGER_AND_CONTENT = [slot('trigger'), slot('content')] as const; + +/** + * THE declaration, one row per renderer. `children` is never listed: it is + * every node's composition key and every walk follows it unconditionally. + */ +export const NODE_SLOT_DECLARATIONS: readonly NodeSlotRow[] = Object.freeze([ + // ── @object-ui/components, overlays ─────────────────────────────────────── + row(['alert-dialog', 'ui:alert-dialog'], TRIGGER_AND_CONTENT), + row(['context-menu', 'ui:context-menu'], [slot('trigger')]), + row(['dialog', 'ui:dialog'], [...TRIGGER_AND_CONTENT, slot('footer')]), + row(['drawer', 'ui:drawer'], TRIGGER_AND_CONTENT), + row(['dropdown-menu', 'ui:dropdown-menu'], [slot('trigger')]), + row(['hover-card', 'ui:hover-card'], TRIGGER_AND_CONTENT), + row(['popover', 'ui:popover'], TRIGGER_AND_CONTENT), + row(['sheet', 'ui:sheet'], [...TRIGGER_AND_CONTENT, slot('footer')]), + // `tooltip` places `content` RAW in a React child position (text only, + // objectui#10295); only its trigger is a node slot. + row(['tooltip', 'ui:tooltip'], [slot('trigger')]), + row(['collapsible', 'ui:collapsible'], TRIGGER_AND_CONTENT), + // ── @object-ui/components, direct slots ─────────────────────────────────── + row(['card', 'ui:card'], [slot('header'), slot('footer')]), + row(['table', 'ui:table'], [slot('footer')]), + row(['data-table', 'ui:data-table'], [slot('emptyAction')]), + row(['empty', 'ui:empty'], [slot('action')]), + row(['header-bar', 'ui:header-bar'], [slot('actions'), slot('rightContent')]), + // ── @object-ui/components, panel lists ──────────────────────────────────── + row(['tabs', 'ui:tabs'], [slot('items[].content')]), + row(['accordion', 'ui:accordion'], [slot('items[].content')]), + row(['list', 'ui:list'], [slot('items[].content')]), + row(['resizable', 'ui:resizable'], [slot('panels[].content')]), + row(['carousel', 'ui:carousel'], [slot('items[]')]), + // ── the page document: five registry keys, one `PageRenderer` ───────────── + row( + ['page', 'ui:page', 'app', 'ui:app', 'utility', 'ui:utility', 'home', 'ui:home', 'record', 'ui:record'], + [slot('regions[].components')], + ), + // ── the `page:*` family — the spec's positions, by reference (see header) ── + row(['page:tabs'], [slot('items[].children')]), + row(['page:accordion'], [slot('items[].children')]), + row(['page:card'], [slot('footer'), retired('body')]), + row(['page:section'], [retired('body')]), + row(['page:footer'], [retired('body')]), + row(['page:sidebar'], [retired('body')]), + // ── plugins ─────────────────────────────────────────────────────────────── + row( + ['detail', 'view:detail', 'detail-view', 'plugin-detail:detail-view'], + [ + slot('header'), + slot('footer'), + slot('actions'), + slot('tabs[].content'), + slot('sections[].fields[].render'), + slot('fields[].render'), + ], + ), + row(['detail-section', 'plugin-detail:detail-section'], [slot('fields[].render')]), + row(['report-viewer', 'plugin-report:report-viewer'], [slot('report.sections[].content')]), + row(['plugin-timeline:timeline'], [slot('items[].content')]), + row(['view-switcher', 'view:view-switcher'], [slot('views[].schema')]), + row(['dashboard', 'plugin-dashboard:dashboard'], [slot('widgets[].component')]), +]); + +const NO_SLOTS: readonly NodeSlotDeclaration[] = Object.freeze([]); + +const BY_TYPE: ReadonlyMap = (() => { + const map = new Map(); + for (const entry of NODE_SLOT_DECLARATIONS) { + for (const type of entry.types) { + if (map.has(type)) throw new Error(`NODE_SLOT_DECLARATIONS lists \`${type}\` twice`); + map.set(type, entry.slots); + } + } + return map; +})(); + +/** + * The node slots of a registry type, by its spelling as authored — the same + * verbatim lookup the three readers make. A type with no row answers the one + * frozen empty list: an unknown or custom type has no declared slot, and its + * `children` are still walked by every reader. + */ +export function nodeSlotsFor(type: string): readonly NodeSlotDeclaration[] { + return BY_TYPE.get(type) ?? NO_SLOTS; +} + +/** One segment of a slot path: the key, and whether `[]` followed it. */ +export interface NodeSlotSegment { + readonly key: string; + readonly each: boolean; +} + +const EACH_SUFFIX = '[]'; + +/** + * A slot path split into its segments — `items[].content` is + * `[{ key: 'items', each: true }, { key: 'content', each: false }]`. An empty + * key, or `[]` on its own, is a malformed path and throws: a declaration that + * cannot be walked must not read as "no slot". + */ +export function nodeSlotPathSegments(path: string): readonly NodeSlotSegment[] { + return path.split('.').map((segment) => { + const each = segment.endsWith(EACH_SUFFIX); + const key = each ? segment.slice(0, -EACH_SUFFIX.length) : segment; + if (key.length === 0) throw new Error(`Malformed node-slot path \`${path}\``); + return { key, each }; + }); +} + +/** One value found at a slot position, with the path that reached it from the node. */ +export interface NodeSlotValue { + /** Keys and array indices from the node to the value: `['items', 0, 'content', 1]`. */ + readonly segments: readonly (string | number)[]; + /** The value at that position — a node, a primitive, or anything else the author wrote. */ + readonly value: unknown; +} + +const isPlainObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * Every value a slot position holds on `node`, in document order, with the + * path to each. A list at the end of the path is expanded to its elements + * (one path entry per index), because a slot holds one node OR a list of + * nodes and both spellings render. Nothing is filtered: a reader decides what + * a node is (an object with a string `type`, for the gate; any object, for + * core), so a primitive or a malformed entry comes back for the reader to + * skip, never silently dropped here. + */ +export function nodeSlotValues(node: unknown, path: string): readonly NodeSlotValue[] { + let holders: NodeSlotValue[] = [{ segments: [], value: node }]; + for (const { key, each } of nodeSlotPathSegments(path)) { + const next: NodeSlotValue[] = []; + for (const holder of holders) { + if (!isPlainObject(holder.value)) continue; + const value = holder.value[key]; + if (!each) { + next.push({ segments: [...holder.segments, key], value }); + } else if (Array.isArray(value)) { + value.forEach((element, index) => { + next.push({ segments: [...holder.segments, key, index], value: element }); + }); + } + } + holders = next; + } + const found: NodeSlotValue[] = []; + for (const holder of holders) { + if (Array.isArray(holder.value)) { + holder.value.forEach((element, index) => { + found.push({ segments: [...holder.segments, index], value: element }); + }); + } else if (holder.value !== undefined) { + found.push(holder); + } + } + return found; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfd2b9b176..d85ca5048b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2836,6 +2836,9 @@ importers: '@object-ui/react': specifier: workspace:* version: link:../react + '@object-ui/types': + specifier: workspace:* + version: link:../types '@objectstack/spec': specifier: ^17.0.0 version: 17.6.0(ai@7.0.65(zod@4.6.5))