From aa9aec690054f131ec6105d376a61dd1ad101dd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 17:52:32 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(sdui):=20PageComponentSchema.dataSource?= =?UTF-8?q?=20=E9=80=90=20block=20=E6=8E=A5=E7=BA=BF,record=5Fpicker=20?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E4=B8=A2=20view=20(objectstack#6953)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectstack#5576 把 spec 的 per-element 数据绑定接到了 `list-view`,同一条声明在 其余每一个 page component 上依旧无人消费。两个缺口都是静默的: - `element:record_picker` 读了五个键里的四个,单独丢掉 `view`。于是 `dataSource: { object: 'account', view: 'hot' }`(spec 自己的例子)构造出全量 account 选择器,而不是 saved view 选出的行 —— 不抛错、不报面板,只是候选列表 比作者写的更宽,用户因此能选到页面声明为范围外的记录。 - `object-grid` / `object-form` / `object-kanban` / `object-calendar` / `object-chart` / `object-metric` / `record:related_list` 整条绑定都不读:每块 都把 fetch 挂在自己的 `objectName` 上,没有任何地方把 `dataSource.object` 映射 过去。按 spec 写、不额外写 `objectName` 的页面渲染出空 —— 不发请求、无诊断。 spec-valid 的 metadata 渲染出空,即 objectstack#4413 的形状。 新增 `packages/react/src/element-data-source/ElementDataSourceGate.tsx` (`useElementDataSourceSchema` / `ElementDataSourceGate` / 两个状态面板)。#5576 的公共件本体一行未改;新增这一层只负责最后一跳 —— 把合成结果写到某个 block 真读 的那几个键上,并统一渲染 loading / 无法解析两种非终态。之所以要这一层:`dataSource` 声明在每一个 page component 上,八个 block 需要的是同一张优先级表、只有键名不同, 那是一份映射描述而不是八套算法;一 block 一份副本正是「additional filter criteria」 变成两种方言的路径。语义完全沿用 #5576(view 作基线、组件显式键覆盖 view、绑定显式 键覆盖两者、`filter` 三方 `and` 合成、view 解析不到报错不回退全量、空 `columns: []` 视作未声明)。 映射只写该 block 真读的键:把合成值写到 block 不读的键上,等于把本单要消除的缺陷 往下挪一层、看起来像接好了。所以 kanban 的 `columns`(泳道,不是字段列表)不会被 view 的字段列表覆盖,没有行上限的 block 就把 `limit` 留空。逐 block 覆盖表连同两处 残留缺口写进 `content/docs/guide/data-source.md`。 钉子:9 个新文件 46 例,每块两方向(带 `dataSource`(含 `view`)/ 不带);公共层另 18 例,钉住优先级表本身与「未映射的键一定不被写入」——两条所有 block 都依赖、而任何 单块测试都看不见的性质。 反向验证(先预判方向再跑,变异不提交): - record_picker 恢复 pre-fix 读法(直读 `schema.dataSource`,丢 `view`):预判合成 相关两例翻红、其余四例绿。实测 2 failed / 4 passed —— 红的是 view 的 filter/sort/cap 与三方 `and` 合成;绿的是「绑定显式键覆盖」(绑定键本来就赢)、 「无法解析的 view 报错」(走 hook 那一肢,变异没碰)、无 view 基线、`properties.object` 简写。 - 公共层去掉 object 映射那一行:预判八个 block 的「查询到达数据层」全部翻红,无绑定 基线与错误面板保持绿。实测 18 failed / 28 passed,跨全部 8 个文件,分布与预判一致。 顺带记录、未在此修:objectstack#7118(`record:related_list.filter` 声明了却无人读, 因此本次故意不映射它的 `filter`,并钉了一条确认「没有任何 filter 到达 RelatedList」 的诚实断言)、objectstack#7119(`object-grid` 的 inputs 是 `filters` 复数、渲染器读 单数 `filter`)、objectstack#7120(`ListViewBlock` 的私有优先级表副本,观察类)、 objectstack#7121(剩余 object-bound public block 仍未消费该绑定)。 `plugin-kanban/src/registration.test.tsx` 顺带从整模块 mock 改成 `importOriginal()` 部分 mock —— 与 `plugin-calendar/src/registration.test.tsx` 同款转换、同一理由 (objectui#3219)。 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt Co-authored-by: Claude --- .../element-datasource-block-wiring-os6953.md | 58 +++ content/docs/guide/data-source.md | 39 ++ ...record-picker-element-data-source.test.tsx | 149 ++++++++ .../src/renderers/basic/record-picker.tsx | 71 +++- .../ObjectCalendar.elementDataSource.test.tsx | 110 ++++++ packages/plugin-calendar/src/index.tsx | 35 +- .../ObjectChart.elementDataSource.test.tsx | 129 +++++++ packages/plugin-charts/src/ObjectChart.tsx | 43 ++- packages/plugin-charts/src/index.tsx | 10 +- .../ObjectMetric.elementDataSource.test.tsx | 120 +++++++ packages/plugin-dashboard/src/index.tsx | 48 ++- ...tedListRenderer.elementDataSource.test.tsx | 132 +++++++ .../src/renderers/record-related-list.tsx | 67 +++- .../src/ObjectForm.elementDataSource.test.tsx | 113 ++++++ packages/plugin-form/src/index.tsx | 25 +- .../ObjectGrid.elementDataSource.test.tsx | 135 +++++++ packages/plugin-grid/src/index.tsx | 38 +- .../ObjectKanban.elementDataSource.test.tsx | 146 ++++++++ packages/plugin-kanban/src/index.tsx | 38 +- .../plugin-kanban/src/registration.test.tsx | 21 +- packages/react/README.md | 37 ++ .../ElementDataSourceGate.tsx | 336 ++++++++++++++++++ .../__tests__/ElementDataSourceGate.test.tsx | 317 +++++++++++++++++ packages/react/src/index.ts | 3 + 24 files changed, 2182 insertions(+), 38 deletions(-) create mode 100644 .changeset/element-datasource-block-wiring-os6953.md create mode 100644 packages/components/src/__tests__/record-picker-element-data-source.test.tsx create mode 100644 packages/plugin-calendar/src/ObjectCalendar.elementDataSource.test.tsx create mode 100644 packages/plugin-charts/src/ObjectChart.elementDataSource.test.tsx create mode 100644 packages/plugin-dashboard/src/ObjectMetric.elementDataSource.test.tsx create mode 100644 packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.elementDataSource.test.tsx create mode 100644 packages/plugin-form/src/ObjectForm.elementDataSource.test.tsx create mode 100644 packages/plugin-grid/src/__tests__/ObjectGrid.elementDataSource.test.tsx create mode 100644 packages/plugin-kanban/src/ObjectKanban.elementDataSource.test.tsx create mode 100644 packages/react/src/element-data-source/ElementDataSourceGate.tsx create mode 100644 packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx diff --git a/.changeset/element-datasource-block-wiring-os6953.md b/.changeset/element-datasource-block-wiring-os6953.md new file mode 100644 index 0000000000..20ffdc0cb5 --- /dev/null +++ b/.changeset/element-datasource-block-wiring-os6953.md @@ -0,0 +1,58 @@ +--- +"@object-ui/react": minor +"@object-ui/components": patch +"@object-ui/plugin-grid": patch +"@object-ui/plugin-form": patch +"@object-ui/plugin-kanban": patch +"@object-ui/plugin-calendar": patch +"@object-ui/plugin-charts": patch +"@object-ui/plugin-dashboard": patch +"@object-ui/plugin-detail": patch +--- + +`PageComponentSchema.dataSource` now reaches every object-bound block, not just +`list-view` — and `element:record_picker` stops discarding `view` +(objectstack#6953). + +objectstack#5576 wired the spec's per-element data binding +(`dataSource: { object, view?, filter?, sort?, limit? }`) to `list-view` and left +the same declaration inert on every other page component. Two gaps remained, and +both were silent: + +- **`element:record_picker` read four of the five keys and dropped `view`.** So + `dataSource: { object: 'account', view: 'hot' }` — the spec's own example — + built a picker over EVERY account instead of the rows the saved view selects. + Nothing threw and nothing rendered an error; the option list was simply wider + than what was authored, which also means a user could select a record the page + said was out of scope. +- **`object-grid` / `object-form` / `object-kanban` / `object-calendar` / + `object-chart` / `object-metric` / `record:related_list` read none of it.** + Each gates its fetch on its own `objectName`, and nothing mapped + `dataSource.object` onto it, so a page written the way the spec documents + rendered an empty grid / a field-less form / a board with no cards / an empty + month / an empty chart / a static metric number — with no request and no + diagnostic anywhere. Spec-valid metadata rendering nothing is the + objectstack#4413 shape. + +Composition follows objectstack#5576's landed semantics unchanged on every block: +a named saved view supplies the baseline, a key written on the component itself +overrides it, an explicit binding key overrides both, `filter` AND-combines +("additional filter criteria" — a binding can narrow a view, never widen it), and +a `view` name that does not resolve renders a configuration error instead of +degrading to the object's full scope. + +- `@object-ui/react` — new `useElementDataSourceSchema(schema, mapping, dataSource?)` + and `ElementDataSourceGate` apply a resolved binding to the schema keys a given + block reads, plus `ElementDataSourceErrorPanel` / `ElementDataSourceLoadingPanel` + for the two non-final states. One precedence table for all blocks rather than + one copy per block — that copy is how "additional filter criteria" would have + become two dialects. +- A mapping names **only** keys its block genuinely reads. A composed value + written onto a key the block ignores would be accepted and dropped, which is + the defect being removed, one layer deeper — so a kanban's swimlane `columns` + never receive a view's field list, and a block with no row cap leaves `limit` + unmapped. The per-block coverage table, including two residual gaps that are + named rather than papered over, is in `content/docs/guide/data-source.md`. + +No behaviour changes for a block that carries no `dataSource`: the binding-free +path returns the schema by reference, so nothing remounts and nothing refetches. diff --git a/content/docs/guide/data-source.md b/content/docs/guide/data-source.md index aff8381758..590936d2e6 100644 --- a/content/docs/guide/data-source.md +++ b/content/docs/guide/data-source.md @@ -233,3 +233,42 @@ it never degrades into an unfiltered query for the object. renderers that need the same resolution, and `@object-ui/core` exposes the pure parts (`isElementDataSourceConfig`, `resolveSavedView`, `composeElementDataSource`). + +### Which blocks consume it, and which keys each one honours + +The binding is declared on every page component, but a component can only honour +the keys it has a read site for — a calendar has no page to cap, a metric is one +aggregated number, a form edits one record. Each block therefore maps the keys it +reads and leaves the rest alone; a key written onto a schema slot the block +ignores would be accepted and dropped, which is the defect this binding removes. + +| block | `object` | `view` | `filter` | `sort` | `limit` | +|---|---|---|---|---|---| +| `list-view` | ✅ | ✅ | ✅ | ✅ | ✅ | +| `object-grid` | ✅ | ✅ | ✅ | ✅ | ✅ | +| `element:record_picker` | ✅ | ✅ | ✅ | ✅ | ✅ | +| `record:related_list` | ✅ | columns / sort / limit | — (see below) | ✅ | ✅ | +| `object-calendar` | ✅ | filter / sort | ✅ | ✅ | — no row cap | +| `object-kanban` | ✅ | filter | ✅ | — no ordering | — fixed window | +| `object-chart` | ✅ | filter | ✅ | — engine orders | — no page | +| `object-metric` | ✅ | filter | ✅ | — single value | — single value | +| `object-form` | ✅ | error-checked only | — no collection query | — | — | + +Reading the `view` column: it lists what a named saved view actually contributes +on that block. A view name that does not resolve is reported as a configuration +error on **every** block in the table, including the ones that take nothing else +from the view — so a typo never passes silently, whatever the block. + +Two current gaps, recorded rather than papered over: + +- `record:related_list` declares a flat `filter` its renderer does not read (the + list scopes itself by the parent relationship alone), so a view named there + contributes columns / sort / limit and its filter is dropped — the list can be + wider than the view it names. +- `object-form` resolves `view` only to report an unresolvable name; a view that + does resolve contributes nothing, because a list view's columns are not a form + layout. + +Blocks not in the table (`object-gantt`, `object-timeline`, `object-map`, +`object-pivot`, `dashboard`, the other `record:*` panels) do not consume the +binding yet. 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 new file mode 100644 index 0000000000..2c0503c456 --- /dev/null +++ b/packages/components/src/__tests__/record-picker-element-data-source.test.tsx @@ -0,0 +1,149 @@ +/** + * 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:record_picker` honours `dataSource.view` (objectstack#6953). + * + * This block DID read the spec's per-element binding — `object`, `filter`, + * `sort`, `limit` — and dropped exactly one key: `view`. So + * `dataSource: { object: 'account', view: 'hot' }`, the spec's own example, + * built a picker over EVERY account instead of the rows the saved view selects. + * + * That symptom is quieter than the one objectstack#5576 fixed on `list-view`: + * nothing throws and nothing renders an error — the option list is simply WIDER + * than what was authored, and a picker whose list is too wide is a picker that + * lets a user select a record the page said was out of scope. It is the failure + * class an AI-authored page hides best, which is why the not-found case below + * reports instead of falling back. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { AdapterCtx, SchemaRenderer } from '@object-ui/react'; +// Registers `element:record_picker` at module scope (not in a hook) — the +// objectui#3010 rule. +import '../renderers'; + +const HOT_VIEW = { + name: 'hot', + label: 'Hot accounts', + columns: ['name', 'rating'], + filter: [['rating', '=', 'hot']], + sort: [{ field: 'name', order: 'desc' }], + pagination: { pageSize: 7 }, +}; + +function makeAdapter(listViews: Record = { hot: HOT_VIEW }) { + return { + find: vi.fn().mockResolvedValue({ + data: [ + { id: 'a1', name: 'Acme', rating: 'hot' }, + { id: 'a2', name: 'Zephyr', rating: 'hot' }, + ], + }), + getObjectSchema: vi.fn().mockResolvedValue({ name: 'account', fields: {}, listViews }), + }; +} + +const renderPicker = (schema: Record, adapter: ReturnType) => + render( + + + , + ); + +const firstQuery = (adapter: ReturnType) => + adapter.find.mock.calls[0][1] as any; + +describe('element:record_picker — dataSource.view (objectstack#6953)', () => { + it('narrows the option list to the saved view’s filter, sort and cap', async () => { + const adapter = makeAdapter(); + renderPicker({ dataSource: { object: 'account', view: 'hot' } }, adapter); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + expect(adapter.find.mock.calls[0][0]).toBe('account'); + const query = firstQuery(adapter); + // The dropped key, now applied: before this, all three were absent and the + // picker offered every record of the object. + expect(query.$filter).toEqual([['rating', '=', 'hot']]); + expect(query.$orderby).toEqual([{ field: 'name', order: 'desc' }]); + expect(query.$top).toBe(7); + }); + + it('AND-combines the binding’s own filter with the view’s', async () => { + const adapter = makeAdapter(); + renderPicker( + { dataSource: { object: 'account', view: 'hot', filter: [['amount', '>', 100]] } }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + // "Additional filter criteria" (spec): the binding narrows the view, never + // widens it — a mistyped per-element filter cannot expose rows the view + // excluded. + const json = JSON.stringify(firstQuery(adapter).$filter); + expect(json).toContain('rating'); + expect(json).toContain('amount'); + }); + + it('lets explicit binding keys override the view’s sort and cap', async () => { + const adapter = makeAdapter(); + renderPicker( + { dataSource: { object: 'account', view: 'hot', sort: [{ field: 'amount', order: 'asc' }], limit: 3 } }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const query = firstQuery(adapter); + expect(query.$orderby).toEqual([{ field: 'amount', order: 'asc' }]); + expect(query.$top).toBe(3); + }); + + it('reports an unresolvable `view` instead of offering every record', async () => { + const adapter = makeAdapter(); + const { container } = renderPicker({ dataSource: { object: 'account', view: 'nope' } }, adapter); + + await waitFor(() => + expect(container.querySelector('[data-testid="record-picker-datasource-error"]')).not.toBeNull(), + ); + // The whole point: no query at all rather than an unfiltered one. + expect(adapter.find).not.toHaveBeenCalled(); + expect(container.textContent).toContain('hot'); + }); + + it('leaves a picker with NO view exactly as it was', async () => { + // The other direction of the single-variable reproduction: the four keys + // this block already read must behave identically. + const adapter = makeAdapter(); + renderPicker( + { + dataSource: { + object: 'account', + filter: [['owner', '=', 'me']], + sort: [{ field: 'name', order: 'asc' }], + limit: 12, + }, + }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const query = firstQuery(adapter); + expect(adapter.find.mock.calls[0][0]).toBe('account'); + expect(query.$filter).toEqual([['owner', '=', 'me']]); + expect(query.$orderby).toEqual([{ field: 'name', order: 'asc' }]); + expect(query.$top).toBe(12); + // No view named ⇒ nothing about saved views is fetched. + expect(adapter.getObjectSchema).not.toHaveBeenCalled(); + }); + + it('still honours the flat `properties.object` shorthand', async () => { + const adapter = makeAdapter(); + renderPicker({ properties: { object: 'account' } }, adapter); + await waitFor(() => expect(adapter.find).toHaveBeenCalledWith('account', expect.any(Object))); + }); +}); diff --git a/packages/components/src/renderers/basic/record-picker.tsx b/packages/components/src/renderers/basic/record-picker.tsx index bc59d01357..bb82d4eacf 100644 --- a/packages/components/src/renderers/basic/record-picker.tsx +++ b/packages/components/src/renderers/basic/record-picker.tsx @@ -9,7 +9,7 @@ * record of an object and writes the selection into a page variable. * * Data binding follows the spec's ElementDataSource (`schema.dataSource`): - * { object, filter?, sort?, limit? } + * { object, view?, filter?, sort?, limit? } * with `properties.object` accepted as a fallback. Display config is read off * `schema.properties`: * { labelField='name', valueField='id', label?, placeholder?, emptyText? } @@ -20,11 +20,26 @@ * picker is uncontrolled (still usable, just inert) so it never throws outside * a Page. The written value drives any predicate referencing `page.` * (e.g. another component's `visible` / `visibility`). + * + * `view` is resolved through {@link useElementDataSource} rather than read off + * the binding directly (objectstack#6953). This block used to take `object` / + * `filter` / `sort` / `limit` off `schema.dataSource` and DROP `view`, so + * `dataSource: { object: 'account', view: 'hot' }` — the spec's own example — + * built an unfiltered picker over every account instead of the rows the saved + * view selects. That symptom is quieter than the one objectstack#5576 fixed on + * `list-view`: nothing errors, the list is simply WIDER than what was authored, + * which is exactly the failure an AI-authored page hides best. */ import * as React from 'react'; import { ComponentRegistry } from '@object-ui/core'; -import { useAdapter, usePageVariableBinding } from '@object-ui/react'; +import { + ElementDataSourceErrorPanel, + ElementDataSourceLoadingPanel, + useAdapter, + useElementDataSource, + usePageVariableBinding, +} from '@object-ui/react'; import { Select, SelectTrigger, @@ -66,22 +81,33 @@ function ElementRecordPickerRenderer({ schema }: { schema: any }) { limit?: number; }>(schema); + const adapter = useAdapter() as any; + // Per-element data binding (ElementDataSourceSchema) takes precedence over the - // flat `properties.object` shorthand. - const ds = (schema?.dataSource ?? {}) as { - object?: string; - filter?: unknown; - sort?: unknown; - limit?: number; - }; - const object = ds.object ?? props.object; - const filter = ds.filter ?? props.filter; - const sort = ds.sort ?? props.sort; - const limit = ds.limit ?? props.limit ?? 50; + // flat `properties.object` shorthand. `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 + // *additional*. + // + // The picker's OWN adapter is passed rather than left to the hook's context + // fallback: this block reads its rows from `useAdapter()` (AppShellContext), + // and resolving `view` against a different source than the one the rows come + // from could report a view as missing on a host that has it. + const dataBinding = useElementDataSource(schema, adapter); + const composed = dataBinding.composed; + // While a named view is unresolved (or unresolvable) there is no object to + // query: reading one would fire the wide query the `view` was written to + // 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); + const filter = composed?.filter ?? props.filter; + const sort = composed?.sort ?? props.sort; + const limit = composed?.limit ?? props.limit ?? 50; const labelField = props.labelField ?? 'name'; const valueField = props.valueField ?? 'id'; - const adapter = useAdapter() as any; const binding = usePageVariableBinding(schema?.id); const [rows, setRows] = React.useState([]); @@ -137,6 +163,23 @@ function ElementRecordPickerRenderer({ schema }: { schema: any }) { const label = toText(props.label); const placeholder = props.placeholder ?? 'Select a record…'; + // Placed AFTER every hook above so the hook order stays stable across + // resolution states. A `view` that names nothing renders a configuration + // error rather than an unfiltered picker: degrading to "all records" turns a + // typo into a silently wider answer on a page that still looks like it works. + if (dataBinding.status === 'missing') { + return ( + + ); + } + if (dataBinding.status === 'loading') { + return ; + } + return (
= { hot: HOT_VIEW }) { + return { + find: vi.fn().mockResolvedValue({ data: [] }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'account', + fields: { + name: { type: 'text' }, + rating: { type: 'text' }, + starts_at: { type: 'datetime' }, + ends_at: { type: 'datetime' }, + }, + listViews, + }), + }; +} + +const renderBlock = (schema: Record, adapter: ReturnType) => + render( + + + , + ); + +describe('object-calendar — dataSource: { object, view } (objectstack#6953)', () => { + it('queries the bound object with the saved view’s filter and sort', async () => { + const adapter = makeAdapter(); + renderBlock( + { type: 'object-calendar', calendar: CALENDAR, dataSource: { object: 'account', view: 'hot' } }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [object, params] = adapter.find.mock.calls[0] as [string, any]; + expect(object).toBe('account'); + expect(params.$filter).toEqual([['rating', '=', 'hot']]); + expect(params.$orderby).toBeTruthy(); + }); + + it('reports an unresolvable `view` instead of fetching the whole object', async () => { + const adapter = makeAdapter(); + const { container } = renderBlock( + { type: 'object-calendar', calendar: CALENDAR, dataSource: { object: 'account', view: 'nope' } }, + adapter, + ); + + await waitFor(() => + expect(container.querySelector('[data-testid="object-calendar-datasource-error"]')).not.toBeNull(), + ); + expect(adapter.find).not.toHaveBeenCalled(); + }); + + it('leaves a calendar with NO dataSource exactly as it was', async () => { + const adapter = makeAdapter(); + renderBlock( + { + type: 'object-calendar', + objectName: 'account', + calendar: CALENDAR, + filter: [['owner', '=', 'me']], + }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [object, params] = adapter.find.mock.calls[0] as [string, any]; + expect(object).toBe('account'); + expect(params.$filter).toEqual([['owner', '=', 'me']]); + }); +}); diff --git a/packages/plugin-calendar/src/index.tsx b/packages/plugin-calendar/src/index.tsx index 8727083135..ff8104ce48 100644 --- a/packages/plugin-calendar/src/index.tsx +++ b/packages/plugin-calendar/src/index.tsx @@ -8,7 +8,11 @@ import React from 'react'; import { ComponentRegistry } from '@object-ui/core'; -import { useSchemaContext } from '@object-ui/react'; +import { + ElementDataSourceGate, + useSchemaContext, + type ElementDataSourceMapping, +} from '@object-ui/react'; import { ObjectCalendar } from './ObjectCalendar'; import type { ObjectCalendarProps } from './ObjectCalendar'; @@ -23,10 +27,37 @@ export type { CalendarViewProps, CalendarEvent } from './CalendarView'; // Import and register calendar-view renderer import './calendar-view-renderer'; +/** + * What `ObjectCalendar` reads for its own query: `objectName`, `filter` and + * `sort` (`ObjectCalendar.tsx` — `$filter: schema.filter`, + * `$orderby: convertSortToQueryParams(schema.sort)`). + * + * `columns` and a row cap are not mapped: a calendar projects the fields its + * `calendar` config names (start/end/title/color), and it fetches the whole + * window rather than a capped page, so neither key has a read site to write to. + */ +const OBJECT_CALENDAR_DATA_SOURCE: ElementDataSourceMapping = { + filter: true, + sort: true, +}; + // Register object-calendar component export const ObjectCalendarRenderer: React.FC<{ schema: any; [key: string]: any }> = ({ schema, ...props }) => { const { dataSource } = useSchemaContext() || {}; - return ; + // The spec's `PageComponentSchema.dataSource` binding (objectstack#6953): a + // calendar authored with the binding and no `objectName` never fetched, and + // rendered an empty month with no error. + return ( + + {(bound) => } + + ); }; ComponentRegistry.register('object-calendar', ObjectCalendarRenderer, { diff --git a/packages/plugin-charts/src/ObjectChart.elementDataSource.test.tsx b/packages/plugin-charts/src/ObjectChart.elementDataSource.test.tsx new file mode 100644 index 0000000000..5cb54af4e0 --- /dev/null +++ b/packages/plugin-charts/src/ObjectChart.elementDataSource.test.tsx @@ -0,0 +1,129 @@ +/** + * 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. + * + * `object-chart` consumes `PageComponentSchema.dataSource` (objectstack#6953). + * + * `ObjectChart` gates BOTH its fetch and its initial loading state on + * `schema.objectName` (`!schema.objectName && !schema.dataset` returns early), + * and nothing mapped the spec's `dataSource.object` onto it — so a chart authored + * with the binding the spec documents rendered an empty frame with no request and + * no error. + * + * The wiring lives in `ObjectChartBlock`, the registry shell beside the + * registration, not in the chart's container internals: consuming the binding is + * a registry-boundary concern. + * + * Only `object` and `filter` are mapped. A chart projects the `aggregate` / + * `xAxisKey` fields it declares, the engine decides the row order, and there is + * no page to cap — so a view's columns, sort and page size have no read site + * here and are deliberately left unmapped rather than written where nothing + * reads them. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +// Registers `object-chart` via `ObjectChartBlock` (the wiring under test). +import './index'; + +const HOT_VIEW = { + name: 'hot', + label: 'Hot accounts', + columns: ['name', 'rating'], + filter: [['rating', '=', 'hot']], + sort: [{ field: 'name', order: 'desc' }], + pagination: { pageSize: 7 }, +}; + +function makeAdapter(listViews: Record = { hot: HOT_VIEW }) { + return { + find: vi.fn().mockResolvedValue({ data: [{ id: '1', name: 'Acme', amount: 5 }] }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + aggregate: vi.fn().mockResolvedValue([]), + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'account', + fields: { name: { type: 'text' }, rating: { type: 'text' }, amount: { type: 'number' } }, + listViews, + }), + }; +} + +const renderBlock = (schema: Record, adapter: ReturnType) => + render( + + + , + ); + +describe('object-chart — dataSource: { object, view } (objectstack#6953)', () => { + it('queries the bound object with the saved view’s filter', async () => { + const adapter = makeAdapter(); + renderBlock( + { type: 'object-chart', chartType: 'bar', xAxisKey: 'name', dataSource: { object: 'account', view: 'hot' } }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [object, params] = adapter.find.mock.calls[0] as [string, any]; + expect(object).toBe('account'); + expect(params.$filter).toEqual([['rating', '=', 'hot']]); + }); + + it('AND-combines the binding’s own filter with the view’s', async () => { + const adapter = makeAdapter(); + renderBlock( + { + type: 'object-chart', + chartType: 'bar', + xAxisKey: 'name', + dataSource: { object: 'account', view: 'hot', filter: [['amount', '>', 100]] }, + }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const json = JSON.stringify((adapter.find.mock.calls[0] as any[])[1].$filter); + expect(json).toContain('rating'); + expect(json).toContain('amount'); + }); + + it('reports an unresolvable `view` instead of charting the whole object', async () => { + const adapter = makeAdapter(); + const { container } = renderBlock( + { type: 'object-chart', chartType: 'bar', xAxisKey: 'name', dataSource: { object: 'account', view: 'nope' } }, + adapter, + ); + + await waitFor(() => + expect(container.querySelector('[data-testid="object-chart-datasource-error"]')).not.toBeNull(), + ); + expect(adapter.find).not.toHaveBeenCalled(); + }); + + it('leaves a chart with NO dataSource exactly as it was', async () => { + const adapter = makeAdapter(); + renderBlock( + { + type: 'object-chart', + objectName: 'account', + chartType: 'bar', + xAxisKey: 'name', + filter: [['owner', '=', 'me']], + }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [object, params] = adapter.find.mock.calls[0] as [string, any]; + expect(object).toBe('account'); + expect(params.$filter).toEqual([['owner', '=', 'me']]); + }); +}); diff --git a/packages/plugin-charts/src/ObjectChart.tsx b/packages/plugin-charts/src/ObjectChart.tsx index f9b4fe8b62..d394ea8070 100644 --- a/packages/plugin-charts/src/ObjectChart.tsx +++ b/packages/plugin-charts/src/ObjectChart.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useContext, useCallback, useMemo, useRef } from 'react'; -import { useDataScope, SchemaRendererContext, SchemaRenderer, useDrillNavigation, useFilterScope } from '@object-ui/react'; +import { useDataScope, SchemaRendererContext, SchemaRenderer, useDrillNavigation, useFilterScope, ElementDataSourceGate, type ElementDataSourceMapping } from '@object-ui/react'; import { ChartRenderer } from './ChartRenderer'; import { ComponentRegistry, extractRecords, computeDrillFilter, isDrillEnabled, resolveDrillTitle, resolveFilterPlaceholders, resolveContextTokens, shiftFilterByCompareTo, compareToTrendLabelKey, buildChartSeries, buildOptionColorMap, buildDimensionLabelMap, relabelDimensions, type CompareToConfig, type DrillEvent, type ChartResultField } from '@object-ui/core'; import { Sheet, SheetContent, SheetHeader, SheetTitle, Dialog, DialogContent, DialogHeader, DialogTitle, RefreshIndicator, Button, ChartSkeleton } from '@object-ui/components'; @@ -905,8 +905,47 @@ export const ObjectChart = (props: any) => { ); }; +/** + * What `ObjectChart` reads for its own query: `objectName` and `filter` (the + * `ds.aggregate` / `ds.find` calls above, both `$filter: schema.filter`). + * + * Neither `columns` nor `sort` nor a row cap is mapped, and the reason is the + * shape of the block rather than an omission: a chart projects the + * `aggregate` / `xAxisKey` fields it declares, the engine returns grouped rows + * whose order the aggregation decides, and there is no page to cap. Writing a + * saved view's column list or sort onto this schema would be a value accepted + * and then dropped — the defect objectstack#6953 exists to remove. + */ +const OBJECT_CHART_DATA_SOURCE: ElementDataSourceMapping = { + filter: true, +}; + +/** + * Registry shell for `object-chart` — maps the spec's + * `PageComponentSchema.dataSource` binding onto the keys {@link ObjectChart} + * reads (objectstack#6953). + * + * Nothing used to map `dataSource.object` onto `objectName`, and this block gates + * BOTH its fetch and its loading state on that key (`!schema.objectName && + * !schema.dataset` returns early) — so a chart authored with the binding the + * spec documents rendered an empty frame with no error and no request. Lives + * here, beside the registration, rather than in `ChartContainerImpl` — the + * binding is a registry-boundary concern, not a rendering one. + */ +export const ObjectChartBlock = (props: any) => ( + + {(bound) => } + +); + // Register it -ComponentRegistry.register('object-chart', ObjectChart, { +ComponentRegistry.register('object-chart', ObjectChartBlock, { namespace: 'plugin-charts', label: 'Object Chart', category: 'view', diff --git a/packages/plugin-charts/src/index.tsx b/packages/plugin-charts/src/index.tsx index 83511c6f3b..c840f06516 100644 --- a/packages/plugin-charts/src/index.tsx +++ b/packages/plugin-charts/src/index.tsx @@ -8,12 +8,12 @@ import { ComponentRegistry } from '@object-ui/core'; import { ChartBarRenderer, ChartRenderer } from './ChartRenderer'; -import { ObjectChart } from './ObjectChart'; +import { ObjectChartBlock } from './ObjectChart'; // Export types for external use export type { BarChartSchema } from './types'; export { ChartBarRenderer, ChartRenderer }; -export { ObjectChart } from './ObjectChart'; +export { ObjectChart, ObjectChartBlock } from './ObjectChart'; // Standard Export Protocol - for manual integration export const chartComponents = { @@ -55,7 +55,11 @@ ComponentRegistry.register( // `plugin-charts:chart` (ChartRenderer) registered below, which owns the bare // `type: 'chart'` schema keyword; this object/aggregate-query variant is // reached via `view:chart` only. -ComponentRegistry.register('chart', ObjectChart, { +// `ObjectChartBlock` (not the bare `ObjectChart`) so this alias consumes the +// spec's per-element `dataSource` binding exactly as `object-chart` does — +// one block reached under two keys must not be bound under only one of them +// (objectstack#6953). +ComponentRegistry.register('chart', ObjectChartBlock, { namespace: 'view', category: 'view', label: 'Chart', diff --git a/packages/plugin-dashboard/src/ObjectMetric.elementDataSource.test.tsx b/packages/plugin-dashboard/src/ObjectMetric.elementDataSource.test.tsx new file mode 100644 index 0000000000..9e57efd869 --- /dev/null +++ b/packages/plugin-dashboard/src/ObjectMetric.elementDataSource.test.tsx @@ -0,0 +1,120 @@ +/** + * 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. + * + * `object-metric` consumes `PageComponentSchema.dataSource` (objectstack#6953). + * + * This widget is registered directly and takes `objectName` / `filter` as PROPS + * (`SchemaRenderer` spreads the schema's own keys onto it), so the spec's + * per-element binding had no path in at all. Its no-object branch is the reason + * the gap is worth a pin: with no `objectName` the widget does not error, it + * renders its STATIC fallback value — a number that looks real and answers + * nothing. + * + * `object` and `filter` are the only mapped keys: a metric is one aggregated + * number, so there is no projection, no ordering and no page for the binding's + * remaining keys to act on. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +// Registers `object-metric` via `ObjectMetricBlock` (the wiring under test). +import './index'; + +const HOT_VIEW = { + name: 'hot', + label: 'Hot accounts', + columns: ['name', 'rating'], + filter: [['rating', '=', 'hot']], + sort: [{ field: 'name', order: 'desc' }], + pagination: { pageSize: 7 }, +}; + +function makeAdapter(listViews: Record = { hot: HOT_VIEW }) { + return { + find: vi.fn().mockResolvedValue({ data: [] }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + aggregate: vi.fn().mockResolvedValue([{ amount: 42 }]), + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'account', + fields: { name: { type: 'text' }, rating: { type: 'text' }, amount: { type: 'number' } }, + listViews, + }), + }; +} + +const AGGREGATE = { field: 'amount', function: 'sum' }; + +const renderBlock = (schema: Record, adapter: ReturnType) => + render( + + + , + ); + +describe('object-metric — dataSource: { object, view } (objectstack#6953)', () => { + it('aggregates over the bound object with the saved view’s filter', async () => { + const adapter = makeAdapter(); + renderBlock( + { + type: 'object-metric', + label: 'Pipeline', + aggregate: AGGREGATE, + dataSource: { object: 'account', view: 'hot' }, + }, + adapter, + ); + + await waitFor(() => expect(adapter.aggregate).toHaveBeenCalled()); + const [object, params] = adapter.aggregate.mock.calls[0] as [string, any]; + expect(object).toBe('account'); + // `ObjectMetricWidget` passes the filter as the aggregate's own `filter` + // key, not as an OData `$filter`. + expect(params.filter).toEqual([['rating', '=', 'hot']]); + }); + + it('reports an unresolvable `view` instead of showing a number for the whole object', async () => { + const adapter = makeAdapter(); + const { container } = renderBlock( + { + type: 'object-metric', + label: 'Pipeline', + aggregate: AGGREGATE, + dataSource: { object: 'account', view: 'nope' }, + }, + adapter, + ); + + await waitFor(() => + expect(container.querySelector('[data-testid="object-metric-datasource-error"]')).not.toBeNull(), + ); + expect(adapter.aggregate).not.toHaveBeenCalled(); + }); + + it('leaves a metric with NO dataSource exactly as it was', async () => { + const adapter = makeAdapter(); + renderBlock( + { + type: 'object-metric', + objectName: 'account', + label: 'Pipeline', + aggregate: AGGREGATE, + filter: [['owner', '=', 'me']], + }, + adapter, + ); + + await waitFor(() => expect(adapter.aggregate).toHaveBeenCalled()); + const [object, params] = adapter.aggregate.mock.calls[0] as [string, any]; + expect(object).toBe('account'); + expect(params.filter).toEqual([['owner', '=', 'me']]); + }); +}); diff --git a/packages/plugin-dashboard/src/index.tsx b/packages/plugin-dashboard/src/index.tsx index 33825ac580..4d43f970d9 100644 --- a/packages/plugin-dashboard/src/index.tsx +++ b/packages/plugin-dashboard/src/index.tsx @@ -6,7 +6,9 @@ * LICENSE file in the root directory of this source tree. */ +import React from 'react'; import { ComponentRegistry } from '@object-ui/core'; +import { ElementDataSourceGate, type ElementDataSourceMapping } from '@object-ui/react'; import { DashboardRenderer } from './DashboardRenderer'; import { DashboardGridLayout } from './DashboardGridLayout'; import { MetricWidget } from './MetricWidget'; @@ -91,10 +93,54 @@ ComponentRegistry.register( } ); +/** + * What `ObjectMetricWidget` reads for its own query: the object it aggregates + * and the filter it aggregates over. A metric is ONE aggregated number — there + * is no projection, no ordering and no page — so `columns` / `sort` / `limit` + * have no read site here and are left unmapped rather than written to a key the + * widget ignores. + */ +const OBJECT_METRIC_DATA_SOURCE: ElementDataSourceMapping = { + filter: true, +}; + +/** + * Registry shell for `object-metric` — the spec's per-element `dataSource` + * binding onto the props {@link ObjectMetricWidget} reads (objectstack#6953). + * + * This widget is registered directly and takes `objectName` / `filter` as PROPS + * (`SchemaRenderer` spreads the schema's own keys onto it), so the binding had + * no path in at all: a metric authored with `dataSource: { object, view }` and + * no flat `objectName` fell through to the widget's no-object branch and showed + * its static fallback value — a number that looks real and answers nothing. + * + * The props keep their standing when there is no binding: `bound` IS the schema + * by reference in that case, so `bound?.x ?? props.x` resolves to what the + * spread already provided, and a host that renders this component with explicit + * props and no schema at all (the dashboard grid path) is untouched. + */ +const ObjectMetricBlock: React.FC<{ schema?: any; [key: string]: any }> = ({ schema, ...props }) => ( + + {(bound) => ( + + )} + +); + // Register object-aware metric widget (async data loading with error states) ComponentRegistry.register( 'object-metric', - ObjectMetricWidget, + ObjectMetricBlock, { namespace: 'plugin-dashboard', label: 'Object Metric', diff --git a/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.elementDataSource.test.tsx b/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.elementDataSource.test.tsx new file mode 100644 index 0000000000..3f9a537f6c --- /dev/null +++ b/packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.elementDataSource.test.tsx @@ -0,0 +1,132 @@ +/** + * 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. + * + * `record:related_list` consumes `PageComponentSchema.dataSource` + * (objectstack#6953). + * + * The spec declares the binding on every page component; this block read none of + * it. Nothing mapped `dataSource.object` onto the `objectName` the renderer + * requires, so a related list authored with the binding hit the + * "missing objectName" placeholder instead of listing anything. + * + * ## The one key that is NOT mapped, and why it is a finding + * + * `filter` stays unmapped: this renderer DECLARES `filter` in its registry + * `inputs` ("Additional filter criteria") and never reads it — `RelatedList` + * builds its query from `{ [referenceField]: parentId }` alone and takes no + * filter prop for the list's own scope. Mapping the composed filter onto + * `schema.filter` would hand it to a key nothing consumes, which is the defect + * objectstack#6953 removes rather than spreads. + * + * The consequence is pinned rather than left implicit (last test): while that + * gap is open, a saved view named here contributes its columns / sort / limit and + * its FILTER is dropped, so the list can be wider than the view it names. When + * the flat `filter` gains a read site (objectstack#7118), `filter: true` belongs + * in the mapping and that test is the one that must change. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import * as React from 'react'; +import { RecordContextProvider } from '@object-ui/react'; +import { RecordRelatedListRenderer } from '../renderers/record-related-list'; + +// Capture what the renderer passes down to RelatedList — the question actually +// asked of the list, not merely that a prop was threaded somewhere. +const h = vi.hoisted(() => ({ captured: null as any })); +vi.mock('../RelatedList', () => ({ + RelatedList: (props: any) => { + h.captured = props; + return
; + }, +})); + +const RECENT_VIEW = { + name: 'recent', + label: 'Recent contacts', + columns: ['name', 'email'], + filter: [['is_active', '=', true]], + sort: [{ field: 'created', order: 'desc' }], + pagination: { pageSize: 3 }, +}; + +const makeDataSource = (listViews: Record = { recent: RECENT_VIEW }) => ({ + find: vi.fn(async () => []), + getObjectSchema: vi.fn(async (name: string) => ({ name, fields: {}, listViews })), +}); + +function renderRelated(schema: Record, ds = makeDataSource()) { + const utils = render( + + + , + ); + return { ...utils, ds }; +} + +beforeEach(() => { + h.captured = null; +}); + +describe('record:related_list — dataSource: { object, view } (objectstack#6953)', () => { + it('maps `object` onto the related objectName it lists', async () => { + renderRelated({ dataSource: { object: 'contact' } }); + // Before the wiring this rendered the "missing objectName" placeholder. + await waitFor(() => expect(h.captured).toBeTruthy()); + expect(h.captured.objectName).toBe('contact'); + expect(h.captured.api).toBe('contact'); + }); + + it('takes the saved view’s columns, sort and row cap', async () => { + renderRelated({ dataSource: { object: 'contact', view: 'recent' } }); + await waitFor(() => expect(h.captured).toBeTruthy()); + expect(h.captured.columns).toEqual(['name', 'email']); + expect(h.captured.defaultSort).toEqual([{ field: 'created', order: 'desc' }]); + expect(h.captured.pageSize).toBe(3); + }); + + it('lets an authored key win over the same key from the view', async () => { + renderRelated({ + columns: ['name'], + limit: 20, + dataSource: { object: 'contact', view: 'recent' }, + }); + await waitFor(() => expect(h.captured).toBeTruthy()); + expect(h.captured.columns).toEqual(['name']); + expect(h.captured.pageSize).toBe(20); + }); + + it('reports an unresolvable `view` instead of listing every child row', async () => { + const { container } = renderRelated({ dataSource: { object: 'contact', view: 'nope' } }); + await waitFor(() => + expect(container.querySelector('[data-testid="record-related-list-datasource-error"]')).not.toBeNull(), + ); + expect(h.captured).toBeNull(); + expect(container.textContent).toContain('recent'); + }); + + it('leaves a related list with NO dataSource exactly as it was', async () => { + renderRelated({ objectName: 'contact', columns: ['name'], limit: 7 }); + await waitFor(() => expect(h.captured).toBeTruthy()); + expect(h.captured.objectName).toBe('contact'); + expect(h.captured.columns).toEqual(['name']); + expect(h.captured.pageSize).toBe(7); + }); + + it('does NOT hand a composed filter to a key this block cannot read (open gap)', async () => { + // Honest pin on the residual gap, not a claim that filtering works: the + // renderer declares `filter` and never reads it, and `RelatedList` has no + // prop for the list's own filter. Writing the view's filter onto + // `schema.filter` would look like wiring and change nothing, so the mapping + // does not — and this asserts that no filter reaches `RelatedList` under any + // spelling. Filed as objectstack#7118; when a read site lands, this flips. + renderRelated({ dataSource: { object: 'contact', view: 'recent', filter: [['x', '=', 1]] } }); + await waitFor(() => expect(h.captured).toBeTruthy()); + expect(h.captured.filter).toBeUndefined(); + expect(h.captured.baseFilter).toBeUndefined(); + }); +}); diff --git a/packages/plugin-detail/src/renderers/record-related-list.tsx b/packages/plugin-detail/src/renderers/record-related-list.tsx index 7b0e2cc572..5129783459 100644 --- a/packages/plugin-detail/src/renderers/record-related-list.tsx +++ b/packages/plugin-detail/src/renderers/record-related-list.tsx @@ -12,7 +12,13 @@ */ import React from 'react'; -import { useRecordContext, useSafeFieldLabel, useRelatedRecordActions } from '@object-ui/react'; +import { + ElementDataSourceGate, + useRecordContext, + useSafeFieldLabel, + useRelatedRecordActions, + type ElementDataSourceMapping, +} from '@object-ui/react'; import { useFieldPermissions, usePermissions } from '@object-ui/permissions'; import { useObjectTranslation, pickLocalized } from '@object-ui/i18n'; import { humanizeLabel } from '@object-ui/fields'; @@ -51,7 +57,7 @@ export interface RecordRelatedListRendererProps { [k: string]: any; } -export const RecordRelatedListRenderer: React.FC = ({ +const RecordRelatedListBody: React.FC = ({ schema = {} as any, className, ...props @@ -240,4 +246,61 @@ export const RecordRelatedListRenderer: React.FC ); }; +/** + * What this block reads for its own query: `objectName`, `columns` (a FIELD + * list), `sort` (`defaultSort`) and `limit` (`pageSize`). + * + * `filter` is NOT mapped, and that is a finding rather than a choice: this + * renderer declares `filter` in its registry `inputs` ("Additional filter + * criteria") and never reads it — `RelatedList` builds its query from + * `{ [referenceField]: parentId }` alone and takes no filter prop for the list's + * own scope. Mapping the composed filter onto `schema.filter` would hand it to a + * key nothing consumes, which is the defect objectstack#6953 removes rather than + * spreads. The consequence is recorded honestly: while that gap is open, a saved + * view named here contributes its columns/sort/limit and its FILTER is dropped, + * so the list can be wider than the view it names. Filed as objectstack#7118; + * when the flat `filter` gains a read site, `filter: true` belongs in this + * mapping and the binding follows it for free. + */ +const RECORD_RELATED_LIST_DATA_SOURCE: ElementDataSourceMapping = { + columns: true, + sort: true, + limit: 'limit', +}; + +/** + * Stable stand-in for a missing `schema`. A fresh `{}` per render would give the + * body a new schema identity every time — the churn `useElementDataSourceSchema` + * avoids by returning the schema BY REFERENCE when there is no binding. + */ +const NO_SCHEMA = {} as RecordRelatedListRendererProps['schema']; + +/** + * `record:related_list` with the spec's per-element `dataSource` binding mapped + * onto the keys the body reads (objectstack#6953). + * + * The gate wraps the EXPORTED name rather than being added at the registration + * site, so a host that imports this renderer directly gets the binding too — a + * block bound under one entry point and unbound under another is the same + * "declared but not reached" shape in miniature. + */ +export const RecordRelatedListRenderer: React.FC = (props) => { + // The record context's adapter, not the schema-renderer context's: this list + // reads its rows through `ctx.dataSource`, and resolving `view` against a + // different source than the rows come from could report a view as missing on + // a host that has it. + const ctx = useRecordContext(); + return ( + + {(bound) => } + + ); +}; + export default RecordRelatedListRenderer; diff --git a/packages/plugin-form/src/ObjectForm.elementDataSource.test.tsx b/packages/plugin-form/src/ObjectForm.elementDataSource.test.tsx new file mode 100644 index 0000000000..4a154f49c5 --- /dev/null +++ b/packages/plugin-form/src/ObjectForm.elementDataSource.test.tsx @@ -0,0 +1,113 @@ +/** + * 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. + * + * `object-form` consumes `PageComponentSchema.dataSource` (objectstack#6953). + * + * `ObjectForm` gates its whole schema fetch on `objectName`, and nothing mapped + * the spec's `dataSource.object` onto it — so a page authored with the binding + * the spec documents rendered a field-less shell with no error. That is the + * silent half of objectstack#6953. + * + * ## Scope, stated so the pin is not over-read + * + * `object` is the only key of the binding this block can honour, and the second + * test says so as a property rather than a comment: a form edits ONE record, so + * there is no collection query for `filter` / `sort` / `limit` to narrow, and a + * saved LIST view's columns are not a form layout. Those keys are deliberately + * left unmapped — writing them onto schema keys `ObjectForm` ignores would + * reproduce the defect this wiring removes, one layer deeper. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +// Registers `object-form` (and the ElementDataSourceGate wiring under test). +import './index'; + +const HOT_VIEW = { + name: 'hot', + label: 'Hot accounts', + columns: ['name', 'rating'], + filter: [['rating', '=', 'hot']], + sort: [{ field: 'name', order: 'desc' }], + pagination: { pageSize: 7 }, +}; + +function makeAdapter(listViews: Record = { hot: HOT_VIEW }) { + return { + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn().mockResolvedValue({ id: 'a1', name: 'Acme' }), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'account', + label: 'Account', + fields: { + name: { name: 'name', type: 'text', label: 'Name' }, + rating: { name: 'rating', type: 'text', label: 'Rating' }, + }, + listViews, + }), + }; +} + +const renderBlock = (schema: Record, adapter: ReturnType) => + render( + + + , + ); + +describe('object-form — dataSource: { object } (objectstack#6953)', () => { + it('fetches the bound object’s schema, so the form has fields at all', async () => { + const adapter = makeAdapter(); + renderBlock({ type: 'object-form', mode: 'create', dataSource: { object: 'account' } }, adapter); + + // The observable difference: `getObjectSchema('account')` is what produces + // the form's fields, and it was never called because `objectName` was unset. + await waitFor(() => expect(adapter.getObjectSchema).toHaveBeenCalledWith('account')); + }); + + it('honours `object` and nothing else the binding may carry', async () => { + const adapter = makeAdapter(); + const { container } = renderBlock( + { + type: 'object-form', + mode: 'create', + dataSource: { object: 'account', view: 'hot', limit: 3, sort: [{ field: 'name', order: 'asc' }] }, + }, + adapter, + ); + + await waitFor(() => expect(adapter.getObjectSchema).toHaveBeenCalledWith('account')); + // A form issues no collection query, so a filter/sort/limit on the binding + // has nothing to act on — and must not silently become one. + expect(adapter.find).not.toHaveBeenCalled(); + expect(container.querySelector('[data-testid="object-form-datasource-error"]')).toBeNull(); + }); + + it('reports an unresolvable `view` rather than rendering as if it resolved', async () => { + const adapter = makeAdapter(); + const { container } = renderBlock( + { type: 'object-form', mode: 'create', dataSource: { object: 'account', view: 'nope' } }, + adapter, + ); + + await waitFor(() => + expect(container.querySelector('[data-testid="object-form-datasource-error"]')).not.toBeNull(), + ); + expect(container.textContent).toContain('hot'); + }); + + it('leaves a form with NO dataSource exactly as it was', async () => { + const adapter = makeAdapter(); + renderBlock({ type: 'object-form', objectName: 'account', mode: 'create' }, adapter); + await waitFor(() => expect(adapter.getObjectSchema).toHaveBeenCalledWith('account')); + }); +}); diff --git a/packages/plugin-form/src/index.tsx b/packages/plugin-form/src/index.tsx index ba64fb45cd..d98a9db2b7 100644 --- a/packages/plugin-form/src/index.tsx +++ b/packages/plugin-form/src/index.tsx @@ -8,7 +8,7 @@ import React, { useContext } from 'react'; import { ComponentRegistry } from '@object-ui/core'; -import { SchemaRendererContext } from '@object-ui/react'; +import { ElementDataSourceGate, SchemaRendererContext } from '@object-ui/react'; import { ObjectForm } from './ObjectForm'; export { ObjectForm }; @@ -60,7 +60,28 @@ const ObjectFormRenderer: React.FC<{ schema: any }> = ({ schema }) => { // through SchemaRenderer (e.g. the Studio view preview) has no fields. const ctx = useContext(SchemaRendererContext as React.Context); const dataSource = ctx?.dataSource ?? undefined; - return ; + // The spec's `PageComponentSchema.dataSource` binding (objectstack#6953). A + // page that declared `dataSource: { object }` and no `objectName` rendered a + // field-less shell: `ObjectForm` gates its whole schema fetch on `objectName`. + // + // `object` is the ONLY key of the binding this block can honour, and the + // mapping says so rather than parking the rest somewhere plausible. A form + // edits ONE record — it has no collection query, so there is nothing for + // `filter` / `sort` / `limit` to narrow, and a saved LIST view's columns are + // not a form layout (`fields` here is an ordered layout, not a projection). + // A `view` name is still resolved, so naming one that does not exist reports + // instead of rendering; a view that DOES resolve contributes nothing on this + // block, which is recorded as the residual gap on objectstack#6953. + return ( + + {(bound) => } + + ); }; ComponentRegistry.register('object-form', ObjectFormRenderer, { diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.elementDataSource.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.elementDataSource.test.tsx new file mode 100644 index 0000000000..c07e5d42a1 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.elementDataSource.test.tsx @@ -0,0 +1,135 @@ +/** + * 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. + * + * `object-grid` consumes `PageComponentSchema.dataSource` (objectstack#6953). + * + * The spec declares this binding on EVERY page component, and objectstack#5576 + * wired it to `list-view` only. On `object-grid` nothing mapped + * `dataSource.object` onto the `objectName` this block requires, so a page that + * declared the binding the spec documents — and no separate `objectName` — + * rendered an EMPTY grid: `getDataConfig` returned `null`, the fetch effect + * never ran, and no error was reported anywhere. "Spec-valid metadata renders + * nothing" is the objectstack#4413 shape. + * + * `object-grid` is the one block the binding maps onto without a gap — it reads + * every key the binding carries — so both directions are asserted end to end + * here: what reaches `dataSource.find`, and that a grid with no binding is + * untouched. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +// Registers `object-grid` (and the ElementDataSourceGate wiring under test). +import '../index'; + +const HOT_VIEW = { + name: 'hot', + label: 'Hot accounts', + columns: ['name', 'rating'], + filter: [['rating', '=', 'hot']], + sort: [{ field: 'name', order: 'desc' }], + pagination: { pageSize: 7 }, +}; + +function makeAdapter(listViews: Record = { hot: HOT_VIEW }) { + return { + find: vi.fn().mockResolvedValue({ data: [{ id: '1', name: 'Acme', rating: 'hot' }], total: 1 }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'account', + fields: { id: { type: 'text' }, name: { type: 'text' }, rating: { type: 'text' } }, + listViews, + }), + }; +} + +const renderBlock = (schema: Record, adapter: ReturnType) => + render( + + + , + ); + +describe('object-grid — dataSource: { object, view } (objectstack#6953)', () => { + it('queries the bound object with the saved view’s filter, sort and row cap', async () => { + const adapter = makeAdapter(); + renderBlock({ type: 'object-grid', dataSource: { object: 'account', view: 'hot' } }, adapter); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + + const [object, params] = adapter.find.mock.calls[0] as [string, any]; + // `object` → `objectName`: the mapping that did not exist. Without it there + // was no query at all, not a wrong one. + expect(object).toBe('account'); + expect(params.$filter).toEqual([['rating', '=', 'hot']]); + // `ObjectGrid` lowers a declared sort to the string form on the wire. + expect(params.$orderby).toBe('name desc'); + // The view's page size is the fetch window, not just a display setting. + expect(params.$top).toBe(7); + }); + + it('AND-combines the binding’s own filter with the view’s', async () => { + const adapter = makeAdapter(); + renderBlock( + { + type: 'object-grid', + dataSource: { object: 'account', view: 'hot', filter: [['amount', '>', 100]] }, + }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const json = JSON.stringify((adapter.find.mock.calls[0] as any[])[1].$filter); + // "Additional filter criteria" (spec): the binding may only narrow the view. + expect(json).toContain('rating'); + expect(json).toContain('amount'); + }); + + it('reports an unresolvable `view` instead of querying the whole object', async () => { + const adapter = makeAdapter(); + const { container } = renderBlock( + { type: 'object-grid', dataSource: { object: 'account', view: 'nope' } }, + adapter, + ); + + await waitFor(() => + expect(container.querySelector('[data-testid="object-grid-datasource-error"]')).not.toBeNull(), + ); + // The point of failing loudly: a typo must not become a wider answer. + expect(adapter.find).not.toHaveBeenCalled(); + expect(container.textContent).toContain('hot'); + }); + + it('leaves a grid with NO dataSource exactly as it was', async () => { + // The other direction of the single-variable reproduction. Nothing about the + // flat-key path may move: same object, same filter, same window. + const adapter = makeAdapter(); + renderBlock( + { + type: 'object-grid', + objectName: 'account', + columns: [{ field: 'name' }], + filter: [['owner', '=', 'me']], + pagination: { pageSize: 25 }, + }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [object, params] = adapter.find.mock.calls[0] as [string, any]; + expect(object).toBe('account'); + expect(params.$filter).toEqual([['owner', '=', 'me']]); + expect(params.$top).toBe(25); + // No view was named, so nothing may be fetched about views either. + expect(adapter.getObjectSchema).toHaveBeenCalledWith('account'); + }); +}); diff --git a/packages/plugin-grid/src/index.tsx b/packages/plugin-grid/src/index.tsx index c5f16140e3..677e2b490f 100644 --- a/packages/plugin-grid/src/index.tsx +++ b/packages/plugin-grid/src/index.tsx @@ -8,7 +8,11 @@ import React from 'react'; import { ComponentRegistry } from '@object-ui/core'; -import { useSchemaContext } from '@object-ui/react'; +import { + ElementDataSourceGate, + useSchemaContext, + type ElementDataSourceMapping, +} from '@object-ui/react'; import { ObjectGrid } from './ObjectGrid'; import { VirtualGrid } from './VirtualGrid'; import { ImportWizard } from './ImportWizard'; @@ -46,10 +50,40 @@ export type { ColumnSummarySetting, ColumnSummaryType, ColumnSummaryResult } fro export type { FormulaBarProps } from './FormulaBar'; export type { SplitPaneGridProps } from './SplitPaneGrid'; +/** + * The keys `ObjectGrid` reads for its own query — every one of them, which makes + * this the only block where the spec's binding maps across without a gap. + * `columns` is a FIELD list here (so a saved view's columns belong on it), the + * filter and sort go straight to `$filter` / `$orderby`, and the row cap is read + * as `pagination.pageSize` (`ObjectGrid.tsx`, `serverPageSize`). + */ +const OBJECT_GRID_DATA_SOURCE: ElementDataSourceMapping = { + columns: true, + filter: true, + sort: true, + limit: 'pagination.pageSize', +}; + // Register object-grid component export const ObjectGridRenderer: React.FC<{ schema: any; [key: string]: any }> = ({ schema, ...props }) => { const { dataSource } = useSchemaContext() || {}; - return ; + // The spec's `PageComponentSchema.dataSource` binding (objectstack#6953). + // Nothing here used to map `dataSource.object` onto the `objectName` this + // block requires, so a page that declared the binding the spec documents — + // and no separate `objectName` — rendered an empty grid: no object, no query, + // no error. The gate maps it and reports an unresolvable `view` instead of + // quietly widening the query to the object's full scope. + return ( + + {(bound) => } + + ); }; ComponentRegistry.register('object-grid', ObjectGridRenderer, { diff --git a/packages/plugin-kanban/src/ObjectKanban.elementDataSource.test.tsx b/packages/plugin-kanban/src/ObjectKanban.elementDataSource.test.tsx new file mode 100644 index 0000000000..22586157e0 --- /dev/null +++ b/packages/plugin-kanban/src/ObjectKanban.elementDataSource.test.tsx @@ -0,0 +1,146 @@ +/** + * 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. + * + * `object-kanban` consumes `PageComponentSchema.dataSource` (objectstack#6953). + * + * The board gates its fetch on `schema.objectName` and nothing mapped the spec's + * `dataSource.object` onto it, so a board authored with the binding the spec + * documents rendered its declared lanes with no cards, no request and no error. + * + * ## Why `columns` is NOT taken from the view + * + * A board's `columns` are its SWIMLANES (`{ id, title }` per `groupBy` value), + * not a field projection. A saved view's `columns: ['name','rating']` written + * there would render two empty lanes named after fields — a wrong answer that + * looks like a rendered board. The mapping therefore takes only `object` and + * `filter`, and the third test pins that the authored lanes survive. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +// Registers `object-kanban` (and the ElementDataSourceGate wiring under test). +import './index'; +// The lane titles asserted below render INSIDE `KanbanRenderer`'s `React.lazy` +// boundary. Importing the chunk at module scope bills the cold transform to the +// import phase (unbounded) instead of racing a `waitFor` budget under full +// parallelism — the objectui#3010 rule, same specifier as `index.tsx`'s factory +// so ESM's module cache makes that factory resolve immediately. +import './KanbanImpl'; + +const HOT_VIEW = { + name: 'hot', + label: 'Hot accounts', + columns: ['name', 'rating'], + filter: [['rating', '=', 'hot']], + sort: [{ field: 'name', order: 'desc' }], + pagination: { pageSize: 7 }, +}; + +const LANES = [ + { id: 'open', title: 'Open' }, + { id: 'won', title: 'Won' }, +]; + +function makeAdapter(listViews: Record = { hot: HOT_VIEW }) { + return { + find: vi.fn().mockResolvedValue({ data: [{ id: '1', name: 'Acme', status: 'open' }] }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'account', + fields: { name: { type: 'text' }, status: { type: 'text' }, rating: { type: 'text' } }, + listViews, + }), + }; +} + +const renderBlock = (schema: Record, adapter: ReturnType) => + render( + + + , + ); + +describe('object-kanban — dataSource: { object, view } (objectstack#6953)', () => { + it('queries the bound object with the saved view’s filter', async () => { + const adapter = makeAdapter(); + renderBlock( + { + type: 'object-kanban', + groupBy: 'status', + columns: LANES, + dataSource: { object: 'account', view: 'hot' }, + }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [object, params] = adapter.find.mock.calls[0] as [string, any]; + expect(object).toBe('account'); + expect(params.$filter).toEqual([['rating', '=', 'hot']]); + }); + + it('keeps the authored SWIMLANES — the view’s field list is not a lane list', async () => { + const adapter = makeAdapter(); + const { container } = renderBlock( + { + type: 'object-kanban', + groupBy: 'status', + columns: LANES, + dataSource: { object: 'account', view: 'hot' }, + }, + adapter, + ); + + // Lane titles, not field names: `rating` must never become a lane on a + // board. `waitFor` because the lanes render past a Suspense boundary. + await waitFor(() => expect(container.textContent).toContain('Open')); + expect(container.textContent).toContain('Won'); + expect(container.textContent).not.toContain('rating'); + }); + + it('reports an unresolvable `view` instead of fetching the whole object', async () => { + const adapter = makeAdapter(); + const { container } = renderBlock( + { + type: 'object-kanban', + groupBy: 'status', + columns: LANES, + dataSource: { object: 'account', view: 'nope' }, + }, + adapter, + ); + + await waitFor(() => + expect(container.querySelector('[data-testid="object-kanban-datasource-error"]')).not.toBeNull(), + ); + expect(adapter.find).not.toHaveBeenCalled(); + }); + + it('leaves a board with NO dataSource exactly as it was', async () => { + const adapter = makeAdapter(); + renderBlock( + { + type: 'object-kanban', + objectName: 'account', + groupBy: 'status', + columns: LANES, + filter: [['owner', '=', 'me']], + }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [object, params] = adapter.find.mock.calls[0] as [string, any]; + expect(object).toBe('account'); + expect(params.$filter).toEqual([['owner', '=', 'me']]); + }); +}); diff --git a/packages/plugin-kanban/src/index.tsx b/packages/plugin-kanban/src/index.tsx index f15b04f92f..2154eb63b5 100644 --- a/packages/plugin-kanban/src/index.tsx +++ b/packages/plugin-kanban/src/index.tsx @@ -8,7 +8,11 @@ import React, { Suspense } from 'react'; import { ComponentRegistry } from '@object-ui/core'; -import { useSchemaContext } from '@object-ui/react'; +import { + ElementDataSourceGate, + useSchemaContext, + type ElementDataSourceMapping, +} from '@object-ui/react'; import { Skeleton } from '@object-ui/components'; import { createSafeTranslation } from '@object-ui/i18n'; import type { KanbanConditionalFormattingRule } from '@object-ui/types'; @@ -355,10 +359,40 @@ ComponentRegistry.register( } ); +/** + * What `ObjectKanban` reads for its own query: `objectName` and `filter` + * (`ObjectKanban.tsx`, the `dataSource.find` call — `$filter: schema.filter`). + * + * `columns` is deliberately NOT mapped. A board's `columns` are its SWIMLANES + * (`{ id, title }` per `groupBy` value), not a field projection — writing a + * saved view's field list there would render a board with one empty lane per + * field name. Nor is `sort` or `limit`: the board fetches with a fixed + * `$top: 100` and no ordering, so there is no key to write them to. Mapping + * either onto something plausible would re-create the defect this wiring + * removes — a value accepted and dropped — one layer deeper. + */ +const OBJECT_KANBAN_DATA_SOURCE: ElementDataSourceMapping = { + filter: true, +}; + // Register object-kanban for ListView integration export const ObjectKanbanRenderer: React.FC<{ schema: any; [key: string]: any }> = ({ schema, ...props }) => { const { dataSource } = useSchemaContext() || {}; - return ; + // The spec's `PageComponentSchema.dataSource` binding (objectstack#6953): + // before this, a board authored with `dataSource: { object, view }` and no + // `objectName` never fetched — the effect is gated on `schema.objectName` — + // so it rendered its declared lanes with no cards and no error. + return ( + + {(bound) => } + + ); }; ComponentRegistry.register( diff --git a/packages/plugin-kanban/src/registration.test.tsx b/packages/plugin-kanban/src/registration.test.tsx index a37008cbfb..a99f7b0f9a 100644 --- a/packages/plugin-kanban/src/registration.test.tsx +++ b/packages/plugin-kanban/src/registration.test.tsx @@ -3,14 +3,19 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { ObjectKanbanRenderer } from './index'; -// Mock dependencies -vi.mock('@object-ui/react', async () => { - const React = await import('react'); - return { - useSchemaContext: vi.fn(() => ({ dataSource: { type: 'mock-datasource' } })), - SchemaRendererContext: React.createContext(null), - }; -}); +// Partial mock — override ONLY what this test controls, keep every other real +// export. Same conversion `plugin-calendar/src/registration.test.tsx` already +// carries, for the same reason (objectui#3219): a whole-module replacement that +// listed just `useSchemaContext` + `SchemaRendererContext` made this file +// sensitive to which exports the renderer happens to use, so `ObjectKanbanRenderer` +// consuming one more of them (`ElementDataSourceGate`, objectstack#6953) failed +// the suite with `No "ElementDataSourceGate" export is defined on the mock` +// rather than telling us anything about the registration this file tests. +vi.mock(import('@object-ui/react'), async (importOriginal) => ({ + ...(await importOriginal()), + // Only the piece this test drives: + useSchemaContext: vi.fn(() => ({ dataSource: { type: 'mock-datasource' } })), +})); // Mock the implementation vi.mock('./ObjectKanban', () => ({ diff --git a/packages/react/README.md b/packages/react/README.md index 63f688e2af..f3f5744344 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -121,6 +121,43 @@ function MyComponent() { } ``` +### useElementDataSourceSchema / ElementDataSourceGate + +Consume `PageComponentSchema.dataSource` — the spec's per-element data binding +(`{ object, view?, filter?, sort?, limit? }`) — in a block that has its own key +names. `useElementDataSource` resolves the binding (fetching the object's saved +views so `view` can be matched); these two apply the composed result to the +block's schema and render the two non-final states. + +```tsx +import { ElementDataSourceGate } from '@object-ui/react' + +// `mapping` names ONLY the keys this block reads. A composed value written onto +// a key the block ignores would be accepted and silently dropped — the defect +// the binding exists to remove. +const OBJECT_GRID_BINDING = { + columns: true, // the view's FIELD list may fill `schema.columns` + filter: true, // AND-combined, never replaced ("additional criteria") + sort: true, + limit: 'pagination.pageSize' as const, +} + +const ObjectGridRenderer = ({ schema, ...props }) => ( + + {(bound) => } + +) +``` + +`object` lands on `objectName` by default (pass `object: 'apiName'` for another +key, or `object: false` for a block that reads the composed binding itself). +Precedence: binding keys beat the component's own, view-sourced values are only a +baseline the component's own key overrides, and `filter` AND-combines all three. +A `view` name that does not resolve renders a configuration error rather than +falling back to the object's full scope. Use `useElementDataSourceSchema` (plus +the exported `ElementDataSourceErrorPanel` / `ElementDataSourceLoadingPanel`) when +a block cannot be wrapped — a renderer whose hooks must run before the panels. + ### useRegistry Access the component registry: diff --git a/packages/react/src/element-data-source/ElementDataSourceGate.tsx b/packages/react/src/element-data-source/ElementDataSourceGate.tsx new file mode 100644 index 0000000000..649e7b07e3 --- /dev/null +++ b/packages/react/src/element-data-source/ElementDataSourceGate.tsx @@ -0,0 +1,336 @@ +/** + * 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. + * + * Mapping half of consuming `PageComponentSchema.dataSource` — the spec's + * `ElementDataSourceSchema` per-element data binding (objectstack#5576 landed + * the resolution; objectstack#6953 wires it to the remaining blocks). + * + * `@object-ui/core` owns the pure half (telling the binding apart from a runtime + * adapter, matching a view name, composing the view with the binding's own + * keys). {@link useElementDataSource} owns the fetch half. This module owns the + * LAST hop: writing the composed result onto the schema keys a given block + * actually reads, and rendering the two non-final states. + * + * It exists because that hop was the part every block would otherwise copy. + * `dataSource` is declared on EVERY page component, so eight blocks need the + * same precedence table (below) with only the KEY NAMES differing — which is a + * mapping description, not eight algorithms. A per-block copy is how the two + * halves drift: one block ANDs the filters and the next replaces them, and the + * spec's "additional filter criteria" quietly becomes two dialects. + * + * `plugin-list`'s `ListViewBlock` predates this module and still carries its own + * copy of the table (they agree today — objectstack#5576's whole suite passes + * against this one unchanged). Collapsing it onto this module is objectstack#7120; + * until then, a change to the rules below belongs in both places. + * + * ## Precedence — one table, applied everywhere + * + * Three kinds of value arrive and they do not have the same standing: + * + * - **`dataSource.*` keys are authoritative.** The author wrote them on THIS + * placement and the spec says the binding "overrides page-level object + * context", so they beat the component's own same-named key. + * - **View-sourced values are a baseline.** A `view` is a *reference*; a key + * written on the component itself is more specific than the view it points + * at, so the component's key wins over one the view supplied. + * - **`filter` never overrides — it combines.** The spec describes the + * binding's filter as "*Additional* filter criteria", so component filter, + * view filter and binding filter all AND together through + * {@link mergeFilterNodes}. A binding can only narrow what the view already + * restricts, never widen it: a mistyped per-element filter cannot expose rows + * the saved view excluded. + * + * An authored-but-EMPTY `columns` counts as "not authored": `[]` is what the + * designer emits for an unconfigured column list, and supplying the columns is + * exactly why a view was named. + * + * ## What a mapping may NOT do + * + * {@link ElementDataSourceMapping} names only keys the target block genuinely + * reads. Writing a composed key onto a schema key the block ignores would + * reproduce the very defect this wiring removes — a value accepted and dropped — + * one layer further in, where it is even harder to see. A block with no row cap + * therefore leaves `limit` unmapped rather than parking it somewhere plausible, + * and the gap is recorded at the call site instead of being papered over here. + * + * ## An unresolvable `view` fails loudly + * + * When the named view does not exist the gate renders a configuration error + * instead of letting the block fall back to the object's default scope. Silently + * widening a named view to "all records" is the failure class the binding exists + * to remove, and it is the one an AI-authored page hides best: the page looks + * like it works. + */ + +import * as React from 'react'; +import { mergeFilterNodes, type ElementDataSourceConfig } from '@object-ui/core'; +import { + useElementDataSource, + type ElementDataSourceStatus, +} from '../hooks/useElementDataSource'; + +/** + * Where a block's row cap lives. Three spellings are real in this repo and each + * is the ONLY one its block reads: `list-view`/`object-grid` read + * `pagination.pageSize` (falling back to a flat `pageSize`), and + * `record:related_list` reads the spec's flat `limit`. + */ +export type ElementDataSourceLimitKey = 'limit' | 'pageSize' | 'pagination.pageSize'; + +/** + * Which schema keys a block reads, so the composed binding lands on those and + * nothing else. + * + * Every field is opt-IN. The default mapping writes only the object name, + * because that is the one key every object-bound block in this repo reads; a + * block that also reads a filter, a sort, a column list or a row cap says so + * here, naming the key it reads. + */ +export interface ElementDataSourceMapping { + /** + * Schema key the binding's `object` lands on — `'objectName'` for every + * object-bound block in this repo, which is the default. `false` maps nothing + * (for a block that reads the composed object itself, like + * `element:record_picker`, whose object lives under `properties`). + */ + object?: string | false; + /** + * Set when the block renders a FIELD column list a saved view can supply. + * Leave unset for a block whose `columns` mean something else — an + * `object-kanban`'s `columns` are its swimlane groups, not fields, and a + * view's field list written there would render a broken board. + */ + columns?: boolean; + /** Set when the block reads `schema.filter` as its query filter. */ + filter?: boolean; + /** Set when the block reads `schema.sort` as its query ordering. */ + sort?: boolean; + /** The key a row cap lands on; omit for a block that enforces no cap. */ + limit?: ElementDataSourceLimitKey; + /** + * Set when the block renders several view kinds off `schema.viewType` + * (`list-view` is the only such container: its registry `inputs` enumerate + * grid/kanban/gallery/…, so naming a saved kanban view and rendering a grid + * would be a silently wrong answer). + */ + viewType?: boolean; +} + +export interface UseElementDataSourceSchemaResult { + /** Resolution state; see {@link ElementDataSourceStatus}. */ + status: ElementDataSourceStatus; + /** + * The schema with the composed binding applied. Returned by REFERENCE when + * there is nothing to apply, so a block that carries no binding never sees a + * new schema identity (which would remount it and refetch on every render). + */ + schema: S; + /** The binding as authored, or `undefined` when the node carries none. */ + config?: ElementDataSourceConfig; + /** Author-facing explanation, set only for `missing`. */ + error?: string; +} + +const readLimit = (base: Record, key: ElementDataSourceLimitKey): unknown => { + if (key === 'pagination.pageSize') return base.pagination?.pageSize; + return base[key]; +}; + +const writeLimit = ( + next: Record, + base: Record, + key: ElementDataSourceLimitKey, + limit: number, +): void => { + if (key === 'pagination.pageSize') { + next.pagination = { ...(base.pagination ?? {}), pageSize: limit }; + return; + } + next[key] = limit; +}; + +/** + * Resolve a block's `dataSource` binding and apply it to the block's own schema + * keys, per {@link ElementDataSourceMapping}. + * + * @param schema The block's schema node (its `dataSource` is read). + * @param mapping Which schema keys this block reads. + * @param dataSource Explicit adapter; falls back to `SchemaRendererContext`. + * + * @example + * ```tsx + * const bound = useElementDataSourceSchema(schema, { filter: true, sort: true }); + * if (bound.status === 'missing') return ; + * return ; + * ``` + */ +export function useElementDataSourceSchema( + schema: S, + mapping: ElementDataSourceMapping = {}, + dataSource?: unknown, +): UseElementDataSourceSchemaResult { + const binding = useElementDataSource(schema, dataSource); + const { object: objectKey = 'objectName', columns, filter, sort, limit, viewType } = mapping; + + const mapped = React.useMemo(() => { + const composed = binding.composed; + if (!composed) return schema; + + const base = (schema ?? {}) as Record; + const next: Record = { ...base }; + + if (objectKey !== false) next[objectKey] = composed.object; + + if (columns) { + // `[]` is the designer's "not configured yet", and supplying the columns + // is the reason a view was named — so an empty authored list yields. + const authored = Array.isArray(base.columns) && base.columns.length > 0; + if (!authored && composed.columns !== undefined) next.columns = composed.columns; + } + + if (filter) { + // Component filter AND (view filter AND binding filter). `composed.filter` + // already carries the latter pair; a single surviving source comes back + // unwrapped, so the common "only the view filters" case stays flat. + const merged = mergeFilterNodes(base.filter, composed.filter); + if (merged !== undefined) next.filter = merged; + else delete next.filter; + } + + // `composed.sort`/`composed.limit` are the BINDING's when it declared one, + // else the view's — so the component's own key may only win over the latter. + if (sort && composed.sort !== undefined) { + const fromView = binding.config?.sort === undefined; + if (!fromView || base.sort === undefined) next.sort = composed.sort; + } + + if (limit && composed.limit !== undefined) { + const fromView = binding.config?.limit === undefined; + if (!fromView || readLimit(base, limit) === undefined) { + writeLimit(next, base, limit, composed.limit); + } + } + + if (viewType && composed.viewType !== undefined && base.viewType === undefined) { + next.viewType = composed.viewType; + } + + return next as S; + }, [schema, binding.composed, binding.config, objectKey, columns, filter, sort, limit, viewType]); + + return React.useMemo( + () => ({ + status: binding.status, + schema: mapped, + config: binding.config, + error: binding.error, + }), + [binding.status, binding.config, binding.error, mapped], + ); +} + +export interface ElementDataSourceStatusPanelProps { + /** + * `data-testid` stem, normally the block's registry key — the panels append + * `-datasource-error` / `-resolving-view`, so a test names the block it is + * asserting about rather than a shared anonymous id. + */ + testId: string; + /** Panel heading; defaults to a block-neutral sentence. */ + title?: string; + /** The author-facing explanation from {@link useElementDataSourceSchema}. */ + message?: string; +} + +/** + * The "named view does not resolve" panel. Same posture (and shape) as + * `SchemaRenderer`'s "Unknown component type": authored metadata pointing at + * something that is not there, reported where the author can see it. + */ +export function ElementDataSourceErrorPanel({ + testId, + title = 'This component’s data source could not be resolved', + message, +}: ElementDataSourceStatusPanelProps): React.ReactElement { + return ( +
+

{title}

+ {message ?

{message}

: null} +
+ ); +} + +/** + * The "saved views are still being fetched" placeholder. Distinct from the error + * panel because a component that treated "not resolved yet" as "does not exist" + * would flash a configuration error on every mount. + */ +export function ElementDataSourceLoadingPanel({ + testId, +}: Pick): React.ReactElement { + return ( +
+ Loading view… +
+ ); +} + +export interface ElementDataSourceGateProps { + /** The block's schema node. */ + schema: S; + /** Which schema keys this block reads; see {@link ElementDataSourceMapping}. */ + mapping?: ElementDataSourceMapping; + /** Explicit adapter; falls back to `SchemaRendererContext`. */ + dataSource?: unknown; + /** `data-testid` stem for the two status panels — the block's registry key. */ + testId: string; + /** Heading for the unresolvable-view panel. */ + errorTitle?: string; + /** + * Renders the block with the bound schema. Called during the gate's own + * render, so it must RETURN AN ELEMENT and never call hooks itself — the + * block's hooks belong to the block. + */ + children: (schema: S) => React.ReactElement | null; +} + +/** + * Wrap an object-bound block so its `dataSource` binding reaches the keys it + * reads, and so the two non-final resolution states render once, the same way, + * for every block. + * + * The block itself is not mounted while a named view is unresolved: rendering it + * against a half-resolved query is how a page shows a wider answer than the one + * that was authored. + */ +export function ElementDataSourceGate({ + schema, + mapping, + dataSource, + testId, + errorTitle, + children, +}: ElementDataSourceGateProps): React.ReactElement | null { + const bound = useElementDataSourceSchema(schema, mapping, dataSource); + + if (bound.status === 'missing') { + return ; + } + if (bound.status === 'loading') { + return ; + } + return children(bound.schema); +} diff --git a/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx b/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx new file mode 100644 index 0000000000..33c37ca09e --- /dev/null +++ b/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx @@ -0,0 +1,317 @@ +/** + * 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. + * + * The mapping half of `PageComponentSchema.dataSource` (objectstack#6953). + * + * `useElementDataSource` resolves the binding; this module writes the composed + * result onto the schema keys a given block reads. Two properties are pinned + * here because every block wiring depends on them and none of the per-block + * suites can see them: + * + * 1. **The precedence table is one table.** Binding beats component key, view + * is only a baseline, `filter` AND-combines instead of replacing. Eight + * blocks share it, so it is asserted once at the source rather than eight + * times through eight renderers. + * 2. **An unmapped key is never written.** A mapping names only the keys its + * block actually reads; writing a composed value onto a key the block + * ignores would recreate the defect this wiring removes — a value accepted + * and silently dropped — one layer deeper, where nothing reports it. The + * `object-kanban` / `object-chart` / `object-metric` wirings are exactly + * that case, so "does not write what it was not told to" is a pin, not a + * nicety. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, renderHook, waitFor } from '@testing-library/react'; +import * as React from 'react'; +import { + ElementDataSourceGate, + useElementDataSourceSchema, + type ElementDataSourceMapping, +} from '../ElementDataSourceGate'; + +const HOT_VIEW = { + name: 'hot', + label: 'Hot accounts', + type: 'kanban', + columns: ['name', 'rating'], + filter: [['rating', '=', 'hot']], + sort: [{ field: 'name', order: 'desc' }], + pagination: { pageSize: 7 }, +}; + +/** An adapter that can answer "what saved views does this object have?". */ +const makeAdapter = (listViews: Record = { hot: HOT_VIEW }) => ({ + find: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ name: 'account', listViews }), +}); + +const FULL: ElementDataSourceMapping = { + columns: true, + filter: true, + sort: true, + limit: 'pagination.pageSize', + viewType: true, +}; + +function useBound(schema: unknown, mapping: ElementDataSourceMapping, adapter: unknown) { + return useElementDataSourceSchema(schema as Record, mapping, adapter); +} + +const resolved = async (schema: unknown, mapping: ElementDataSourceMapping, adapter?: unknown) => { + const { result } = renderHook(() => useBound(schema, mapping, adapter ?? makeAdapter())); + await waitFor(() => expect(result.current.status).not.toBe('loading')); + return result; +}; + +describe('useElementDataSourceSchema — no binding', () => { + it('returns the schema BY REFERENCE so the block is not remounted', () => { + const schema = { type: 'object-grid', objectName: 'task' }; + const { result } = renderHook(() => useBound(schema, FULL, makeAdapter())); + expect(result.current.status).toBe('absent'); + // Identity, not deep equality: a fresh object every render would give the + // block a new schema each time — remount, refetch, lost scroll. + expect(result.current.schema).toBe(schema); + }); + + it('treats a runtime ADAPTER parked under `dataSource` as no binding', () => { + // The two collide by name; `isElementDataSourceConfig` is what tells them + // apart, and a host handing us the adapter must not be read as metadata. + const schema = { type: 'object-grid', objectName: 'task', dataSource: { find: () => [] } }; + const { result } = renderHook(() => useBound(schema, FULL, makeAdapter())); + expect(result.current.status).toBe('absent'); + expect(result.current.schema).toBe(schema); + }); +}); + +describe('useElementDataSourceSchema — binding without a view', () => { + it('maps `object` onto `objectName` and needs no fetch to do it', () => { + const { result } = renderHook(() => + useBound({ type: 'object-grid', dataSource: { object: 'account' } }, FULL, makeAdapter()), + ); + expect(result.current.status).toBe('ready'); + expect(result.current.schema.objectName).toBe('account'); + }); + + it('maps `object` onto a block-specific key when the mapping names one', () => { + const { result } = renderHook(() => + useBound({ dataSource: { object: 'account' } }, { object: 'api' }, makeAdapter()), + ); + expect(result.current.schema.api).toBe('account'); + expect(result.current.schema.objectName).toBeUndefined(); + }); + + it('maps nothing for `object: false` (the block reads the binding itself)', () => { + const { result } = renderHook(() => + useBound({ dataSource: { object: 'account' } }, { object: false }, makeAdapter()), + ); + expect(result.current.schema.objectName).toBeUndefined(); + }); + + it('AND-combines the binding filter with the component filter', () => { + const { result } = renderHook(() => + useBound( + { + filter: [['owner', '=', 'me']], + dataSource: { object: 'account', filter: [['rating', '=', 'hot']] }, + }, + FULL, + makeAdapter(), + ), + ); + // Two sources ⇒ one `and` node. The binding NARROWS the component's filter; + // it can never widen it, which is the direction the spec's "additional + // filter criteria" fixes. + expect(JSON.stringify(result.current.schema.filter)).toContain('and'); + expect(JSON.stringify(result.current.schema.filter)).toContain('rating'); + expect(JSON.stringify(result.current.schema.filter)).toContain('owner'); + }); +}); + +describe('useElementDataSourceSchema — binding with a saved view', () => { + it('applies the view’s columns, filter, sort, row cap and kind', async () => { + const result = await resolved({ type: 'list-view', dataSource: { object: 'account', view: 'hot' } }, FULL); + expect(result.current.status).toBe('resolved'); + const bound = result.current.schema; + expect(bound.objectName).toBe('account'); + expect(bound.columns).toEqual(['name', 'rating']); + expect(bound.filter).toEqual([['rating', '=', 'hot']]); + expect(bound.sort).toEqual([{ field: 'name', order: 'desc' }]); + expect(bound.pagination).toEqual({ pageSize: 7 }); + expect(bound.viewType).toBe('kanban'); + }); + + it('lets an authored key win over the same key from the view', async () => { + const result = await resolved( + { + columns: ['id'], + sort: [{ field: 'created', order: 'asc' }], + pagination: { pageSize: 25 }, + viewType: 'grid', + dataSource: { object: 'account', view: 'hot' }, + }, + FULL, + ); + const bound = result.current.schema; + // A `view` is a reference; a key written on the component itself is more + // specific than the view it points at. + expect(bound.columns).toEqual(['id']); + expect(bound.sort).toEqual([{ field: 'created', order: 'asc' }]); + expect(bound.pagination).toEqual({ pageSize: 25 }); + expect(bound.viewType).toBe('grid'); + }); + + it('treats an authored EMPTY `columns` as not authored', async () => { + // `[]` is what the designer emits for an unconfigured column list, and + // supplying the columns is exactly why a view was named. + const result = await resolved({ columns: [], dataSource: { object: 'account', view: 'hot' } }, FULL); + expect(result.current.schema.columns).toEqual(['name', 'rating']); + }); + + it('lets an explicit BINDING key override the view (not just the component)', async () => { + const result = await resolved( + { + sort: [{ field: 'created', order: 'asc' }], + pagination: { pageSize: 25 }, + dataSource: { object: 'account', view: 'hot', sort: [{ field: 'amount', order: 'asc' }], limit: 3 }, + }, + FULL, + ); + const bound = result.current.schema; + expect(bound.sort).toEqual([{ field: 'amount', order: 'asc' }]); + expect(bound.pagination).toEqual({ pageSize: 3 }); + }); + + it('ANDs the view filter, the binding filter and the component filter', async () => { + const result = await resolved( + { + filter: [['owner', '=', 'me']], + dataSource: { object: 'account', view: 'hot', filter: [['amount', '>', 100]] }, + }, + FULL, + ); + const json = JSON.stringify(result.current.schema.filter); + expect(json).toContain('rating'); // the view's + expect(json).toContain('amount'); // the binding's + expect(json).toContain('owner'); // the component's + }); + + it('writes the row cap to the flat `limit` key when that is what the block reads', async () => { + const result = await resolved( + { dataSource: { object: 'account', view: 'hot' } }, + { limit: 'limit' }, + ); + expect(result.current.schema.limit).toBe(7); + expect(result.current.schema.pagination).toBeUndefined(); + }); +}); + +describe('useElementDataSourceSchema — an unmapped key is never written', () => { + it('writes ONLY the object name for the default mapping', async () => { + // `object-form`'s wiring: one record, no collection query, so the binding's + // remaining keys have no read site. They must not be parked on the schema — + // a key written where nothing reads it is the defect, not the fix. + const result = await resolved( + { type: 'object-form', dataSource: { object: 'account', view: 'hot', limit: 10, sort: [{ field: 'x', order: 'asc' }] } }, + {}, + ); + const bound = result.current.schema; + expect(bound.objectName).toBe('account'); + expect(bound.columns).toBeUndefined(); + expect(bound.filter).toBeUndefined(); + expect(bound.sort).toBeUndefined(); + expect(bound.limit).toBeUndefined(); + expect(bound.pagination).toBeUndefined(); + expect(bound.viewType).toBeUndefined(); + }); + + it('writes the filter but not the view’s columns for a filter-only mapping', async () => { + // `object-kanban`'s wiring. Its `columns` are SWIMLANES, not fields: the + // view's `['name','rating']` written there would render two empty lanes. + const lanes = [{ id: 'open', title: 'Open' }]; + const result = await resolved( + { type: 'object-kanban', columns: lanes, groupBy: 'status', dataSource: { object: 'account', view: 'hot' } }, + { filter: true }, + ); + const bound = result.current.schema; + expect(bound.objectName).toBe('account'); + expect(bound.filter).toEqual([['rating', '=', 'hot']]); + expect(bound.columns).toBe(lanes); + expect(bound.sort).toBeUndefined(); + }); +}); + +describe('ElementDataSourceGate — resolution states', () => { + const Block = ({ schema }: { schema: any }) => ( +
{String(schema?.objectName)}
+ ); + + it('renders the block once the view resolves', async () => { + const { getByTestId } = render( + + {(bound) => } + , + ); + await waitFor(() => expect(getByTestId('block').textContent).toBe('account')); + }); + + it('reports an unresolvable `view` instead of rendering the block unfiltered', async () => { + // The failure this whole binding exists to remove: falling back to the + // object's default scope turns a typo into a WIDER answer on a page that + // still looks like it works. + const { getByTestId, queryByTestId } = render( + + {(bound) => } + , + ); + await waitFor(() => expect(queryByTestId('probe-datasource-error')).not.toBeNull()); + expect(queryByTestId('block')).toBeNull(); + // The known-view list is the part that actually gets an author unstuck. + expect(getByTestId('probe-datasource-error').textContent).toContain('hot'); + }); + + it('shows a placeholder — not the error — while the views are being fetched', () => { + let release: (v: unknown) => void = () => {}; + const adapter = { + getObjectSchema: vi.fn().mockReturnValue(new Promise((r) => { release = r; })), + }; + const { queryByTestId } = render( + + {(bound) => } + , + ); + // "Not resolved yet" is not "does not exist" — conflating them would flash a + // configuration error on every mount. + expect(queryByTestId('probe-resolving-view')).not.toBeNull(); + expect(queryByTestId('probe-datasource-error')).toBeNull(); + release({ name: 'account', listViews: { hot: HOT_VIEW } }); + }); + + it('renders the block untouched when there is no binding at all', () => { + const { getByTestId } = render( + + {(bound) => } + , + ); + expect(getByTestId('block').textContent).toBe('task'); + }); +}); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index c54f0c287c..a76f55ea91 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -12,6 +12,9 @@ export * from './context'; // will be empty for now export * from './LazyPluginLoader'; export * from './spec-bridge'; export * from './data-invalidation'; +// PageComponentSchema.dataSource — mapping the spec's per-element data binding +// onto the schema keys each object-bound block reads (objectstack#6953). +export * from './element-data-source/ElementDataSourceGate'; // i18n utilities export { resolveI18nLabel } from './utils/i18n'; From b26dc25500cf8e921de05ad2b8aa08e58392772c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 18:20:25 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(react):=20ElementDataSourceGate=20?= =?UTF-8?q?=E4=B8=8D=E6=8A=8A=20spec=20BINDING=20=E5=BD=93=E6=88=90=20adap?= =?UTF-8?q?ter=20(objectstack#6953)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 绑定与运行时 adapter 同名 —— `SchemaRenderer` 现在已不再 spread `schema.dataSource` 为 prop,但 host(或旧的缓存 bundle)把 BINDING 从 adapter 参数递进来时,取 saved view 会走到「这个 data source 无法列出保存的视图」,于是一个真实存在的 view 被报成 解析不到。`ListViewBlock` 本来就带这条防御(#5576),现在提到公共层,一次覆盖所有 block。 钉子:把 `{ object, view }` 当 adapter 传入时,状态是 missing 且错误正文点明「无法 列出视图」这一事实,而不是宣称 view 不存在 —— 即没有走 binding-as-adapter 那条路。 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt Co-authored-by: Claude --- .../ElementDataSourceGate.tsx | 15 ++++++++++++-- .../__tests__/ElementDataSourceGate.test.tsx | 20 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/react/src/element-data-source/ElementDataSourceGate.tsx b/packages/react/src/element-data-source/ElementDataSourceGate.tsx index 649e7b07e3..406adf4d81 100644 --- a/packages/react/src/element-data-source/ElementDataSourceGate.tsx +++ b/packages/react/src/element-data-source/ElementDataSourceGate.tsx @@ -67,7 +67,11 @@ */ import * as React from 'react'; -import { mergeFilterNodes, type ElementDataSourceConfig } from '@object-ui/core'; +import { + isElementDataSourceConfig, + mergeFilterNodes, + type ElementDataSourceConfig, +} from '@object-ui/core'; import { useElementDataSource, type ElementDataSourceStatus, @@ -173,7 +177,14 @@ export function useElementDataSourceSchema( mapping: ElementDataSourceMapping = {}, dataSource?: unknown, ): UseElementDataSourceSchemaResult { - const binding = useElementDataSource(schema, dataSource); + // Defence in depth for the collision the binding and the adapter share by + // NAME: even though `SchemaRenderer` no longer spreads `schema.dataSource` as + // a prop, a host (or an older cached bundle) handing us the spec BINDING under + // this argument must never be mistaken for an adapter — that is how a + // spec-compliant page reported "this data source cannot list the saved views". + // Same guard `ListViewBlock` carries, applied for every block at once. + const adapter = isElementDataSourceConfig(dataSource) ? undefined : dataSource; + const binding = useElementDataSource(schema, adapter); const { object: objectKey = 'objectName', columns, filter, sort, limit, viewType } = mapping; const mapped = React.useMemo(() => { diff --git a/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx b/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx index 33c37ca09e..6a4623eb7c 100644 --- a/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx +++ b/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx @@ -78,6 +78,26 @@ describe('useElementDataSourceSchema — no binding', () => { expect(result.current.schema).toBe(schema); }); + it('never mistakes the spec BINDING passed as the adapter for an adapter', async () => { + // The name collision, from the other side: a host (or an older cached + // bundle) handing the gate the binding under the adapter argument used to + // make a real saved view report as unresolvable ("this data source cannot + // list the saved views"). The context adapter is used instead. + const { result } = renderHook(() => + useBound( + { dataSource: { object: 'account', view: 'hot' } }, + FULL, + { object: 'account', view: 'hot' }, + ), + ); + await waitFor(() => expect(result.current.status).not.toBe('loading')); + // No context provider in this harness, so the honest answer is "nobody here + // can list views" — but crucially NOT via the binding-as-adapter path, and + // the message names that fact rather than claiming the view is absent. + expect(result.current.status).toBe('missing'); + expect(result.current.error).toContain('cannot list the saved views'); + }); + it('treats a runtime ADAPTER parked under `dataSource` as no binding', () => { // The two collide by name; `isElementDataSourceConfig` is what tells them // apart, and a host handing us the adapter must not be read as metadata.