diff --git a/.changeset/11880-element-datasource-only.md b/.changeset/11880-element-datasource-only.md new file mode 100644 index 0000000000..8321a1e5fe --- /dev/null +++ b/.changeset/11880-element-datasource-only.md @@ -0,0 +1,13 @@ +--- +'@object-ui/components': patch +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +`element:record_picker` and `element:number` bind data through the node-level `dataSource` only (objectui#11880, part of the objectui half of objectstack#11509, ruled A-narrow). + +- Neither element reads the flat `properties.object` / `filter` / `sort` / `limit` beside the binding any more: the `composed?.x ?? props.x` fallbacks are gone, and `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 flat keys stay published until `@objectstack/spec` retires them in v18; each input's description now says it is not read and names the binding member that is. +- The Studio page designer writes `element:number`'s object to `dataSource.object` at node level, and its measure picker reads the object from there. A stored `properties.object` stays visible in the inspector's Advanced section. +- `element.number.noObject` now names only `dataSource.object`, in all ten language packs. + +Breaking for an author who wrote the flat keys on these two elements: move them into `dataSource`. `element:repeater` is unchanged. diff --git a/apps/console/src/__tests__/filterContextTokensSweep-10666.test.tsx b/apps/console/src/__tests__/filterContextTokensSweep-10666.test.tsx index 962494d19a..00bce375a8 100644 --- a/apps/console/src/__tests__/filterContextTokensSweep-10666.test.tsx +++ b/apps/console/src/__tests__/filterContextTokensSweep-10666.test.tsx @@ -190,6 +190,13 @@ const element = (base: { type: string; id: string; properties: Record }) => (filter: Filter) => ({ + ...base, + dataSource: { object: OBJECT, filter }, +}); const INLINE = { provider: 'value', items: [{ ...ROW }] }; @@ -229,11 +236,11 @@ const ROWS: NodeRow[] = [ { node: 'element:repeater', shape: 'tuple', path: 'dataSource', filterParam: '$filter', withFilter: element({ type: 'element:repeater', id: 'rep', properties: { object: OBJECT, fields: ['name'] } }) }, { node: 'element:number (aggregate)', shape: 'tuple', path: 'dataSource', filterParam: 'filter', aggregate: true, - withFilter: element({ type: 'element:number', id: 'num', properties: { object: OBJECT, aggregate: 'count' } }) }, + withFilter: bound({ type: 'element:number', id: 'num', properties: { aggregate: 'count' } }) }, { node: 'element:number (find fallback)', shape: 'tuple', path: 'dataSource', filterParam: '$filter', aggregate: false, - withFilter: element({ type: 'element:number', id: 'num', properties: { object: OBJECT, aggregate: 'count' } }) }, + withFilter: bound({ type: 'element:number', id: 'num', properties: { aggregate: 'count' } }) }, { node: 'element:record_picker', shape: 'rule', path: 'dataSource', filterParam: '$filter', - withFilter: element({ type: 'element:record_picker', id: 'rp', properties: { object: OBJECT } }) }, + withFilter: bound({ type: 'element:record_picker', id: 'rp', properties: {} }) }, // Regression rows (objectui#10607, objectui#10666 part 1). { node: 'object-gallery (regression)', shape: 'tuple', path: 'dataSource', filterParam: '$filter', withFilter: top({ type: 'object-gallery', objectName: OBJECT, gallery: { titleField: 'name' } }) }, 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 0205333997..352c9908a6 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -2772,15 +2772,15 @@ const MEMBER_PINS: Record = { }, 'element:number.dataSource': { 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 OUTRANKS `properties.object` (`dataSource.object ?? properties.object`, one value for the fetch guard, `aggregate` / `find` and the `useDataInvalidation` key — a bus event for the outranked flat object does not re-read, one for the bound object does), and while a named `view` is unresolved there is NO object, so a flat `properties.object` beside an unresolvable view aggregates nothing; a named `view`\'s `filter` scopes the aggregate, and an unresolvable `view` REPORTS and aggregates nothing; `filter` is AND-combined with `properties.filter` — the `ElementDataSourceGate` rule, lowered and merged the same way — so a binding filter and the flat one both reach the aggregate, a view filter and the flat one both reach it, and a merge 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 object stands, and the `properties` form with no binding is the control. ⚠️ 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).', + 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. `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).', + 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: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 leaves every one of the four flat keys (`object`/`filter`/`sort`/`limit`) behaving exactly as it did before a `dataSource` was authored at all, which is the control that keeps the override rows from reading as a coincidence. `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).', + 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).', }, 'element:record_picker.emptyText': { file: 'apps/console/src/__tests__/component-input-union-specimens.test.ts', @@ -2788,7 +2788,7 @@ const MEMBER_PINS: Record = { }, 'element:record_picker.filter': { file: 'packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts', - pins: 'The `array` arm is `ViewFilterRuleSchema[]` — the one filter orthography this map\'s array-declared `filter` doors share — with the MongoDB-style RECORD spelling (`{ status }`, `{ $and }`) refused by kind at `filter` and a bare tuple refused at `filter.0`, and the description pinned to the renderer\'s own precedence read (`composed?.filter ?? props.filter`). objectui#3830 declared the key; objectstack#14406 converged it off the record form and objectui#7663 moved this side onto the array arm.', + pins: 'The `array` arm is `ViewFilterRuleSchema[]` — the one filter orthography this map\'s array-declared `filter` doors share — with the MongoDB-style RECORD spelling (`{ status }`, `{ $and }`) refused by kind at `filter` and a bare tuple refused at `filter.0`, and the description pinned to say the key is NOT READ and to name `dataSource.filter`, the one filter the renderer reads since objectui#11880 (`composed?.filter`). objectui#3830 declared the key; objectstack#14406 converged it off the record form and objectui#7663 moved this side onto the array arm; it stays published until the spec\'s v18 retirement (objectstack#11509).', }, 'element:record_picker.label': { file: 'packages/components/src/renderers/basic/__tests__/record-picker-label-placeholder-i18n.test.tsx', @@ -2800,7 +2800,7 @@ const MEMBER_PINS: Record = { }, 'element:record_picker.sort': { file: 'packages/components/src/__tests__/record-picker-sort-members.test.tsx', - pins: 'An IDENTITY pin, unlike the sibling block\'s: `record-picker.tsx` assigns `query.$orderby = sort` with no `normalizeSortSpec` in between, so a member missing `field` is NOT dropped the way `record:related_list.sort` (objectui#8071 slice 2) drops one — pinned as the current, undocumented divergence between two blocks whose registrations both say only "array of objects". Order is preserved for a multi-member array, no `$orderby` is sent when the key is absent, and PRECEDENCE against `dataSource` is pinned in both directions: a binding that declares its own `sort` replaces the flat key outright, and the flat key still applies when the binding carries none — `composed?.sort ?? props.sort`, identical to `filter`\'s read (objectui#8071).', + pins: 'An IDENTITY pin, unlike the sibling block\'s: `record-picker.tsx` assigns `query.$orderby = sort` with no `normalizeSortSpec` in between, so a member missing `field` is NOT dropped the way `record:related_list.sort` (objectui#8071 slice 2) drops one — pinned as the current, undocumented divergence between two blocks whose registrations both say only "array of objects". Order is preserved for a multi-member array and no `$orderby` is sent when the key is absent. Since objectui#11880 the read is `composed?.sort` alone, so these rows drive the binding\'s `dataSource.sort`, and the flat `sort` — still published, its description saying NOT READ — is pinned unread both beside a binding that declares its own `sort` and beside one that carries none (objectui#8071, objectui#11880).', }, 'element:text.content': { file: 'apps/console/src/__tests__/component-input-union-specimens.test.ts', diff --git a/content/docs/guide/data-source.md b/content/docs/guide/data-source.md index 121f987730..2688c8b17b 100644 --- a/content/docs/guide/data-source.md +++ b/content/docs/guide/data-source.md @@ -303,20 +303,23 @@ 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 two `element:*` rows keep their display configuration 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`, `{ "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: set +dataSource.object." notice instead of a count, and an `element:record_picker` +offers no records. The Studio page designer writes the metric's object into +`dataSource`. #### Scoping a filter to the record in view: `{record_id}` diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.elementDataSource-11880.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.elementDataSource-11880.test.tsx new file mode 100644 index 0000000000..1f02ff399f --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.elementDataSource-11880.test.tsx @@ -0,0 +1,118 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11880 — the page designer writes `element:number`'s data binding + * into the node-level `dataSource`, the one place the renderer reads it. + * + * `element:number` reads its object from `dataSource` only (objectstack#11509, + * ruled A-narrow). The inspector's curated Object picker wrote + * `properties.object` — a key the renderer no longer reads, so a metric built + * in Studio would show its no-object notice. These rows drive the REAL + * inspector and assert the patch it hands the editor: the value lands at node + * level under `dataSource`, never under `properties`, and the measure picker + * resolves its object from `dataSource.object`. (`element:repeater` is not in + * this change: its binding is deferred, objectui#11880.) + */ + +import { describe, it, expect, vi, afterEach, type Mock } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { PageSchema } from '@objectstack/spec/ui'; +import { PageBlockInspector } from './PageBlockInspector'; +import type { MetadataInspectorProps } from '../inspector-registry'; + +// The field catalog hook, observed: which object each field picker asked for. +const fieldsFor = vi.hoisted(() => vi.fn((_objectName: string | undefined) => undefined)); +vi.mock('../previews/useObjectFields', () => ({ + useObjectFields: (objectName: string | undefined) => { + fieldsFor(objectName); + return { + fields: objectName ? [{ name: 'email', label: 'Email', type: 'text', hidden: false }] : [], + loading: false, + error: null, + }; + }, +})); +// No object catalog: the Object picker falls back to its labelled text box. +vi.mock('../useMetadata', () => ({ + useMetadataClient: () => ({ get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]) }), +})); + +afterEach(() => { + cleanup(); + fieldsFor.mockClear(); +}); + +const BLOCK_PATH = 'regions[0].components[0]'; + +/** Author a page the way a user does, parse it with the spec, and hand the RESULT to the inspector. */ +function pageDraft(block: Record): Record { + return PageSchema.parse({ + name: 'home', + label: 'Home', + type: 'home', + template: 'default', + regions: [{ name: 'main', components: [{ id: 'b1', ...block }] }], + }) as unknown as Record; +} + +function renderInspector(draft: Record): Mock { + const onPatch: Mock = vi.fn(); + render( + {}} + readOnly={false} + locale="en-US" + />, + ); + return onPatch; +} + +/** The block as the inspector last wrote it (the patch is shallow: `{ regions }`). */ +const committedBlock = (onPatch: Mock): Record => + (onPatch.mock.calls.at(-1)![0] as any).regions[0].components[0]; + +describe('the designer writes the node-level `dataSource` for element:number (objectui#11880)', () => { + it('the Object picker writes `dataSource.object`, not `properties.object`', () => { + const onPatch = renderInspector(pageDraft({ type: 'element:number', properties: {} })); + fireEvent.change(screen.getByLabelText('Object'), { target: { value: 'contact' } }); + const block = committedBlock(onPatch); + expect(block.dataSource).toEqual({ object: 'contact' }); + // The parsed page's empty bag, untouched: no `properties.object`. + expect(block.properties).toEqual({}); + }); + + it('CONTROL: a display control on the same block still writes `properties`, beside the binding', () => { + const onPatch = renderInspector(pageDraft({ type: 'element:number', dataSource: { object: 'contact' } })); + fireEvent.change(screen.getByLabelText('Prefix'), { target: { value: '$' } }); + const block = committedBlock(onPatch); + expect(block.properties).toEqual({ prefix: '$' }); + expect(block.dataSource).toEqual({ object: 'contact' }); + }); + + it('CONTROL: element:repeater, outside this change, still writes `properties.object`', () => { + const onPatch = renderInspector(pageDraft({ type: 'element:repeater', properties: {} })); + fireEvent.change(screen.getByLabelText('Object'), { target: { value: 'contact' } }); + const block = committedBlock(onPatch); + expect(block.properties).toEqual({ object: 'contact' }); + expect(block.dataSource).toBeUndefined(); + }); + + it('the measure picker reads its object from `dataSource.object`, not a flat `properties.object`', () => { + renderInspector(pageDraft({ type: 'element:number', dataSource: { object: 'contact' }, properties: { object: 'lead' } })); + const asked = new Set(fieldsFor.mock.calls.map((call) => call[0])); + expect(asked).toContain('contact'); + expect(asked).not.toContain('lead'); + }); + + it('the Object picker shows the bound object, and a stored flat `properties.object` stays visible under Advanced', () => { + renderInspector(pageDraft({ type: 'element:number', dataSource: { object: 'contact' }, properties: { object: 'lead' } })); + expect((screen.getByLabelText('Object') as HTMLInputElement).value).toBe('contact'); + // The Advanced editor labels a generic property by its key. + expect((screen.getByLabelText('object') as HTMLInputElement).value).toBe('lead'); + }); +}); 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/__tests__/block-config-schema-parity-8216.test.ts b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts index d9ff23d971..088df3baa6 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts @@ -78,6 +78,16 @@ * every value by name. `@object-ui/test-support`'s `isShapeKeyTombstoned` * is the shared judge (objectui#3809 / objectui#4947). * + * ## A control homed in the `dataSource` binding is judged by the binding + * + * A field marked `at: 'dataSource'` (objectui#11880) writes the node-level + * `dataSource.`, not `properties.`, so the block's props row is the + * wrong oracle for it: it is judged against the spec's `ElementDataSourceSchema` + * — the schema `PageComponentSchema.dataSource` is — on the SPEC face, and is + * not judged by the block's props on either face. Judging it at the props row + * would read a binding control as a retired flat key the day the spec + * tombstones `element:number.object`, which is exactly the move it made. + * * The REQUIRED direction — a schema-required key with no control — is measured * and reported on objectui#8216, and deliberately NOT gated here: objectui#7772's * triage records "an inspector need not expose every declared key" as a product @@ -98,7 +108,7 @@ */ import { describe, it, expect } from 'vitest'; -import { ComponentPropsMap } from '@objectstack/spec/ui'; +import { ComponentPropsMap, ElementDataSourceSchema } from '@objectstack/spec/ui'; import * as objectUiZod from '@object-ui/types/zod'; import { arrayElementSchema, @@ -240,8 +250,13 @@ function judge(schema: unknown, name: string): Omit | undefined function census(): Violation[] { const found: Violation[] = []; for (const [blockType, fields] of Object.entries(BLOCK_CONFIG)) { + // Binding controls are judged by the binding's own schema, once. + for (const field of fields.filter((f) => f.at === 'dataSource')) { + const bound = judge(ElementDataSourceSchema, field.name); + if (bound) found.push({ id: `${blockType}::dataSource.${field.name}@spec`, ...bound }); + } for (const { face, schema } of oraclesFor(blockType)) { - for (const field of fields) { + for (const field of fields.filter((f) => f.at !== 'dataSource')) { const top = judge(schema, field.name); if (top) found.push({ id: `${blockType}::${field.name}@${face}`, ...top }); @@ -325,6 +340,17 @@ describe('BLOCK_CONFIG ↔ node-schema parity — the instruments (objectui#8216 expect(judge(NODE_ORACLES['object-kanban'], 'groupBy')).toBeUndefined(); }); + it('the binding oracle can say no — and yes — for a control homed in `dataSource` (objectui#11880)', () => { + expect(judge(ElementDataSourceSchema, 'object')).toBeUndefined(); + expect(judge(ElementDataSourceSchema, 'limit')).toBeUndefined(); + expect(judge(ElementDataSourceSchema, 'titleField')?.kind).toBe('MISSING'); + // The population is real: element:number's Object picker lives there. + const homed = Object.entries(BLOCK_CONFIG).flatMap(([type, fields]) => + fields.filter((f) => f.at === 'dataSource').map((f) => `${type}.${f.name}`), + ); + expect(homed).toContain('element:number.object'); + }); + it('the SPEC face really refuses an undeclared key by name, not silently', () => { // The MISSING verdict above is a key-set read. This is the refusal it stands // for, measured through the parser — so a schema that quietly became 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..bcfb9f8cd5 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: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. */ @@ -328,9 +343,14 @@ export const BLOCK_CONFIG: Record = { { 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 metric's query is the node-level `dataSource` + // binding, the only place `element:number` reads its object from, so the + // Object picker writes `dataSource.object` and the measure picker reads its + // object from there. It wrote `properties.object` before: a key the renderer + // no longer reads and the spec retires in v18. '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/__tests__/record-picker-element-data-source.test.tsx b/packages/components/src/__tests__/record-picker-element-data-source.test.tsx index 2c0503c456..f064a0c81d 100644 --- a/packages/components/src/__tests__/record-picker-element-data-source.test.tsx +++ b/packages/components/src/__tests__/record-picker-element-data-source.test.tsx @@ -22,6 +22,7 @@ import { describe, it, expect, vi } from 'vitest'; import { render, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; import React from 'react'; import { AdapterCtx, SchemaRenderer } from '@object-ui/react'; // Registers `element:record_picker` at module scope (not in a hook) — the @@ -141,9 +142,35 @@ describe('element:record_picker — dataSource.view (objectstack#6953)', () => { expect(adapter.getObjectSchema).not.toHaveBeenCalled(); }); - it('still honours the flat `properties.object` shorthand', async () => { + // objectui#11880 — the binding is the picker's ONE source. Until then a flat + // `properties.object` / `filter` / `sort` / `limit` stood in for whichever + // binding member was absent (`composed?.x ?? props.x`); objectstack#11509 + // retires those keys, and the picker reads none of them. + it('a flat `properties.object` alone binds nothing: no query, and the control is disabled', async () => { const adapter = makeAdapter(); - renderPicker({ properties: { object: 'account' } }, adapter); - await waitFor(() => expect(adapter.find).toHaveBeenCalledWith('account', expect.any(Object))); + const { container } = renderPicker({ properties: { object: 'account' } }, adapter); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(adapter.find).not.toHaveBeenCalled(); + expect(container.querySelector('[data-testid="record-picker-trigger"]')).toBeDisabled(); + }); + + it('beside a binding, the flat `filter` / `sort` / `limit` are not read', async () => { + const adapter = makeAdapter(); + renderPicker( + { + dataSource: { object: 'account' }, + properties: { + object: 'lead', + filter: [{ field: 'owner', operator: 'equals', value: 'me' }], + sort: [{ field: 'name', order: 'asc' }], + limit: 12, + }, + }, + adapter, + ); + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + expect(adapter.find.mock.calls[0][0]).toBe('account'); + // Only the renderer's own default cap: no flat key reaches the query. + expect(firstQuery(adapter)).toEqual({ $top: 50 }); }); }); diff --git a/packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts b/packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts index e53264e8a3..593b8145ca 100644 --- a/packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts +++ b/packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts @@ -26,6 +26,14 @@ * page — a warning against a key the renderer then filtered the picker's whole * candidate set by. The last test in this file is that path, end to end. * + * ⚠️ objectui#11880: the flat `filter` (with `object`, `sort` and `limit`) + * stays PUBLISHED and is NOT READ. The picker reads its query from the + * node-level `dataSource` binding only (objectstack#11509, ruled A-narrow); the + * pinned spec still declares the flat keys, and their published retirement + * ships with the spec half and the pin bump that carries it. So the parity + * rows below still hold, and the description rows pin that the published + * text says the key is not read and names the binding member that is. + * * Expectations are derived from the spec at runtime, not restated. */ @@ -119,7 +127,7 @@ describe('element:record_picker — registry inputs vs @objectstack/spec', () => expect(specTopLevelKeys().length).toBeGreaterThan(0); }); - it('publishes `filter`, which the renderer has read all along', () => { + it('publishes `filter` while the pinned spec declares it (not read since objectui#11880)', () => { // A KEY-reachability claim, so the criterion is that the key SURVIVES the // parse — not that the parse succeeds. Neither refusal mode makes // `success === true` proof on its own: under rc.6's strip mode an @@ -244,18 +252,16 @@ describe('element:record_picker — registry inputs vs @objectstack/spec', () => expect(codesFor('[["a","=",1]]')).toEqual([]); }); - it('the `filter` description says which of the two filters an author writes wins', () => { - // The renderer reads `composed?.filter ?? props.filter`, so a node that - // carries a `dataSource` filter DROPS this key rather than combining with - // it — while the binding's own filter AND-combines with the saved view it - // names. A description saying only "filter criteria" would be true and - // useless: an author writing both would have no way to know which one - // decides the candidate set, which is the one thing objectui#3830 insists - // this entry has to state. + it('the `filter` description says the key is not read, and names the binding member that is (objectui#11880)', () => { + // The renderer reads `composed?.filter` only, so this key never applies — + // the one thing an author cannot read off the spec, which still declares + // it. objectui#3830 insisted the entry state which filter decides the + // candidate set; since objectui#11880 the answer is the binding's. const description = filterDescription(); - expect(description).toMatch(/dataSource/); - expect(description).toMatch(/precedence/i); + expect(description).toMatch(/NOT READ/); + expect(description).toMatch(/dataSource\.filter/); expect(description).toMatch(/\$filter/); + expect(description).not.toMatch(/precedence/i); }); it('the `limit` description states the composer\'s precedence, not the pre-objectui#10016 one (objectui#10399)', () => { @@ -275,6 +281,8 @@ describe('element:record_picker — registry inputs vs @objectstack/spec', () => // The fall-through itself, by concept rather than by sentence. expect(description).toMatch(/dataSource\.view/); expect(description).toMatch(/not authored/i); + // objectui#11880: this key is not among the sources any more. + expect(description).toMatch(/NOT READ/); }); it('declares no default for `filter` — the spec parses none in', () => { @@ -320,9 +328,10 @@ describe('element:record_picker — registry inputs vs @objectstack/spec', () => expect(unknownProps.join(' | ')).toMatch(/searchFields/); expect(unknownProps.join(' | ')).not.toMatch(/"filter"/); - // And the key survives into the compiled tree as itself — the whole point of - // publishing it is that the author's `filter` reaches the renderer, which - // turns it into the picker query's `$filter`. + // And the key survives into the compiled tree as itself. Since + // objectui#11880 the renderer does not read it (the binding's + // `dataSource.filter` is the picker's `$filter`); the key stays accepted + // until the spec's v18 retirement refuses it. expect(r.tree).toMatchObject({ type: TYPE, filter: RULE_ARRAY }); expect(r.diagnostics.some((d) => d.severity === 'error')).toBe(false); }); diff --git a/packages/components/src/__tests__/record-picker-sort-members.test.tsx b/packages/components/src/__tests__/record-picker-sort-members.test.tsx index 802251c633..d646691a95 100644 --- a/packages/components/src/__tests__/record-picker-sort-members.test.tsx +++ b/packages/components/src/__tests__/record-picker-sort-members.test.tsx @@ -5,45 +5,34 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * `element:record_picker.sort` — the MEMBER shape the renderer reads - * (objectui#8068 / objectui#8071 slice 3). + * `element:record_picker`'s `sort` — the MEMBER shape the renderer reads + * (objectui#8068 / objectui#8071 slice 3), on the one door it is read through + * since objectui#11880: the node-level `dataSource` binding's `sort`. * - * The registration declares `{ name: 'sort', type: 'array', of: 'object' }` — - * `of` names a coarse MEMBER KIND and nothing finer (objectui#8067) — so the - * published surface says "an array of objects" and stops there. What actually - * reaches the wire is answered here, against the read site, not restated from - * the declaration. + * The flat `properties.sort` was a registered input (`type: 'array', of: + * 'object'`) read as `composed?.sort ?? props.sort`. objectstack#11509 (ruled + * A-narrow) retires it with the other flat query keys, and the picker now + * reads `dataSource.sort` only: the flat key stays published until the spec's + * v18 retirement ships, and is not read. The member contract is unchanged and + * is pinned below on the binding. * * ## Where the read happens, and why it is an IDENTITY pin * * `record-picker.tsx`: * - * const sort = composed?.sort ?? props.sort; + * const sort = composed?.sort; * … * if (sort) query.$orderby = sort; * * There is no `normalizeSortSpec` here, unlike `record:related_list.sort` - * (objectui#8071 slice 2) — the array authored on `sort` is assigned to - * `$orderby` BY REFERENCE, unfiltered and untransformed. That is the sharp edge - * this file exists to pin: a malformed member (missing `field`) is NOT dropped - * the way the sibling block drops one, because nothing here inspects a member - * at all. Declaring `of: 'object'` says a member is an object; it says nothing - * about whether the renderer looks inside one, and for this block it does not. + * (objectui#8071 slice 2) — the array authored on `dataSource.sort` reaches + * `$orderby` BY REFERENCE (`composeElementDataSource` passes `config.sort` + * through), unfiltered and untransformed. A malformed member (missing `field`) + * is NOT dropped the way the sibling block drops one, because nothing here + * inspects a member at all. * - * ## The other half — precedence against `dataSource` - * - * Identical to `filter`'s PRECEDENCE, stated in that key's own description: - * `composed?.sort ?? props.sort`, so a node-level `dataSource` binding (or the - * saved view its `view` names) REPLACES this key outright rather than merging - * with it, and the flat key applies only when the node carries no `dataSource`, - * or that `dataSource` and its view both leave `sort` unset - * (`composeElementDataSource`: `config.sort ?? view?.sort`). Both directions are - * pinned, because a renderer that always preferred one source would satisfy - * only one of them. - * - * No adapter's `getObjectSchema` is needed: every case below either omits - * `dataSource` or names no `view`, so the saved-view fetch never engages — - * matching the disposition `record-picker-element-data-source.test.tsx` already + * No adapter's `getObjectSchema` is needed: no case below names a `view`, so + * the saved-view fetch never engages — matching the disposition `record-picker-element-data-source.test.tsx` already * established for the `view`-bearing cases of this same block. */ @@ -62,11 +51,10 @@ const makeAdapter = () => ({ }); /** - * `object` / `filter` / `sort` / `limit` / `label` / `placeholder` live under + * `label` / `placeholder` and the other display keys live under * `schema.properties` (the spec's element config bag, `readProps` above); the - * per-element `dataSource` BINDING is a sibling top-level schema key, read - * directly off `schema.dataSource` by `useElementDataSource`. Getting the two - * confused is the one mistake that would make every case below fetch nothing. + * per-element `dataSource` BINDING — the query — is a sibling top-level schema + * key, read directly off `schema.dataSource` by `useElementDataSource`. */ const renderPicker = ( properties: Record, @@ -90,11 +78,11 @@ const renderPicker = ( /** The `$orderby` this block put on the wire, or `undefined` when it sent none. */ async function orderbyOf( - properties: Record, - dataSource?: Record, + binding: Record, + properties: Record = {}, ): Promise { const adapter = makeAdapter(); - renderPicker({ object: 'account', ...properties }, adapter, dataSource); + renderPicker(properties, adapter, { object: 'account', ...binding }); await waitFor(() => expect(adapter.find).toHaveBeenCalled()); return (adapter.find.mock.calls[0] as any[])[1].$orderby; } @@ -102,39 +90,33 @@ async function orderbyOf( const input = (name: string) => ComponentRegistry.getConfig('element:record_picker')?.inputs?.find((i) => i.name === name); -describe('element:record_picker — the `sort` MEMBER shape the renderer reads (objectui#8068)', () => { - it('CONTROL — sort is declared as an array of OBJECTS, and the declaration stops there', () => { - // Calibration for everything below: this pin exists BECAUSE the published - // surface names the member kind and nothing else. If a future change adds a - // description or narrows `of`, this stays true — it is the read site below - // that would then need re-reading, not this row. - expect(input('sort')).toBeDefined(); +describe('element:record_picker — the `sort` MEMBER shape the renderer reads (objectui#8068, objectui#11880)', () => { + it('CONTROL — the flat `sort` is still declared as an array of OBJECTS, and says it is not read', () => { + // Published until the spec's v18 retirement (objectstack#11509); the + // description is what tells an author the binding is the door. expect(input('sort')?.type).toBe('array'); expect(input('sort')?.of).toBe('object'); + expect(input('sort')?.description).toMatch(/NOT READ/); + expect(input('dataSource')).toBeDefined(); }); - it('SUBJECT — an authored array reaches $orderby BY IDENTITY, not a copy', async () => { - // `toBe`, not `toEqual`: `query.$orderby = sort` is a reference assignment, - // so a fix that started cloning or lowering the array would turn this red - // even though `toEqual` would still pass. + it('SUBJECT — an authored `dataSource.sort` reaches $orderby BY IDENTITY, not a copy', async () => { + // `toBe`, not `toEqual`: the binding's array is assigned by reference, so + // a fix that started cloning or lowering it would turn this red even + // though `toEqual` would still pass. const sort = [{ field: 'name', order: 'asc' }]; - const adapter = makeAdapter(); - renderPicker({ object: 'account', sort }, adapter); - await waitFor(() => expect(adapter.find).toHaveBeenCalled()); - expect((adapter.find.mock.calls[0] as any[])[1].$orderby).toBe(sort); + expect(await orderbyOf({ sort })).toBe(sort); }); - it('resolves the flat `object` shorthand the same way (reachability before absence)', async () => { + it('the bound object reaches the query (reachability before absence)', async () => { // Without this, every case below that never checks `object` reaching the // query could be satisfied by a picker that never queried at all. const adapter = makeAdapter(); - renderPicker({ object: 'account' }, adapter); + renderPicker({}, adapter, { object: 'account' }); await waitFor(() => expect(adapter.find).toHaveBeenCalledWith('account', expect.any(Object))); }); it('keeps multi-member order, in the order authored', async () => { - // A reader who only saw the identity assertion above could still believe a - // reordering read the "same" array. This is the row that would catch it. const sort = [ { field: 'stage', order: 'asc' }, { field: 'created', order: 'desc' }, @@ -145,38 +127,25 @@ describe('element:record_picker — the `sort` MEMBER shape the renderer reads ( it("passes a member missing `field` through UNFILTERED — the block does NOT drop it", async () => { // The declared behavioural difference from `record:related_list.sort` // (objectui#8071 slice 2), which drops such a member silently via - // `normalizeSortSpec`'s `.filter((s) => !!s?.field)`. This block has no - // equivalent filter, so the malformed member survives alongside its - // well-formed sibling — pinned as the current behaviour it is, because - // `of: 'object'` alone gives an author no way to discover which sibling - // block validates a member and which does not. + // `normalizeSortSpec`'s `.filter((s) => !!s?.field)`. const sort = [{ order: 'desc' }, { field: 'created', order: 'desc' }]; expect(await orderbyOf({ sort })).toEqual(sort); }); it('sends NO $orderby when `sort` is absent, rather than an empty clause', async () => { - // Non-vacuity for the identity/order rows above: without this, a renderer - // that always sent a hard-coded `$orderby` could still satisfy them by - // coincidence on the one case that names a real array. expect(await orderbyOf({})).toBeUndefined(); }); - it('a `dataSource` binding that declares its own `sort` REPLACES the flat key outright', async () => { - // PRECEDENCE, direction 1. The flat `sort` is authored too, so a renderer - // that merged rather than replaced — or that read the flat key first — - // could not pass this row. + it('a flat `properties.sort` is not read beside a binding that carries its own `sort`', async () => { const dsSort = [{ field: 'amount', order: 'desc' }]; const flatSort = [{ field: 'name', order: 'asc' }]; - expect(await orderbyOf({ sort: flatSort }, { object: 'account', sort: dsSort })).toBe(dsSort); + expect(await orderbyOf({ sort: dsSort }, { sort: flatSort })).toBe(dsSort); }); - it('the flat `sort` still applies when the `dataSource` binding carries none', async () => { - // PRECEDENCE, direction 2 — the complement the row above cannot exercise. A - // `dataSource` is present (so a renderer gating on "dataSource present ⇒ - // ignore the flat key entirely" would fail here), but it names no `sort` and - // no `view`, so `composed.sort` is `undefined` and `composed?.sort ?? - // props.sort` must fall through to the flat key. + it('…nor beside a binding that carries none: no $orderby at all (objectui#11880)', async () => { + // The row that used to pin the fall-through to the flat key + // (`composed?.sort ?? props.sort`), inverted by the retirement. const flatSort = [{ field: 'name', order: 'asc' }]; - expect(await orderbyOf({ sort: flatSort }, { object: 'account' })).toBe(flatSort); + expect(await orderbyOf({}, { sort: flatSort })).toBeUndefined(); }); }); diff --git a/packages/components/src/renderers/basic/__tests__/element-number.contractEnvelope-6726.test.tsx b/packages/components/src/renderers/basic/__tests__/element-number.contractEnvelope-6726.test.tsx index fd70e1e1e2..e999bda906 100644 --- a/packages/components/src/renderers/basic/__tests__/element-number.contractEnvelope-6726.test.tsx +++ b/packages/components/src/renderers/basic/__tests__/element-number.contractEnvelope-6726.test.tsx @@ -58,9 +58,10 @@ function mount(envelope: Envelope) { { type: 'element:number', id: 'metric', - // Element config lives in the `properties` bag (`readProps`), not - // on the node — the same door an authored page writes through. - properties: { object: 'contact', aggregate: 'count' }, + // Display config lives in the `properties` bag (`readProps`); the + // query is the node-level `dataSource` binding (objectui#11880). + dataSource: { object: 'contact' }, + properties: { aggregate: 'count' }, } as never } /> 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 b73cdef81a..b9dabfc3d1 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 @@ -25,20 +25,22 @@ * * | member | read? | how | * |----------|-------|------------------------------------------------------------| - * | `object` | yes | `dataSource.object ?? properties.object` — ONE value for | - * | | | the fetch guard, `aggregate` / `find` and the bus key; no | - * | | | object at all while a named view is unresolved | + * | `object` | yes | `dataSource.object` ONLY — ONE value for the fetch guard, | + * | | | `aggregate` / `find` and the bus key; no object at all | + * | | | while a named view is unresolved (objectui#11880) | * | `view` | yes | a saved view's `filter` scopes the aggregate; one that | * | | | cannot be resolved REPORTS and aggregates nothing | - * | `filter` | yes | `properties.filter` AND (view AND binding filter) — the | - * | | | `ElementDataSourceGate` rule: neither is dropped, and a | - * | | | refused merge REPORTS and aggregates nothing | + * | `filter` | yes | view AND binding filter, lowered; a refused one REPORTS | + * | | | and aggregates nothing. `properties.filter` is not read | + * | | | (objectui#11880) | * | `sort` | no | an aggregate has no ordering — never reaches the call | * | `limit` | no | an aggregate has no row cap — a capped count is a wrong | * | | | number, so neither the binding's nor a view's cap reaches | * | | | the call | * - * A metric with no binding at all is the control: it behaves exactly as before. + * Since objectui#11880 (objectstack#11509, ruled A-narrow) the binding is the + * metric's ONE source: the flat `properties.object` / `filter` are read by + * nothing, and a metric with no binding shows its no-object state. * No record-context binding is added here — that is objectui#7297's, and the * filter shape note is objectui#8945's. * @@ -76,23 +78,19 @@ const BINDING_FILTER = [{ field: 'owner', operator: 'equals', value: 'ada' }]; const PROPS_FILTER = [{ field: 'region', operator: 'equals', value: 'emea' }]; /** * A rule the converter refuses: an ARRAY comparand on single-valued `equals` - * (objectui#8557). Written as `properties.filter` beside a binding, it is the - * merge's own refusal, not the view's. + * (objectui#8557). Written as the binding's own `filter`, it is the lowering's + * refusal, not the view's. */ const REFUSED_FILTER = [{ field: 'tags', operator: 'equals', value: ['a'] }]; /** * The wire shapes, written out rather than computed with the renderer's own - * helpers, so a pin cannot agree with an implementation by construction. With - * a binding present, every filter source is lowered to the ObjectQL AST and - * the survivors are AND-combined, each as its own child — the - * `ElementDataSourceGate` merge. With NO binding, `properties.filter` reaches - * the adapter exactly as authored (the control rows, and - * `elementNumberFilterMembers-8071.test.tsx`). + * helpers, so a pin cannot agree with an implementation by construction. The + * binding's filter (AND-combined with its view's) is lowered to the ObjectQL + * AST before it reaches the adapter. */ const HOT_NODE = [['status', 'equals', 'hot']]; const BINDING_NODE = [['owner', 'equals', 'ada']]; -const PROPS_NODE = [['region', 'equals', 'emea']]; /** An adapter that CAN aggregate — the primary path. */ function makeAdapter() { @@ -126,7 +124,7 @@ const BOUND = { properties: { aggregate: 'count' }, }; -/** The `properties` form — the control. */ +/** The flat `properties` form — read by nothing since objectui#11880. */ const FLAT = { type: 'element:number', id: 'flat', @@ -146,7 +144,7 @@ describe('element:number reads its object from `dataSource.object` (objectui#109 expect(screen.queryByText('—')).toBeNull(); }); - it('dataSource.object wins over properties.object when both are set', async () => { + it('a flat properties.object beside the binding is not read: only the bound object is aggregated', async () => { const adapter = makeAdapter(); mount({ ...BOUND, properties: { object: 'account', aggregate: 'count' } }, adapter); await waitFor(() => expect(adapter.aggregate).toHaveBeenCalledTimes(1)); @@ -154,12 +152,13 @@ describe('element:number reads its object from `dataSource.object` (objectui#109 expect(adapter.aggregate.mock.calls.map((call) => call[0])).toEqual(['contact']); }); - it('control: the properties form is unchanged', async () => { + it('the flat properties form alone binds nothing: the no-object state, and no read (objectui#11880)', async () => { const adapter = makeAdapter(); mount(FLAT, adapter); - await waitFor(() => expect(adapter.aggregate).toHaveBeenCalledTimes(1)); - expect(adapter.aggregate).toHaveBeenCalledWith('contact', countBag(undefined)); - await waitFor(() => expect(screen.getByText('7')).toBeTruthy()); + expect(await screen.findByTestId('element-number-no-object')).toBeTruthy(); + await settle(); + expect(adapter.aggregate).not.toHaveBeenCalled(); + expect(adapter.find).not.toHaveBeenCalled(); // No binding means no saved-view read either. expect(adapter.getObjectSchema).not.toHaveBeenCalled(); }); @@ -172,13 +171,14 @@ describe('element:number reads its object from `dataSource.object` (objectui#109 await waitFor(() => expect(screen.getByText('3')).toBeTruthy()); }); - it('a binding that names no object supplies nothing, and the flat object stands', async () => { + it('a binding that names no object supplies nothing: the no-object state, whatever the flat object says', async () => { // The spec gate's own reading of "supplies": an empty or non-string - // `dataSource.object` waives nothing, so it cannot displace the flat key. + // `dataSource.object` names nothing, and the flat key is not read. const adapter = makeAdapter(); mount({ ...FLAT, dataSource: { object: '' } }, adapter); - await waitFor(() => expect(adapter.aggregate).toHaveBeenCalledTimes(1)); - expect(adapter.aggregate).toHaveBeenCalledWith('contact', countBag(undefined)); + expect(await screen.findByTestId('element-number-no-object')).toBeTruthy(); + await settle(); + expect(adapter.aggregate).not.toHaveBeenCalled(); }); }); @@ -202,7 +202,7 @@ describe('element:number re-reads the dataSource form on the data-invalidation b expect(adapter.aggregate, 'the bound object changed and the metric never re-read').toHaveBeenCalledTimes(2); }); - it('the key is the binding, not the flat object it outranks', async () => { + it('the key is the binding, not a flat object beside it', async () => { const adapter = makeAdapter(); mount({ ...BOUND, properties: { object: 'account', aggregate: 'count' } }, adapter); await waitFor(() => expect(adapter.aggregate).toHaveBeenCalledTimes(1)); @@ -212,7 +212,7 @@ describe('element:number re-reads the dataSource form on the data-invalidation b notifyDataChanged({ objectName: 'account' }); }); await settle(); - expect(adapter.aggregate, 'a change to the outranked flat object re-read the metric').toHaveBeenCalledTimes(1); + expect(adapter.aggregate, 'a change to the unread flat object re-read the metric').toHaveBeenCalledTimes(1); await act(async () => { notifyDataChanged({ objectName: 'contact' }); @@ -231,7 +231,7 @@ describe('element:number — the `dataSource` members it reads, and the ones it expect(adapter.aggregate).toHaveBeenCalledWith('contact', countBag(BINDING_NODE)); }); - it('filter: properties.filter is AND-combined with the binding filter — both reach the aggregate, neither is dropped', async () => { + it('filter: properties.filter is not read — the binding filter alone reaches the aggregate, with no AND (objectui#11880)', async () => { const both = makeAdapter(); mount( { @@ -242,17 +242,17 @@ describe('element:number — the `dataSource` members it reads, and the ones it both, ); await waitFor(() => expect(both.aggregate).toHaveBeenCalledTimes(1)); - expect(both.aggregate).toHaveBeenCalledWith('contact', countBag(['and', PROPS_NODE, BINDING_NODE])); + expect(both.aggregate).toHaveBeenCalledWith('contact', countBag(BINDING_NODE)); cleanup(); - // A binding that carries no filter leaves the node's own to apply alone. + // A binding that carries no filter scopes nothing: the node's own is not read. const flatOnly = makeAdapter(); mount({ ...BOUND, properties: { aggregate: 'count', filter: PROPS_FILTER } }, flatOnly); await waitFor(() => expect(flatOnly.aggregate).toHaveBeenCalledTimes(1)); - expect(flatOnly.aggregate).toHaveBeenCalledWith('contact', countBag(PROPS_NODE)); + expect(flatOnly.aggregate).toHaveBeenCalledWith('contact', countBag(undefined)); }); - it("filter: a named view's filter and properties.filter are both applied, AND-combined", async () => { + it("filter: a named view's filter scopes the aggregate, and properties.filter beside it is not read", async () => { const adapter = makeAdapter(); mount( { @@ -263,16 +263,16 @@ describe('element:number — the `dataSource` members it reads, and the ones it adapter, ); await waitFor(() => expect(adapter.aggregate).toHaveBeenCalledTimes(1)); - expect(adapter.aggregate).toHaveBeenCalledWith('contact', countBag(['and', PROPS_NODE, HOT_NODE])); + expect(adapter.aggregate).toHaveBeenCalledWith('contact', countBag(HOT_NODE)); }); - it('filter: a merge the converter refuses reports on the error panel and aggregates nothing', async () => { + it('filter: a binding filter the converter refuses reports on the error panel and aggregates nothing', async () => { const adapter = makeAdapter(); mount( { ...BOUND, - dataSource: { object: 'contact', filter: BINDING_FILTER }, - properties: { aggregate: 'count', filter: REFUSED_FILTER }, + dataSource: { object: 'contact', filter: REFUSED_FILTER }, + properties: { aggregate: 'count' }, }, adapter, ); @@ -303,9 +303,10 @@ describe('element:number — the `dataSource` members it reads, and the ones it }); it('view: an unresolvable one does not fall back to properties.object — no object while it is unresolved', async () => { - // The `unresolved ? undefined :` half of the resolution line. Without it, - // a flat object beside the binding would be aggregated WITHOUT the view's - // filter — the wider count the view was written to prevent. + // The `unresolved ? undefined :` half of the resolution line, and since + // objectui#11880 the flat object is not read at all: a count over it + // WITHOUT the view's filter is the wider count the view was written to + // prevent. const adapter = makeAdapter(); mount( { @@ -349,13 +350,19 @@ 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, and object is no longer required', () => { + it('the registration declares dataSource as an object binding; the flat `object` / `filter` say they are not read (objectui#11880)', () => { 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).toBeTruthy(); 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/); }); it('the html tier accepts the dataSource form without a diagnostic', () => { diff --git a/packages/components/src/renderers/basic/__tests__/elementNumber.noObjectState-10951.test.tsx b/packages/components/src/renderers/basic/__tests__/elementNumber.noObjectState-10951.test.tsx index d95a69f100..0e7a321370 100644 --- a/packages/components/src/renderers/basic/__tests__/elementNumber.noObjectState-10951.test.tsx +++ b/packages/components/src/renderers/basic/__tests__/elementNumber.noObjectState-10951.test.tsx @@ -21,8 +21,10 @@ * * - an aggregate with neither object: the notice, not the dash, and no query; * - a `dataSource` that names no object is still authored absence; - * - controls, each able to fail: `properties.object` alone and - * `dataSource.object` alone aggregate as before; a binding whose `view` is + * - since objectui#11880 a flat `properties.object` alone is authored absence + * too: the binding is the only place the metric reads its object from; + * - controls, each able to fail: `dataSource.object` alone aggregates as + * before; a binding whose `view` is * still resolving, or failed to resolve, keeps its own panel and never * shows the new state; a node with no `aggregate` keeps today's dash. * @@ -49,7 +51,8 @@ const settle = () => act(() => new Promise((resolve) => setTimeout(resolve const NOTICE_ID = 'element-number-no-object'; /** Written out, not read from the pack: a pin must not agree by construction. */ -const NOTICE_EN = 'No object named: set object or dataSource.object.'; +// objectui#11880: the notice names only the binding member the metric reads. +const NOTICE_EN = 'No object named: set dataSource.object.'; /** * An adapter that can aggregate and can list saved views. `views` decides what @@ -103,16 +106,21 @@ describe('element:number — an aggregate naming no object shows the "no object }); }); -describe('element:number — controls: every form that names an object is unchanged (objectui#10951)', () => { - it('control: properties.object alone aggregates and paints the value', async () => { +describe('element:number — a flat properties.object names nothing (objectui#11880)', () => { + it('properties.object alone is authored absence too: the notice, and nothing is queried', async () => { + // The row that was this file's first control until objectui#11880: the + // metric reads its object from `dataSource.object` only. const adapter = makeAdapter(); mount({ type: 'element:number', id: 'n', properties: { object: 'contact', aggregate: 'count' } }, adapter); + await settle(); - await waitFor(() => expect(screen.getByText('7')).toBeTruthy()); - expect(adapter.aggregate.mock.calls.map((c) => c[0])).toEqual(['contact']); - expect(screen.queryByTestId(NOTICE_ID)).toBeNull(); + expect(screen.getByTestId(NOTICE_ID).textContent).toBe(NOTICE_EN); + expect(adapter.aggregate).not.toHaveBeenCalled(); + expect(adapter.find).not.toHaveBeenCalled(); }); +}); +describe('element:number — controls: every form that names an object is unchanged (objectui#10951)', () => { it('control: dataSource.object alone aggregates and paints the value', async () => { const adapter = makeAdapter(); mount( diff --git a/packages/components/src/renderers/basic/__tests__/elementNumber.noObjectStateLocale-10951.test.tsx b/packages/components/src/renderers/basic/__tests__/elementNumber.noObjectStateLocale-10951.test.tsx index 4852b3f0b5..61d6664da9 100644 --- a/packages/components/src/renderers/basic/__tests__/elementNumber.noObjectStateLocale-10951.test.tsx +++ b/packages/components/src/renderers/basic/__tests__/elementNumber.noObjectStateLocale-10951.test.tsx @@ -52,7 +52,7 @@ describe('element:number "no object named" notice — localized (objectui#10951) // `waitFor`, not `findBy`: a lazily loaded catalogue may land after the // first paint, and the element exists (in English) before it does. await waitFor(() => - expect(screen.getByTestId(NOTICE_ID).textContent).toBe('未指定对象:请设置 object 或 dataSource.object。'), + expect(screen.getByTestId(NOTICE_ID).textContent).toBe('未指定对象:请设置 dataSource.object。'), ); }); @@ -60,7 +60,7 @@ describe('element:number "no object named" notice — localized (objectui#10951) renderIn('de'); await waitFor(() => expect(screen.getByTestId(NOTICE_ID).textContent).toBe( - 'Kein Objekt angegeben: Legen Sie object oder dataSource.object fest.', + 'Kein Objekt angegeben: Legen Sie dataSource.object fest.', ), ); }); diff --git a/packages/components/src/renderers/basic/__tests__/elementNumber.recordIdFilterToken-7297.test.tsx b/packages/components/src/renderers/basic/__tests__/elementNumber.recordIdFilterToken-7297.test.tsx index 1499cd3cdf..cb3dbeab60 100644 --- a/packages/components/src/renderers/basic/__tests__/elementNumber.recordIdFilterToken-7297.test.tsx +++ b/packages/components/src/renderers/basic/__tests__/elementNumber.recordIdFilterToken-7297.test.tsx @@ -20,7 +20,9 @@ * - on a record page the count follows the record: two records, two counts, * with no remount between them; * - the component-level `dataSource.filter` takes the token too, through the - * same resolution; + * same resolution (since objectui#11880 it is the metric's ONLY filter: + * every row below authors its filter there, and the binding's rule array + * reaches the adapter lowered to the ObjectQL AST); * - with no record in context the token is refused by name (the * `onUnresolved` channel, defaulting to `console.warn`) and left as written, * so the query can only be refused or match nothing, never count everybody; @@ -67,13 +69,20 @@ function makeAdapter() { }; } +/** One equality rule of a binding filter. */ +const eq = (field: string, value: string) => ({ field, operator: 'equals', value }); + /** The person's record page: "how many open tasks does THIS person have". */ const OPEN_TASKS = { type: 'element:number', id: 'open-tasks', - properties: { object: 'task', aggregate: 'count', filter: { assignee: '{record_id}', status: 'open' } }, + dataSource: { object: 'task', filter: [eq('assignee', '{record_id}'), eq('status', 'open')] }, + properties: { aggregate: 'count' }, }; +/** {@link OPEN_TASKS} with another binding filter. */ +const openTasksFiltered = (filter: unknown[]) => ({ ...OPEN_TASKS, dataSource: { object: 'task', filter } }); + function Page({ adapter, recordId, schema = OPEN_TASKS }: { adapter: object; recordId?: string; schema?: object }) { const tree = ; return ( @@ -99,12 +108,18 @@ describe('element:number resolves {record_id} against the mounted record (object const { rerender } = render(); await waitFor(() => expect(screen.getByText('3')).toBeTruthy()); - expect(adapter.aggregate).toHaveBeenLastCalledWith('task', countBag({ assignee: 'rec_ada', status: 'open' })); + expect(adapter.aggregate).toHaveBeenLastCalledWith( + 'task', + countBag([['assignee', 'equals', 'rec_ada'], ['status', 'equals', 'open']]), + ); // Same tree, same mounted metric; only the record in context moves. rerender(); await waitFor(() => expect(screen.getByText('5')).toBeTruthy()); - expect(adapter.aggregate).toHaveBeenLastCalledWith('task', countBag({ assignee: 'rec_grace', status: 'open' })); + expect(adapter.aggregate).toHaveBeenLastCalledWith( + 'task', + countBag([['assignee', 'equals', 'rec_grace'], ['status', 'equals', 'open']]), + ); expect(screen.queryByText('3')).toBeNull(); }); @@ -133,17 +148,14 @@ describe('element:number resolves {record_id} against the mounted record (object , ); await waitFor(() => expect(adapter.aggregate).toHaveBeenCalled()); expect(adapter.aggregate).toHaveBeenLastCalledWith( 'task', - countBag({ assignee: 'rec_ada', reviewer: 'usr_viewer' }), + countBag([['assignee', 'equals', 'rec_ada'], ['reviewer', 'equals', 'usr_viewer']]), ); }); }); @@ -157,7 +169,10 @@ describe('element:number refuses {record_id} by name with no record in context ( await waitFor(() => expect(adapter.aggregate).toHaveBeenCalled()); // The condition reaches the query as authored: dropping it would count // every task (the card's failure), `null` would count none. - expect(adapter.aggregate).toHaveBeenLastCalledWith('task', countBag({ assignee: '{record_id}', status: 'open' })); + expect(adapter.aggregate).toHaveBeenLastCalledWith( + 'task', + countBag([['assignee', 'equals', '{record_id}'], ['status', 'equals', 'open']]), + ); const named = warn.mock.calls.map((c) => String(c[0])).filter((m) => m.includes('"{record_id}"')); expect(named.length).toBeGreaterThan(0); @@ -185,7 +200,7 @@ describe('element:number refuses {record_id} by name with no record in context ( render( , ); diff --git a/packages/components/src/renderers/basic/__tests__/elementNumberFilterMembers-8071.test.tsx b/packages/components/src/renderers/basic/__tests__/elementNumberFilterMembers-8071.test.tsx index f338a07833..ab71d25697 100644 --- a/packages/components/src/renderers/basic/__tests__/elementNumberFilterMembers-8071.test.tsx +++ b/packages/components/src/renderers/basic/__tests__/elementNumberFilterMembers-8071.test.tsx @@ -7,7 +7,17 @@ */ /** - * `element:number.filter` — the MEMBER SHAPE this metric reads (objectui#8071). + * `element:number`'s filter — the MEMBER SHAPE this metric reads (objectui#8071). + * + * ⚠️ objectui#11880 moved the door: the metric reads its filter from the + * node-level `dataSource.filter` only (objectstack#11509, ruled A-narrow), and + * the flat `properties.filter` this file first pinned stays published (until + * the spec's v18 retirement) and is not read. Every row below drives the + * binding. What changed on the wire is the + * SHAPE of the value: the binding's filter is lowered to the ObjectQL AST + * (`toFilterNodeSafely`) before it reaches either spelling, where the flat key + * used to arrive verbatim. The two spellings and the by-value rule are + * unchanged. * * The member pin for this key. Unlike the other keys this card has pinned, * `filter` carries no NAMED member set of its own: it is a query predicate @@ -28,17 +38,15 @@ * confidently wrong number. No diagnostic, no empty state — the failure mode * this whole direction (objectui#8068) exists to make loud. * - * The third member semantic is the re-query rule. The effect keys on - * `JSON.stringify(props.filter)`, not on the object identity: - * - * const filterKey = React.useMemo( - * () => (props.filter ? JSON.stringify(props.filter) : ''), [props.filter]); + * The third member semantic is the re-query rule. The effect keys on the + * resolved filter's CONTENT (`filterKey`, a `JSON.stringify` of it), not on + * the object identity, * * so the predicate is read BY VALUE. A parent that rebuilds an equal filter * literal every render must NOT re-probe the server, and a parent that changes * one comparand MUST. Both arms are asserted, because a dependency array - * "simplified" to `props.filter` passes the second and turns the first into a - * per-render fetch storm. + * "simplified" to the raw filter object passes the second and turns the first + * into a per-render fetch storm. * * ## Nothing pre-existing covered this * @@ -61,14 +69,17 @@ import '../../../renderers'; afterEach(cleanup); -const OPEN_ONLY = { stage: { $ne: 'closed' } }; +const OPEN_ONLY = [{ field: 'stage', operator: 'not_equals', value: 'closed' }]; +/** {@link OPEN_ONLY} as the metric hands it on: lowered to the ObjectQL AST. */ +const OPEN_ONLY_NODE = [['stage', 'not_equals', 'closed']]; const metric = (filter?: unknown) => ({ type: 'element:number', id: 'metric', - // Element config lives in the `properties` bag (`readProps`), not on the - // node — the same door an authored page writes through. - properties: { object: 'opportunity', field: 'amount', aggregate: 'sum', ...(filter ? { filter } : {}) }, + // The query is the node-level `dataSource` binding (objectui#11880); the + // aggregate's own keys live in the `properties` bag (`readProps`). + dataSource: { object: 'opportunity', ...(filter ? { filter } : {}) }, + properties: { field: 'amount', aggregate: 'sum' }, }); /** An adapter that CAN aggregate — the primary path. */ @@ -101,11 +112,11 @@ describe('element:number filter — member shape (objectui#8071)', () => { field: 'amount', function: 'sum', groupBy: '_all', - filter: OPEN_ONLY, + filter: OPEN_ONLY_NODE, }); - // Verbatim: the renderer does not normalise, wrap or re-key the predicate - // on this path. - expect((adapter.aggregate as any).mock.calls[0][1].filter).toEqual(OPEN_ONLY); + // Lowered, and otherwise as authored: the renderer does not wrap or + // re-key the predicate on this path. + expect((adapter.aggregate as any).mock.calls[0][1].filter).toEqual(OPEN_ONLY_NODE); }); it('reaches the `find()` fallback WRAPPED as `$filter` — the other spelling', async () => { @@ -115,11 +126,11 @@ describe('element:number filter — member shape (objectui#8071)', () => { mount(adapter, OPEN_ONLY); await waitFor(() => expect(adapter.find).toHaveBeenCalledOnce()); - expect(adapter.find).toHaveBeenCalledWith('opportunity', { $filter: OPEN_ONLY }); + expect(adapter.find).toHaveBeenCalledWith('opportunity', { $filter: OPEN_ONLY_NODE }); }); it('sends NO options at all to `find()` when no filter is authored', async () => { - // `props.filter ? { $filter: props.filter } : undefined`. The control for + // `queryFilter ? { $filter: queryFilter } : undefined`. The control for // the row above: an unfiltered metric must not ship an empty envelope, // which some adapters read as "match nothing". const adapter = findingOnly(); @@ -134,13 +145,13 @@ describe('element:number filter — member shape (objectui#8071)', () => { // the normal case, not an exotic one, and a dependency on the reference // would turn every such render into a server round trip. const adapter = aggregating(); - const view = mount(adapter, { stage: { $ne: 'closed' } }); + const view = mount(adapter, [{ field: 'stage', operator: 'not_equals', value: 'closed' }]); await waitFor(() => expect(adapter.aggregate).toHaveBeenCalledOnce()); view.rerender( {/* Deep-equal, freshly allocated — a different object, the same predicate. */} - + , ); @@ -152,16 +163,16 @@ describe('element:number filter — member shape (objectui#8071)', () => { // The other arm, and the non-vacuity control for the row above: without it // a renderer that never re-probed at all would pass that assertion. const adapter = aggregating(); - const view = mount(adapter, { stage: { $ne: 'closed' } }); + const view = mount(adapter, [{ field: 'stage', operator: 'not_equals', value: 'closed' }]); await waitFor(() => expect(adapter.aggregate).toHaveBeenCalledOnce()); view.rerender( - + , ); await waitFor(() => expect(adapter.aggregate).toHaveBeenCalledTimes(2)); - expect((adapter.aggregate as any).mock.calls[1][1].filter).toEqual({ stage: { $ne: 'won' } }); + expect((adapter.aggregate as any).mock.calls[1][1].filter).toEqual([['stage', 'not_equals', 'won']]); }); }); diff --git a/packages/components/src/renderers/basic/__tests__/elementReaders.invalidationRefetch-10623.test.tsx b/packages/components/src/renderers/basic/__tests__/elementReaders.invalidationRefetch-10623.test.tsx index 92d85eb375..f8745d299e 100644 --- a/packages/components/src/renderers/basic/__tests__/elementReaders.invalidationRefetch-10623.test.tsx +++ b/packages/components/src/renderers/basic/__tests__/elementReaders.invalidationRefetch-10623.test.tsx @@ -61,15 +61,27 @@ function mount(schema: Record, adapter: ReturnType) => a.aggregate }, - { block: 'element:repeater', schema: REPEATER, read: (a: ReturnType) => a.find }, + { + block: 'element:number', + schema: NUMBER, + unbound: { type: 'element:number', id: 'n', properties: { aggregate: 'count' } }, + read: (a: ReturnType) => a.aggregate, + }, + { + block: 'element:repeater', + schema: REPEATER, + unbound: { type: 'element:repeater', id: 'rep', properties: { fields: ['name'] } }, + read: (a: ReturnType) => a.find, + }, ] as const; -describe.each(cases)('$block re-reads on the data-invalidation bus (objectui#10623)', ({ schema, read }) => { +describe.each(cases)('$block re-reads on the data-invalidation bus (objectui#10623)', ({ schema, unbound, read }) => { it('an unscoped change (objectName "*") re-runs its read once', async () => { const adapter = makeAdapter(); mount(schema, adapter); @@ -107,8 +119,7 @@ describe.each(cases)('$block re-reads on the data-invalidation bus (objectui#106 it('a block with no object binding reads nothing on an invalidation', async () => { const adapter = makeAdapter(); - const { object: _object, ...unbound } = schema.properties; - mount({ ...schema, properties: unbound }, adapter); + mount(unbound, adapter); await settle(); await act(async () => { diff --git a/packages/components/src/renderers/basic/__tests__/record-picker.busReread-10853.test.tsx b/packages/components/src/renderers/basic/__tests__/record-picker.busReread-10853.test.tsx index 4efdf9671c..5a910c78ea 100644 --- a/packages/components/src/renderers/basic/__tests__/record-picker.busReread-10853.test.tsx +++ b/packages/components/src/renderers/basic/__tests__/record-picker.busReread-10853.test.tsx @@ -75,7 +75,8 @@ function BoundValue() { return {String(variables.sel)}; } -const PICKER = { type: 'element:record_picker', id: 'picker', properties: { object: 'account' } }; +// The picker reads its object from the node-level `dataSource` binding (objectui#11880). +const PICKER = { type: 'element:record_picker', id: 'picker', dataSource: { object: 'account' } }; function mountPicker(stored: Row[], node: Record = PICKER) { const made = makeAdapter(stored); diff --git a/packages/components/src/renderers/basic/__tests__/record-picker.contractEnvelope-6726.test.tsx b/packages/components/src/renderers/basic/__tests__/record-picker.contractEnvelope-6726.test.tsx index 6c079a3e11..4b46a0d802 100644 --- a/packages/components/src/renderers/basic/__tests__/record-picker.contractEnvelope-6726.test.tsx +++ b/packages/components/src/renderers/basic/__tests__/record-picker.contractEnvelope-6726.test.tsx @@ -59,9 +59,9 @@ function mount(envelope: Envelope) { { type: 'element:record_picker', id: 'picker', - // Element config lives in the `properties` bag (`readProps`), not - // on the node — the same door an authored page writes through. - properties: { object: 'account' }, + // Display config lives in the `properties` bag (`readProps`); the + // query is the node-level `dataSource` binding (objectui#11880). + dataSource: { object: 'account' }, } as never } /> diff --git a/packages/components/src/renderers/basic/__tests__/record-picker.sortKey-10664.test.tsx b/packages/components/src/renderers/basic/__tests__/record-picker.sortKey-10664.test.tsx index c7118b146a..04715e315c 100644 --- a/packages/components/src/renderers/basic/__tests__/record-picker.sortKey-10664.test.tsx +++ b/packages/components/src/renderers/basic/__tests__/record-picker.sortKey-10664.test.tsx @@ -36,23 +36,21 @@ const settle = () => act(() => new Promise((resolve) => setTimeout(resolve const BY_NAME_ASC = [{ field: 'name', order: 'asc' }]; const BY_NAME_DESC = [{ field: 'name', order: 'desc' }]; -type Where = 'properties' | 'binding'; type HostHandle = { setSort: (sort: unknown) => void }; /** - * Holds the picker's `sort` as state and rebuilds the node on every change. - * `where` puts it on the flat `properties.sort` or on a `dataSource` binding - * (no `view`, so nothing waits on a saved-view read). + * Holds the picker's `sort` as state and rebuilds the node on every change. The + * sort is the `dataSource` binding's (no `view`, so nothing waits on a + * saved-view read): the picker reads its query from the binding only + * (objectui#11880), so the flat `properties.sort` arm this file also drove is + * gone. */ -const Host = React.forwardRef(function Host({ adapter, where }, ref) { +const Host = React.forwardRef(function Host({ adapter }, ref) { const [sort, setSort] = React.useState(BY_NAME_ASC); React.useImperativeHandle(ref, () => ({ setSort }), []); const schema = React.useMemo( - () => - where === 'properties' - ? { type: 'element:record_picker', id: 'picker', properties: { object: 'account', sort } } - : { type: 'element:record_picker', id: 'picker', properties: {}, dataSource: { object: 'account', sort } }, - [sort, where], + () => ({ type: 'element:record_picker', id: 'picker', properties: {}, dataSource: { object: 'account', sort } }), + [sort], ); return ( @@ -61,10 +59,10 @@ const Host = React.forwardRef(functi ); }); -function mount(where: Where) { +function mount() { const adapter = { find: vi.fn(async () => ({ data: [{ id: 'a1', name: 'Acme' }], total: 1 })), getObjectSchema: vi.fn() }; const host = React.createRef(); - render(); + render(); return { adapter, host }; } @@ -73,22 +71,20 @@ const orderbys = (adapter: { find: { mock: { calls: any[][] } } }) => adapter.find.mock.calls.map((c) => c[1]?.$orderby); describe('element:record_picker keys its read on the sort it sends (objectui#10664)', () => { - for (const where of ['properties', 'binding'] as const) { - it(`SUBJECT: a changed sort on the ${where} re-reads, with the new \`$orderby\``, async () => { - const { adapter, host } = mount(where); - await waitFor(() => expect(adapter.find).toHaveBeenCalled()); - await settle(); - expect(orderbys(adapter)).toEqual([BY_NAME_ASC]); + it('SUBJECT: a changed sort on the binding re-reads, with the new `$orderby`', async () => { + const { adapter, host } = mount(); + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + await settle(); + expect(orderbys(adapter)).toEqual([BY_NAME_ASC]); - await act(async () => { host.current!.setSort(BY_NAME_DESC); }); - await settle(); + await act(async () => { host.current!.setSort(BY_NAME_DESC); }); + await settle(); - expect(orderbys(adapter), 'the changed sort never reached a read').toEqual([BY_NAME_ASC, BY_NAME_DESC]); - }); - } + expect(orderbys(adapter), 'the changed sort never reached a read').toEqual([BY_NAME_ASC, BY_NAME_DESC]); + }); it('CONTROL: an equal sort in a fresh array does not re-read', async () => { - const { adapter, host } = mount('properties'); + const { adapter, host } = mount(); await waitFor(() => expect(adapter.find).toHaveBeenCalled()); await settle(); const atRest = adapter.find.mock.calls.length; diff --git a/packages/components/src/renderers/basic/elements.tsx b/packages/components/src/renderers/basic/elements.tsx index 2162032400..356a1ded60 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,21 +626,20 @@ 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 (
- {t('element.number.noObject', { defaultValue: 'No object named: set object or dataSource.object.' })} + {t('element.number.noObject', { defaultValue: 'No object named: set dataSource.object.' })}
); } @@ -668,9 +657,13 @@ 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`, and `object` is no longer `required` because the binding can -// supply it. 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`. 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), { namespace: 'element', skipFallback: true, @@ -681,7 +674,7 @@ ComponentRegistry.register('number', elementDataSourceBlock(ElementNumberRendere name: 'object', type: 'string', description: - 'Object the aggregate runs over. Required unless a node-level `dataSource` binding names one; when both are set, `dataSource.object` wins.', + '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)' }, @@ -689,7 +682,7 @@ ComponentRegistry.register('number', elementDataSourceBlock(ElementNumberRendere name: 'filter', type: 'array', description: - 'Criteria the aggregate is scoped by. When a node-level `dataSource` binding also supplies a filter (its own, or the saved view its `view` names), the two are AND-combined: neither is dropped.', + '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' }, diff --git a/packages/components/src/renderers/basic/record-picker.tsx b/packages/components/src/renderers/basic/record-picker.tsx index c3ffb23db2..d100673a37 100644 --- a/packages/components/src/renderers/basic/record-picker.tsx +++ b/packages/components/src/renderers/basic/record-picker.tsx @@ -10,8 +10,9 @@ * * Data binding follows the spec's ElementDataSource (`schema.dataSource`): * { object, view?, filter?, sort?, limit? } - * with `properties.object` accepted as a fallback. Display config is read off - * `schema.properties`: + * and is read from there ONLY: the flat `properties.object` / `filter` / + * `sort` / `limit` are not read (objectui#11880, objectstack#11509 ruled + * A-narrow). Display config is read off `schema.properties`: * { labelField='name', valueField='id', label?, placeholder?, emptyText? } * * The selection is written through `usePageVariableBinding(schema.id)`: the @@ -71,7 +72,6 @@ function toText(v: unknown): string { function ElementRecordPickerRenderer({ schema }: { schema: any }) { const props = readProps<{ - object?: string; labelField?: string; valueField?: string; // All three are `string | I18nLabel` because that is what the contract says: @@ -84,16 +84,14 @@ function ElementRecordPickerRenderer({ schema }: { schema: any }) { label?: string | I18nLabel; placeholder?: string | I18nLabel; emptyText?: string | I18nLabel; - filter?: unknown; - sort?: any; - limit?: number; aria?: AriaProps; }>(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,10 +385,19 @@ ComponentRegistry.register('record_picker', elementDataSourceBlock(ElementRecord skipFallback: true, label: 'Record Picker', category: 'input', + // ⚠️ 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 has read it all along - // (`composed?.filter ?? props.filter` above, into `query.$filter`), and the + // 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 @@ -404,7 +411,12 @@ ComponentRegistry.register('record_picker', elementDataSourceBlock(ElementRecord // `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' }, + { + 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 @@ -428,11 +440,11 @@ ComponentRegistry.register('record_picker', elementDataSourceBlock(ElementRecord // 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. + // 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: - '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.', + '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' }, @@ -492,13 +504,13 @@ ComponentRegistry.register('record_picker', elementDataSourceBlock(ElementRecord // `validateTree` reports on, and `sdui-intrinsics.d.ts` types. of: 'object', description: - 'Row order, as an array of `{ field, order }` entries — `[{ field: "name", order: "asc" }]`. It becomes the `$orderby` of the picker\'s own query, so it decides the order records are offered in. `order` is `asc` or `desc`; the terse string form (`"name asc"`) is not accepted by the contract. PRECEDENCE: identical to `filter` above and for the same reason — the renderer reads `dataSource.sort ?? sort`, so a node-level `dataSource` binding (or the saved view its `view` names) REPLACES this key outright rather than merging with it; it applies only when the node carries no `dataSource`, or that `dataSource` and its view both leave `sort` unset.', + 'NOT READ (objectui#11880): the picker\'s row order is the node-level `dataSource.sort` (else the sort of the saved view its `view` names), which becomes the `$orderby` of the picker\'s own query. This flat key never applies; `@objectstack/spec` retires it in v18 (objectstack#11509). Either door takes an array of `{ field, order }` entries — `[{ field: "name", order: "asc" }]`, `order` `asc` or `desc`; the terse string form (`"name asc"`) is not accepted by the contract.', }, { name: 'limit', type: 'number', description: - 'Maximum number of records the picker offers, as a whole number. It becomes the `$top` of the picker\'s own query, so it bounds what the user can choose from rather than how the list is displayed — a record outside the limit cannot be picked at all, and the control gives no sign that more exist. PRECEDENCE, first source that supplies a cap wins: (1) `dataSource.limit`; (2) the row cap of the saved view that `dataSource.view` names; (3) THIS key; (4) 50. A cap in (1) or (2) counts only when it is a positive integer — one the contract refuses (`0`, a negative, a non-integer) is treated as NOT AUTHORED and falls through to the next source exactly as an absent one does (objectui#10016), so a refused `dataSource.limit` does not win. DEFAULT: the 50 in (4) is applied by the renderer — the trailing `?? 50` where the picker resolves its `limit` — not by the schema.', + 'NOT READ (objectui#11880): the number of records the picker offers is capped by, first source that supplies a cap: (1) `dataSource.limit`; (2) the row cap of the saved view that `dataSource.view` names; (3) 50. The cap becomes the `$top` of the picker\'s own query, so a record outside it cannot be picked at all. A cap in (1) or (2) counts only when it is a positive integer — one the contract refuses (`0`, a negative, a non-integer) is treated as NOT AUTHORED and falls through exactly as an absent one does (objectui#10016). This flat key is not among the sources; `@objectstack/spec` retires it in v18 (objectstack#11509).', }, { name: 'emptyText', @@ -519,7 +531,7 @@ ComponentRegistry.register('record_picker', elementDataSourceBlock(ElementRecord // render, never before and never after. type: ['string', 'object'], description: - 'Text shown in place of the row list when the query returns no records (renderer default "No records", `record-picker.tsx:213`). Unlike `filter` / `sort` / `limit` this is display-only — it never reaches the query, and a node-level `dataSource` binding does not override it. Accepts either a plain string or an inline per-locale map (`{ en: "None", "zh-CN": "无记录" }`) — the `I18nLabel` union rc.6 widened this key to — and the renderer resolves the map against the active language at the read site, falling back through base language, `default`, then `en`. An authored empty string stays empty; the "No records" default applies only when the key is absent.', + 'Text shown in place of the row list when the query returns no records (renderer default "No records"). Unlike the node-level `dataSource` binding\'s `filter` / `sort` / `limit` this is display-only — it never reaches the query, and the binding does not override it. Accepts either a plain string or an inline per-locale map (`{ en: "None", "zh-CN": "无记录" }`) — the `I18nLabel` union rc.6 widened this key to — and the renderer resolves the map against the active language at the read site, falling back through base language, `default`, then `en`. An authored empty string stays empty; the "No records" default applies only when the key is absent.', }, ], }); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 246e4dcfd5..a7010ef30c 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -4413,7 +4413,7 @@ const ar = { }, element: { number: { - noObject: "لم يُحدَّد أي كائن: عيّن object أو dataSource.object.", + noObject: "لم يُحدَّد أي كائن: عيّن dataSource.object.", }, }, }; diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index d75d8c1ba8..6b1e793f3d 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -4045,7 +4045,7 @@ const de = { }, element: { number: { - noObject: "Kein Objekt angegeben: Legen Sie object oder dataSource.object fest.", + noObject: "Kein Objekt angegeben: Legen Sie dataSource.object fest.", }, }, }; diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 740a55f83b..1f30ff78ea 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -1268,7 +1268,8 @@ const en = { // objectui#11605 — an object-bound block whose node names its object in // neither place (no own key, no `dataSource.object`). `{{property}}` is the // block's object key (`objectName`), interpolated and never translated; the - // wording is `element.number.noObject`'s with the property as a hole. + // wording was `element.number.noObject`'s with the property as a hole, + // before objectui#11880 dropped that notice's flat `object`. noObject: 'No object named: set {{property}} or dataSource.object.', }, detail: { @@ -4723,10 +4724,10 @@ const en = { }, element: { // objectui#10951 — `element:number` authored with an aggregate and no - // object (neither `object` nor `dataSource.object`). Kept terse: this pack - // is eager, and the console closure budget weighs it. + // `dataSource.object` (objectui#11880: the flat `object` is not read). Kept + // terse: this pack is eager, and the console closure budget weighs it. number: { - noObject: 'No object named: set object or dataSource.object.', + noObject: 'No object named: set dataSource.object.', }, }, } as const; diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 605631d8c5..818a326f2f 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -4134,7 +4134,7 @@ const es = { }, element: { number: { - noObject: "No se ha indicado ningún objeto: define object o dataSource.object.", + noObject: "No se ha indicado ningún objeto: define dataSource.object.", }, }, }; diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 3a58b3c6b9..5c5b6eb849 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -4131,7 +4131,7 @@ const fr = { }, element: { number: { - noObject: "Aucun objet indiqué : définissez object ou dataSource.object.", + noObject: "Aucun objet indiqué : définissez dataSource.object.", }, }, }; diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index cf1df2ac03..45bfab107f 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -4046,7 +4046,7 @@ const ja = { }, element: { number: { - noObject: "オブジェクトが指定されていません。object または dataSource.object を設定してください。", + noObject: "オブジェクトが指定されていません。dataSource.object を設定してください。", }, }, }; diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index e00f908f6e..507487f300 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -4043,7 +4043,7 @@ const ko = { }, element: { number: { - noObject: "개체가 지정되지 않았습니다. object 또는 dataSource.object를 설정하세요.", + noObject: "개체가 지정되지 않았습니다. dataSource.object를 설정하세요.", }, }, }; diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index b649e60bbc..1dca95fa0d 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -4129,7 +4129,7 @@ const pt = { }, element: { number: { - noObject: "Nenhum objeto indicado: defina object ou dataSource.object.", + noObject: "Nenhum objeto indicado: defina dataSource.object.", }, }, }; diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 39c9ef2030..a32a7ed908 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -4239,7 +4239,7 @@ const ru = { }, element: { number: { - noObject: "Объект не указан: задайте object или dataSource.object.", + noObject: "Объект не указан: задайте dataSource.object.", }, }, }; diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 1e43b022e2..2282ca241a 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -4116,7 +4116,7 @@ const zh = { }, element: { number: { - noObject: '未指定对象:请设置 object 或 dataSource.object。', + noObject: '未指定对象:请设置 dataSource.object。', }, }, } as const;