diff --git a/.changeset/10909-element-number-datasource.md b/.changeset/10909-element-number-datasource.md new file mode 100644 index 0000000000..600b5bdf24 --- /dev/null +++ b/.changeset/10909-element-number-datasource.md @@ -0,0 +1,20 @@ +--- +'@object-ui/components': minor +--- + +`element:number` reads its object from the node-level `dataSource` binding, as the spec declares it (objectui#10909). + +`PageComponentSchema.dataSource` is the spec's per-element data binding, and the spec lint gate waives a missing `properties.object` when `dataSource.object` names one. `ElementNumberRenderer` read its object only from `properties.object`, so a metric written `{ type: 'element:number', dataSource: { object: 'contact' }, properties: { aggregate: 'count' } }` — which the spec lint gate accepts, as `objectui validate` will once objectui#10908 arms the type — issued no query and painted the empty dash, with nothing to tell the author why. + +The renderer now resolves the binding the way `element:record_picker` does: + +- `object` is `dataSource.object ?? properties.object`, resolved once and used for the fetch guard, the `aggregate` / `find` call and the data-invalidation bus key. The binding wins when both are set. +- A named `view` is honoured: its filter scopes the aggregate. A view that cannot be resolved renders the shared "data source could not be resolved" panel and aggregates nothing, rather than counting every record of the object. +- `properties.filter` is AND-combined with the binding's `filter` and with its view's, the rule every block behind `ElementDataSourceGate` applies: neither is dropped, so a validated `properties.filter` can never be discarded and widen the count. A filter the converter refuses while combining them renders the same configuration-error panel, naming the refused rule, and aggregates nothing. +- The binding's `sort` and `limit` are not read: an aggregate has no ordering, and a capped count would be a wrong number. + +A metric with no `dataSource` behaves exactly as before. + +**The registration's declared inputs move.** `element:number` is now registered through `elementDataSourceBlock`, so its registration and the published manifest declare the `dataSource` input (the one injected declaration every reader of the binding carries), and the html tier no longer reports `has no prop "dataSource"` on the node. `object` is no longer `required`, because the binding can supply it; its description, and a new `filter` description, say how each combines with the binding. + +**Clause-②: yes (widening)** — no export moves, but the published `element:number` entry in `sdui.manifest.json` widens: it declares `dataSource`, and `object` is no longer `required`. The html tier, and the objectstack CLI's JSX gate that reads this manifest, therefore accept a node bound through `dataSource` alone — and also one that names neither `object` nor a binding, which no longer draws `missing-required-prop` and paints the empty dash. 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 5d7962e411..5833da1029 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -2301,6 +2301,10 @@ const MEMBER_PINS: Record = { file: 'apps/console/src/__tests__/component-input-union-specimens.test.ts', pins: 'The `object` arm is the inline translation map `{ en, "zh-CN" }` and nothing else — driven through the real `manifestFromConfigs` + `validateTree` pair the JSX-page compiler and the save gate use, each positive paired with a value matching NEITHER arm that must still be reported (objectui#4970).', }, + '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).', + }, '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).', diff --git a/content/docs/guide/data-source.md b/content/docs/guide/data-source.md index bf771e6ef2..883c1afc2a 100644 --- a/content/docs/guide/data-source.md +++ b/content/docs/guide/data-source.md @@ -253,6 +253,7 @@ ignores would be accepted and dropped, which is the defect this binding removes. | `list-view` | ✅ | ✅ | ✅ | ✅ | ✅ | | `object-grid` | ✅ | ✅ | ✅ | ✅ | ✅ | | `element:record_picker` | ✅ | ✅ | ✅ | ✅ | ✅ | +| `element:number` | ✅ | filter | ✅ | — single value | — single value | | `record:related_list` | ✅ | columns / filter / sort / limit | ✅ | ✅ | ✅ | | `object-calendar` | ✅ | filter / sort | ✅ | ✅ | — platform ceiling | | `object-kanban` | ✅ | filter / limit | ✅ | — no ordering | ✅ (`limit`) | @@ -279,6 +280,19 @@ of the binding and stays the author's — it has to name a field on the bound ch object, so rebinding `object` without updating it is an authoring error the panel cannot paper over. +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`, +`{ "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. + On `record:related_list` and `record:line_items` the composed filter is AND-combined with the parent relationship condition, never substituted for it: a child panel is always scoped to the record it appears on, and an *additional* 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 new file mode 100644 index 0000000000..b73cdef81a --- /dev/null +++ b/packages/components/src/renderers/basic/__tests__/elementNumber.dataSourceBinding-10909.test.tsx @@ -0,0 +1,374 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `element:number.dataSource` — the metric reads its object from the spec's + * per-element binding (objectui#10909). + * + * `PageComponentSchema.dataSource` is the spec's per-element data binding, and + * the spec lint gate (`validateComponentProps`) waives a missing + * `properties.object` when `dataSource.object` names one, on the stated ground + * that objectui's element renderers read the binding FIRST + * (`ds.object ?? props.object`). `ElementNumberRenderer` read only + * `properties.object`, so `{ dataSource: { object }, properties: { aggregate } }` + * — which the spec lint gate accepts — issued no query at all and painted the + * empty dash, with nothing to tell the author why. + * + * The renderer now resolves the binding the way its element twin + * `element:record_picker` does, through `useElementDataSource`. This file pins + * the member set it READS and the members it deliberately does not: + * + * | 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 | + * | `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 | + * | `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. + * No record-context binding is added here — that is objectui#7297's, and the + * filter shape note is objectui#8945's. + * + * Driven through the real `SchemaRenderer` and this package's own + * registrations, under the `AdapterCtx` provider the renderer reads its adapter + * from, with `aggregate` / `find` spies. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, act, cleanup, screen, waitFor } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { AdapterCtx, SchemaRenderer, notifyDataChanged } from '@object-ui/react'; +import { manifestFromConfigs, validateTree } from '@object-ui/sdui-parser'; +// Registers every `element:*` renderer at module scope, not in a hook +// (object-ui/no-dynamic-import-in-test-hook, objectui#3010). +import '../../../renderers'; + +afterEach(cleanup); + +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 100))); + +/** A saved view of `contact` — its filter is the observable proof the view resolved. */ +const HOT_FILTER = [{ field: 'status', operator: 'equals', value: 'hot' }]; +const HOT_VIEW = { + name: 'hot', + filter: HOT_FILTER, + // Neither of these may reach an aggregate: see the whole-bag rows below. + sort: [{ field: 'name', order: 'asc' }], + pagination: { pageSize: 5 }, + columns: ['name'], +}; + +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. + */ +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`). + */ +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() { + const rows = [{ id: 'r1' }, { id: 'r2' }, { id: 'r3' }]; + return { + aggregate: vi.fn(async (..._args: unknown[]) => [{ count: 7 }]), + find: vi.fn(async (..._args: unknown[]) => ({ data: rows, total: rows.length })), + getObjectSchema: vi.fn(async (name: string) => ({ name, fields: {}, listViews: { hot: HOT_VIEW } })), + }; +} + +/** An adapter that cannot — `find()` is the only way to a number. */ +function makeFindOnlyAdapter() { + const { aggregate: _aggregate, ...rest } = makeAdapter(); + return rest; +} + +function mount(schema: Record, adapter: object) { + return render( + + + , + ); +} + +/** The spec-valid `dataSource` form the card names. */ +const BOUND = { + type: 'element:number', + id: 'bound', + dataSource: { object: 'contact' }, + properties: { aggregate: 'count' }, +}; + +/** The `properties` form — the control. */ +const FLAT = { + type: 'element:number', + id: 'flat', + properties: { object: 'contact', aggregate: 'count' }, +}; + +/** The options bag a count over `contact` sends, filter aside. */ +const countBag = (filter: unknown) => ({ field: undefined, function: 'count', groupBy: '_all', filter }); + +describe('element:number reads its object from `dataSource.object` (objectui#10909)', () => { + it('the dataSource form calls aggregate for the bound object and paints the value', async () => { + const adapter = makeAdapter(); + mount(BOUND, adapter); + await waitFor(() => expect(adapter.aggregate).toHaveBeenCalledTimes(1)); + expect(adapter.aggregate).toHaveBeenCalledWith('contact', countBag(undefined)); + await waitFor(() => expect(screen.getByText('7')).toBeTruthy()); + expect(screen.queryByText('—')).toBeNull(); + }); + + it('dataSource.object wins over properties.object when both are set', async () => { + const adapter = makeAdapter(); + mount({ ...BOUND, properties: { object: 'account', aggregate: 'count' } }, adapter); + await waitFor(() => expect(adapter.aggregate).toHaveBeenCalledTimes(1)); + await settle(); + expect(adapter.aggregate.mock.calls.map((call) => call[0])).toEqual(['contact']); + }); + + it('control: the properties form is unchanged', 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()); + // No binding means no saved-view read either. + expect(adapter.getObjectSchema).not.toHaveBeenCalled(); + }); + + it('the find() fallback reads the bound object when the adapter cannot aggregate', async () => { + const adapter = makeFindOnlyAdapter(); + mount(BOUND, adapter); + await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1)); + expect(adapter.find).toHaveBeenCalledWith('contact', undefined); + await waitFor(() => expect(screen.getByText('3')).toBeTruthy()); + }); + + it('a binding that names no object supplies nothing, and the flat object stands', 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. + const adapter = makeAdapter(); + mount({ ...FLAT, dataSource: { object: '' } }, adapter); + await waitFor(() => expect(adapter.aggregate).toHaveBeenCalledTimes(1)); + expect(adapter.aggregate).toHaveBeenCalledWith('contact', countBag(undefined)); + }); +}); + +describe('element:number re-reads the dataSource form on the data-invalidation bus (objectui#10909)', () => { + it('one bus event re-reads it, keyed on the resolved object', async () => { + const adapter = makeAdapter(); + mount(BOUND, adapter); + await waitFor(() => expect(adapter.aggregate).toHaveBeenCalledTimes(1)); + await settle(); + + await act(async () => { + notifyDataChanged({ objectName: 'some_other_object' }); + }); + await settle(); + expect(adapter.aggregate, 'a change to another object re-read the metric').toHaveBeenCalledTimes(1); + + await act(async () => { + notifyDataChanged({ objectName: 'contact' }); + }); + await settle(); + 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 () => { + const adapter = makeAdapter(); + mount({ ...BOUND, properties: { object: 'account', aggregate: 'count' } }, adapter); + await waitFor(() => expect(adapter.aggregate).toHaveBeenCalledTimes(1)); + await settle(); + + await act(async () => { + notifyDataChanged({ objectName: 'account' }); + }); + await settle(); + expect(adapter.aggregate, 'a change to the outranked flat object re-read the metric').toHaveBeenCalledTimes(1); + + await act(async () => { + notifyDataChanged({ objectName: 'contact' }); + }); + await settle(); + expect(adapter.aggregate).toHaveBeenCalledTimes(2); + expect(adapter.aggregate.mock.calls.map((call) => call[0])).toEqual(['contact', 'contact']); + }); +}); + +describe('element:number — the `dataSource` members it reads, and the ones it does not (objectui#10909)', () => { + it('filter: the binding filter scopes the aggregate', async () => { + const adapter = makeAdapter(); + mount({ ...BOUND, dataSource: { object: 'contact', filter: BINDING_FILTER } }, adapter); + await waitFor(() => expect(adapter.aggregate).toHaveBeenCalledTimes(1)); + 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 () => { + const both = makeAdapter(); + mount( + { + ...BOUND, + dataSource: { object: 'contact', filter: BINDING_FILTER }, + properties: { aggregate: 'count', filter: PROPS_FILTER }, + }, + both, + ); + await waitFor(() => expect(both.aggregate).toHaveBeenCalledTimes(1)); + expect(both.aggregate).toHaveBeenCalledWith('contact', countBag(['and', PROPS_NODE, BINDING_NODE])); + cleanup(); + + // A binding that carries no filter leaves the node's own to apply alone. + 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)); + }); + + it("filter: a named view's filter and properties.filter are both applied, AND-combined", async () => { + const adapter = makeAdapter(); + mount( + { + ...BOUND, + dataSource: { object: 'contact', view: 'hot' }, + properties: { aggregate: 'count', filter: PROPS_FILTER }, + }, + adapter, + ); + await waitFor(() => expect(adapter.aggregate).toHaveBeenCalledTimes(1)); + expect(adapter.aggregate).toHaveBeenCalledWith('contact', countBag(['and', PROPS_NODE, HOT_NODE])); + }); + + it('filter: a merge 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 }, + }, + adapter, + ); + const panel = await screen.findByTestId('element-number-datasource-error'); + // The refusal's own subject, so the author is told which rule to fix. + expect(panel.textContent).toContain('tags'); + await settle(); + // Refused, not dropped: no count over a filter that lost a source. + expect(adapter.aggregate).not.toHaveBeenCalled(); + expect(adapter.find).not.toHaveBeenCalled(); + }); + + it("view: a named saved view's filter scopes the aggregate", async () => { + const adapter = makeAdapter(); + mount({ ...BOUND, dataSource: { object: 'contact', view: 'hot' } }, adapter); + await waitFor(() => expect(adapter.aggregate).toHaveBeenCalledTimes(1)); + expect(adapter.getObjectSchema).toHaveBeenCalledWith('contact'); + expect(adapter.aggregate).toHaveBeenCalledWith('contact', countBag(HOT_NODE)); + }); + + it('view: one that cannot be resolved reports and aggregates nothing', async () => { + const adapter = makeAdapter(); + mount({ ...BOUND, dataSource: { object: 'contact', view: 'no_such_view' } }, adapter); + await waitFor(() => expect(screen.getByTestId('element-number-datasource-error')).toBeTruthy()); + await settle(); + expect(adapter.aggregate).not.toHaveBeenCalled(); + expect(adapter.find).not.toHaveBeenCalled(); + }); + + 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. + const adapter = makeAdapter(); + mount( + { + ...BOUND, + dataSource: { object: 'contact', view: 'no_such_view' }, + properties: { object: 'contact', aggregate: 'count' }, + }, + adapter, + ); + await waitFor(() => expect(screen.getByTestId('element-number-datasource-error')).toBeTruthy()); + await settle(); + expect(adapter.aggregate).not.toHaveBeenCalled(); + expect(adapter.find).not.toHaveBeenCalled(); + }); + + it('sort and limit are not read: the options bag stays whole with a binding and a view carrying both', async () => { + const adapter = makeAdapter(); + mount( + { + ...BOUND, + dataSource: { object: 'contact', view: 'hot', sort: [{ field: 'name', order: 'desc' }], limit: 2 }, + }, + adapter, + ); + await waitFor(() => expect(adapter.aggregate).toHaveBeenCalledTimes(1)); + const [object, bag] = adapter.aggregate.mock.calls[0] as [string, Record]; + expect(object).toBe('contact'); + expect(Object.keys(bag).sort()).toEqual(['field', 'filter', 'function', 'groupBy']); + expect(bag).toEqual(countBag(HOT_NODE)); + }); + + it('limit is not read on the find() fallback either: a count is never capped', async () => { + const adapter = makeFindOnlyAdapter(); + mount({ ...BOUND, dataSource: { object: 'contact', limit: 2 } }, adapter); + await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1)); + expect(adapter.find).toHaveBeenCalledWith('contact', undefined); + await waitFor(() => expect(screen.getByText('3')).toBeTruthy()); + }); +}); + +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', () => { + 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'); + const object = inputs().find((input) => input.name === 'object'); + expect(object).toBeTruthy(); + expect(object?.required).not.toBe(true); + }); + + it('the html tier accepts the dataSource form without a diagnostic', () => { + const manifest = manifestFromConfigs( + ComponentRegistry.getAllConfigs() as unknown as Parameters[0], + ); + const { diagnostics } = validateTree( + { type: 'element:number', dataSource: { object: 'contact' }, aggregate: 'count' } as never, + manifest, + ); + expect(diagnostics.map((d) => d.message)).toEqual([]); + // Control: a block that does not read the binding is still told. + const control = validateTree({ type: 'element:text', dataSource: { object: 'contact' } } as never, manifest); + expect(control.diagnostics.map((d) => d.message)).toContain(' has no prop "dataSource"'); + }); +}); diff --git a/packages/components/src/renderers/basic/elements.tsx b/packages/components/src/renderers/basic/elements.tsx index 71dfadc05a..3ab0d9a745 100644 --- a/packages/components/src/renderers/basic/elements.tsx +++ b/packages/components/src/renderers/basic/elements.tsx @@ -22,12 +22,24 @@ * 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). */ import * as React from 'react'; -import { ComponentRegistry } from '@object-ui/core'; -import type { ActionDef } from '@object-ui/core'; -import { useAdapter, useAction, useDataInvalidation, useFilterScope, useResolvedFilter } from '@object-ui/react'; +import { ComponentRegistry, elementDataSourceBlock, mergeFilterNodes, toFilterNodeSafely } from '@object-ui/core'; +import type { ActionDef, FilterOperatorError } from '@object-ui/core'; +import { + ElementDataSourceErrorPanel, + ElementDataSourceLoadingPanel, + useAdapter, + useAction, + useDataInvalidation, + useElementDataSource, + useFilterScope, + useResolvedFilter, +} from '@object-ui/react'; import { useObjectTranslation, pickLocalized, @@ -380,6 +392,48 @@ function ElementNumberRenderer({ schema }: { schema: any }) { aria?: Record; }>(schema); 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. + // + // `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 + // honoured (its filter scopes the aggregate); while it is unresolved or + // unresolvable there is NO object, so nothing is aggregated over the wider + // set the view was written to narrow, and the render reports instead. The + // binding's `sort` and `limit` are deliberately not read: an aggregate has no + // ordering, and a capped count is a wrong number. + // + // The renderer's own adapter is passed so the view resolves against the same + // source the aggregate reads from. + const dataBinding = useElementDataSource(schema, adapter); + const composed = dataBinding.composed; + // 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). + 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 }; + const bound = toFilterNodeSafely(composed.filter); + if (!bound.ok) return { filter: undefined, refusal: bound.refusal }; + return { filter: mergeFilterNodes(own.node, bound.node) }; + }, [composed, props.filter]); + const filterRefusal = scopedFilter.refusal; + const unresolved = dataBinding.status === 'loading' || dataBinding.status === 'missing'; + const object = unresolved || filterRefusal ? undefined : (composed?.object ?? props.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 @@ -395,20 +449,23 @@ 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`. + // 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). const filterScope = useFilterScope(); - const queryFilter = useResolvedFilter(props.filter, filterScope); + const queryFilter = useResolvedFilter(scopedFilter.filter, filterScope); const filterKey = React.useMemo(() => (queryFilter ? JSON.stringify(queryFilter) : ''), [queryFilter]); // objectui#10623 — the data-invalidation bus (`notifyDataChanged` from // `@object-ui/react`), read the objectui#10494 way: the nonce moves when a // write to the object this number AGGREGATES is declared, and the effect // below names it, so the value is re-read. Subscribed only when the effect - // can query: no adapter or no aggregate means no read to repeat. - const invalidationNonce = useDataInvalidation(adapter && props.aggregate ? props.object : undefined); + // can query: no adapter or no aggregate means no read to repeat. Keyed on the + // RESOLVED object, so a bound metric re-reads for the object it aggregates. + const invalidationNonce = useDataInvalidation(adapter && props.aggregate ? object : undefined); React.useEffect(() => { let cancelled = false; - if (!adapter || !props.object || !props.aggregate) { + if (!adapter || !object || !props.aggregate) { setLoading(false); return; } @@ -417,7 +474,7 @@ function ElementNumberRenderer({ schema }: { schema: any }) { (async () => { try { if (typeof adapter.aggregate === 'function') { - const rows = await adapter.aggregate(props.object, { + const rows = await adapter.aggregate(object, { field: props.field, function: props.aggregate, groupBy: '_all', @@ -434,7 +491,7 @@ function ElementNumberRenderer({ schema }: { schema: any }) { } else if (typeof adapter.find === 'function') { // Last-resort: pull all rows and aggregate client-side. Costly // but matches the chart renderer fallback path. - const res = await adapter.find(props.object, queryFilter ? { $filter: queryFilter } : undefined); + const res = await adapter.find(object, queryFilter ? { $filter: queryFilter } : undefined); // `data` is the ONE rows member `QueryResult` (`@object-ui/types`) // declares; the bare-array arm stays because fakes at this seam // really do answer with a plain array. A `res?.records` arm sat @@ -467,7 +524,25 @@ function ElementNumberRenderer({ schema }: { schema: any }) { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [adapter, props.object, props.field, props.aggregate, filterKey, invalidationNonce]); + }, [adapter, object, props.field, props.aggregate, filterKey, invalidationNonce]); + + // After every hook above, so hook order stays stable across resolution + // states. A `view` that names nothing, or a filter the merge refuses, + // reports rather than aggregating the whole object: one confident number + // over the wrong set is the quiet failure a metric has no second chance to + // show. + if (dataBinding.status === 'missing' || filterRefusal) { + return ( + + ); + } + if (dataBinding.status === 'loading') { + return ; + } return (
@@ -479,16 +554,32 @@ function ElementNumberRenderer({ schema }: { schema: any }) { ); } -ComponentRegistry.register('number', ElementNumberRenderer, { +// 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. +ComponentRegistry.register('number', elementDataSourceBlock(ElementNumberRenderer), { namespace: 'element', skipFallback: true, label: 'Number', category: 'content', inputs: [ - { name: 'object', type: 'string', required: true, description: 'Object the aggregate runs over' }, + { + 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.', + }, { name: 'aggregate', type: 'enum', enum: ['count', 'sum', 'avg', 'min', 'max'], required: true }, { name: 'field', type: 'string', description: 'Measure field (required for every aggregate except count)' }, - { name: 'filter', type: 'array' }, + { + 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.', + }, { name: 'format', type: 'enum', enum: ['number', 'currency', 'percent'] }, { name: 'prefix', type: 'string' }, { name: 'suffix', type: 'string' },