diff --git a/.changeset/12085-element-binding-inputs.md b/.changeset/12085-element-binding-inputs.md new file mode 100644 index 0000000000..59ce47c764 --- /dev/null +++ b/.changeset/12085-element-binding-inputs.md @@ -0,0 +1,11 @@ +--- +'@object-ui/components': minor +--- + +`element:repeater` and `element:number` publish the node-level `dataSource` binding as their only query input (objectui#12085). `@objectstack/spec` retires the element layer's flat binding keys as tombstones (objectstack#11509, objectstack PR #22421), and the repeater's registration still required a flat `object`, so the html-page validator refused a repeater bound through `dataSource` alone and accepted the spelling the spec retires. + +**Narrowing, no widening.** The `element:repeater` registration drops its `object` (which was `required: true`), `filter`, `sort` and `limit` inputs. The `element:number` registration drops its `object` and `filter` inputs, which the metric has not read since objectui#11880. The injected `dataSource` input is unchanged on both. In the manifest these registrations feed, a repeater bound only through `dataSource` now draws no diagnostic (it drew `missing-required-prop`), and each of those six flat keys now draws `unknown-prop`. No export is added or removed, and no input is added. + +**Rendering is unchanged.** The repeater still reads its flat `properties.object` / `filter` / `sort` / `limit` as the binding's fallback, for metadata written before the retirement. That read is not published as an authoring surface. The metric reads its query from `dataSource` only, as before. + +objectstack's `sdui.manifest.json` is regenerated from these registrations at objectstack's next console pin bump; this release does not change that file. diff --git a/.changeset/12093-spec-main-compile.md b/.changeset/12093-spec-main-compile.md new file mode 100644 index 0000000000..960a6c41a4 --- /dev/null +++ b/.changeset/12093-spec-main-compile.md @@ -0,0 +1,4 @@ +--- +--- + +No release. objectui#12093 makes objectui compile against `@objectstack/spec` built from objectstack `main` (the Spec Main Shape Gate) while it keeps compiling, unchanged, against the installed release. The edits are tests, plus two type-only source edits with no runtime effect: `@object-ui/plugin-view`'s `ObjectView` reads the deprecated `table.defaultFilters` through a typed legacy read (the same value, read the same way, now typed by the rule-array shape legacy metadata carries rather than by the row's declaration, a retired-key tombstone on objectstack `main`), and `@object-ui/app-shell`'s zh-CN overlay-scope label table keys `org` beside the spec's union (objectstack `main` narrows that union to `env`). diff --git a/apps/console/src/__tests__/objectname-binding-required-11605.test.ts b/apps/console/src/__tests__/objectname-binding-required-11605.test.ts index 28fcd28e1b..809dc9e64f 100644 --- a/apps/console/src/__tests__/objectname-binding-required-11605.test.ts +++ b/apps/console/src/__tests__/objectname-binding-required-11605.test.ts @@ -119,14 +119,18 @@ const DOC_ROWS = bindingDocRows(); /** * The key the binding's `object` lands on. The doc: "it lands on the block's * own object key, which is `objectName` everywhere except `record:line_items`" - * (its row spells that key in the `object` column), and "The two `element:*` - * rows keep their configuration in the node's `properties` bag ... - * `dataSource.object` wins over `properties.object`". + * (its row spells that key in the `object` column). + * + * `undefined` for the `element:*` blocks: each reads `dataSource.object` + * itself, so the binding lands on no key of theirs (objectui#11880), and since + * objectui#12085 neither `element:number` nor `element:repeater` publishes a + * flat `object` for it to land on. Row 3 asserts that exception is exactly the + * `element:*` tags, so it cannot quietly cover an `object-*` block. */ -function objectKeyOf(tag: string): string { +function objectKeyOf(tag: string): string | undefined { const named = /\(`([^`]+)`\)/.exec(DOC_ROWS.get(tag)?.object ?? ''); if (named) return named[1]; - if (tag.startsWith('element:')) return 'object'; + if (tag.startsWith('element:')) return undefined; return 'objectName'; } @@ -137,7 +141,8 @@ function objectKeyOf(tag: string): string { * are supplied too, but no registration requires one, so they are not read. */ function bindingSupplies(tag: string): Set { - const supplied = new Set([objectKeyOf(tag)]); + const key = objectKeyOf(tag); + const supplied = new Set(key === undefined ? [] : [key]); const view = DOC_ROWS.get(tag)?.view ?? ''; if (view === '✅' || view.includes('columns')) supplied.add('columns'); return supplied; @@ -234,11 +239,21 @@ describe('objectui#11605 — a bound registration requires no input its binding expect(undocumented, `${BINDING_DOC} must say what the binding supplies on each bound block`).toEqual([]); for (const tag of BOUND) { const key = objectKeyOf(tag); + if (key === undefined) continue; expect( inputsOf(tag).map((input) => input.name), `<${tag}>: the binding's object lands on "${key}", which the entry does not declare`, ).toContain(key); } + // The exception is the `element:*` tags and nothing else, and it is not + // vacuous: the two whose flat `object` objectui#12085 stopped publishing + // are bound entries of the shipped manifest that declare no `object`. + const landsNowhere = BOUND.filter((tag) => objectKeyOf(tag) === undefined); + expect(landsNowhere.every((tag) => tag.startsWith('element:'))).toBe(true); + for (const tag of ['element:number', 'element:repeater']) { + expect(landsNowhere, tag).toContain(tag); + expect(inputsOf(tag).map((input) => input.name), tag).not.toContain('object'); + } // Controls on the doc reading: the exception it names, and a full-view row. expect(objectKeyOf('record:line_items')).toBe('childObject'); expect(bindingSupplies('object-grid').has('columns')).toBe(true); diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index daeda9a05d..bf3f38deab 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -1555,6 +1555,23 @@ const UNPUBLISHED_EXEMPTIONS: Record = { // (record 5968177777) — is STRUCK: that card's build published the key in // `GRID_QUERY_INPUTS` together with its reader, so the entry went stale and // `carries no stale unpublished-key exemption` would refuse it. + + // ── objectui#12085: the element-layer flat binding keys objectstack#11509 retires ── + // The repeater still READS its four as a fallback for metadata written + // before the retirement; the metric reads neither of its two. Each entry + // dies on its own at the pin that carries the tombstones. + 'element:repeater.object': + 'objectui#12085: NOT published, and publishing it would be WRONG, not premature. `@objectstack/spec` retires the repeater\'s flat `object` as a tombstone (objectstack#11509, objectstack PR #22421), and the manifest this registration feeds is read by objectstack\'s `os validate` against that spec: a published `object` made the html tier accept the retired spelling, and its `required: true` refused a repeater bound through `dataSource` alone. The binding\'s `dataSource.object` is the published door (the injected `dataSource` input). The renderer still READS the flat key as the binding\'s fallback, for metadata written before the retirement; a back-compat read is not an authoring surface (the split `page:card.body` records above). The installed pin predates the retirement, which is why the reverse direction still asks for the key here; at the pin that carries objectstack#11509\'s tombstone the key leaves the accepted set, and `carries no stale unpublished-key exemption` deletes this entry there.', + 'element:repeater.filter': + 'objectui#12085: NOT published, and publishing it would be WRONG, not premature. `@objectstack/spec` retires the repeater\'s flat `filter` as a tombstone (objectstack#11509, objectstack PR #22421), and the manifest this registration feeds is read by objectstack\'s `os validate` against that spec: a published `filter` made the html tier accept the retired spelling. The binding\'s `dataSource.filter` is the published door (the injected `dataSource` input). The renderer still READS the flat key as the binding\'s fallback, for metadata written before the retirement; a back-compat read is not an authoring surface (the split `page:card.body` records above). The installed pin predates the retirement, which is why the reverse direction still asks for the key here; at the pin that carries objectstack#11509\'s tombstone the key leaves the accepted set, and `carries no stale unpublished-key exemption` deletes this entry there.', + 'element:repeater.sort': + 'objectui#12085: NOT published, and publishing it would be WRONG, not premature. `@objectstack/spec` retires the repeater\'s flat `sort` as a tombstone (objectstack#11509, objectstack PR #22421), and the manifest this registration feeds is read by objectstack\'s `os validate` against that spec: a published `sort` made the html tier accept the retired spelling. The binding\'s `dataSource.sort` is the published door (the injected `dataSource` input). The renderer still READS the flat key as the binding\'s fallback, for metadata written before the retirement; a back-compat read is not an authoring surface (the split `page:card.body` records above). The installed pin predates the retirement, which is why the reverse direction still asks for the key here; at the pin that carries objectstack#11509\'s tombstone the key leaves the accepted set, and `carries no stale unpublished-key exemption` deletes this entry there.', + 'element:repeater.limit': + 'objectui#12085: NOT published, and publishing it would be WRONG, not premature. `@objectstack/spec` retires the repeater\'s flat `limit` as a tombstone (objectstack#11509, objectstack PR #22421), and the manifest this registration feeds is read by objectstack\'s `os validate` against that spec: a published `limit` made the html tier accept the retired spelling. The binding\'s `dataSource.limit` is the published door (the injected `dataSource` input). The renderer still READS the flat key as the binding\'s fallback, for metadata written before the retirement; a back-compat read is not an authoring surface (the split `page:card.body` records above). The installed pin predates the retirement, which is why the reverse direction still asks for the key here; at the pin that carries objectstack#11509\'s tombstone the key leaves the accepted set, and `carries no stale unpublished-key exemption` deletes this entry there.', + 'element:number.object': + 'objectui#12085: NOT published, and publishing it would be WRONG, not premature. The renderer does not read the flat `object` (since objectui#11880 the metric\'s query is the node-level `dataSource` only), and `@objectstack/spec` retires it as a tombstone (objectstack#11509, objectstack PR #22421), so a published `object` advertised configuration the platform drops and its own spec refuses. The binding\'s `dataSource.object` is the published door (the injected `dataSource` input). The installed pin predates the retirement, which is why the reverse direction still asks for the key here; at the pin that carries objectstack#11509\'s tombstone the key leaves the accepted set, and `carries no stale unpublished-key exemption` deletes this entry there.', + 'element:number.filter': + 'objectui#12085: NOT published, and publishing it would be WRONG, not premature. The renderer does not read the flat `filter` (since objectui#11880 the metric\'s query is the node-level `dataSource` only), and `@objectstack/spec` retires it as a tombstone (objectstack#11509, objectstack PR #22421), so a published `filter` advertised configuration the platform drops and its own spec refuses. The binding\'s `dataSource.filter` is the published door (the injected `dataSource` input). The installed pin predates the retirement, which is why the reverse direction still asks for the key here; at the pin that carries objectstack#11509\'s tombstone the key leaves the accepted set, and `carries no stale unpublished-key exemption` deletes this entry there.', }; /** @@ -2754,17 +2771,14 @@ const MEMBER_PINS: Record = { file: 'packages/components/src/renderers/basic/__tests__/element-list-inputs-11168.test.tsx', pins: 'Members are a bare field name or `{ field }`, read through the real `SchemaRenderer` over a recording adapter: each prints that field\'s value of the record, in list order after the title, and a field the record lacks prints an em dash. A `label` member is never printed (the list has no header row) while its `field` still is, and the spec row refuses it; the key declares no `of` because its member contract is that union (objectui#11168 slice 2).', }, - 'element:repeater.filter': { - file: 'packages/components/src/renderers/basic/__tests__/element-list-inputs-11168.test.tsx', - pins: 'Members are `ViewFilterRule` objects `{ field, operator, value }`, asserted on EVERY query the adapter is handed: they reach `$filter` member for member, with a `{current_user_id}` / `{current_org_id}` token in `value` resolved first and the other members unchanged; a token-free list arrives unchanged and an absent key sends no `$filter`. The lowering to the server\'s filter AST is the adapter\'s own contract, pinned in `@object-ui/data-objectstack` (objectui#11168 slice 2).', - }, + // `element:repeater.filter` and `.sort` stood here (objectui#11168 slice 2). + // objectui#12085 stopped publishing them: the query's members are the + // binding's, pinned under `element:repeater.dataSource` below, and the flat + // keys are covered by `UNPUBLISHED_EXEMPTIONS` until the pin that carries + // objectstack#11509's tombstones. 'element:repeater.dataSource': { file: 'packages/components/src/renderers/basic/__tests__/data-list.elementDataSource-11880.test.tsx', - pins: 'The per-element binding\'s members as this LIST reads them, through the REAL renderer and asserted at the read it issues (objectui#11880). The binding is read FIRST and the flat `properties` query keys are its fallback until the spec\'s v18 pin bump: a repeater bound only through `dataSource` lists its rows (it showed "No records" before), the flat keys alone read exactly as before, and a binding naming no object supplies nothing. Beside the flat keys, the precedence is `ElementDataSourceGate`\'s one table: `object` is the binding\'s; `filter` is the flat one AND-combined with the binding\'s (itself AND its saved view\'s), so neither is dropped; `sort` / `limit` are the binding\'s own, else the flat key, else the saved view\'s, and a refused cap (`0`) is not authored. An unresolvable `view`, or a filter the merge\'s lowering refuses, REPORTS on the error panel and issues no read. The bus re-reads for the BOUND object, not the flat one beside it. ⚠️ The key is INJECTED by `Registry.register` (`ELEMENT_DATA_SOURCE_INPUT`), not written by the block: the declaration lists the binding\'s five members generically, and this pin carries which of them THIS block reads.', - }, - 'element:repeater.sort': { - file: 'packages/components/src/renderers/basic/__tests__/element-list-inputs-11168.test.tsx', - pins: 'Members are `SortItem` objects `{ field, order }`, asserted on EVERY query the adapter is handed: they reach `$orderby` unchanged and in list order, and an absent key sends no `$orderby`. The renderer reads no member itself (`data-list.sortKey-10664.test.tsx` pins that the read is keyed on the list\'s content); serializing it is the adapter\'s own contract (objectui#11168 slice 2).', + pins: 'The per-element binding\'s members as this LIST reads them, through the REAL renderer and asserted at the read it issues (objectui#11880). The binding is read FIRST and the flat `properties` query keys are its fallback, for metadata written before the spec retired them (objectstack#11509); since objectui#12085 the registration publishes the binding alone: a repeater bound only through `dataSource` lists its rows (it showed "No records" before), the flat keys alone read exactly as before, and a binding naming no object supplies nothing. Beside the flat keys, the precedence is `ElementDataSourceGate`\'s one table: `object` is the binding\'s; `filter` is the flat one AND-combined with the binding\'s (itself AND its saved view\'s), so neither is dropped; `sort` / `limit` are the binding\'s own, else the flat key, else the saved view\'s, and a refused cap (`0`) is not authored. An unresolvable `view`, or a filter the merge\'s lowering refuses, REPORTS on the error panel and issues no read. The bus re-reads for the BOUND object, not the flat one beside it. ⚠️ The key is INJECTED by `Registry.register` (`ELEMENT_DATA_SOURCE_INPUT`), not written by the block: the declaration lists the binding\'s five members generically, and this pin carries which of them THIS block reads.', }, 'element:button.action': { file: 'packages/components/src/__tests__/elementButtonActionMembers-8071.test.tsx', @@ -2778,10 +2792,9 @@ const MEMBER_PINS: Record = { file: 'packages/components/src/renderers/basic/__tests__/elementNumber.dataSourceBinding-10909.test.tsx', pins: 'The per-element binding\'s members as this METRIC reads them, through the REAL renderer and asserted at the call it fires, with the whole options bag compared so a member forwarded by accident is red. Read: `object` is what gets aggregated, and since objectui#11880 the ONLY object (one value for the fetch guard, `aggregate` / `find` and the `useDataInvalidation` key — a bus event for a flat `properties.object` beside it does not re-read, one for the bound object does); while a named `view` is unresolved there is NO object, so nothing is aggregated; a named `view`\'s `filter` scopes the aggregate, and an unresolvable `view` REPORTS and aggregates nothing; `filter` is the binding\'s (AND-combined with its view\'s), lowered, and the flat `properties.filter` is not read — a binding filter the converter refuses REPORTS on the error panel and aggregates nothing. Not read: `sort` and `limit`, from the binding or from the view, never reach `aggregate()` or the `find()` fallback, because an aggregate has no ordering and a capped count is a wrong number. A binding naming no object supplies nothing, and the flat `properties` form alone shows the no-object notice and aggregates nothing (objectui#11880). ⚠️ The key is INJECTED by `Registry.register` (`ELEMENT_DATA_SOURCE_INPUT`), not written by the block: the declaration lists the binding\'s five members generically (`{ object, view, filter, sort, limit }`) and does not say which of them THIS block reads — that per-block half is what this pin carries (objectui#10909).', }, - 'element:number.filter': { - file: 'packages/components/src/renderers/basic/__tests__/elementNumberFilterMembers-8071.test.tsx', - pins: 'The TWO wire spellings one authored predicate takes, chosen by an adapter capability the author cannot see, plus the re-query rule — asserted through the real renderer on a stubbed adapter. Since objectui#11880 the predicate is the binding\'s `dataSource.filter` (lowered to the ObjectQL AST); this flat key stays published, NOT READ, until the spec\'s v18 retirement. `aggregate()` receives it FLAT under its own name and beside the members that make the call an aggregate (`field`, `function`, `groupBy: \'_all\'`), asserted as the whole options bag rather than the one key; the `find()` fallback receives it WRAPPED as `$filter`; and an unfiltered metric sends `undefined` rather than an empty envelope some adapters read as "match nothing". Collapsing the two spellings into one drops the predicate silently and the metric paints a confidently wrong number over every row, with no diagnostic and no empty state. The third half is that the key is read BY VALUE, not by identity (`filterKey` is a `JSON.stringify` memo): a deep-equal filter rebuilt by a re-rendering parent must NOT re-probe, while a changed comparand MUST and carries the new predicate — each arm the other\'s control, so a dependency array "simplified" to the raw object (a per-render fetch storm) is red. New file (objectui#8071 slice 7).', - }, + // `element:number.filter` stood here (objectui#8071 slice 7). objectui#12085 + // stopped publishing it: the metric's filter is the binding's, pinned under + // `element:number.dataSource` above. 'element:record_picker.dataSource': { file: 'packages/components/src/__tests__/record-picker-element-data-source.test.tsx', pins: 'The per-element binding\'s own members, read through the REAL renderer and asserted at the query it fires: `view` resolves a saved view whose `filter`/`sort`/row cap become the picker\'s baseline, an authored binding key WINS over the same key from the view, the binding\'s own `filter` arrives AND-combined with the view\'s rather than replacing it, and an unresolvable `view` reports instead of falling back to every record of the object — the quieter failure mode objectstack#6953 fixed, one class below the throw objectstack#5576 fixed on `list-view`. A `view`-less binding passes its own keys through as authored, which is the control that keeps the override rows from reading as a coincidence; and since objectui#11880 the four flat keys (`object`/`filter`/`sort`/`limit`) are read by nothing — a flat `properties.object` alone issues no query, and beside a binding the flat `filter`/`sort`/`limit` change nothing. `columns` is deliberately absent from this pin: unlike `record:related_list.dataSource` (objectui#8071 slice 2), this renderer never reads `composed.columns` at all — a select dropdown has no column list to narrow. Pre-existing file, promoted to a pin here after being read end to end (objectui#8071).', diff --git a/content/docs/guide/data-source.md b/content/docs/guide/data-source.md index f3b4eff881..4464eadbf4 100644 --- a/content/docs/guide/data-source.md +++ b/content/docs/guide/data-source.md @@ -308,10 +308,11 @@ from that source and shows no hint. in the node's `properties` bag and take their query from the binding **only**: every query key either one reads comes from `dataSource` and from nowhere else. The flat `properties.object`, `properties.filter`, `properties.sort` and -`properties.limit` that the spec still declares on these two elements are not -read, and `@objectstack/spec` retires them in v18 (objectstack#11509). The -binding's filter is AND-combined with its view's filter, as on every block -above. On `element:number`, +`properties.limit` on these two elements are not read, and `@objectstack/spec` +retires them (objectstack#11509). `element:number` no longer offers its flat +`object` or `filter` (objectui#12085), so the page validator reports either one +as a prop the metric does not have. The binding's filter is AND-combined with its +view's filter, as on every block above. On `element:number`, `{ "dataSource": { "object": "contact" }, "properties": { "aggregate": "count" } }` is a complete metric; its `sort` and `limit` are not read, because an aggregate has no ordering and a capped count would be a wrong number, and a filter the @@ -324,10 +325,14 @@ offers no records. The Studio page designer writes the metric's object into `element:repeater` reads the binding **first**, and its flat `properties.object`, `properties.filter`, `properties.sort` and -`properties.limit` only as the fallback, until `@objectstack/spec` retires those -four keys in v18 (objectstack#11509) and its pin bump moves them into -`dataSource`. A repeater bound only through `dataSource` lists the records it -names, and one carrying only the flat keys reads them exactly as before. Where a +`properties.limit` only as a fallback, for metadata written before +`@objectstack/spec` retired those four keys (objectstack#11509); the spec's +conversion moves them into `dataSource`. The repeater offers the binding alone +(objectui#12085): it requires no prop, and the page validator accepts a repeater +bound through `dataSource` alone and reports a flat `object`, `filter`, `sort` +or `limit` as a prop the repeater does not have. A repeater bound only through +`dataSource` lists the records it names, and one carrying only the flat keys +reads them exactly as before. Where a node carries both, the repeater follows the precedence `ElementDataSourceGate` applies to the object-bound blocks above: the binding's `object` wins; `properties.filter` is AND-combined with the binding's filter (and its view's), so neither is dropped; and for `sort` diff --git a/packages/app-shell/src/views/metadata-admin/LayeredDiff.overlayScope.test.tsx b/packages/app-shell/src/views/metadata-admin/LayeredDiff.overlayScope.test.tsx index 0b880f8998..0161ed1820 100644 --- a/packages/app-shell/src/views/metadata-admin/LayeredDiff.overlayScope.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/LayeredDiff.overlayScope.test.tsx @@ -64,9 +64,12 @@ describe('LayeredDiff overlay-scope badge (objectui#4982)', () => { // `translateConsoleValue` returns the input verbatim for every non-zh // locale, for all four layer badges. Whether the other nine locale packs // should gain these values is a separate decision, deliberately not made - // here; this case pins that this fix did not quietly make it. - render(); - expect(screen.getByText('org')).toBeInTheDocument(); + // here; this case pins that this fix did not quietly make it. `env` is a + // scope on the installed spec and on objectstack `main` alike, which + // narrows the enum to it (objectstack#15206 S5); `org` stood here, a scope + // on the installed spec only (objectui#12093). + render(); + expect(screen.getByText('env')).toBeInTheDocument(); }); it('keeps the no-overlay branch on the "none" badge', () => { diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 4913c949ff..df8e191159 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -7147,8 +7147,15 @@ export function translateFlowMeta( * Deliberately zh-only, like every other group here — `translateConsoleValue` * returns the raw value for the other nine locale packs by existing design, and * whether to extend it is a separate decision, not a rider on this fix. + * + * `org` is keyed beside the spec's union, not through it (objectui#12093): + * the installed `@objectstack/spec` declares `org | env`, and objectstack + * `main` narrows the enum to `env` (objectstack#15206 S5, ADR-0131), where a + * keyed `org` would be an unknown property and stop this file compiling in the + * Spec Main Shape Gate. A server on the installed release still sends `org`, + * so its label stays; the completeness check above is unchanged on both specs. */ -const LAYER_SCOPE_ZH: Record, string> = { +const LAYER_SCOPE_ZH: Record | 'org', string> = { org: '组织', env: '环境', }; diff --git a/packages/components/src/__tests__/page-variables.test.tsx b/packages/components/src/__tests__/page-variables.test.tsx index 239d307bb3..8b8a1de082 100644 --- a/packages/components/src/__tests__/page-variables.test.tsx +++ b/packages/components/src/__tests__/page-variables.test.tsx @@ -210,7 +210,8 @@ describe('element:record_picker', () => { schema={{ type: 'element:record_picker', id: 'picker', - properties: { object: 'showcase_project', emptyText: 'No projects' }, + dataSource: { object: 'showcase_project' }, + properties: { emptyText: 'No projects' }, }} />, ); diff --git a/packages/components/src/renderers/basic/__tests__/data-list.elementDataSource-11880.test.tsx b/packages/components/src/renderers/basic/__tests__/data-list.elementDataSource-11880.test.tsx index 19420dd42e..7f254598aa 100644 --- a/packages/components/src/renderers/basic/__tests__/data-list.elementDataSource-11880.test.tsx +++ b/packages/components/src/renderers/basic/__tests__/data-list.elementDataSource-11880.test.tsx @@ -9,9 +9,10 @@ /** * objectui#11880 (the repeater half) — `element:repeater` reads the node-level * `dataSource` binding FIRST, with its flat `properties` query keys as the - * fallback until the spec's v18 pin bump retires them (ruling + * fallback for metadata written before the spec retired them (ruling * objectstack-ai/objectstack#11509, A-narrow, objectui first; triage on - * objectui#11880: direction A). + * objectui#11880: direction A). Since objectui#12085 the registration publishes + * the binding alone: the fallback is read, never offered. * * ## The defect * @@ -47,6 +48,8 @@ import * as React from 'react'; import { render, screen, waitFor, act, cleanup } from '@testing-library/react'; import { ComponentRegistry } from '@object-ui/core'; import { AdapterCtx, SchemaRenderer, notifyDataChanged } from '@object-ui/react'; +import { manifestFromConfigs, validateTree } from '@object-ui/sdui-parser'; +import type { SchemaElement } from '@object-ui/sdui-parser'; // Registers every `element:*` renderer at module scope, not in a hook // (object-ui/no-dynamic-import-in-test-hook, objectui#3010). import '../../../renderers'; @@ -215,16 +218,41 @@ describe('element:repeater reads the node-level `dataSource` (objectui#11880)', }); }); -describe('element:repeater declares what it reads (objectui#11880)', () => { +describe('element:repeater publishes the binding as its one query door (objectui#11880, objectui#12085)', () => { const inputs = () => (ComponentRegistry.getConfig('element:repeater')?.inputs ?? []) as Array<{ name: string; required?: boolean }>; - it('publishes the injected `dataSource` beside the flat query keys it still falls back to', () => { + /** The page validator's verdict on one node, over the manifest the registry publishes. */ + const diagnose = (node: Record) => + validateTree( + node as unknown as SchemaElement, + manifestFromConfigs(ComponentRegistry.getAllConfigs() as unknown as Parameters[0]), + ).diagnostics.map((diagnostic) => ({ severity: diagnostic.severity, code: diagnostic.code })); + + it('publishes the injected `dataSource`, and none of the flat query keys `@objectstack/spec` retires', () => { const names = inputs().map((input) => input.name); expect(names).toContain('dataSource'); - for (const key of ['object', 'filter', 'sort', 'limit']) expect(names, key).toContain(key); + // The renderer still READS these four as the binding's fallback (pinned + // above), for metadata written before the retirement; a back-compat read is + // not an authoring surface, so none of them is published. + for (const key of ['object', 'filter', 'sort', 'limit']) expect(names, key).not.toContain(key); + }); + + it('requires no input: the object is named by `dataSource.object`', () => { + expect(inputs().filter((input) => input.required).map((input) => input.name)).toEqual([]); + }); + + it('the html tier accepts a repeater bound through `dataSource` alone (it raised `missing-required-prop` before)', () => { + expect(diagnose({ type: 'element:repeater', dataSource: { object: 'contact', limit: 5 }, titleField: 'name' })).toEqual([]); }); - it('`object` stays required, as the spec row requires it', () => { - expect(inputs().find((input) => input.name === 'object')?.required).toBe(true); + it('the html tier reports each retired flat query key as a prop the repeater does not have', () => { + const bound = { type: 'element:repeater', dataSource: { object: 'contact' } }; + const flat: Record = { object: 'contact', filter: ACTIVE, sort: BY_NAME, limit: 5 }; + for (const [key, value] of Object.entries(flat)) { + expect(diagnose({ ...bound, [key]: value }), key).toEqual([{ severity: 'warning', code: 'unknown-prop' }]); + } + // Control: the same node without the flat key draws nothing, so each + // report above is that key's. + expect(diagnose(bound)).toEqual([]); }); }); diff --git a/packages/components/src/renderers/basic/__tests__/element-list-inputs-11168.test.tsx b/packages/components/src/renderers/basic/__tests__/element-list-inputs-11168.test.tsx index c909557935..40ef7cba08 100644 --- a/packages/components/src/renderers/basic/__tests__/element-list-inputs-11168.test.tsx +++ b/packages/components/src/renderers/basic/__tests__/element-list-inputs-11168.test.tsx @@ -38,7 +38,10 @@ * the spec refuses it. * - `element:repeater.filter` / `.sort` — declared as lists of objects * (`of: 'object'`), the only member kind the spec rows accept, and their - * members pinned at the adapter seam below. + * members pinned at the adapter seam below. Since objectui#12085 neither + * is published: `@objectstack/spec` retires the flat query keys + * (objectstack#11509), the registration offers the node-level + * `dataSource` binding alone, and the rows below mount through it. */ import { describe, it, expect, vi, afterEach } from 'vitest'; @@ -158,10 +161,14 @@ describe('element:definition-list.items — optional, as the renderer and the sp }); it('CONTROL: a required input on a sibling block is still reported when it is missing', () => { - // `element:repeater.object` IS required (the renderer never queries + // `element:number.aggregate` IS required (the metric computes nothing // without it), so the empty verdict above is the declaration, not a - // validator that stopped checking. - expect(diagnose({ type: REPEATER })).toEqual([{ severity: 'error', code: 'missing-required-prop' }]); + // validator that stopped checking. (`element:repeater.object` was this + // control until objectui#12085: the repeater requires nothing now, its + // object being named by `dataSource.object`.) + expect(diagnose({ type: 'element:number', dataSource: { object: 'contact' } })).toEqual([ + { severity: 'error', code: 'missing-required-prop' }, + ]); }); it('is published as a list of objects', () => { @@ -215,13 +222,18 @@ describe('element:definition-list.items — the members the renderer reads (obje const ROW = { id: 'r1', name: 'Ada', owner: 'u1', email: 'ada@example.com' }; -/** Mount a repeater over an adapter that records every query and answers {@link ROW}. */ -function mountRepeater(properties: Record) { +/** + * Mount a repeater over an adapter that records every query and answers + * {@link ROW}. The object, and any `query` member (`filter`, `sort`), are + * the node-level `dataSource` binding's: the one query door the registration + * publishes (objectui#12085). + */ +function mountRepeater(properties: Record, query: Record = {}) { const find = vi.fn(async (_object: string, _query: Record) => ({ data: [ROW] })); render( - + , ); @@ -264,9 +276,9 @@ describe('element:repeater.fields — what each entry prints (objectui#11168)', }); }); -describe('element:repeater.filter — the rules the adapter is handed (objectui#11168)', () => { +describe('element:repeater `dataSource.filter` — the rules the adapter is handed (objectui#11168, objectui#12085)', () => { it('the rules reach `$filter` member for member, with a context token in `value` resolved first', async () => { - const find = mountRepeater({ + const find = mountRepeater({}, { filter: [ { field: 'owner', operator: 'equals', value: '{current_user_id}' }, { field: 'org', operator: 'equals', value: '{current_org_id}' }, @@ -286,7 +298,7 @@ describe('element:repeater.filter — the rules the adapter is handed (objectui# it('CONTROL: a token-free rule list arrives unchanged, and no `filter` sends no `$filter`', async () => { const rules = [{ field: 'status', operator: 'not_equals', value: 'lost' }]; - const find = mountRepeater({ filter: rules }); + const find = mountRepeater({}, { filter: rules }); await waitFor(() => expect(find).toHaveBeenCalled()); for (const query of queries(find)) expect(query.$filter).toEqual(rules); cleanup(); @@ -295,21 +307,19 @@ describe('element:repeater.filter — the rules the adapter is handed (objectui# for (const query of queries(bare)) expect(query).not.toHaveProperty('$filter'); }); - it('is published as a list of objects, and the spec refuses the record form', () => { - expect(inputsOf(REPEATER).get('filter')?.of).toBe('object'); - expect(refusal(REPEATER, { object: 'contact', filter: { owner: 'u1' } })).toEqual([ - { code: 'invalid_type', path: 'filter' }, - ]); + it('is the binding\'s member, not a published flat input: the flat `filter` is retired upstream (objectui#12085)', () => { + expect(inputsOf(REPEATER).has('dataSource')).toBe(true); + expect(inputsOf(REPEATER).has('filter')).toBe(false); }); }); -describe('element:repeater.sort — the items the adapter is handed (objectui#11168)', () => { +describe('element:repeater `dataSource.sort` — the items the adapter is handed (objectui#11168, objectui#12085)', () => { it('the items reach `$orderby` unchanged and in list order', async () => { const sort = [ { field: 'name', order: 'desc' }, { field: 'email', order: 'asc' }, ]; - const find = mountRepeater({ sort }); + const find = mountRepeater({}, { sort }); await waitFor(() => expect(find).toHaveBeenCalled()); for (const query of queries(find)) expect(query.$orderby).toEqual(sort); }); @@ -320,10 +330,8 @@ describe('element:repeater.sort — the items the adapter is handed (objectui#11 for (const query of queries(find)) expect(query).not.toHaveProperty('$orderby'); }); - it('is published as a list of objects, and the spec refuses a bare field name as a member', () => { - expect(inputsOf(REPEATER).get('sort')?.of).toBe('object'); - expect(refusal(REPEATER, { object: 'contact', sort: ['name'] })).toEqual([ - { code: 'invalid_type', path: 'sort.0' }, - ]); + it('is the binding\'s member, not a published flat input: the flat `sort` is retired upstream (objectui#12085)', () => { + expect(inputsOf(REPEATER).has('dataSource')).toBe(true); + expect(inputsOf(REPEATER).has('sort')).toBe(false); }); }); diff --git a/packages/components/src/renderers/basic/__tests__/elementNumber.dataSourceBinding-10909.test.tsx b/packages/components/src/renderers/basic/__tests__/elementNumber.dataSourceBinding-10909.test.tsx index b9dabfc3d1..321f115fc4 100644 --- a/packages/components/src/renderers/basic/__tests__/elementNumber.dataSourceBinding-10909.test.tsx +++ b/packages/components/src/renderers/basic/__tests__/elementNumber.dataSourceBinding-10909.test.tsx @@ -350,19 +350,28 @@ describe('element:number — the `dataSource` members it reads, and the ones it describe('element:number declares the `dataSource` binding it reads (objectui#10909)', () => { const inputs = () => ComponentRegistry.getMeta('element:number')?.inputs ?? []; - it('the registration declares dataSource as an object binding; the flat `object` / `filter` say they are not read (objectui#11880)', () => { + it('the registration declares dataSource as an object binding, and publishes no flat `object` / `filter` (objectui#12085)', () => { const dataSource = inputs().find((input) => input.name === 'dataSource'); expect(dataSource, 'element:number publishes no dataSource input').toBeTruthy(); expect((dataSource as { binding?: string }).binding).toBe('object'); - // Published until the spec's v18 retirement (objectstack#11509), never - // required, and each description names the binding member that IS read. - const object = inputs().find((input) => input.name === 'object'); - expect(object?.required).not.toBe(true); - expect(object?.description).toMatch(/NOT READ/); - expect(object?.description).toMatch(/dataSource\.object/); - const filter = inputs().find((input) => input.name === 'filter'); - expect(filter?.description).toMatch(/NOT READ/); - expect(filter?.description).toMatch(/dataSource\.filter/); + // Nothing reads the flat pair (objectui#11880), and `@objectstack/spec` + // retires both as tombstones (objectstack#11509), so neither is offered. + const names = inputs().map((input) => input.name); + for (const key of ['object', 'filter']) expect(names, key).not.toContain(key); + }); + + it('the html tier reports a flat `object` / `filter` as props the metric does not have', () => { + const manifest = manifestFromConfigs( + ComponentRegistry.getAllConfigs() as unknown as Parameters[0], + ); + const bound = { type: 'element:number', dataSource: { object: 'contact' }, aggregate: 'count' }; + const verdict = (node: Record) => + validateTree(node as never, manifest).diagnostics.map((d) => ({ severity: d.severity, code: d.code })); + for (const [key, value] of Object.entries({ object: 'contact', filter: [{ field: 'status', operator: 'equals', value: 'hot' }] })) { + expect(verdict({ ...bound, [key]: value }), key).toEqual([{ severity: 'warning', code: 'unknown-prop' }]); + } + // Control: the bound node alone draws nothing, so each report above is that key's. + expect(verdict(bound)).toEqual([]); }); it('the html tier accepts the dataSource form without a diagnostic', () => { diff --git a/packages/components/src/renderers/basic/__tests__/record-picker-label-association.test.tsx b/packages/components/src/renderers/basic/__tests__/record-picker-label-association.test.tsx index 351fca722a..f36b47ae06 100644 --- a/packages/components/src/renderers/basic/__tests__/record-picker-label-association.test.tsx +++ b/packages/components/src/renderers/basic/__tests__/record-picker-label-association.test.tsx @@ -60,12 +60,12 @@ afterEach(cleanup); function renderPicker(properties: Record, schemaExtra: Record = {}) { const C = ComponentRegistry.get('element:record_picker') as React.ComponentType; if (!C) throw new Error('element:record_picker is not registered'); - return render(); + return render(); } describe('element:record_picker — label→combobox association (objectui#5771)', () => { it('resolves the label to the combobox, so getByLabelText finds the trigger', () => { - renderPicker({ object: 'account', label: 'Owner' }, { id: 'owner_picker' }); + renderPicker({ label: 'Owner' }, { id: 'owner_picker' }); const combobox = screen.getByRole('combobox'); expect(screen.getByLabelText('Owner')).toBe(combobox); @@ -73,7 +73,7 @@ describe('element:record_picker — label→combobox association (objectui#5771) }); it('gives the combobox the LABEL as its accessible name — the caption the docs advertise', () => { - renderPicker({ object: 'account', label: 'Owner' }, { id: 'owner_picker' }); + renderPicker({ label: 'Owner' }, { id: 'owner_picker' }); // Findable BY that accessible name, the way an author-facing a11y check // (and the testing-library query the PM route calls for) would drive it. @@ -85,7 +85,7 @@ describe('element:record_picker — label→combobox association (objectui#5771) // The #3341 failure mode: two attributes present but pointing at nothing. // Read `for` off the rendered label and resolve it through the DOM, rather // than asserting `id`/`htmlFor` as two independent strings. - renderPicker({ object: 'account', label: 'Owner' }, { id: 'owner_picker' }); + renderPicker({ label: 'Owner' }, { id: 'owner_picker' }); const label = document.querySelector('label[for]'); expect(label).not.toBeNull(); @@ -98,7 +98,7 @@ describe('element:record_picker — label→combobox association (objectui#5771) it('registers the label on the trigger via the DOM `labels` collection', () => { // The association browsers actually use for both the accessible name and // click-to-focus. - renderPicker({ object: 'account', label: 'Owner' }, { id: 'owner_picker' }); + renderPicker({ label: 'Owner' }, { id: 'owner_picker' }); const trigger = screen.getByTestId('record-picker-trigger') as HTMLButtonElement; expect(trigger.labels).not.toBeNull(); @@ -121,7 +121,7 @@ describe('element:record_picker — label→combobox association (objectui#5771) // accessible name either (measured — `role="combobox"` does not support // "name from content"), so the empty name below is not an artifact of // omitting `placeholder`. - renderPicker({ object: 'account', label: 'Owner', placeholder: 'Select…' }); + renderPicker({ label: 'Owner', placeholder: 'Select…' }); const trigger = screen.getByTestId('record-picker-trigger'); expect(trigger).toHaveTextContent('Select…'); @@ -133,7 +133,7 @@ describe('element:record_picker — label→combobox association (objectui#5771) }); it('renders no label element when `label` is absent — the fix cannot be "always associate"', () => { - renderPicker({ object: 'account', placeholder: 'Select…' }, { id: 'owner_picker' }); + renderPicker({ placeholder: 'Select…' }, { id: 'owner_picker' }); // No `label` was authored, so there is nothing for `htmlFor`/`id` to // associate — pinned so a later change cannot silently start minting a @@ -152,7 +152,8 @@ describe('element:record_picker — label→combobox association (objectui#5771) schema={{ type: 'element:record_picker', id: 'owner_picker', - properties: { object: 'account', label: { en: 'Owner', 'zh-CN': '负责人' } }, + dataSource: { object: 'account' }, + properties: { label: { en: 'Owner', 'zh-CN': '负责人' } }, }} /> , @@ -167,7 +168,8 @@ describe('element:record_picker — label→combobox association (objectui#5771) schema={{ type: 'element:record_picker', id: 'owner_picker', - properties: { object: 'account', label: 'Owner' }, + dataSource: { object: 'account' }, + properties: { label: 'Owner' }, }} />, ); diff --git a/packages/components/src/renderers/basic/data-list.tsx b/packages/components/src/renderers/basic/data-list.tsx index 5c52af2fb6..feabf8d81a 100644 --- a/packages/components/src/renderers/basic/data-list.tsx +++ b/packages/components/src/renderers/basic/data-list.tsx @@ -158,8 +158,9 @@ function RepeaterRenderer({ schema }: { schema: any }) { // .dataSource`), resolved through `useElementDataSource` as `element:number` // and `element:record_picker` resolve theirs, against the repeater's own // adapter. It is read FIRST; the flat `properties` query keys are the - // fallback until the spec retires them at the v18 pin bump (objectstack#11509, - // ruled A-narrow, objectui first). Where a node carries both, the precedence + // fallback, a back-compat read for metadata written before the spec retired + // them (objectstack#11509, ruled A-narrow, objectui first). They are not + // published as inputs (objectui#12085). Where a node carries both, the precedence // is the one table `ElementDataSourceGate` applies to every gate-wrapped // block, so the repeater adds no dialect of its own: // - `object`: the binding's; @@ -303,42 +304,32 @@ function RepeaterRenderer({ schema }: { schema: any }) { // The renderer READS the node-level `dataSource` binding (objectui#11880), so // it declares it from the seam every reader of the binding declares it from: // the marker makes `Registry.register` emit `ELEMENT_DATA_SOURCE_INPUT` into -// these `inputs`. The flat query keys stay published and READ, as the binding's -// fallback, until the spec retires them at the v18 pin bump (objectstack#11509); -// `object` stays required because the spec row requires it. The seam comes from -// `@object-ui/core` for the measured reason `element:record_picker`'s -// registration states. +// these `inputs`. That binding is the ONE query door published here +// (objectui#12085): `@objectstack/spec` retires the flat `object` / `filter` / +// `sort` / `limit` as tombstones (objectstack#11509), and publishing them made +// the html tier accept the retired spelling while a required `object` refused a +// repeater bound through `dataSource` alone. The renderer still READS the flat +// four as the binding's fallback, for metadata written before that retirement: +// the spec row records the read as the console's tolerance, not a door it keeps, +// and a back-compat read is not an authoring surface, so it is not declared. +// The seam comes from `@object-ui/core` for the measured reason +// `element:record_picker`'s registration states. ComponentRegistry.register('repeater', elementDataSourceBlock(RepeaterRenderer), { namespace: 'element', skipFallback: true, label: 'Repeater', category: 'content', - // objectui#11168 slice 2 — the members of `fields`, `filter` and `sort` are - // pinned in `__tests__/element-list-inputs-11168.test.tsx`: what the - // renderer prints for each `fields` entry, and what it hands the adapter for - // `filter` (context tokens resolved first) and `sort`. + // objectui#11168 slice 2 — the members of `fields` are pinned in + // `__tests__/element-list-inputs-11168.test.tsx`: what the renderer prints + // for each entry. The query's own members (`filter`, `sort`) are the + // binding's, pinned in `__tests__/data-list.elementDataSource-11880.test.tsx`. inputs: [ - { - name: 'object', - type: 'string', - required: true, - description: 'Object whose records the list repeats over; a node-level `dataSource.object` wins', - }, { name: 'titleField', type: 'string' }, { name: 'fields', type: 'array', description: 'Fields shown after the title on each line, in order: a bare field name, or `{ field }`', }, - { - name: 'filter', - type: 'array', - of: 'object', - description: - 'Filter rules `[{ field, operator, value }]`. A string `value` may be a context token such as `{current_user_id}`, resolved before the query', - }, - { name: 'sort', type: 'array', of: 'object', description: 'Sort order `[{ field, order }]`, applied in list order' }, - { name: 'limit', type: 'number' }, { name: 'emptyText', type: 'string' }, { name: 'divided', type: 'boolean', description: 'Separator between rows' }, ], diff --git a/packages/components/src/renderers/basic/elements.tsx b/packages/components/src/renderers/basic/elements.tsx index 356a1ded60..cd427aacb4 100644 --- a/packages/components/src/renderers/basic/elements.tsx +++ b/packages/components/src/renderers/basic/elements.tsx @@ -657,33 +657,21 @@ function ElementNumberRenderer({ schema }: { schema: any }) { // The renderer READS the node-level `dataSource` binding (objectui#10909), so it // declares it from the seam every reader of the binding declares it from: the // marker below makes `Registry.register` emit `ELEMENT_DATA_SOURCE_INPUT` into -// these `inputs`. The flat `object` / `filter` stay PUBLISHED and are NOT -// READ: the binding is the only place the metric reads them from -// (objectui#11880). The spec still declares both, and their published -// retirement ships with the spec half of objectstack#11509 and the pin bump -// that carries it; until then each description says so. Same shape as -// `element:record_picker`'s registration, and the seam comes from -// `@object-ui/core` for the same measured reason stated there. +// these `inputs`, and the binding is the only place the metric reads its +// object and filter from (objectui#11880). The flat `object` / `filter` are not +// published (objectui#12085): nothing reads them, and `@objectstack/spec` +// retires both as tombstones (objectstack#11509), so a published flat key +// would point an author at a spelling the contract refuses and the metric +// ignores. The seam comes from `@object-ui/core` for the measured reason +// `element:record_picker`'s registration states. ComponentRegistry.register('number', elementDataSourceBlock(ElementNumberRenderer), { namespace: 'element', skipFallback: true, label: 'Number', category: 'content', inputs: [ - { - name: 'object', - type: 'string', - description: - 'NOT READ (objectui#11880): the aggregate runs over the object the node-level `dataSource.object` names, and a node without one shows the "no object named" notice. `@objectstack/spec` retires this flat key in v18 (objectstack#11509).', - }, { name: 'aggregate', type: 'enum', enum: ['count', 'sum', 'avg', 'min', 'max'], required: true }, { name: 'field', type: 'string', description: 'Measure field (required for every aggregate except count)' }, - { - name: 'filter', - type: 'array', - description: - 'NOT READ (objectui#11880): the aggregate is scoped by the node-level `dataSource.filter`, AND-combined with the filter of the saved view its `view` names. This flat key is not combined with it and never applies; `@objectstack/spec` retires it in v18 (objectstack#11509).', - }, { name: 'format', type: 'enum', enum: ['number', 'currency', 'percent'] }, { name: 'prefix', type: 'string' }, { name: 'suffix', type: 'string' }, diff --git a/packages/data-objectstack/src/metadata-client.overlayScope.test.ts b/packages/data-objectstack/src/metadata-client.overlayScope.test.ts index cc53bb7fbf..a966c69aa6 100644 --- a/packages/data-objectstack/src/metadata-client.overlayScope.test.ts +++ b/packages/data-objectstack/src/metadata-client.overlayScope.test.ts @@ -43,17 +43,37 @@ import { GetMetaItemLayeredResponseSchema } from '@objectstack/spec/api'; import type { MetadataLayered, MetadataOverlayScope } from './metadata-client'; // ── Compile-time: the alias IS the spec's vocabulary, both directions ──────── +// +// This file is compiled against TWO specs: the pull-request type-check against +// the INSTALLED `@objectstack/spec`, and the Spec Main Shape Gate against one +// built from objectstack `main`. The two vocabularies differ today: objectstack +// PR #22628 (objectstack#15206 S5, ADR-0131) narrowed `overlayScope` on `main` +// to `env`, while the installed release still declares `org | env`. So the +// expected set is READ OFF THE SCHEMA VALUE, never written down here +// (objectui#12093): each line below holds on either spec and fails on either +// when the alias stops being the spec's. + +/** The value set the spec schema itself declares, read off the schema VALUE (not the published type the alias indexes). */ +const specOverlayScope = GetMetaItemLayeredResponseSchema.shape.overlayScope; +type SpecOverlayScope = (typeof specOverlayScope)['_zod']['output']; // Every value the producer can send flows into the alias. A narrower // restatement (say, dropping `env`) fails here. -const _specCovers = null as unknown as 'org' | 'env' | null satisfies MetadataOverlayScope; -// …and the alias is no WIDER than that. This is the line that fails the day the -// spec adds a scope — deliberately, because a new scope needs a zh-CN label -// (see mechanism 3 above) rather than silently reaching the badge. -const _noWider = null as unknown as MetadataOverlayScope satisfies 'org' | 'env' | null; +const _specCovers = null as unknown as SpecOverlayScope satisfies MetadataOverlayScope; +// …and the alias is no WIDER than the spec: a revert to `string | null`, or a +// restatement that keeps a scope the spec dropped, fails here. +const _noWider = null as unknown as MetadataOverlayScope satisfies SpecOverlayScope; +// …and no wider than the scopes `LAYER_SCOPE_ZH` labels (mechanism 3 above). +// This bound is the LABEL TABLE, not the vocabulary: a superset of the spec on +// both specs. It is the line that fails the day the spec adds a scope — +// deliberately, because a new scope needs a zh-CN label rather than silently +// reaching the badge. +const _labelled = null as unknown as MetadataOverlayScope satisfies 'org' | 'env' | null; // The interface field is the alias, not a widened `string`. const _fieldIsAlias = null as unknown as MetadataLayered['overlayScope'] satisfies MetadataOverlayScope; -const _org: MetadataLayered['overlayScope'] = 'org'; +// `env` and `null` are values on both specs. An `org` literal stood here, and +// `org` is a value on the installed spec only; `_specCovers` above carries it +// wherever the spec declares it. const _env: MetadataLayered['overlayScope'] = 'env'; const _none: MetadataLayered['overlayScope'] = null; // @ts-expect-error — objectui#4982: the spelling the old comment claimed is not @@ -62,11 +82,15 @@ const _none: MetadataLayered['overlayScope'] = null; const _notASpecScope: MetadataLayered['overlayScope'] = 'organization'; // ── Runtime: what the producer's schema actually declares ──────────────────── - -const specOverlayScope = GetMetaItemLayeredResponseSchema.shape.overlayScope; +// +// vitest runs against the INSTALLED spec only (the Spec Main Shape Gate +// type-checks and runs no test), so the literal below is a reading of that +// release. objectstack `main` declares `env` alone (objectstack#15206 S5): at +// the pin that carries it, this line and `LAYER_SCOPE_ZH`'s `org` entry move +// together. describe('MetadataLayered.overlayScope tracks the spec enum (objectui#4982)', () => { - it('the producer vocabulary is exactly org | env, plus null', () => { + it('the producer vocabulary is exactly org | env, plus null, on the installed spec', () => { // Read through `@object-ui/test-support` rather than `.unwrap().options` // (objectui#5872 class (4)). The bare spelling was a SEVENTH way to ask this // question and the only one with neither a cast nor a guard: it answered diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.defaultFiltersRuleArray-6152.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.defaultFiltersRuleArray-6152.test.tsx index 5fe230c4e7..d73132551c 100644 --- a/packages/plugin-grid/src/__tests__/ObjectGrid.defaultFiltersRuleArray-6152.test.tsx +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.defaultFiltersRuleArray-6152.test.tsx @@ -16,8 +16,15 @@ * registered renderer and a `ValueDataSource` that APPLIES the `$filter` it receives, * so "the same rows" is a reading of what the grid draws, not only of what it sends. * - * The fixtures are typed `ObjectGridSchema` with no cast: the rule array is what the - * declaration takes. + * The fixtures are typed by the shape legacy metadata carries (objectui#12093). The + * declaration follows the `object-grid` row by reference: the rule array on the + * installed `@objectstack/spec`, a retired-key tombstone on objectstack `main` + * (objectstack#11509), which no current document can author. The grid still READS the + * key for metadata written before that retirement, and that read is what this file + * pins, so a `defaultFilters` node is built by `withDefaultFilters` below from the `filter` rule + * array, the shape the key carries on both specs. Which spec says the rule array is + * the declaration is `grid-default-filters-gantt-map-filter-round10-6152.test.ts`'s + * question in `@object-ui/types`, answered there against the row. */ import { describe, it, expect, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; @@ -36,7 +43,7 @@ const ROWS = [ ]; const NAMES = ROWS.map((r) => r.name); -type Rules = NonNullable; +type Rules = NonNullable; const OPEN: Rules = [{ field: 'status', operator: 'equals', value: 'open' }]; const CLOSED: Rules = [{ field: 'status', operator: 'equals', value: 'closed' }]; @@ -82,6 +89,10 @@ const node = (extra: Partial): ObjectGridSchema => ({ ...extra, }); +/** A node carrying the legacy `defaultFilters` (see the header): written as legacy metadata has it. */ +const withDefaultFilters = (defaultFilters: Rules, extra: Partial = {}): ObjectGridSchema => + ({ ...node(extra), defaultFilters }) as unknown as ObjectGridSchema; + describe('objectui#6152 round 10 — a `defaultFilters` rule array draws what the same `filter` draws', () => { it('CONTROL: with neither key, every row is drawn and no `$filter` goes out', async () => { const { $filter, drawn } = await draw(node({})); @@ -90,25 +101,25 @@ describe('objectui#6152 round 10 — a `defaultFilters` rule array draws what th }); it('`defaultFilters: [rule]` sends the lowered AST and draws the matching rows', async () => { - const { $filter, drawn } = await draw(node({ defaultFilters: OPEN })); + const { $filter, drawn } = await draw(withDefaultFilters(OPEN)); expect($filter).toEqual([['status', 'equals', 'open']]); expect(drawn).toEqual(['Acme', 'Cyan']); }); it('the same array written as `filter` sends the same `$filter` and draws the same rows', async () => { - const legacy = await draw(node({ defaultFilters: OPEN })); + const legacy = await draw(withDefaultFilters(OPEN)); const canonical = await draw(node({ filter: OPEN })); expect(legacy).toEqual(canonical); }); it('LIT CONTROL: a different rule array moves both readings, so the equality above can fail', async () => { - const { $filter, drawn } = await draw(node({ defaultFilters: CLOSED })); + const { $filter, drawn } = await draw(withDefaultFilters(CLOSED)); expect($filter).toEqual([['status', 'equals', 'closed']]); expect(drawn).toEqual(['Beta']); }); it('CONTROL: written both ways, `filter` still wins', async () => { - const { $filter, drawn } = await draw(node({ filter: OPEN, defaultFilters: CLOSED })); + const { $filter, drawn } = await draw(withDefaultFilters(CLOSED, { filter: OPEN })); expect($filter).toEqual([['status', 'equals', 'open']]); expect(drawn).toEqual(['Acme', 'Cyan']); }); diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.filterContextTokens-10607.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.filterContextTokens-10607.test.tsx index bc6c35bdfc..488a995f59 100644 --- a/packages/plugin-grid/src/__tests__/ObjectGrid.filterContextTokens-10607.test.tsx +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.filterContextTokens-10607.test.tsx @@ -73,6 +73,18 @@ function gridNode(over: Partial): ObjectGridSchema { return { type: 'object-grid', objectName: 'task', columns: COLUMNS, ...over }; } +/** + * A node carrying the deprecated `defaultFilters` as metadata written on the + * installed `@objectstack/spec` has it: the `filter` rule array. objectstack + * `main` retires the key (objectstack#11509, a tombstone there), so the declared + * `ObjectGridSchema.defaultFilters` refuses the value on that spec; the grid + * still READS it for such metadata, which is what the two rows below pin + * (objectui#12093). + */ +function legacyDefaultFiltersNode(rules: ViewFilterRule[]): ObjectGridSchema { + return { ...gridNode({}), defaultFilters: rules } as unknown as ObjectGridSchema; +} + function ui(adapter: Adapter, node: ObjectGridSchema, user: string | null = USER, org: string | null = ORG) { return ( @@ -124,7 +136,7 @@ describe('object-grid — the node’s own filter reaches the query resolved (ob const adapter = makeAdapter(); // objectui#6152 round 10: `defaultFilters` is the row's rule array too (it was a // `Record`, the `{ owner: '{current_user_id}' }` this cell used to write). - render(ui(adapter, gridNode({ defaultFilters: MINE }))); + render(ui(adapter, legacyDefaultFiltersNode(MINE))); const sent = JSON.stringify(await queriedFilter(adapter.find)); expect(sent).toContain(USER); expect(sent).not.toContain('{current_user_id}'); @@ -158,7 +170,7 @@ describe('object-grid — the node’s own filter reaches the query resolved (ob it('re-queries with the new id when the user changes and only `defaultFilters` carries the token', async () => { const adapter = makeAdapter(); - const node = gridNode({ defaultFilters: MINE }); + const node = legacyDefaultFiltersNode(MINE); const { rerender } = render(ui(adapter, node)); expect(JSON.stringify(await queriedFilter(adapter.find, 0))).toContain(USER); await settle(); diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 5fae6dbb4b..2ed855c6da 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -486,7 +486,23 @@ type NamedViewConfig = NonNullable[string]; interface AuthoredFilterSegments { view: NamedListView['filter']; table: ObjectGridSchema['filter']; - tableDefaults: ObjectGridSchema['defaultFilters']; + tableDefaults: ObjectGridSchema['filter']; +} + +/** + * The deprecated `table.defaultFilters`, read as the legacy rung's value + * (objectui#12093). Its declared type follows the `object-grid` row by + * reference: the `filter` rule array on the installed `@objectstack/spec`, and + * a retired-key tombstone on objectstack `main` (objectstack#11509), where no + * current document can author it. This component keeps READING it for + * metadata written before that retirement, exactly as before, so the value is + * typed by the shape such metadata has: the rule array `filter` takes on both + * specs. A back-compat read, not an authoring surface; it goes with the rung + * at the pin that carries the tombstone. + */ +function legacyTableDefaults(table: ObjectViewSchema['table']): ObjectGridSchema['filter'] { + const legacy: unknown = table?.defaultFilters; + return legacy as ObjectGridSchema['filter']; } /** @@ -1385,7 +1401,7 @@ export const ObjectView: React.FC = ({ { view: currentNamedViewConfig?.filter || activeViewQueryInputs?.filter, table: schema.table?.filter, - tableDefaults: schema.table?.defaultFilters, + tableDefaults: legacyTableDefaults(schema.table), }, filterScope, ); diff --git a/packages/plugin-view/src/__tests__/ObjectView.tableSlotRelay-10976.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.tableSlotRelay-10976.test.tsx index e8d2745bd8..2c0bf13f29 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.tableSlotRelay-10976.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.tableSlotRelay-10976.test.tsx @@ -135,8 +135,18 @@ const RELAYED: Required> = { singleClickEdit: true, }; +/** + * `table.defaultFilters` as metadata written on the installed `@objectstack/spec` + * carries it: the `filter` rule array. objectstack `main` retires the key + * (objectstack#11509, a tombstone there), and this component still READS it by + * name for such metadata (objectui#11880 item 5), so the sample is typed by the + * shape it has on the installed spec rather than by the slot's declaration, + * which is the tombstone on `main` (objectui#12093). + */ +type LegacyDefaultFilters = { defaultFilters: NonNullable }; + /** One authored value per by-name key, each copied onto the node unchanged. */ -const BY_NAME: Required>> = { +const BY_NAME: Required>> & LegacyDefaultFilters = { className: 'table-probe', columns: ['subject', 'stage'], // objectui#6152 round 10: the grid row's rule array (the slot's type was a `Record`). Its diff --git a/packages/types/src/__tests__/authoring-nodes-11364.test.ts b/packages/types/src/__tests__/authoring-nodes-11364.test.ts index 63bf99e786..66fa215a03 100644 --- a/packages/types/src/__tests__/authoring-nodes-11364.test.ts +++ b/packages/types/src/__tests__/authoring-nodes-11364.test.ts @@ -40,6 +40,7 @@ import { describe, expect, it } from 'vitest'; import type { z } from 'zod'; +import type { ElementRecordPickerPropsSchema as SpecElementRecordPickerPropsSchema } from '@objectstack/spec/ui'; import type { BaseSchema } from '../base.js'; import type { AuthoringNode, @@ -122,16 +123,32 @@ describe('objectui#11364: the spec-declared nodes have a TypeScript authoring fa const input: ElementTextInputNode = { type: 'element:text_input', id: 'ws', properties: { label: 'Workspace', inputType: 'email' } }; // @ts-expect-error `lable` is not a member of ComponentPropsMap['element:text_input'] const badInput: ElementTextInputNode = { type: 'element:text_input', properties: { lable: 'Workspace' } }; - // The spec's props gate waives the bag's `object` beside a `dataSource.object`. + // The object is named by the node-level `dataSource` binding, which the renderer reads. const bound: ElementRecordPickerNode = { type: 'element:record_picker', dataSource: { object: 'account' }, properties: { placeholder: 'Pick an account' }, }; - const unbound: ElementRecordPickerNode = { type: 'element:record_picker', properties: { object: 'account', emptyText: 'None' } }; // @ts-expect-error `objct` is not a member of ComponentPropsMap['element:record_picker'] const badPicker: ElementRecordPickerNode = { type: 'element:record_picker', properties: { objct: 'account' } }; - expect([input, badInput, bound, unbound, badPicker]).toHaveLength(5); + expect([input, badInput, bound, badPicker]).toHaveLength(4); + }); + + it('element:record_picker: the bag\'s flat `object` type-checks exactly where the installed row takes it (objectui#12093)', () => { + // objectstack#11509 retires the flat `object` (objectstack PR #22421: a tombstone + // on objectstack `main`), and the installed release still declares it. This file + // is compiled against both: by the pull-request type-check against the installed + // spec, and by the Spec Main Shape Gate against `main`. So the pin is the ROW's + // verdict held on the node type, not either answer written down: where the row + // refuses the key, `ElementRecordPickerNode` refuses a bag that writes it. This + // replaces the `unbound` literal that asserted the flat key compiles. + type NodeTakesFlatObject = + { type: 'element:record_picker'; properties: { object: string } } extends ElementRecordPickerNode ? true : false; + type RowTakesFlatObject = { object: string } extends Pick, 'object'> + ? true + : false; + const verdict: Equal = true; + expect(verdict).toBe(true); }); it('the spec-row nodes carry the node envelope and refuse a node-level child list, exactly as element:text does', () => { @@ -140,7 +157,12 @@ describe('objectui#11364: the spec-declared nodes have a TypeScript authoring fa const styles = { small: { padding: '8px' } }; const textStyled: PostRemovalSchema = { type: 'element:text', properties: { content: 'x' }, responsiveStyles: styles }; const inputStyled: PostRemovalSchema = { type: 'element:text_input', properties: { label: 'x' }, responsiveStyles: styles }; - const pickerStyled: PostRemovalSchema = { type: 'element:record_picker', properties: { object: 'a' }, responsiveStyles: styles }; + const pickerStyled: PostRemovalSchema = { + type: 'element:record_picker', + dataSource: { object: 'a' }, + properties: {}, + responsiveStyles: styles, + }; // The `body` / `children` tombstones every arm carries: a node-level child list is refused // after the removal on all three, and on each node type itself. // @ts-expect-error control: `element:text` refuses a node-level `children` (objectui#9256) diff --git a/packages/types/src/__tests__/element-repeater-data-source-11880.test.ts b/packages/types/src/__tests__/element-repeater-data-source-11880.test.ts index 474ec4e251..06ccea488e 100644 --- a/packages/types/src/__tests__/element-repeater-data-source-11880.test.ts +++ b/packages/types/src/__tests__/element-repeater-data-source-11880.test.ts @@ -47,6 +47,7 @@ import { describe, expect, it } from 'vitest'; import type { z } from 'zod'; import { ComponentPropsMap, ElementDataSourceSchema, PageComponentSchema } from '@objectstack/spec/ui'; +import type { ElementRepeaterPropsSchema as SpecElementRepeaterPropsSchema } from '@objectstack/spec/ui'; import { ElementRepeaterBlockSchema, StrictAnyComponentSchema, safeValidateSchema } from '../zod/index.zod.js'; import { stripImportedDefaults } from '../zod/imported-defaults.js'; @@ -54,6 +55,8 @@ import type { PublicBlockNodeOf } from '../authoring-nodes.js'; const TYPE = 'element:repeater'; +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; + /** The spec's row, as the published map carries it. */ const ROW = (ComponentPropsMap as unknown as Record)[TYPE]; @@ -237,14 +240,16 @@ describe('`dataSource` on the repeater arm IS the spec\'s binding (objectui#1188 }); it('the TypeScript authoring face types the binding as the spec\'s, and refuses a member it does not declare', () => { + // objectui#12093: the object is named by the binding alone. The bag's flat + // `object` is retired upstream; the pin below holds it to the installed row. const bound: PublicBlockNodeOf<'element:repeater'> = { type: TYPE, - properties: { object: 'contact', fields: ['name'] }, + properties: { fields: ['name'] }, dataSource: { object: 'contact', view: 'active', limit: 5 }, }; const aliased: PublicBlockNodeOf<'element:repeater'> = { type: TYPE, - properties: { object: 'contact' }, + properties: {}, // @ts-expect-error `objectName` is not a member of the spec's ElementDataSourceSchema dataSource: { object: 'contact', objectName: 'contact' }, }; @@ -257,6 +262,24 @@ describe('`dataSource` on the repeater arm IS the spec\'s binding (objectui#1188 }; expect([bound, aliased, bindingOnly].map((node) => node.type)).toEqual([TYPE, TYPE, TYPE]); }); + + it('the bag\'s flat `object` type-checks exactly where the installed row takes it (objectui#12093)', () => { + // objectstack#11509 retires the flat `object` (objectstack PR #22421: a tombstone + // on objectstack `main`), and the installed release still declares it. This file + // is compiled against both: by the pull-request type-check against the installed + // spec, and by the Spec Main Shape Gate against `main`. So the pin is the ROW's + // verdict held on the face, not either answer written down: where the row + // refuses the key, the typed node refuses it. A re-forked `object: string` on + // the bag turns this red on `main`; a bag that dropped the key while the row + // still takes it turns it red on the installed spec. + type FaceTakesFlatObject = + { type: typeof TYPE; properties: { object: string } } extends PublicBlockNodeOf<'element:repeater'> ? true : false; + type RowTakesFlatObject = { object: string } extends Pick, 'object'> + ? true + : false; + const verdict: Equal = true; + expect(verdict).toBe(true); + }); }); describe('the repeater bag is its row with `object` alone made optional (objectui#12056)', () => { diff --git a/packages/types/src/__tests__/grid-default-filters-gantt-map-filter-round10-6152.test.ts b/packages/types/src/__tests__/grid-default-filters-gantt-map-filter-round10-6152.test.ts index c73668671a..0cd78ac668 100644 --- a/packages/types/src/__tests__/grid-default-filters-gantt-map-filter-round10-6152.test.ts +++ b/packages/types/src/__tests__/grid-default-filters-gantt-map-filter-round10-6152.test.ts @@ -215,13 +215,31 @@ describe('objectui#6152 round 10 — the TypeScript face', () => { const mapRecord: TsObjectMapSchema = { type: 'object-map', objectName: 'site', filter: { status: 'open' } }; // @ts-expect-error `filter` is the `ViewFilterRule` array, not the AST tuple array const mapTuples: TsObjectMapSchema = { type: 'object-map', objectName: 'site', filter: [['status', '=', 'open']] }; - // Lit controls on the same faces. - const grid: TsObjectGridSchema = { type: 'object-grid', objectName: 'task', defaultFilters: RULES }; - const slot: TsObjectViewSchema = { type: 'object-view', objectName: 'task', table: { defaultFilters: RULES } }; + // Lit controls on the same faces. The grid's `defaultFilters` is not one of them + // any more: its verdict on the rule array is the row's (objectui#12093, below). const gantt: TsObjectGanttSchema = { type: 'object-gantt', objectName: 'task', filter: RULES }; const map: TsObjectMapSchema = { type: 'object-map', objectName: 'site', filter: RULES }; expect([gridRecord, slotRecord, ganttTuples, mapRecord, mapTuples]).toHaveLength(5); - expect([grid.defaultFilters, slot.table?.defaultFilters, gantt.filter, map.filter]).toEqual([RULES, RULES, RULES, RULES]); + expect([gantt.filter, map.filter]).toEqual([RULES, RULES]); + }); + + it('`defaultFilters` takes the rule array on both grid faces exactly where the installed row does (objectui#12093)', () => { + // objectstack#11509 retires `object-grid.defaultFilters` (objectstack PR #22421: a + // tombstone on objectstack `main`), and the installed release still declares it as + // the rule array. This file is compiled against both: by the pull-request type-check + // against the installed spec, and by the Spec Main Shape Gate against `main`. So the + // pin is the ROW's verdict held on each face, not either answer written down: where + // the row refuses the rule array, the flat mirror and the `object-view` `table` slot + // refuse it too. These replace the two literals that asserted the rule array + // compiles there. + type RowTakesRules = { defaultFilters: ViewFilterRule[] } extends Pick ? true : false; + type GridTakesRules = { defaultFilters: ViewFilterRule[] } extends Pick ? true : false; + type SlotTakesRules = { defaultFilters: ViewFilterRule[] } extends Pick, 'defaultFilters'> + ? true + : false; + const grid: Equal = true; + const slot: Equal = true; + expect([grid, slot]).toEqual([true, true]); }); }); @@ -230,9 +248,12 @@ type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B type Expect = T; export type assertionRound10MembersAreTheRows = [ + // `defaultFilters` is the row's member on both grid faces, by reference, whatever the + // row declares: the rule array on the installed release, the tombstone on objectstack + // `main` (objectstack#11509). It used to be written down as `ViewFilterRule[]`, which + // only the installed release satisfies (objectui#12093). Expect>, - Expect>, - Expect['defaultFilters'], ViewFilterRule[] | undefined>>, + Expect['defaultFilters'], SpecObjectGridProps['defaultFilters']>>, Expect>, Expect>, Expect>,