From 07ed35f00a3ebec7b441fe318f32c61460ef10fd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 15:07:55 +0000 Subject: [PATCH 1/4] fix(components,app-shell): page elements bind data through the node-level dataSource only element:repeater reads its query (object, filter, sort, limit) from the dataSource binding, gate-wrapped like element:number; element:record_picker and element:number drop their flat-key fallbacks and the number's AND with properties.filter; the Studio designer writes dataSource.object (and the repeater's dataSource.limit) at node level, and its field pickers read the object from there. Docs and changeset follow. Part of objectui#11880 (items 1-4). Claude-Session: https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU Co-authored-by: Claude --- .changeset/11880-element-datasource-only.md | 12 ++ content/docs/guide/data-source.md | 42 +++++-- .../inspectors/PageBlockInspector.tsx | 33 +++-- .../metadata-admin/previews/block-config.ts | 46 +++++-- .../src/renderers/basic/data-list.tsx | 95 +++++++++----- .../src/renderers/basic/elements.tsx | 86 +++++-------- .../src/renderers/basic/record-picker.tsx | 119 ++++-------------- 7 files changed, 217 insertions(+), 216 deletions(-) create mode 100644 .changeset/11880-element-datasource-only.md diff --git a/.changeset/11880-element-datasource-only.md b/.changeset/11880-element-datasource-only.md new file mode 100644 index 0000000000..98719cce17 --- /dev/null +++ b/.changeset/11880-element-datasource-only.md @@ -0,0 +1,12 @@ +--- +'@object-ui/components': patch +'@object-ui/app-shell': patch +--- + +Page elements bind data through the node-level `dataSource` only (objectui#11880, the objectui half of objectstack#11509, ruled A-narrow). + +- `element:repeater` reads its query from the `dataSource` binding: `object`, `filter`, `sort` and `limit`, composed with the saved view its `view` names. A repeater bound only through `dataSource` used to render "No records". An unresolvable `view` now shows the configuration-error panel, and a resolving one the loading panel. Its registration declares the binding through the injected `dataSource` input and no longer publishes `object`, `filter`, `sort` or `limit`. +- `element:record_picker` and `element:number` no longer read the flat `properties.object` / `filter` / `sort` / `limit` beside the binding, and no longer publish them as inputs. `element:number`'s filter is the binding's alone: the AND with `properties.filter` is gone. An element whose node names no `dataSource.object` issues no query. +- The Studio page designer writes `dataSource.object` for `element:repeater` and `element:number`, and `dataSource.limit` for the repeater's row cap, at node level. Their field pickers read the object from `dataSource.object`. A stored `properties.object` stays visible in the inspector's Advanced section. + +Breaking for an author who wrote the flat keys on these three elements: move them into `dataSource`. `@objectstack/spec` retires the flat keys in v18. diff --git a/content/docs/guide/data-source.md b/content/docs/guide/data-source.md index 121f987730..fd2ad7eb75 100644 --- a/content/docs/guide/data-source.md +++ b/content/docs/guide/data-source.md @@ -256,6 +256,7 @@ ignores would be accepted and dropped, which is the defect this binding removes. | `object-grid` | ✅ | ✅ | ✅ | ✅ | ✅ | | `element:record_picker` | ✅ | ✅ | ✅ | ✅ | ✅ | | `element:number` | ✅ | filter | ✅ | — single value | — single value | +| `element:repeater` | ✅ | filter / sort / limit | ✅ | ✅ | ✅ | | `record:related_list` | ✅ | columns / filter / sort / limit | ✅ | ✅ | ✅ | | `object-calendar` | ✅ | filter / sort | ✅ | ✅ | — platform ceiling | | `object-kanban` | ✅ | filter / limit | ✅ | — no ordering | ✅ (`limit`) | @@ -303,20 +304,37 @@ rows, a form's inline fields — `customFields`, or `sections` whose every field inline — a chart's `dataset`, a `bind` path, or a metric's `fallbackValue`) draws from that source and shows no hint. -The two `element:*` rows keep their configuration in the node's `properties` bag, -so the binding does not land on a schema key there: each reads it directly, and -`dataSource.object` wins over `properties.object`. They differ on `filter`. -`element:record_picker` takes the binding's (or its view's) filter in place of -`properties.filter`, which applies only when neither supplies one. -`element:number` AND-combines `properties.filter` with the binding's filter and -its view's — the rule the gate-wrapped blocks above follow — so neither is -dropped, and a filter refused while combining them shows the configuration-error -panel instead of a count. On `element:number`, +The three `element:*` rows keep their display configuration in the node's +`properties` bag and take their query from the binding **only**: `object`, +`filter`, `sort` and `limit` are read from `dataSource` and from nowhere else. +There is no `properties.object`, `properties.filter`, `properties.sort` or +`properties.limit` on these three elements — the renderers do not read them, 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. A repeater is +bound like this: + +```json +{ + "type": "element:repeater", + "dataSource": { + "object": "contact", + "sort": [{ "field": "name", "order": "asc" }], + "limit": 10 + }, + "properties": { "titleField": "name", "fields": ["email"] } +} +``` + +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. An `element:number` -that sets `aggregate` but names no object in either place (no `properties.object`, -no `dataSource.object`) shows a short "no object named" notice instead of a count. +has no ordering and a capped count would be a wrong number, and a filter the +converter refuses shows the configuration-error panel instead of a count. An +element whose node names no `dataSource.object` issues no query: an +`element:number` that sets `aggregate` shows a short "no object named" notice +instead of a count, an `element:record_picker` offers no records, and an +`element:repeater` shows its empty-state text. The Studio page designer writes +these elements' object (and the repeater's row cap) into `dataSource`. #### Scoping a filter to the record in view: `{record_id}` diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx index b8c8dd302c..effeb978bb 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.tsx @@ -472,23 +472,40 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection block.type as string | undefined, (block.properties as Record) || {}, ); + // The node-level `dataSource` binding (objectui#11880): a field marked + // `at: 'dataSource'` reads and writes its member, patched at node level the + // way `visibleWhen` is below, never under `properties`. + const binding: Record = + block.dataSource && typeof block.dataSource === 'object' && !Array.isArray(block.dataSource) + ? (block.dataSource as Record) + : {}; + const readBinding = (name: string): unknown => binding[name]; + const patchBinding = (name: string, value: unknown) => + patch({ dataSource: { ...binding, [name]: value } } as Partial); // The record page's bound object — drives `field-picker`/`field-list` with - // objectFrom:'page'. (objectFrom:'self' reads a sibling block property.) + // objectFrom:'page'. (objectFrom:'self' reads a sibling block property, and + // objectFrom:'dataSource' the binding's object.) const pageObject = typeof (draft as any)?.object === 'string' ? ((draft as any).object as string) : undefined; const resolveObject = (src: BlockPropField & { objectFrom?: string; objectProp?: string }): string | undefined => src.objectFrom === 'page' ? pageObject - : src.objectProp != null && blockProps[src.objectProp] != null - ? String(blockProps[src.objectProp]) - : undefined; + : src.objectFrom === 'dataSource' + ? (typeof binding.object === 'string' ? binding.object : undefined) + : src.objectProp != null && blockProps[src.objectProp] != null + ? String(blockProps[src.objectProp]) + : undefined; const readProp = (name: string): unknown => blockProps[name] ?? (block as any)[name]; const patchProp = (name: string, value: unknown) => patch({ properties: { ...blockProps, [name]: value } } as Partial); // Properties already handled by curated fields — excluded from the generic - // "Advanced" section so each property has exactly one editor. + // "Advanced" section so each property has exactly one editor. A field homed + // in the binding edits no `properties` key, so a stored `properties.` + // of the same name stays visible there rather than hidden behind it. const curatedNames = new Set( - (blockHasConfig(block.type) ? BLOCK_CONFIG[block.type as string] : []).map((f) => f.name), + (blockHasConfig(block.type) ? BLOCK_CONFIG[block.type as string] : []) + .filter((f) => f.at !== 'dataSource') + .map((f) => f.name), ); const advancedKeys = Object.keys(blockProps).filter( (key) => !curatedNames.has(key) && !STRUCTURAL_PROP_KEYS.has(key), @@ -779,7 +796,9 @@ export function PageBlockInspector({ selection, draft, onPatch, onClearSelection
{t('engine.inspector.pageBlock.properties', locale)}
- {BLOCK_CONFIG[block.type as string].map((f) => renderField(f, readProp, patchProp))} + {BLOCK_CONFIG[block.type as string].map((f) => + f.at === 'dataSource' ? renderField(f, readBinding, patchBinding) : renderField(f, readProp, patchProp), + )} )} diff --git a/packages/app-shell/src/views/metadata-admin/previews/block-config.ts b/packages/app-shell/src/views/metadata-admin/previews/block-config.ts index 9ef081f287..e9786df839 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/block-config.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/block-config.ts @@ -5,8 +5,9 @@ * * The page block inspector renders these as typed fields that edit the block's * `properties` (the spec convention; the renderer hoists `properties.*` to the - * top level). Keep each field `name` aligned with the property name the - * corresponding renderer reads. Add block types here as they are needed. + * top level), or — for a field marked `at: 'dataSource'` — the node-level + * `dataSource` binding. Keep each field `name` aligned with the property name + * the corresponding renderer reads. Add block types here as they are needed. * * Field kinds: * text | number | boolean | select — scalar props @@ -76,8 +77,13 @@ /** Where a field/field-list picker resolves its object from: * - 'page' — the record page's bound object (draft.object) - * - 'self' — a sibling property on the same block (objectProp) */ -export type ObjectSource = { objectFrom: 'page' } | { objectFrom: 'self'; objectProp: string }; + * - 'self' — a sibling property on the same block (objectProp) + * - 'dataSource' — the block's node-level `dataSource.object` binding, the + * one place an `element:*` block reads its object from (objectui#11880) */ +export type ObjectSource = + | { objectFrom: 'page' } + | { objectFrom: 'self'; objectProp: string } + | { objectFrom: 'dataSource' }; /** * A field's input hint — EXACTLY ONE of two shapes, chosen where the field is @@ -105,8 +111,16 @@ export type PlaceholderSpec = { key: string; literal?: never } | { literal: stri * One curated property editor. Every `label` is a translation key, and every * `placeholder` declares whether it is one — see the file header for the five * key shapes and the tests that enforce them. + * + * `at` says where the value lives on the block node (objectui#11880). Absent, + * the default, is `properties.`. `'dataSource'` is the node-level + * `dataSource.`: a member of the spec's `ElementDataSourceSchema` + * binding, which `element:repeater`, `element:number` and + * `element:record_picker` read their query from and nowhere else + * (objectstack#11509, ruled A-narrow). Honoured on a block's top-level fields; + * an `array` field's item editors always write into the item. */ -export type BlockPropField = +export type BlockPropField = ( | { name: string; label: string; kind: 'text'; placeholder?: PlaceholderSpec } | { name: string; label: string; kind: 'number'; placeholder?: PlaceholderSpec } | { name: string; label: string; kind: 'boolean' } @@ -123,7 +137,8 @@ export type BlockPropField = // Schema-driven pickers — dropdowns populated from the live metadata. | { name: string; label: string; kind: 'object-picker'; placeholder?: PlaceholderSpec } | ({ name: string; label: string; kind: 'field-picker'; placeholder?: PlaceholderSpec } & ObjectSource) - | ({ name: string; label: string; kind: 'field-list'; placeholder?: PlaceholderSpec } & ObjectSource); + | ({ name: string; label: string; kind: 'field-list'; placeholder?: PlaceholderSpec } & ObjectSource) +) & { at?: 'dataSource' }; /** Shared alignment options. The keys name the field (`align`), so reusing this * const on a field called anything else turns the key-derivation pin red. */ @@ -320,17 +335,24 @@ export const BLOCK_CONFIG: Record = { { name: 'columns', label: 'engine.inspector.pageBlock.field.element:definition-list.columns', kind: 'number', placeholder: { literal: '1' } }, { name: 'inline', label: 'engine.inspector.pageBlock.field.element:definition-list.inline', kind: 'boolean' }, ], + // objectui#11880 — the list's query is the node-level `dataSource` binding, + // the only place `element:repeater` reads it from, so `object` and `limit` + // write `dataSource.object` / `dataSource.limit` and the field pickers read + // their object from there. They wrote `properties.object` / `.limit` before: + // keys the renderer no longer reads and the spec retires in v18. 'element:repeater': [ - { name: 'object', label: 'engine.inspector.pageBlock.field.element:repeater.object', kind: 'object-picker' }, - { name: 'titleField', label: 'engine.inspector.pageBlock.field.element:repeater.titleField', kind: 'field-picker', objectFrom: 'self', objectProp: 'object' }, - { name: 'fields', label: 'engine.inspector.pageBlock.field.element:repeater.fields', kind: 'field-list', objectFrom: 'self', objectProp: 'object' }, - { name: 'limit', label: 'engine.inspector.pageBlock.field.element:repeater.limit', kind: 'number', placeholder: { literal: '10' } }, + { name: 'object', label: 'engine.inspector.pageBlock.field.element:repeater.object', kind: 'object-picker', at: 'dataSource' }, + { name: 'titleField', label: 'engine.inspector.pageBlock.field.element:repeater.titleField', kind: 'field-picker', objectFrom: 'dataSource' }, + { name: 'fields', label: 'engine.inspector.pageBlock.field.element:repeater.fields', kind: 'field-list', objectFrom: 'dataSource' }, + { name: 'limit', label: 'engine.inspector.pageBlock.field.element:repeater.limit', kind: 'number', placeholder: { literal: '10' }, at: 'dataSource' }, { name: 'emptyText', label: 'engine.inspector.pageBlock.field.element:repeater.emptyText', kind: 'text' }, { name: 'divided', label: 'engine.inspector.pageBlock.field.element:repeater.divided', kind: 'boolean' }, ], + // objectui#11880 — the same move for the metric: its object is + // `dataSource.object`, and the measure picker reads its object from there. 'element:number': [ - { name: 'object', label: 'engine.inspector.pageBlock.field.element:number.object', kind: 'object-picker' }, - { name: 'field', label: 'engine.inspector.pageBlock.field.element:number.field', kind: 'field-picker', objectFrom: 'self', objectProp: 'object' }, + { name: 'object', label: 'engine.inspector.pageBlock.field.element:number.object', kind: 'object-picker', at: 'dataSource' }, + { name: 'field', label: 'engine.inspector.pageBlock.field.element:number.field', kind: 'field-picker', objectFrom: 'dataSource' }, { name: 'aggregate', label: 'engine.inspector.pageBlock.field.element:number.aggregate', diff --git a/packages/components/src/renderers/basic/data-list.tsx b/packages/components/src/renderers/basic/data-list.tsx index e54574ef40..5c1f509274 100644 --- a/packages/components/src/renderers/basic/data-list.tsx +++ b/packages/components/src/renderers/basic/data-list.tsx @@ -14,12 +14,21 @@ * row, no toolbar/card/pagination. * * Props are read off `schema.properties` (spec convention) with a `schema.props` - * fallback, matching the other `element:*` renderers. + * fallback, matching the other `element:*` renderers. The repeater's DATA + * binding is not a prop: it is the node-level `dataSource` (objectui#11880). */ import * as React from 'react'; -import { ComponentRegistry } from '@object-ui/core'; -import { useAdapter, useDataInvalidation, useFilterScope, useResolvedFilter } from '@object-ui/react'; +import { ComponentRegistry, elementDataSourceBlock } from '@object-ui/core'; +import { + ElementDataSourceErrorPanel, + ElementDataSourceLoadingPanel, + useAdapter, + useDataInvalidation, + useElementDataSource, + useFilterScope, + useResolvedFilter, +} from '@object-ui/react'; import { cn } from '../../lib/utils'; import { readProps } from './readProps'; @@ -123,34 +132,45 @@ interface RepeaterColumn { function RepeaterRenderer({ schema }: { schema: any }) { const props = readProps<{ - object?: string; titleField?: string; fields?: Array; - filter?: unknown; - sort?: any; - limit?: number; emptyText?: string; divided?: boolean; className?: string; }>(schema); const adapter = useAdapter() as any; + // objectui#11880 — the node-level `dataSource` binding (the spec's + // `ElementDataSourceSchema`) is the ONE place this list's query is read + // from: its `object`, its `filter`, its `sort` and its `limit`, composed with + // the saved view its `view` names (objectstack#11509, ruled A-narrow). The + // flat `properties.object` / `filter` / `sort` / `limit` are not read: a + // repeater bound only through `dataSource` used to render "No records", + // and one that names no `dataSource.object` now issues no query at all. + // While a named `view` is unresolved (or unresolvable) there is no object, + // so nothing is listed over the wider set the view was written to narrow; + // the render reports instead. The repeater's own adapter is passed so the + // view resolves against the source its rows come from. + const dataBinding = useElementDataSource(schema, adapter); + const composed = dataBinding.composed; + const object = composed?.object; + const sort = composed?.sort; + const limit = composed?.limit; const [rows, setRows] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); - // objectui#10666 — the repeater's own `filter`, with every context token + // objectui#10666 — the binding's `filter`, with every context token // (`{current_user_id}`, `{current_org_id}`, the date macros) resolved ONCE // through `@object-ui/core`'s shared `resolveFilterPlaceholders`, against the // session scope the host provides, and HELD by structure (`useResolvedFilter` - // in `@object-ui/react`). It sent the literal token on `$filter` before. The - // query and its content key below read THIS, never the raw `props.filter`. + // in `@object-ui/react`). The query and its content key below read THIS. const filterScope = useFilterScope(); - const queryFilter = useResolvedFilter(props.filter, filterScope); + const queryFilter = useResolvedFilter(composed?.filter, filterScope); const filterKey = React.useMemo(() => (queryFilter ? JSON.stringify(queryFilter) : ''), [queryFilter]); // objectui#10664 — the sort reaches `$orderby` below, so the fetch effect // keys on it, by CONTENT the way `filterKey` keys the filter: a fresh array // with the same entries is not a change (AGENTS.md #10). - const sortKey = React.useMemo(() => (props.sort ? JSON.stringify(props.sort) : ''), [props.sort]); + const sortKey = React.useMemo(() => (sort ? JSON.stringify(sort) : ''), [sort]); const cols: RepeaterColumn[] = React.useMemo( () => (props.fields ?? []).map((f) => (typeof f === 'string' ? { field: f } : f)), @@ -163,12 +183,12 @@ function RepeaterRenderer({ schema }: { schema: any }) { // below names it, so the rows are re-read. Subscribed only when the effect // can query: without an adapter `find` there is no read to repeat. const invalidationNonce = useDataInvalidation( - adapter && typeof adapter.find === 'function' ? props.object : undefined, + adapter && typeof adapter.find === 'function' ? object : undefined, ); React.useEffect(() => { let cancelled = false; - if (!adapter || !props.object || typeof adapter.find !== 'function') { + if (!adapter || !object || typeof adapter.find !== 'function') { setLoading(false); return; } @@ -178,9 +198,9 @@ function RepeaterRenderer({ schema }: { schema: any }) { try { const query: any = {}; if (queryFilter) query.$filter = queryFilter; - if (props.sort) query.$orderby = props.sort; - if (props.limit) query.$top = props.limit; - const res = await adapter.find(props.object, query); + if (sort) query.$orderby = sort; + if (limit) query.$top = limit; + const res = await adapter.find(object, query); // `data` is the ONE rows member `QueryResult` (`@object-ui/types`) // declares; the bare-array arm stays because fakes at this seam really // do answer with a plain array. A `res?.records` arm sat between them @@ -201,8 +221,22 @@ function RepeaterRenderer({ schema }: { schema: any }) { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [adapter, props.object, filterKey, sortKey, props.limit, invalidationNonce]); + }, [adapter, object, filterKey, sortKey, limit, invalidationNonce]); + // After every hook above, so hook order stays stable across resolution + // states — the same two panels `element:record_picker` draws. + if (dataBinding.status === 'missing') { + return ( + + ); + } + if (dataBinding.status === 'loading') { + return ; + } if (loading) return

Loading…

; if (error) return

{error}

; if (rows.length === 0) { @@ -230,32 +264,27 @@ function RepeaterRenderer({ schema }: { schema: any }) { ); } -ComponentRegistry.register('repeater', RepeaterRenderer, { +// 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 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 — what the renderer prints for each `fields` entry + // is pinned in `__tests__/element-list-inputs-11168.test.tsx`. The query + // keys `object` / `filter` / `sort` / `limit` are not inputs: the list reads + // them from the injected `dataSource` binding only (objectui#11880). inputs: [ - { name: 'object', type: 'string', required: true, description: 'Object whose records the list repeats over' }, { 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 2162032400..93796c36c9 100644 --- a/packages/components/src/renderers/basic/elements.tsx +++ b/packages/components/src/renderers/basic/elements.tsx @@ -22,13 +22,12 @@ * All props are read off `schema.properties` per the spec's * `UIComponent.properties` convention; `schema.props` is also accepted * as a fallback so authors transitioning between conventions keep working. - * `element:number` also reads the node-level `dataSource` binding: its - * `object` wins over the flat one, and its filter is AND-combined with the - * flat one (objectui#10909). + * `element:number` reads its query from the node-level `dataSource` binding + * ONLY: its `object` and its `filter` (objectui#10909, objectui#11880). */ import * as React from 'react'; -import { ComponentRegistry, elementDataSourceBlock, mergeFilterNodes, toFilterNodeSafely } from '@object-ui/core'; +import { ComponentRegistry, elementDataSourceBlock, toFilterNodeSafely } from '@object-ui/core'; import type { ActionDef, FilterOperatorError } from '@object-ui/core'; import { ElementDataSourceErrorPanel, @@ -474,10 +473,8 @@ function formatValue( function ElementNumberRenderer({ schema }: { schema: any }) { const props = readProps<{ - object?: string; field?: string; aggregate?: 'count' | 'sum' | 'avg' | 'min' | 'max'; - filter?: unknown; format?: 'number' | 'currency' | 'percent'; prefix?: string; suffix?: string; @@ -486,10 +483,10 @@ function ElementNumberRenderer({ schema }: { schema: any }) { const adapter = useAdapter() as any; // objectui#10909 — the spec's per-element binding (`PageComponentSchema // .dataSource`), resolved the way the element twin `element:record_picker` - // resolves it: through `useElementDataSource`. The spec lint gate waives a - // missing `properties.object` when `dataSource.object` names one, on the - // precedence `ds.object ?? props.object`; before this, a metric bound only - // through the binding issued no query and painted the empty dash. + // resolves it: through `useElementDataSource`. Since objectui#11880 it is + // the ONLY source of the metric's object and filter: the flat + // `properties.object` / `filter` are not read (objectstack#11509, ruled + // A-narrow). // // `object` resolves ONCE, here, and that one value is the fetch guard, the // `aggregate` / `find` target and the bus key below. A named `view` is @@ -504,29 +501,23 @@ function ElementNumberRenderer({ schema }: { schema: any }) { const dataBinding = useElementDataSource(schema, adapter); const composed = dataBinding.composed; const { t } = useObjectTranslation(); - // The filter this metric aggregates over. With no binding it is the node's - // own `filter` exactly as authored, so the `properties` form is unchanged. - // With one, the node's own filter is AND-combined with the binding's (which - // `useElementDataSource` has already AND-combined with its view's): neither - // is dropped, so a validated `properties.filter` can never be discarded and - // widen the count. That is the rule `ElementDataSourceGate` applies for every - // gate-wrapped block that reads a filter, lowered and merged the same way - // (`toFilterNodeSafely` + `mergeFilterNodes`). A source the converter - // refuses is kept as a VALUE and answered with the configuration-error panel - // below — never merged as "no filter", which would count every row. - // Memoised for cost only: the result is read by content (`useResolvedFilter` - // holds it by structure), never by identity (AGENTS.md #10). + // The filter this metric aggregates over: the binding's (which + // `useElementDataSource` has already AND-combined with its view's), lowered + // the way `ElementDataSourceGate` lowers a filter (`toFilterNodeSafely`). A + // filter the converter refuses is kept as a VALUE and answered with the + // configuration-error panel below — never read as "no filter", which would + // count every row. Memoised for cost only: the result is read by content + // (`useResolvedFilter` holds it by structure), never by identity + // (AGENTS.md #10). const scopedFilter = React.useMemo((): { filter: unknown; refusal?: FilterOperatorError } => { - if (!composed) return { filter: props.filter }; - const own = toFilterNodeSafely(props.filter); - if (!own.ok) return { filter: undefined, refusal: own.refusal }; + if (!composed) return { filter: undefined }; const bound = toFilterNodeSafely(composed.filter); if (!bound.ok) return { filter: undefined, refusal: bound.refusal }; - return { filter: mergeFilterNodes(own.node, bound.node) }; - }, [composed, props.filter]); + return { filter: bound.node }; + }, [composed]); const filterRefusal = scopedFilter.refusal; const unresolved = dataBinding.status === 'loading' || dataBinding.status === 'missing'; - const object = unresolved || filterRefusal ? undefined : (composed?.object ?? props.object); + const object = unresolved || filterRefusal ? undefined : composed?.object; // Tenant default currency (ADR-0053) for a `currency`-format metric; the // display locale resolves through the shared precedence (tenant regional // default → active UI language), so the metric follows a language switch even @@ -542,9 +533,8 @@ function ElementNumberRenderer({ schema }: { schema: any }) { // session scope the host provides, and HELD by structure (`useResolvedFilter` // in `@object-ui/react`). Both reads below (the `aggregate` filter and the // `find` fallback's `$filter`) sent the literal token before; they and the - // content key read THIS, never the raw `props.filter`. What it resolves is - // the scoped filter above: the node's own, AND-combined with the binding's - // when there is one (objectui#10909). + // content key read THIS. What it resolves is the binding's filter above + // (objectui#10909, objectui#11880). const filterScope = useFilterScope(); const queryFilter = useResolvedFilter(scopedFilter.filter, filterScope); const filterKey = React.useMemo(() => (queryFilter ? JSON.stringify(queryFilter) : ''), [queryFilter]); @@ -636,14 +626,13 @@ function ElementNumberRenderer({ schema }: { schema: any }) { if (dataBinding.status === 'loading') { return ; } - // objectui#10951 — an aggregate that names no object at all, in either - // place. `object` stopped being `required` when the binding became a second - // way to supply it (objectui#10944), and the manifest cannot say "one of the - // two", so the html tier no longer reports this node: say it here rather - // than paint the dash. Only AUTHORED absence qualifies (`absent` = no - // binding naming an object); a binding whose view is still resolving or - // failed to resolve is answered by the two panels above. - if (props.aggregate && !props.object && dataBinding.status === 'absent') { + // objectui#10951 — an aggregate whose node names no `dataSource.object` + // (objectui#11880: a flat `properties.object` names nothing any more). The + // manifest cannot require a binding member, so the html tier does not report + // this node: say it here rather than paint the dash. Only AUTHORED absence + // qualifies (`absent` = no binding naming an object); a binding whose view is + // still resolving or failed to resolve is answered by the two panels above. + if (props.aggregate && dataBinding.status === 'absent') { return (
(schema); const adapter = useAdapter() as any; - // Per-element data binding (ElementDataSourceSchema) takes precedence over the - // flat `properties.object` shorthand. `dataBinding.composed` carries the + // The per-element data binding (ElementDataSourceSchema) is the ONE source of + // the picker's query (objectui#11880): with no `dataSource.object` there is + // no object, and the picker issues no read. `dataBinding.composed` carries the // binding's own keys already combined with the saved view its `view` names — // the view supplies the baseline, an explicit binding key overrides it, and // `filter` AND-combines because the spec calls the binding's filter @@ -110,18 +108,18 @@ function ElementRecordPickerRenderer({ schema }: { schema: any }) { // narrow. `undefined` parks the fetch effect below; the render returns a // status panel instead. const unresolved = dataBinding.status === 'loading' || dataBinding.status === 'missing'; - const object = unresolved ? undefined : (composed?.object ?? props.object); - // objectui#10666 — whichever filter wins (the binding's, or the node's own), - // with every context token (`{current_user_id}`, `{current_org_id}`, the date + const object = unresolved ? undefined : composed?.object; + // objectui#10666 — the binding's filter (AND-combined with its view's), with + // every context token (`{current_user_id}`, `{current_org_id}`, the date // macros) resolved ONCE through `@object-ui/core`'s shared // `resolveFilterPlaceholders`, against the session scope the host provides, // and HELD by structure (`useResolvedFilter` in `@object-ui/react`). The // picker sent the literal token on `$filter` before; the query and its // content key read this value. const filterScope = useFilterScope(); - const filter = useResolvedFilter(composed?.filter ?? props.filter, filterScope); - const sort = composed?.sort ?? props.sort; - const limit = composed?.limit ?? props.limit ?? 50; + const filter = useResolvedFilter(composed?.filter, filterScope); + const sort = composed?.sort; + const limit = composed?.limit ?? 50; const labelField = props.labelField ?? 'name'; const valueField = props.valueField ?? 'id'; @@ -364,8 +362,8 @@ function ElementRecordPickerRenderer({ schema }: { schema: any }) { } // This block CONSUMES the gate's family without the JSX wrapper — the hook plus -// the two status panels — because its object lives under `properties` rather -// than on a schema key the gate could write. It reads `dataSource` exactly as +// the two status panels — because its query has no schema key of its own the +// gate could write the binding onto. It reads `dataSource` exactly as // the wrapping blocks do, so it declares it from the same seam: the marker is // applied to the renderer at its registration rather than at a gate tag it does // not have. Found by the render probe in @@ -387,53 +385,13 @@ ComponentRegistry.register('record_picker', elementDataSourceBlock(ElementRecord skipFallback: true, label: 'Record Picker', category: 'input', - // `filter` is DECLARED, not merely honoured (objectui#3830) — the fourth key - // of objectui#3808's A class, which that issue's own three-way triage dropped - // between the raw key dump and the lists. The renderer has read it all along - // (`composed?.filter ?? props.filter` above, into `query.$filter`), and the - // spec declares it (`ElementRecordPickerProps.filter`), but while it was - // missing from this list every layer that reads a manifest said the opposite: - // `element:record_picker` is not in `PUBLIC_BLOCKS` ("record picking is a - // field widget, not a page block"), so the gap was not in `sdui.manifest.json` - // — it was in the JSX-page compiler's prop whitelist, which - // `renderers/layout/page.tsx` builds from `getKnownTypes()` plus these same - // `inputs`. A JSX page writing `filter` got an `unknown-prop` warning from - // `sdui-parser/src/validate.ts` on a key the renderer then went on to filter - // by. That is objectui#3407 in the same shape as `readonly` — honoured, - // undiscoverable — and the reverse half of the parity gate in - // `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`, whose - // explicit exemption for this key is deleted by the same change. + // The query keys `object` / `filter` / `sort` / `limit` are NOT inputs + // (objectui#11880): the picker reads them from the node-level `dataSource` + // binding only, which `elementDataSourceBlock` above declares through the + // injected `ELEMENT_DATA_SOURCE_INPUT`. Publishing the flat four would + // advertise keys the renderer drops — the spec retires them in v18 + // (objectstack#11509, ruled A-narrow). inputs: [ - { name: 'object', type: 'string' }, - { - name: 'filter', - // `'array'` is the spec's shape, not a chosen arm. objectstack#14406 - // CONVERGED this key onto `z.array(ViewFilterRuleSchema)` — the last - // record-form `filter` in `ComponentPropsMap`, closing the maintainer's - // 2026-08-25 ruling that the platform carries one filter orthography - // (objectui#6206-B). It was `FilterConditionSchema` (the MongoDB-style - // `z.record(z.string(), z.unknown()).and(z.object({ $and, $or, $not }))`) - // until then, and this entry said `'object'` for exactly that reason. - // `checkType`'s `'array'` case in `sdui-parser/src/validate.ts` accepts - // what the spec now accepts here and rejects what it rejects — the record - // form included, which the spec refuses by KIND. Both halves are verified - // against `ElementRecordPickerPropsSchema.safeParse` in the parity test - // next to this file. So this stays the case in the family where the - // coarse vocabulary lines up with the contract exactly as declared: one - // arm, no union to spell. ⚠️ The array is a rule-OBJECT list, not a tuple - // list: `[['a', '=', 1]]` is refused at `filter.0`, and the coarse - // `'array'` cannot say so — which is what the description below is for. - // Contrast `element:text_input.defaultValue`, whose `string | number` - // needs two arms (objectui#3832), and `emptyText` below, which declares - // two for the same reason once its render site learned to resolve both - // (objectui#5590). - type: 'array', - // Taken from what the renderer DOES with the key, because the one thing - // an author cannot read off the spec is which of the two places they may - // write a filter actually wins. - description: - 'Filter rules narrowing which records the picker offers, as a ViewFilterRule ARRAY — `[{ field: "status", operator: "equals", value: "open" }, …]`, the one filter orthography this map\'s array-declared `filter` doors share. It becomes the `$filter` of the picker\'s own query, so it decides which records exist for the user, not merely how they are shown. PRECEDENCE: a node-level `dataSource` binding wins outright. The renderer reads `dataSource.filter ?? filter`, so when the binding — or the saved view its `view` names, which AND-combine with each other because the spec calls the binding\'s filter *additional* — supplies a filter, THIS key is dropped entirely rather than merged into it; it applies only when the node carries no `dataSource`, or that `dataSource` and its view both leave `filter` unset. MEMBERS ARE RULE OBJECTS: the MongoDB-style record form (`{ status: "open" }`, `{ $and: [ … ] }`) was retired by objectstack#14406 and the spec now refuses it by kind, and a bare tuple (`["a", "=", 1]`) is refused as a member because each entry must be an object.', - }, { name: 'labelField', type: 'string' }, { name: 'valueField', type: 'string' }, { @@ -465,41 +423,6 @@ ComponentRegistry.register('record_picker', elementDataSourceBlock(ElementRecord description: 'Caption rendered above the picker, in a `
); } @@ -657,8 +657,11 @@ 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` are not inputs: the binding is -// the only place the metric reads them from (objectui#11880). Same shape as +// 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. ComponentRegistry.register('number', elementDataSourceBlock(ElementNumberRenderer), { @@ -667,8 +670,20 @@ ComponentRegistry.register('number', elementDataSourceBlock(ElementNumberRendere 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/components/src/renderers/basic/record-picker.tsx b/packages/components/src/renderers/basic/record-picker.tsx index 9d6b383f38..d100673a37 100644 --- a/packages/components/src/renderers/basic/record-picker.tsx +++ b/packages/components/src/renderers/basic/record-picker.tsx @@ -385,13 +385,67 @@ ComponentRegistry.register('record_picker', elementDataSourceBlock(ElementRecord skipFallback: true, label: 'Record Picker', category: 'input', - // The query keys `object` / `filter` / `sort` / `limit` are NOT inputs - // (objectui#11880): the picker reads them from the node-level `dataSource` - // binding only, which `elementDataSourceBlock` above declares through the - // injected `ELEMENT_DATA_SOURCE_INPUT`. Publishing the flat four would - // advertise keys the renderer drops — the spec retires them in v18 - // (objectstack#11509, ruled A-narrow). + // ⚠️ objectui#11880: the four query keys `object` / `filter` / `sort` / + // `limit` below stay PUBLISHED and are NOT READ. The picker reads its query + // from the node-level `dataSource` binding only (objectstack#11509, ruled + // A-narrow); the spec still declares the flat four, and their published + // retirement (tombstones, the `validate-component-props` refusal) ships with + // the spec half and the pin bump that carries it. Until then each + // description says the key is not read and names the binding member that is. + // The history below is why each was declared in the first place. + // + // `filter` is DECLARED, not merely honoured (objectui#3830) — the fourth key + // of objectui#3808's A class, which that issue's own three-way triage dropped + // between the raw key dump and the lists. The renderer read it then + // (`composed?.filter ?? props.filter`, into `query.$filter`), and the + // spec declares it (`ElementRecordPickerProps.filter`), but while it was + // missing from this list every layer that reads a manifest said the opposite: + // `element:record_picker` is not in `PUBLIC_BLOCKS` ("record picking is a + // field widget, not a page block"), so the gap was not in `sdui.manifest.json` + // — it was in the JSX-page compiler's prop whitelist, which + // `renderers/layout/page.tsx` builds from `getKnownTypes()` plus these same + // `inputs`. A JSX page writing `filter` got an `unknown-prop` warning from + // `sdui-parser/src/validate.ts` on a key the renderer then went on to filter + // by. That is objectui#3407 in the same shape as `readonly` — honoured, + // undiscoverable — and the reverse half of the parity gate in + // `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`, whose + // explicit exemption for this key is deleted by the same change. inputs: [ + { + name: 'object', + type: 'string', + description: + 'NOT READ (objectui#11880): the picker queries the object the node-level `dataSource.object` names, and a node without one offers no records. `@objectstack/spec` retires this flat key in v18 (objectstack#11509).', + }, + { + name: 'filter', + // `'array'` is the spec's shape, not a chosen arm. objectstack#14406 + // CONVERGED this key onto `z.array(ViewFilterRuleSchema)` — the last + // record-form `filter` in `ComponentPropsMap`, closing the maintainer's + // 2026-08-25 ruling that the platform carries one filter orthography + // (objectui#6206-B). It was `FilterConditionSchema` (the MongoDB-style + // `z.record(z.string(), z.unknown()).and(z.object({ $and, $or, $not }))`) + // until then, and this entry said `'object'` for exactly that reason. + // `checkType`'s `'array'` case in `sdui-parser/src/validate.ts` accepts + // what the spec now accepts here and rejects what it rejects — the record + // form included, which the spec refuses by KIND. Both halves are verified + // against `ElementRecordPickerPropsSchema.safeParse` in the parity test + // next to this file. So this stays the case in the family where the + // coarse vocabulary lines up with the contract exactly as declared: one + // arm, no union to spell. ⚠️ The array is a rule-OBJECT list, not a tuple + // list: `[['a', '=', 1]]` is refused at `filter.0`, and the coarse + // `'array'` cannot say so — which is what the description below is for. + // Contrast `element:text_input.defaultValue`, whose `string | number` + // needs two arms (objectui#3832), and `emptyText` below, which declares + // two for the same reason once its render site learned to resolve both + // (objectui#5590). + type: 'array', + // Taken from what the renderer DOES with the key: since objectui#11880 + // it reads `dataSource.filter` only, so the description says this key is + // not read and names the binding member that is. + description: + 'NOT READ (objectui#11880): the picker\'s filter is the node-level `dataSource.filter` — AND-combined with the filter of the saved view its `view` names, because the spec calls the binding\'s filter *additional* — and it becomes the `$filter` of the picker\'s own query, so it decides which records exist for the user. This flat key is not merged with it and never applies; `@objectstack/spec` retires it in v18 (objectstack#11509). Either door takes a ViewFilterRule ARRAY — `[{ field: "status", operator: "equals", value: "open" }, …]`, the one filter orthography this map\'s array-declared `filter` doors share; the MongoDB-style record form (`{ status: "open" }`, `{ $and: [ … ] }`) was retired by objectstack#14406, and a bare tuple (`["a", "=", 1]`) is refused as a member because each entry must be an object.', + }, { name: 'labelField', type: 'string' }, { name: 'valueField', type: 'string' }, { @@ -423,6 +477,41 @@ ComponentRegistry.register('record_picker', elementDataSourceBlock(ElementRecord description: 'Caption rendered above the picker, in a `