diff --git a/.changeset/4795-bindable-text-keys.md b/.changeset/4795-bindable-text-keys.md new file mode 100644 index 0000000000..c650b7b171 --- /dev/null +++ b/.changeset/4795-bindable-text-keys.md @@ -0,0 +1,50 @@ +--- +'@object-ui/react': minor +--- + +Expression-bindable text keys: `statistic.value`, `card.title`, `button.label` +and their siblings now evaluate `${...}` on the node (objectui#4795 Direction 1, +maintainer ruling 2026-08-25). + +**What changes for you.** Four text keys — `title`, `label`, `value`, +`description` — can now carry an expression written directly on the component +node, on the component types that declare them: + +| Component | Bindable node keys | +|---|---| +| `statistic` | `label`, `value`, `description` | +| `card` | `title`, `description` | +| `button` | `label` | + +```json +{ "type": "statistic", "label": "Active users", "value": "${data.metrics.active}" } +``` + +That node used to render the literal text `${data.metrics.active}`. A dashboard +`statistic` previously had no way at all to bind a dynamic number — the +documented workaround (moving the key under `props`) evaluated the value and +then discarded it, painting a blank card instead. Both shapes are fixed by the +same change: the value is evaluated once, at the single place that produces +evaluated schema, and lands where the renderers already read. + +**No component behaviour changed.** `statistic.tsx`, `card.tsx` and `button.tsx` +are untouched — they always read these keys off the node; nothing was writing an +evaluated value there. + +**Scope, and how it grows.** The list is closed and lives in +`@objectstack/spec` (`EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT`); the renderer +reads that declaration rather than keeping a copy. On any other component type +these four keys are still read raw, so an expression reaches the screen as +literal text — notably `text`, whose `value` is read but has no declaration. +Adding a type or a key is a change to the spec, never something the renderer +infers. + +**Nothing is newly rejected.** This release only widens what evaluates; no +metadata that used to render now fails to. The build-time rejection of `${...}` +in undeclared keys — the second half of the same ruling — is not in this release +and is still open. + +Published authoring guidance updated to match: `skills/objectui/rules/protocol.md` +(new "Bindable Text Keys" rule), plus the `page-builder`, `schema-expressions` +and `data-integration` guides, which taught the now-retired "never evaluated" +statement and its host-pre-resolution workaround. diff --git a/packages/components/src/__tests__/bindable-text-keys-4795.test.tsx b/packages/components/src/__tests__/bindable-text-keys-4795.test.tsx new file mode 100644 index 0000000000..bd53807b3d --- /dev/null +++ b/packages/components/src/__tests__/bindable-text-keys-4795.test.tsx @@ -0,0 +1,105 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#4795 Direction 1 — the same contract as + * `packages/react/src/__tests__/SchemaRenderer.bindableTextKeys.test.tsx`, but + * driven through the REAL production renderers with no stand-in for the + * read-back half. + * + * That companion file pins the memo against probes, because `@object-ui/react` + * by design does not depend on `@object-ui/components`. It can therefore prove + * the value was EVALUATED, and only assert the read-back against a mirror of + * the real read points. This file closes that gap: `statistic`, `card` and + * `button` here are the shipped renderers, so a passing assertion below means + * the evaluated value actually reached the DOM — which is the whole of what + * objectui#4795 measured as missing ("evaluated AND read back"). + * + * ## Why this file contains no renderer-specific fix to guard + * + * It guards the OPPOSITE. The ruling's implementation caution was that these + * read-back sites must be "converged on evaluated values, not patched per + * component" — and none of `data-display/statistic.tsx`, `layout/card.tsx` or + * `form/button.tsx` is touched by this card. They already read the right place; + * the single memo leg upstream now writes an evaluated value there. So these + * assertions passing while those three files are untouched IS the convergence + * claim, stated as a measurement rather than as a promise. + */ + +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +// Module scope, not a hook — the cold transform would otherwise be billed to +// `hookTimeout` (object-ui/no-dynamic-import-in-test-hook, objectui#3010). +import '../renderers'; + +const DATA = { total: 99, caption: 'Active users', note: '+20.1% from last month' }; + +const renderNode = (schema: any) => + render( + + + , + ); + +describe('objectui#4795 — declared text keys are evaluated AND read back, through real renderers', () => { + it('`statistic` binds label / value / description', () => { + renderNode({ + type: 'statistic', + label: '${data.caption}', + value: '${data.total}', + description: '${data.note}', + }); + expect(screen.getByText('Active users')).toBeTruthy(); + expect(screen.getByText('99')).toBeTruthy(); + expect(screen.getByText('+20.1% from last month')).toBeTruthy(); + // The defect, stated in the negative: the literal source must be gone. + expect(screen.queryByText('${data.total}')).toBeNull(); + }); + + it('`statistic` interpolates inside surrounding text', () => { + renderNode({ type: 'statistic', value: 'Total: ${data.total}' }); + expect(screen.getByText('Total: 99')).toBeTruthy(); + }); + + it('`card` binds title / description', () => { + renderNode({ type: 'card', title: '${data.caption}', description: '${data.note}' }); + expect(screen.getByText('Active users')).toBeTruthy(); + expect(screen.getByText('+20.1% from last month')).toBeTruthy(); + }); + + it('`button` binds label', () => { + renderNode({ type: 'button', label: 'Refresh ${data.total}' }); + expect(screen.getByText('Refresh 99')).toBeTruthy(); + }); +}); + +describe('objectui#4795 — the undeclared half stays inert, through real renderers', () => { + /** + * `basic/text.tsx` renders `schema.content || schema.value`, so `text.value` + * IS a top-level read-back site — and `text` has no row in the spec's + * carriage map, so the memo must not evaluate it. This assertion therefore + * pins a KNOWN, reported gap rather than a desired behaviour: the literal on + * screen is what an author writing the form the expressions guide teaches + * gets today, and closing it is a spec-side row (objectstack), not a + * renderer-side inference here. If a row is ever added upstream, this is the + * test that will go red and say so. + */ + it('`text.value` is still not evaluated — no spec row (reported upstream)', () => { + renderNode({ type: 'text', value: '${data.total}' }); + expect(screen.getByText('${data.total}')).toBeTruthy(); + }); + + it('a key outside the component\'s declared row stays inert (`card.value`)', () => { + const { container } = renderNode({ type: 'card', title: 'Fixed', value: '${data.total}' }); + // `card` declares title/description only; `value` is neither evaluated nor + // read back, so nothing from it reaches the DOM text. + expect(container.textContent).toContain('Fixed'); + expect(container.textContent).not.toContain('99'); + }); +}); diff --git a/packages/components/src/__tests__/skill-guide-provider-envelope.test.tsx b/packages/components/src/__tests__/skill-guide-provider-envelope.test.tsx index c93be1a4e8..1f4f35f4b8 100644 --- a/packages/components/src/__tests__/skill-guide-provider-envelope.test.tsx +++ b/packages/components/src/__tests__/skill-guide-provider-envelope.test.tsx @@ -20,7 +20,20 @@ * - `properties.*` — evaluated, then HOISTED onto the node by the COMPAT * hoist (`type` / `id` excepted). It therefore lands * exactly where every renderer reads, in every namespace. - * - a node key — read, but never expression-evaluated. + * - a node key — read, and expression-evaluated only if + * `@objectstack/spec` DECLARES it bindable for that + * component type (objectui#4795 Direction 1, ruled + * 2026-08-25). Every other node key is read raw. + * + * ⚠️ That third line said a flat "read, but never expression-evaluated" when + * this file was written, and the reading below said so too. objectui#4795 + * closed the gap for the closed set `title` / `label` / `value` / + * `description`, per the carriage map in + * `EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT` — `card` carries `title` and + * `description`. The rest of this file's measurement is untouched: the two + * envelope fates above are exactly what #5372 measured, and the card's + * question ② (whether `properties` is an official `ui:*` authoring channel) + * is still open and still not answered here. * * So the one spelling the rules told an author not to reach for was the only * one that reaches a provider's data, and the two the rules endorsed both fail @@ -158,9 +171,19 @@ describe('#5372 behaviour — the hoist is not `element:*`-only', () => { expect(header()).toBe('Customer Summary'); }); - it('a node-level `title` is read but never evaluated', () => { + // objectui#4795 Direction 1 flipped this reading. It used to assert the + // literal `${data.label}` on screen — a node key was read but never + // evaluated — and that is the defect the 2026-08-25 ruling retired for the + // spec-DECLARED keys. Kept as a pair so the file still states both halves: + // a declared key now evaluates, an undeclared one on the same node does not. + it('a node-level `title` is read AND evaluated — `card` declares it', () => { renderNode({ type: 'card', title: '${data.label}' }); - expect(header()).toBe('${data.label}'); + expect(header()).toBe('Evaluated Title'); + }); + + it('a node key `card` does NOT declare is still read raw (`value`)', () => { + renderNode({ type: 'card', title: 'Fixed', value: '${data.label}' }); + expect(header()).toBe('Fixed'); }); it('the same expression under `properties` is evaluated AND read', () => { diff --git a/packages/react/package.json b/packages/react/package.json index 780971cbe1..6b75ea85ea 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -34,7 +34,7 @@ "@object-ui/data-objectstack": "workspace:*", "@object-ui/i18n": "workspace:*", "@object-ui/types": "workspace:*", - "@objectstack/spec": "^17.0.0", + "@objectstack/spec": "^17.1.0", "react-hook-form": "^7.85.0" }, "peerDependencies": { diff --git a/packages/react/src/SchemaRenderer.tsx b/packages/react/src/SchemaRenderer.tsx index d269cfa673..7f9427de8f 100644 --- a/packages/react/src/SchemaRenderer.tsx +++ b/packages/react/src/SchemaRenderer.tsx @@ -32,6 +32,7 @@ import { resolveKeyedI18nLabel } from './utils/i18n.js'; import { isConfigBag } from './utils/configBag.js'; import { reportUnevaluatedExpressions } from './utils/unevaluatedExpression.js'; import { reportDroppedPropsBag } from './utils/propsBagDiagnostic.js'; +import { expressionBindableTextKeysFor } from '@objectstack/spec/ui'; import { reportUnresolvableVisibilityPredicate, reportAdapterOnlyDataPredicate, @@ -1070,7 +1071,79 @@ export const SchemaRenderer: ForwardRefExoticComponent< if (typeof newSchema.content === 'string') { newSchema.content = evaluator.evaluate(newSchema.content); } - + + /** + * Evaluate the SPEC-DECLARED, expression-bindable TOP-LEVEL text keys + * (objectui#4795 Direction 1, maintainer ruling 2026-08-25). + * + * ## The hole this closes + * + * A value reaches the screen only if it is BOTH evaluated here AND read + * back by the renderer off the node. `content` above satisfies both; the + * other text keys satisfied neither at once, so + * `{ type: 'statistic', value: '${data.total}' }` — whose renderer reads + * `schema.value` (`data-display/statistic.tsx`) — put the literal + * `${data.total}` on screen, and the `props`-envelope workaround the + * objectui#4786 teaching rewrite retired rendered BLANK instead (evaluated, + * then spread as a React prop nobody reads). `statistic` is the dashboard + * workhorse and had no way at all to bind a dynamic value. + * + * ## Why this is ONE leg here and not a patch in each renderer + * + * The ruling's own implementation caution: the read-back sites must be + * "converged on evaluated values, not patched per component". They already + * agree on WHERE to read (the node's top level) — what they lacked was + * anything writing an evaluated value there. Writing it at the single + * producer of evaluated schema means `statistic.tsx`, `card.tsx` and + * `button.tsx` are UNCHANGED by this card and every future top-level reader + * is covered by construction. Four fixed components and no contract was the + * failure mode named at dispatch. + * + * ## The vocabulary is the spec's, and is consumed rather than copied + * + * `@objectstack/spec` declares it (objectstack#9599): the closed set + * `title` / `label` / `value` / `description`, plus the per-component + * carriage map saying which of them each component type actually reads + * back. The 2026-08-18 ruling is explicit that this memo CONSUMES the + * declaration "rather than hard-coding a twin list" — so the only thing + * this file knows is the name of the lookup. A row added upstream starts + * working here with no edit; a row this file invented would be the second + * dialect the declaration exists to prevent. + * + * ## The type string is passed VERBATIM + * + * `expressionBindableTextKeysFor` keys on the bare registry name, and the + * spec states the answer for an unlisted type is the empty set — "closed + * and mechanically answerable in both directions, never inferred from what + * a renderer happens to read". So no prefix-stripping normalization: it + * would look harmless (`ui:statistic` → `statistic`) and would in the same + * motion grant rows to `element:button` and `page:card`, whose renderers + * read their config out of the bag via `readProps()` and never touch these + * keys on the node — re-manufacturing the evaluated-but-not-read-back half + * of the very table this card exists to close. Measured on this tree: the + * authored corpus spells these types bare (`statistic` 42, `card` 135, + * `button` 158 nodes) and `ui:*` zero times, so verbatim is also the + * spelling authors actually use. `action:button` (5 nodes) has no row and + * is reported upstream rather than inferred here. + * + * ## Ordering and idempotence + * + * After the `properties` hoist deliberately, exactly like `content`: a + * value arriving through that channel was already evaluated by the + * `properties` leg, so it no longer carries a `${…}` and + * `evaluator.evaluate` returns it unchanged. The `typeof === 'string'` + * guard is doing real work rather than mirroring the line above — it stops + * this loop CREATING an absent key as `undefined`, which would change what + * `{ ...schema }` spreads and what `key in schema` answers downstream. + */ + for (const key of expressionBindableTextKeysFor( + typeof newSchema.type === 'string' ? newSchema.type : '', + )) { + if (typeof newSchema[key] === 'string') { + newSchema[key] = evaluator.evaluate(newSchema[key]); + } + } + // Evaluate 'props' — the legacy alias of the config bag. // // The guard MIRRORS the `properties` branch above rather than testing bare diff --git a/packages/react/src/__tests__/SchemaRenderer.bindableTextKeys.test.tsx b/packages/react/src/__tests__/SchemaRenderer.bindableTextKeys.test.tsx new file mode 100644 index 0000000000..c3e58e14d3 --- /dev/null +++ b/packages/react/src/__tests__/SchemaRenderer.bindableTextKeys.test.tsx @@ -0,0 +1,196 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#4795 Direction 1 — the top-level text keys the evaluation memo + * evaluates, and the ones it deliberately leaves inert. + * + * ## What was broken + * + * A value reaches the screen only if it is BOTH evaluated by the memo AND read + * back by the renderer off the node. Before this leg the memo evaluated + * `content`, the two config bags and the predicate keys — so + * `{ type: 'statistic', value: '${data.total}' }`, whose renderer reads + * `schema.value`, put the literal `${data.total}` on screen. + * + * ## The contract this pins, and where it lives + * + * NOT here. `@objectstack/spec` declares it (objectstack#9599): the closed + * vocabulary `EXPRESSION_BINDABLE_TEXT_KEYS` and the per-component carriage map + * `EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT`, read through + * `expressionBindableTextKeysFor(type)`. The memo CONSUMES that lookup; this + * file pins that it consumes it, never a twin list of its own — so the + * assertions below name the spec's exports rather than restating their contents + * as literals, and a spec-side row change lands here as a passing test rather + * than a red one somebody has to reconcile by hand. + * + * The one thing stated as a literal is the CLOSED-ness of the vocabulary + * itself, which is the maintainer ruling (2026-08-25) rather than a detail: + * four keys, and a fifth is a new decision. + * + * ## The negative half is the load-bearing half + * + * The spec's answer for a type with no row is the frozen EMPTY set — "closed + * and mechanically answerable in both directions, never inferred from what a + * renderer happens to read". So a closed-set key on an undeclared type stays + * inert, and that is the contract, not a gap this file should paper over: the + * `text` / `ui:statistic` cases below fail loudly if a later edit ever widens + * the lookup by inference. + */ + +import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import { + EXPRESSION_BINDABLE_TEXT_KEYS, + EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT, + expressionBindableTextKeysFor, +} from '@objectstack/spec/ui'; +import { SchemaRenderer } from '../SchemaRenderer'; +import { SchemaRendererContext } from '../context/SchemaRendererContext'; + +const DATA = { total: 99, name: 'Widgets', note: 'since last week' }; + +/** + * Probes that read the SAME top-level keys the real renderers read, which is + * what makes "evaluated" and "read back" one observation instead of two. + * `@object-ui/react` does not depend on `@object-ui/components` by design, so + * the read points are mirrored here; the sites they mirror are + * `data-display/statistic.tsx` (`schema.label` / `schema.value` / + * `schema.description`), `layout/card.tsx` (`schema.title` / + * `schema.description`) and `form/button.tsx` (`schema.label`). + */ +const TopLevelProbe = ({ schema }: any) => ( +
+); + +beforeAll(() => { + for (const type of ['statistic', 'card', 'button', 'text', 'ui:statistic', 'constructor']) { + // Registered with a namespace, so the registry ALSO creates the bare-name + // fallback entry — and the bare name is the spelling these nodes are + // authored with, which is the one the memo asks the spec about. + ComponentRegistry.register(type, TopLevelProbe, { namespace: 'test-4795' }); + } +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const renderNode = (schema: any) => + render( + + + , + ); + +const read = (key: string) => screen.getByTestId('probe').getAttribute(`data-${key}`); + +describe('objectui#4795 — the memo consumes the spec-declared bindable text keys', () => { + it('the vocabulary it consumes is the spec\'s closed four, not a local list', () => { + expect([...EXPRESSION_BINDABLE_TEXT_KEYS]).toEqual(['title', 'label', 'value', 'description']); + }); + + describe.each(Object.keys(EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT))( + 'declared type `%s`', + (type) => { + const declared = expressionBindableTextKeysFor(type); + + it(`evaluates exactly its declared keys (${declared.join(', ')}) and reads them back`, () => { + const node: any = { type }; + for (const key of EXPRESSION_BINDABLE_TEXT_KEYS) node[key] = '${data.total}'; + renderNode(node); + + for (const key of EXPRESSION_BINDABLE_TEXT_KEYS) { + if (declared.includes(key as any)) { + expect(read(key), `${type}.${key} is declared and must be evaluated`).toBe('99'); + } else { + expect(read(key), `${type}.${key} is NOT declared and must stay inert`).toBe('${data.total}'); + } + } + }); + }, + ); + + it('interpolates inside a surrounding string, like `content` does', () => { + renderNode({ type: 'statistic', value: 'Total: ${data.total} (${data.note})' }); + expect(read('value')).toBe('Total: 99 (since last week)'); + }); + + it('leaves a non-string value untouched', () => { + renderNode({ type: 'statistic', value: 0, label: null }); + expect(read('value')).toBe('0'); + expect(read('label')).toBe('null'); + }); + + it('leaves a plain string with no template untouched', () => { + renderNode({ type: 'card', title: 'Revenue' }); + expect(read('title')).toBe('Revenue'); + }); +}); + +describe('objectui#4795 — the closed set is closed in the OTHER direction too', () => { + /** + * `text` reads `schema.content || schema.value` (`basic/text.tsx`), so its + * `value` IS a top-level read-back site — and it still has no spec row, so + * the memo must leave it alone. Declaring it here instead would be exactly + * the renderer-side inference the spec module forbids in its own docblock; + * the route for it is a spec row, not this file. + */ + it('a closed-set key on an undeclared type stays inert (`text.value`)', () => { + expect(expressionBindableTextKeysFor('text')).toHaveLength(0); + renderNode({ type: 'text', value: '${data.total}' }); + expect(read('value')).toBe('${data.total}'); + }); + + /** + * The lookup is fed the AUTHORED type string verbatim. The spec keys its map + * on the bare registry name, and a namespaced spelling has no row — so it + * gets the empty set. Pinned rather than left implicit because the tempting + * "just strip the prefix" would silently grant rows to `element:button` and + * `page:card`, whose renderers do not read these keys off the node at all. + */ + it('a namespaced spelling is not silently normalized (`ui:statistic`)', () => { + expect(expressionBindableTextKeysFor('ui:statistic')).toHaveLength(0); + renderNode({ type: 'ui:statistic', value: '${data.total}' }); + expect(read('value')).toBe('${data.total}'); + }); + + /** A prototype-chain name must not answer with a function off `Object.prototype`. */ + it('a prototype-chain type name gets the empty set', () => { + expect(expressionBindableTextKeysFor('constructor')).toHaveLength(0); + renderNode({ type: 'constructor', title: '${data.total}' }); + expect(read('title')).toBe('${data.total}'); + }); +}); + +describe('objectui#4795 — it composes with the config-bag legs already in the memo', () => { + /** + * `properties.*` is evaluated by its own leg and then hoisted onto the node, + * so by the time this leg runs the hoisted value carries no `${…}` left to + * evaluate. Re-evaluating it is a no-op (`evaluate` returns a template-free + * string as-is) — the same idempotence the `content` leg relies on. + */ + it('a value arriving through the `properties` hoist is not double-evaluated', () => { + renderNode({ type: 'statistic', properties: { value: '${data.total}' } }); + expect(read('value')).toBe('99'); + }); + + it('a top-level key loses to `properties` on the same key, as objectui#5123 rules', () => { + renderNode({ type: 'statistic', value: '${data.total}', properties: { value: '${data.name}' } }); + expect(read('value')).toBe('Widgets'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6941818824..7aa49d1a53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2721,7 +2721,7 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^17.0.0 + specifier: ^17.1.0 version: 17.2.0(ai@7.0.65(zod@4.4.3)) react: specifier: 19.2.8 diff --git a/skills/objectui/guides/data-integration.md b/skills/objectui/guides/data-integration.md index 144ab29cfa..63b4b6b91b 100644 --- a/skills/objectui/guides/data-integration.md +++ b/skills/objectui/guides/data-integration.md @@ -239,8 +239,9 @@ is both expression-evaluated and read back by the renderer, and the provider's } ``` -A `statistic`'s `label` / `value` / `description` are read off the node but are -**not** expression-evaluated, so give them values the host already resolved: +A `statistic` declares `label` / `value` / `description` as expression-bindable +(objectui#4795), so these are evaluated on the node and read back. Host-resolved +literals work exactly as before: ```json { diff --git a/skills/objectui/guides/page-builder.md b/skills/objectui/guides/page-builder.md index 9cd5b10fe4..4833406a88 100644 --- a/skills/objectui/guides/page-builder.md +++ b/skills/objectui/guides/page-builder.md @@ -188,17 +188,18 @@ clear the first gate; only the short list below clears the second. | `disabled` / `disabledOn` | Boolean expression. Passed as prop to component. | | `props.*` | Template-evaluated, but handed to the component as React props — a `ui:*` / `page:*` renderer never reads the result back, so the evaluated value is discarded. Only `element:*` components consume it. Do not use it as an expression carrier. | | `properties.*` | Template-evaluated **and hoisted onto the node**, so unlike `props` the result is read — by every namespace. Its status as an authoring channel is open (objectui#4795); see [`rules/protocol.md`](../rules/protocol.md) before reaching for it. | +| `title` / `label` / `value` / `description` | Template-evaluated **on the component types that declare them** — `statistic` (`label`/`value`/`description`), `card` (`title`/`description`), `button` (`label`). The list is closed and declared in `@objectstack/spec`; see [`rules/protocol.md`](../rules/protocol.md). | **NOT evaluated (raw strings passed through):** | Field | What to do instead | |-------|-----------| -| `title` / `label` / `value` / `description` | Read by the renderer, but never template-evaluated — an inline `${...}` reaches the screen as literal text. Moving it under `props` does not help: it gets evaluated there and then dropped. Resolve the value in the host **before** handing the schema to `SchemaRenderer` (the same pattern the i18n guide uses for `t(...)`), or carry it on a `text` node's `content`. | +| `title` / `label` / `value` / `description` **on any other type** | Read by the renderer, but not template-evaluated there — an inline `${...}` reaches the screen as literal text. Moving it under `props` does not help: it gets evaluated there and then dropped. Resolve the value in the host **before** handing the schema to `SchemaRenderer` (the same pattern the i18n guide uses for `t(...)`), or carry it on a `text` node's `content`. | | `className` | Not expression-evaluated. Use static Tailwind classes only. | | `id` | Static string. No expressions. | -**Correct pattern** — a `statistic`'s text keys sit on the node and carry -values the host already resolved: +**Correct pattern** — a `statistic`'s text keys sit on the node. Static values +work as-is, and so do expressions (`statistic` declares all three): ```json { "type": "statistic", @@ -209,8 +210,8 @@ values the host already resolved: } ``` -**Correct pattern for a live-bound number** — `content` is the one text key -that is both evaluated and read: +**Correct pattern on a type that does NOT declare the key** — `content` is +evaluated on every component type, so a `text` child carries the binding: ```json { "type": "card", @@ -232,7 +233,8 @@ that is both evaluated and read: } ``` -**Also wrong (value shows as raw `${...}` text — read, but not evaluated):** +**Right, since objectui#4795 — `statistic` declares `value` and `label`, so +these are evaluated on the node and read back:** ```json { "type": "statistic", @@ -241,6 +243,10 @@ that is both evaluated and read: } ``` +⚠️ The same two keys on a type with no declaration (e.g. `text`) still reach the +screen as literal `${...}`. The declaring types are listed in +[`rules/protocol.md`](../rules/protocol.md). + For the full expression syntax reference (operators, formula functions, security model), see the `objectui-schema-expressions` skill. ## CSS theming template for third-party apps diff --git a/skills/objectui/guides/schema-expressions.md b/skills/objectui/guides/schema-expressions.md index 0c9f63999f..7ea9413227 100644 --- a/skills/objectui/guides/schema-expressions.md +++ b/skills/objectui/guides/schema-expressions.md @@ -43,6 +43,7 @@ see "The `props` envelope is evaluated but not read" below. | `visibleOn` | Condition | boolean | `"visibleOn": "data.permissions.canView"` | | `disabled` | Condition | boolean | `"disabled": "${form.isSubmitting}"` | | `disabledOn` | Condition | boolean | `"disabledOn": "!data.hasPermission"` | +| `title` / `label` / `value` / `description` | Template (`${}`) | Preserves original type | **Declared types only** — `statistic` (`label`/`value`/`description`), `card` (`title`/`description`), `button` (`label`). Closed set, declared in `@objectstack/spec` (objectui#4795). | **Precedence rule:** `visible` takes priority over `hidden`. If both are present, `visible` wins. @@ -50,9 +51,10 @@ see "The `props` envelope is evaluated but not read" below. These top-level schema fields are **not** processed by ExpressionEvaluator: -- `value`, `label`, `description`, `title` — read by the renderers, but never - template-evaluated. Resolve them in the host before rendering, or carry the - bound text on a `text` node's `content`. +- `value`, `label`, `description`, `title` **on a type that does not declare + them** (see the row above) — read by the renderers, but not template-evaluated + there. Resolve them in the host before rendering, or carry the bound text on a + `text` node's `content`. - `className` — always a static Tailwind class string - `id` — always a static string - `type` — component type identifier @@ -74,13 +76,14 @@ literal `${...}` on screen for nothing on screen. Put keys on the node. // ❌ Evaluated, then dropped — renders an empty card { "type": "card", "props": { "title": "${data.customer.name}" } } -// ❌ Read, but never evaluated — renders the literal "${data.customer.name}" +// ✅ `card` declares `title`, so on the node it is evaluated AND read { "type": "card", "title": "${data.customer.name}" } -// ✅ `content` is the one text key that is both evaluated and read -{ "type": "card", "title": "Customer", "children": [ - { "type": "text", "content": "${data.customer.name}" } -] } +// ❌ `text` does not declare `value` — renders the literal "${data.customer.name}" +{ "type": "text", "value": "${data.customer.name}" } + +// ✅ `content` is evaluated on every component type +{ "type": "text", "content": "${data.customer.name}" } ``` The `element:*` namespace is where `props` is read: those components take their diff --git a/skills/objectui/rules/protocol.md b/skills/objectui/rules/protocol.md index 555a95293c..ba1341424e 100644 --- a/skills/objectui/rules/protocol.md +++ b/skills/objectui/rules/protocol.md @@ -19,6 +19,7 @@ value must clear before it reaches the screen. | `visibleOn` | Condition | boolean | `"visibleOn": "data.permissions.canView"` | | `disabled` | Condition | boolean | `"disabled": "${form.isSubmitting}"` | | `disabledOn` | Condition | boolean | `"disabledOn": "!data.hasPermission"` | +| `title` / `label` / `value` / `description` | Template (`${}`) | Preserves original type | **Only on the component types the spec declares** — see "Rule: Bindable Text Keys" below. `"value": "${data.total}"` on a `statistic` renders the number. | | `properties.*` | Template (`${}`) | Preserves original type | Evaluated, then **hoisted onto the node** (`type` / `id` excepted), so the result lands where every renderer reads. See "Rule: Keys Live on the Node" below. | | `props.*` | Template (`${}`) | Preserves original type | Evaluated, then spread as **React props** — a `ui:*` / `page:*` renderer reads `schema.*` and never sees the result. Consumed only by `element:*` components. | @@ -28,12 +29,13 @@ value must clear before it reaches the screen. These top-level schema fields are passed as raw strings: -- `value`, `label`, `description`, `title` — read by the renderers, but never - template-evaluated. **Do not "move them to `props`"** to make an expression - work: under `props` they are evaluated and then discarded, so the component - paints an empty frame instead. Resolve the value in the host before handing - the schema to `SchemaRenderer`, or carry it on a `text` node's `content`, - which is the one text key that is both evaluated and read. +- `value`, `label`, `description`, `title` — evaluated **only on the component + types that declare them** (see "Rule: Bindable Text Keys"); read raw + everywhere else. **Do not "move them to `props`"** to make an expression work + on a type that does not declare them: under `props` they are evaluated and + then discarded, so the component paints an empty frame instead. On an + undeclared type, resolve the value in the host before handing the schema to + `SchemaRenderer`, or carry it on a `text` node's `content`. - `className` — always a static Tailwind class string - `id` — always a static string - `type` — component type identifier @@ -60,6 +62,35 @@ interface UIComponent { } ``` +## Rule: Bindable Text Keys + +Four text keys can carry a `${}` expression **on the node itself** — `title`, +`label`, `value`, `description` — but only on the component types that declare +them. The list is closed and lives in `@objectstack/spec` +(`EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT`); the renderer reads that +declaration rather than keeping its own copy. + +| Component | Bindable node keys | +|---|---| +| `statistic` | `label`, `value`, `description` | +| `card` | `title`, `description` | +| `button` | `label` | + +**✅ CORRECT — a dashboard number that moves with the data:** +```json +{ + "type": "statistic", + "label": "Active users", + "value": "${data.metrics.activeUsers}" +} +``` + +On any other component type these four keys are read **raw**: the expression +reaches the screen as literal text. There is no way to opt a type in from +metadata — a new type/key pair is a change to the spec's declaration, not +something the renderer infers. Until then, resolve the value in the host, or +use a `text` node's `content`, which every type evaluates. + ## Rule: Keys Live on the Node, Not in a `props` Envelope Every `ui:*` / `page:*` renderer reads its configuration off the node —