From 21f2f4f3a9c1c41ae0651738a532dff03853850f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 20:35:10 +0000 Subject: [PATCH] =?UTF-8?q?fix(sdui):=20PageComponentSchema.dataSource=20?= =?UTF-8?q?=E6=8E=A5=E5=88=B0=E5=89=A9=E4=BD=99=207=20=E4=B8=AA=20object-b?= =?UTF-8?q?ound=20block=20(objectstack#7121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectstack#6953(objectui PR #3969)把 spec 的 per-element 数据绑定接到了它点名的 8 个 block,同一条声明在其余 7 个 object-bound public block 上依旧无人消费: object-gantt / object-timeline / object-map / object-pivot / object-master-detail-form / embeddable-form / record:line_items。 每一块都把取数挂在自己的对象键上,而没有任何地方把 `dataSource.object` 映射过去 —— 按 spec 写、不额外写 `objectName` 的页面渲染出空甘特 / 空时间轴 / 无标记的地图 / 空交叉表 / 无字段的表单,不发请求、也没有任何诊断。spec-valid 的 metadata 渲染出空, 即 objectstack#4413 的形状。 复用 #6953 落地的共享层(`useElementDataSourceSchema` / `ElementDataSourceGate`), 一行未改;合成语义完全沿用 #5576(view 作基线、组件显式键覆盖 view、绑定显式键覆盖 两者、`filter` 三方 `and` 合成、view 解析不到报错不回退全量)。 映射只写该 block 真读的键(逐块查过读点): - object-gantt (plugin-gantt/src/index.tsx) object + filter + sort `$filter: schema.filter` / `$orderby: convertSortToQueryParams(schema.sort)`; reload 不发 `$top`、字段由 `gantt` 配置点名 → limit/columns 不映射。 - object-map (plugin-map/src/index.tsx) object + filter + sort(同上读点) - object-pivot (plugin-dashboard/src/index.tsx) object + filter 交叉表由 rowField/columnField 自己定序、总计不能在截断页上算 → sort/limit 不映射。 - object-timeline (plugin-timeline/src/index.tsx) object only 取数就是 `find(objectName, { options: { $top: 100 } })` —— 无 filter/orderby 读点、 窗口硬编码 → filter/sort/limit 刻意不映射。 - embeddable-form / object-master-detail-form (plugin-form/src/index.tsx) object only 写一条记录,没有集合查询给 filter/sort/limit 收窄;master-detail 的 object 是**父** 对象,子集合按 `details[]` 的 FK 取、固定 `$top: 500`。 - record:line_items (plugin-form/src/index.tsx) object → **childObject** 它列的/取的/写的集合是 `childObject`(与 record:related_list 的 `objectName` 同义: 都是该面板绑定的子对象);`relationshipField` 不属于绑定,仍归作者;查询只有父 FK + 固定 `$top: 500`;`columns` 是可编辑网格的 `GridColumn[]`,不是字段名投影 —— view 的 列表在这里是**形状不对**,不只是更宽。 把合成值写到 block 不读的键上,等于把本单要消除的缺陷往下挪一层、看起来像接好了, 所以上面每一个「不映射」都各钉了一条诚实断言(查询里确实没有那个键)。逐块覆盖表与 两处残缺口(timeline / line_items 的 view 贡献为零)补进了 `content/docs/guide/data-source.md`。 钉子:7 个新测试文件 31 例,每块两方向(带 `dataSource`(含 `view`)/ 不带),外加 「未映射的键一定不进查询」。 反向验证(先预判方向再跑,变异不提交): - 撤掉 object-map 的 gate(恢复 pre-fix 渲染器):预判 4 条带绑定的钉子翻红(dataConfig 为 null → 压根不发 find、也不渲染错误面板)、无绑定基线保持绿。实测 `4 failed | 1 passed`,红的正是那 4 条(全部卡在 `waitFor(find 被调用)` / 错误面板), 绿的是「不带 dataSource 时行为逐字一致」。 - 把 record:line_items 的映射键从 `childObject` 改回默认 `objectName`:预判两条对象身份 断言翻红(合成值落到没人读的键上 → 查询对象变 `undefined`)、错误面板与无绑定基线保持 绿。实测 `2 failed | 2 passed`,报文正是 `expected undefined to be 'invoice_line'` —— 「写到没有读点的键上」这一失败模式被直接观测到。 顺手记录、未在本 PR 修:objectstack#7137(timeline / line_items 无 filter/sort 读点, 命名的 view 贡献为零)、objectstack#7138(ObjectMap 从 `schema.filter.map` 读地图配置)、 objectstack#7139(plugin-map 模块作用域的 console.log)。 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt Co-Authored-By: Claude --- ...ment-datasource-remaining-blocks-os7121.md | 54 ++++++ content/docs/guide/data-source.md | 39 +++- .../ObjectPivot.elementDataSource.test.tsx | 151 ++++++++++++++++ packages/plugin-dashboard/src/index.tsx | 42 ++++- .../EmbeddableForm.elementDataSource.test.tsx | 138 ++++++++++++++ .../LineItemsPanel.elementDataSource.test.tsx | 169 ++++++++++++++++++ ...asterDetailForm.elementDataSource.test.tsx | 163 +++++++++++++++++ packages/plugin-form/src/index.tsx | 100 ++++++++++- .../ObjectGantt.elementDataSource.test.tsx | 166 +++++++++++++++++ packages/plugin-gantt/src/index.tsx | 44 ++++- .../src/ObjectMap.elementDataSource.test.tsx | 152 ++++++++++++++++ packages/plugin-map/src/index.tsx | 44 ++++- .../ObjectTimeline.elementDataSource.test.tsx | 134 ++++++++++++++ packages/plugin-timeline/src/index.tsx | 46 ++++- 14 files changed, 1423 insertions(+), 19 deletions(-) create mode 100644 .changeset/element-datasource-remaining-blocks-os7121.md create mode 100644 packages/plugin-dashboard/src/ObjectPivot.elementDataSource.test.tsx create mode 100644 packages/plugin-form/src/EmbeddableForm.elementDataSource.test.tsx create mode 100644 packages/plugin-form/src/LineItemsPanel.elementDataSource.test.tsx create mode 100644 packages/plugin-form/src/MasterDetailForm.elementDataSource.test.tsx create mode 100644 packages/plugin-gantt/src/ObjectGantt.elementDataSource.test.tsx create mode 100644 packages/plugin-map/src/ObjectMap.elementDataSource.test.tsx create mode 100644 packages/plugin-timeline/src/ObjectTimeline.elementDataSource.test.tsx diff --git a/.changeset/element-datasource-remaining-blocks-os7121.md b/.changeset/element-datasource-remaining-blocks-os7121.md new file mode 100644 index 0000000000..ef1a8d1d1c --- /dev/null +++ b/.changeset/element-datasource-remaining-blocks-os7121.md @@ -0,0 +1,54 @@ +--- +"@object-ui/plugin-gantt": patch +"@object-ui/plugin-timeline": patch +"@object-ui/plugin-map": patch +"@object-ui/plugin-dashboard": patch +"@object-ui/plugin-form": patch +--- + +`PageComponentSchema.dataSource` now reaches the remaining object-bound public +blocks: `object-gantt` / `object-timeline` / `object-map` / `object-pivot` / +`object-master-detail-form` / `embeddable-form` / `record:line_items` +(objectstack#7121). + +objectstack#6953 wired the spec's per-element data binding +(`dataSource: { object, view?, filter?, sort?, limit? }`) to the eight blocks it +named and left the same declaration inert on these seven. Each gates its fetch on +its own object key and nothing mapped `dataSource.object` onto it, so a page +written the way the spec documents rendered an empty gantt / an empty timeline +rail / a map with no markers / an empty cross-tab / a field-less form — 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, through the +shared `ElementDataSourceGate` (no change to it or to the resolution layer): 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 on every one of +these blocks instead of degrading to the object's full scope. + +Each block maps **only** the keys it genuinely reads, which for this batch means +several keys stay deliberately unmapped rather than being parked somewhere +plausible: + +- `object-gantt` and `object-map` take `object` / `filter` / `sort`; neither has a + row cap or a field-list read site. +- `object-pivot` takes `object` / `filter`; a cross-tab orders itself by its own + row/column grouping and cannot be computed over a truncated page. +- `object-timeline` takes `object` only — its fetch is + `find(objectName, { options: { $top: 100 } })`, with no filter/sort read site + at all, so a named view is error-checked and then contributes nothing. +- `embeddable-form` and `object-master-detail-form` take `object` only (the + parent object, in the master-detail case); a form that writes one record has no + collection query for `filter` / `sort` / `limit` to narrow. +- `record:line_items` takes `object` onto **`childObject`** — the collection it + actually lists — and nothing else: its query is the parent FK plus a fixed + `$top: 500`, and its `columns` are editable `GridColumn` objects rather than a + field-name projection a view could supply. + +The per-block coverage table, including every residual gap named above, is in +`content/docs/guide/data-source.md`. + +No behaviour change 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 590936d2e6..2221cf1437 100644 --- a/content/docs/guide/data-source.md +++ b/content/docs/guide/data-source.md @@ -252,23 +252,46 @@ ignores would be accepted and dropped, which is the defect this binding removes. | `object-kanban` | ✅ | filter | ✅ | — no ordering | — fixed window | | `object-chart` | ✅ | filter | ✅ | — engine orders | — no page | | `object-metric` | ✅ | filter | ✅ | — single value | — single value | +| `object-gantt` | ✅ | filter / sort | ✅ | ✅ | — no row cap | +| `object-map` | ✅ | filter / sort | ✅ | ✅ | — no row cap | +| `object-pivot` | ✅ | filter | ✅ | — grouping orders | — totals need all rows | +| `object-timeline` | ✅ | error-checked only | — no read site | — no read site | — fixed window | | `object-form` | ✅ | error-checked only | — no collection query | — | — | +| `embeddable-form` | ✅ | error-checked only | — no collection query | — | — | +| `object-master-detail-form` | ✅ | error-checked only | — no collection query | — | — | +| `record:line_items` | ✅ (`childObject`) | error-checked only | — parent-scoped only | — no read site | — fixed window | 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: +Reading the `object` column: it lands on the block's own object key, which is +`objectName` everywhere except `record:line_items`, where the collection the panel +lists, fetches and writes is `childObject`. Its `relationshipField` is *not* part +of the binding and stays the author's — it has to name a field on the bound child +object, so rebinding `object` without updating it is an authoring error the panel +cannot paper over. + +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. +- `object-timeline` has no `$filter` / `$orderby` read site at all: its fetch is + `find(objectName, { options: { $top: 100 } })`. A view named there is resolved + and then contributes nothing, so the rail can be wider than the view it names. + The keys stay unmapped rather than being written where nothing reads them. +- `record:line_items` likewise takes only the object from the binding: its query is + the parent FK plus a fixed `$top: 500`, and its `columns` are editable + `GridColumn` objects (`{ field, type, … }`) rather than a field-name projection, + so a view's column list would be the wrong *shape*, not merely a wider answer. +- `object-form` / `embeddable-form` / `object-master-detail-form` resolve `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. On the + master-detail form the bound object is the **parent**; child collections come + from `details[]`, by FK. + +Blocks not in the table (`dashboard`, the other `record:*` panels) do not consume +the binding yet. diff --git a/packages/plugin-dashboard/src/ObjectPivot.elementDataSource.test.tsx b/packages/plugin-dashboard/src/ObjectPivot.elementDataSource.test.tsx new file mode 100644 index 0000000000..6072d59470 --- /dev/null +++ b/packages/plugin-dashboard/src/ObjectPivot.elementDataSource.test.tsx @@ -0,0 +1,151 @@ +/** + * 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-pivot` consumes `PageComponentSchema.dataSource` (objectstack#7121). + * + * `ObjectPivotTable` gates its fetch effect on `schema.objectName`, and nothing + * mapped the spec's `dataSource.object` onto it: a pivot authored the way the + * spec documents rendered an empty cross-tab with no request and no diagnostic — + * the same shape its sibling `object-metric` had before objectstack#6953. + * + * `object` and `filter` are the only mapped keys: a cross-tab orders itself by + * its own `rowField` / `columnField` grouping, issues no `$top` (a total over a + * truncated page would be wrong), and its "columns" are `columnField` VALUES, not + * a field projection a saved view could supply. + */ + +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-pivot` via `ObjectPivotBlock` (the wiring under test). +import './index'; + +const HOT_VIEW = { + name: 'hot', + label: 'Hot deals', + columns: ['stage', 'region'], + filter: [['rating', '=', 'hot']], + sort: [{ field: 'stage', 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(), + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'opportunity', + fields: { + stage: { type: 'text' }, + region: { type: 'text' }, + rating: { type: 'text' }, + amount: { type: 'number' }, + }, + listViews, + }), + }; +} + +const PIVOT = { + rowField: 'stage', + columnField: 'region', + valueField: 'amount', + aggregation: 'sum', +}; + +const renderBlock = (schema: Record, adapter: ReturnType) => + render( + + + , + ); + +describe('object-pivot — dataSource: { object, view } (objectstack#7121)', () => { + it('queries the bound object with the saved view’s filter', async () => { + const adapter = makeAdapter(); + renderBlock( + { type: 'object-pivot', ...PIVOT, dataSource: { object: 'opportunity', view: 'hot' } }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [object, params] = adapter.find.mock.calls[0] as [string, any]; + expect(object).toBe('opportunity'); + expect(params.$filter).toEqual([['rating', '=', 'hot']]); + }); + + it('narrows, never widens: the binding’s own filter ANDs with the view’s', async () => { + const adapter = makeAdapter(); + renderBlock( + { + type: 'object-pivot', + ...PIVOT, + dataSource: { object: 'opportunity', view: 'hot', filter: [['owner', '=', 'me']] }, + }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [, params] = adapter.find.mock.calls[0] as [string, any]; + expect(JSON.stringify(params.$filter)).toContain('rating'); + expect(JSON.stringify(params.$filter)).toContain('owner'); + }); + + it('reports an unresolvable `view` instead of cross-tabbing the whole object', async () => { + const adapter = makeAdapter(); + const { container } = renderBlock( + { type: 'object-pivot', ...PIVOT, dataSource: { object: 'opportunity', view: 'nope' } }, + adapter, + ); + + await waitFor(() => + expect(container.querySelector('[data-testid="object-pivot-datasource-error"]')).not.toBeNull(), + ); + expect(adapter.find).not.toHaveBeenCalled(); + }); + + it('writes no ordering and no row cap — a cross-tab reads neither', async () => { + const adapter = makeAdapter(); + renderBlock( + { + type: 'object-pivot', + ...PIVOT, + dataSource: { object: 'opportunity', view: 'hot', sort: [{ field: 'amount', order: 'asc' }], limit: 3 }, + }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [, params] = adapter.find.mock.calls[0] as [string, any]; + // Deliberately unmapped, not overlooked: the pivot's query carries only the + // filter. Pinned so adding either mapping has to be a deliberate edit. + expect(params.$orderby).toBeUndefined(); + expect(params.$top).toBeUndefined(); + }); + + it('leaves a pivot with NO dataSource exactly as it was', async () => { + const adapter = makeAdapter(); + renderBlock( + { + type: 'object-pivot', + objectName: 'opportunity', + ...PIVOT, + filter: [['owner', '=', 'me']], + }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [object, params] = adapter.find.mock.calls[0] as [string, any]; + expect(object).toBe('opportunity'); + expect(params.$filter).toEqual([['owner', '=', 'me']]); + }); +}); diff --git a/packages/plugin-dashboard/src/index.tsx b/packages/plugin-dashboard/src/index.tsx index 4d43f970d9..515db8e5ab 100644 --- a/packages/plugin-dashboard/src/index.tsx +++ b/packages/plugin-dashboard/src/index.tsx @@ -192,10 +192,50 @@ ComponentRegistry.register( } ); +/** + * What `ObjectPivotTable` reads for its own query: the object it cross-tabs and + * the filter it cross-tabs over (`ObjectPivotTable.tsx` — + * `dataSource.find(schema.objectName, { $filter: resolveFilterPlaceholders(schema.filter, …) })`). + * + * `sort` / `limit` / `columns` are deliberately unmapped, none of them having a + * read site here: a pivot's ordering comes out of its own row/column grouping + * (`rowField` / `columnField`), its fetch issues no `$top` because a cross-tab + * over a truncated page would report wrong totals, and its "columns" are the + * `columnField` VALUES, not a field projection a saved view could supply. + */ +const OBJECT_PIVOT_DATA_SOURCE: ElementDataSourceMapping = { + filter: true, +}; + +/** + * Registry shell for `object-pivot` — the spec's per-element `dataSource` + * binding onto the schema keys {@link ObjectPivotTable} reads (objectstack#7121). + * + * Without it a pivot authored the way the spec documents (`dataSource: { object, + * view }`, no flat `objectName`) fell through the `if (!dataSource || + * !schema.objectName) return;` guard in its fetch effect: an empty cross-tab, no + * request, no diagnostic. Same shape the sibling `object-metric` above had. + * + * Props pass through untouched, and `bound` IS the schema by reference when there + * is no binding — so the dashboard/manual paths that render this component with a + * plain schema behave exactly as before. + */ +const ObjectPivotBlock: React.FC<{ schema?: any; [key: string]: any }> = ({ schema, ...props }) => ( + + {(bound) => } + +); + // Register object-aware pivot table (async data loading) ComponentRegistry.register( 'object-pivot', - ObjectPivotTable, + ObjectPivotBlock, { namespace: 'plugin-dashboard', label: 'Object Pivot Table', diff --git a/packages/plugin-form/src/EmbeddableForm.elementDataSource.test.tsx b/packages/plugin-form/src/EmbeddableForm.elementDataSource.test.tsx new file mode 100644 index 0000000000..5f894d7cf4 --- /dev/null +++ b/packages/plugin-form/src/EmbeddableForm.elementDataSource.test.tsx @@ -0,0 +1,138 @@ +/** + * 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. + * + * `embeddable-form` consumes `PageComponentSchema.dataSource` (objectstack#7121). + * + * `EmbeddableForm` reads `config.objectName` twice — to let its inner + * `ObjectForm` fetch the object's fields, and to `create()` the submission — and + * nothing mapped the spec's `dataSource.object` onto it. A public form authored + * with the binding the spec documents therefore rendered a field-less shell that + * could not submit: no request, no error. Same silent shape `object-form` had + * before objectstack#6953. + * + * `object` is the only mapped key, and the second case pins that as a property: a + * form that CREATES one record has no collection query for `filter` / `sort` / + * `limit` to narrow, and `fields` is an ordered layout rather than a projection a + * list view's columns could fill. + */ + +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 `embeddable-form` (and the ElementDataSourceGate wiring under test). +import './index'; + +const HOT_VIEW = { + name: 'hot', + label: 'Hot leads', + columns: ['name', 'email'], + 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(), + create: vi.fn().mockResolvedValue({ id: 'l1' }), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'lead', + label: 'Lead', + fields: { + name: { name: 'name', type: 'text', label: 'Name' }, + email: { name: 'email', type: 'text', label: 'Email' }, + rating: { name: 'rating', type: 'text', label: 'Rating' }, + }, + listViews, + }), + }; +} + +/** A public form's declared field list — what makes the inner form fetch at all. */ +const FIELDS = ['name', 'email']; + +const renderBlock = (schema: Record, adapter: ReturnType) => + render( + + + , + ); + +describe('embeddable-form — dataSource: { object } (objectstack#7121)', () => { + it('fetches the bound object’s schema, so the form has fields at all', async () => { + const adapter = makeAdapter(); + renderBlock( + { + type: 'embeddable-form', + formId: 'contact-us', + fields: FIELDS, + dataSource: { object: 'lead' }, + }, + adapter, + ); + + await waitFor(() => expect(adapter.getObjectSchema).toHaveBeenCalledWith('lead')); + }); + + it('honours `object` and nothing else the binding may carry', async () => { + const adapter = makeAdapter(); + const { container } = renderBlock( + { + type: 'embeddable-form', + formId: 'contact-us', + fields: FIELDS, + dataSource: { + object: 'lead', + view: 'hot', + filter: [['owner', '=', 'me']], + sort: [{ field: 'name', order: 'asc' }], + limit: 3, + }, + }, + adapter, + ); + + await waitFor(() => expect(adapter.getObjectSchema).toHaveBeenCalledWith('lead')); + // A submission 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="embeddable-form-datasource-error"]')).toBeNull(); + }); + + it('reports an unresolvable `view` rather than rendering as if it resolved', async () => { + const adapter = makeAdapter(); + const { container } = renderBlock( + { + type: 'embeddable-form', + formId: 'contact-us', + fields: FIELDS, + dataSource: { object: 'lead', view: 'nope' }, + }, + adapter, + ); + + await waitFor(() => + expect(container.querySelector('[data-testid="embeddable-form-datasource-error"]')).not.toBeNull(), + ); + // The known-view list is what gets the author unstuck. + expect(container.textContent).toContain('hot'); + }); + + it('leaves a form with NO dataSource exactly as it was', async () => { + const adapter = makeAdapter(); + renderBlock( + { type: 'embeddable-form', formId: 'contact-us', objectName: 'lead', fields: FIELDS }, + adapter, + ); + + await waitFor(() => expect(adapter.getObjectSchema).toHaveBeenCalledWith('lead')); + }); +}); diff --git a/packages/plugin-form/src/LineItemsPanel.elementDataSource.test.tsx b/packages/plugin-form/src/LineItemsPanel.elementDataSource.test.tsx new file mode 100644 index 0000000000..d3d16e63d9 --- /dev/null +++ b/packages/plugin-form/src/LineItemsPanel.elementDataSource.test.tsx @@ -0,0 +1,169 @@ +/** + * 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:line_items` consumes `PageComponentSchema.dataSource` (objectstack#7121). + * + * This is the one block in the batch whose object does NOT live under + * `objectName`: the collection it lists, fetches and writes is + * `schema.childObject`, so the binding's `object` maps THERE — the same way it + * maps onto `record:related_list`'s `objectName`, which likewise names the CHILD + * object the panel is bound to. Authored with the binding and no `childObject`, + * the panel used to query the object `undefined`. + * + * Nothing else is mapped, and the last two cases pin that rather than trusting a + * comment: `relationshipField` stays the author's (it must name a field on the + * bound child object), the query is `{ [relationshipField]: parentId }` and a + * fixed `$top: 500` — so `filter` / `sort` / `limit` have no read site — and + * `columns` here is `GridColumn[]` driving an EDITABLE grid, not a field-name + * projection a saved view could fill. + */ + +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 `record:line_items` (and the ElementDataSourceGate wiring under test). +import './index'; + +const HOT_VIEW = { + name: 'hot', + label: 'Billable lines', + // Deliberately DIFFERENT from the authored grid columns below, so the + // "columns are not mapped" pin has something to observe. + columns: ['qty', 'price'], + filter: [['billable', '=', true]], + sort: [{ field: 'qty', order: 'desc' }], + pagination: { pageSize: 7 }, +}; + +const COLUMNS = [{ field: 'qty', label: 'Qty', type: 'number' as const }]; + +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(), + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'invoice_line', + fields: { + qty: { name: 'qty', type: 'number', label: 'Qty' }, + price: { name: 'price', type: 'currency', label: 'Price' }, + billable: { name: 'billable', type: 'boolean', label: 'Billable' }, + }, + listViews, + }), + }; +} + +const renderBlock = (schema: Record, adapter: ReturnType) => + render( + + + , + ); + +const headerTexts = (container: HTMLElement) => + Array.from(container.querySelectorAll('thead th')).map((th) => th.textContent ?? ''); + +describe('record:line_items — dataSource: { object } (objectstack#7121)', () => { + it('lists the CHILD object named by the binding, scoped to the parent record', async () => { + const adapter = makeAdapter(); + renderBlock( + { + type: 'record:line_items', + relationshipField: 'invoice', + parentId: 'inv-1', + columns: COLUMNS, + dataSource: { object: 'invoice_line' }, + }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [object, params] = adapter.find.mock.calls[0] as [string, any]; + expect(object).toBe('invoice_line'); + // The parent relationship is still what scopes the list — the binding named + // the object, not the scope. + expect(params.$filter).toEqual({ invoice: 'inv-1' }); + expect(params.$top).toBe(500); + }); + + it('reports an unresolvable `view` instead of listing the whole child object', async () => { + const adapter = makeAdapter(); + const { container } = renderBlock( + { + type: 'record:line_items', + relationshipField: 'invoice', + parentId: 'inv-1', + columns: COLUMNS, + dataSource: { object: 'invoice_line', view: 'nope' }, + }, + adapter, + ); + + await waitFor(() => + expect(container.querySelector('[data-testid="record-line-items-datasource-error"]')).not.toBeNull(), + ); + expect(adapter.find).not.toHaveBeenCalled(); + }); + + it('a resolvable view contributes nothing: no filter, no ordering, no cap, no columns', async () => { + const adapter = makeAdapter(); + const { container } = renderBlock( + { + type: 'record:line_items', + relationshipField: 'invoice', + parentId: 'inv-1', + columns: COLUMNS, + dataSource: { + object: 'invoice_line', + view: 'hot', + sort: [{ field: 'price', order: 'asc' }], + limit: 3, + }, + }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [object, params] = adapter.find.mock.calls[0] as [string, any]; + expect(object).toBe('invoice_line'); + // The query is the FK scope and the fixed window, exactly as before: none of + // the view's filter/sort nor the binding's sort/limit has a read site here. + expect(params.$filter).toEqual({ invoice: 'inv-1' }); + expect(params.$top).toBe(500); + expect(params.$orderby).toBeUndefined(); + + // And the editable grid keeps the authored GridColumn list — a view's bare + // field names would arrive with no `field`/`type` and render header-less, + // type-less cells. Wrong SHAPE, not merely a wider answer. + await waitFor(() => expect(headerTexts(container)).toContain('Qty')); + expect(headerTexts(container).join('|')).not.toContain('price'); + }); + + it('leaves a panel with NO dataSource exactly as it was', async () => { + const adapter = makeAdapter(); + renderBlock( + { + type: 'record:line_items', + childObject: 'invoice_line', + relationshipField: 'invoice', + parentId: 'inv-1', + columns: COLUMNS, + }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [object, params] = adapter.find.mock.calls[0] as [string, any]; + expect(object).toBe('invoice_line'); + expect(params.$filter).toEqual({ invoice: 'inv-1' }); + expect(params.$top).toBe(500); + }); +}); diff --git a/packages/plugin-form/src/MasterDetailForm.elementDataSource.test.tsx b/packages/plugin-form/src/MasterDetailForm.elementDataSource.test.tsx new file mode 100644 index 0000000000..e63bb00d6c --- /dev/null +++ b/packages/plugin-form/src/MasterDetailForm.elementDataSource.test.tsx @@ -0,0 +1,163 @@ +/** + * 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-master-detail-form` consumes `PageComponentSchema.dataSource` + * (objectstack#7121). + * + * `schema.objectName` is the PARENT object here, and everything downstream is + * gated on it: the parent `ObjectForm` fetches its fields through it and + * `deriveDetail` needs it to find each child's relationship field back to the + * parent. Nothing mapped the spec's `dataSource.object` onto it, so a + * master-detail form authored the way the spec documents rendered a parent shell + * whose details could not resolve their own FK — no request, no error. + * + * `object` is the only mapped key: the parent is ONE record (no collection query + * for `filter` / `sort` / `limit` to narrow) and the child collections are fetched + * by FK from `schema.details` at a fixed `$top: 500`, not from anything the + * binding carries. + */ + +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-master-detail-form` (and the gate wiring under test). +import './index'; + +const HOT_VIEW = { + name: 'hot', + label: 'Open invoices', + columns: ['number', 'status'], + filter: [['status', '=', 'open']], + sort: [{ field: 'number', order: 'desc' }], + pagination: { pageSize: 7 }, +}; + +/** + * Fully configured details (FK + typed columns) so `needsDerive` is false and the + * section header renders on the first pass — the marker below reads it to prove + * the block itself mounted, not merely that the gate resolved. + */ +const DETAILS = [ + { + childObject: 'invoice_line', + relationshipField: 'invoice', + title: 'Invoice lines', + columns: [{ field: 'qty', type: 'number' as const }], + }, +]; + +function makeAdapter(listViews: Record = { hot: HOT_VIEW }) { + return { + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + findOne: vi.fn().mockResolvedValue({ id: 'i1' }), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'invoice', + label: 'Invoice', + fields: { + number: { name: 'number', type: 'text', label: 'Number' }, + status: { name: 'status', type: 'text', label: 'Status' }, + }, + listViews, + }), + }; +} + +const renderBlock = (schema: Record, adapter: ReturnType) => + render( + + + , + ); + +describe('object-master-detail-form — dataSource: { object } (objectstack#7121)', () => { + it('fetches the bound PARENT object’s schema, so the header form has fields', async () => { + const adapter = makeAdapter(); + renderBlock( + { + type: 'object-master-detail-form', + mode: 'create', + details: DETAILS, + dataSource: { object: 'invoice' }, + }, + adapter, + ); + + // No `view` in this binding, so the gate itself never reads the object + // definition — this call can only be the parent form's. + await waitFor(() => expect(adapter.getObjectSchema).toHaveBeenCalledWith('invoice')); + }); + + it('honours `object` and nothing else the binding may carry', async () => { + const adapter = makeAdapter(); + const { container } = renderBlock( + { + type: 'object-master-detail-form', + mode: 'create', + details: DETAILS, + dataSource: { + object: 'invoice', + view: 'hot', + filter: [['owner', '=', 'me']], + sort: [{ field: 'number', order: 'asc' }], + limit: 3, + }, + }, + adapter, + ); + + // The block mounted (its own section header is on screen) … + await waitFor(() => expect(container.textContent).toContain('Invoice lines')); + // … and the resolvable view contributed nothing: a create-mode master-detail + // form issues no parent collection query for a filter/sort/limit to reach. + expect(adapter.find.mock.calls.filter(([object]) => object === 'invoice')).toHaveLength(0); + expect( + container.querySelector('[data-testid="object-master-detail-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-master-detail-form', + mode: 'create', + details: DETAILS, + dataSource: { object: 'invoice', view: 'nope' }, + }, + adapter, + ); + + await waitFor(() => + expect( + container.querySelector('[data-testid="object-master-detail-form-datasource-error"]'), + ).not.toBeNull(), + ); + // The block is not mounted behind the error — no half-resolved header form. + expect(container.textContent).not.toContain('Invoice lines'); + expect(container.textContent).toContain('hot'); + }); + + it('leaves a master-detail form with NO dataSource exactly as it was', async () => { + const adapter = makeAdapter(); + const { container } = renderBlock( + { + type: 'object-master-detail-form', + objectName: 'invoice', + mode: 'create', + details: DETAILS, + }, + adapter, + ); + + await waitFor(() => expect(adapter.getObjectSchema).toHaveBeenCalledWith('invoice')); + expect(container.textContent).toContain('Invoice lines'); + }); +}); diff --git a/packages/plugin-form/src/index.tsx b/packages/plugin-form/src/index.tsx index d98a9db2b7..5e2c0ed3ad 100644 --- a/packages/plugin-form/src/index.tsx +++ b/packages/plugin-form/src/index.tsx @@ -8,7 +8,11 @@ import React, { useContext } from 'react'; import { ComponentRegistry } from '@object-ui/core'; -import { ElementDataSourceGate, SchemaRendererContext } from '@object-ui/react'; +import { + ElementDataSourceGate, + SchemaRendererContext, + type ElementDataSourceMapping, +} from '@object-ui/react'; import { ObjectForm } from './ObjectForm'; export { ObjectForm }; @@ -148,7 +152,27 @@ const EmbeddableFormRenderer: React.FC<{ schema: any }> = ({ schema }) => { // objectstack#4413 shape; `public-block-binding-reach.test.tsx` is what // catches it now. const ctx = useContext(SchemaRendererContext as React.Context); - return ; + // The spec's `PageComponentSchema.dataSource` binding (objectstack#7121). Same + // standing, and the same single mapped key, as `object-form` above: + // `EmbeddableForm` reads `config.objectName` to fetch the object's fields and + // to `create()` the submission, so a form authored with `dataSource: { object }` + // and no flat `objectName` rendered a field-less shell that could not submit. + // + // `filter` / `sort` / `limit` have no read site on a form that CREATES one + // record — there is no collection query for them to narrow — and `fields` is an + // ordered layout, not a projection a list view's columns could fill, so none of + // them is mapped. A `view` name is still resolved (a typo reports rather than + // rendering), and a view that does resolve contributes nothing here. + return ( + + {(bound) => } + + ); }; ComponentRegistry.register('embeddable-form', EmbeddableFormRenderer, { @@ -190,7 +214,30 @@ import { MasterDetailForm } from './MasterDetailForm'; const MasterDetailFormRenderer: React.FC<{ schema: any }> = ({ schema }) => { const ctx = useContext(SchemaRendererContext as React.Context); const dataSource = ctx?.dataSource ?? undefined; - return ; + // The spec's `PageComponentSchema.dataSource` binding (objectstack#7121). + // `schema.objectName` is the PARENT object here, and everything downstream is + // gated on it — `deriveDetail` needs it to find each child's relationship field + // back to the parent, and the parent form/save path names it directly. A + // master-detail form authored with `dataSource: { object }` and no flat + // `objectName` therefore rendered a parent shell whose details could not even + // resolve their own FK. + // + // `object` is the only key mapped, and for the same reason as the two forms + // above: the parent is ONE record (no collection query for `filter` / `sort` / + // `limit` to narrow) and the child collections are fetched by FK from + // `schema.details`, at a fixed `$top: 500`, not from anything the binding + // carries. `details[].columns` is a per-child editable grid spec, not a + // projection a saved view could supply. + return ( + + {(bound) => } + + ); }; ComponentRegistry.register('object-master-detail-form', MasterDetailFormRenderer, { @@ -209,8 +256,53 @@ ComponentRegistry.register('object-master-detail-form', MasterDetailFormRenderer // usable on a record/detail page or slotted slot. import { LineItemsPanel } from './LineItemsPanel'; +/** + * `record:line_items` is the one block in this batch whose object does NOT live + * under `objectName`: the collection it lists, fetches and writes is + * `schema.childObject` (`LineItemsPanel.tsx` — `dataSource.find(schema.childObject, + * …)`, `getObjectSchema(schema.childObject)`, and the child leg of the save + * batch). So the binding's `object` maps THERE, the same way it maps onto + * `record:related_list`'s `objectName` — in both blocks that key names the CHILD + * object the panel is bound to, which is what `dataSource.object` means. + * + * Nothing else is mapped, and each omission has a read site behind it (or the + * lack of one): + * + * - `relationshipField` stays the author's: it is not part of the binding, and it + * must name a field ON the bound child object. Rebinding `object` without + * updating it is an authoring error the panel cannot paper over. + * - `filter` has no read site — the query is `{ [relationshipField]: parentId }` + * and nothing else, so the panel is scoped by the parent record alone. + * - `sort` and a row cap likewise: rows come back at a fixed `$top: 500` in + * storage order. + * - `columns` is NOT a field-name projection here. It is `GridColumn[]` + * (`{ field, type, options, computed, expr, … }`) driving an EDITABLE grid; a + * saved view's column list would arrive as bare names and render a grid of + * column definitions with no `field`. Wrong shape, not merely a wider answer. + * + * Consequence, stated rather than hidden: a `view` named on this block is + * resolved (so a typo reports instead of silently widening) and then contributes + * nothing — its filter/sort/columns are all dropped, because the panel has no + * read site for any of them. + */ +const RECORD_LINE_ITEMS_DATA_SOURCE: ElementDataSourceMapping = { + object: 'childObject', +}; + const LineItemsPanelRenderer: React.FC<{ schema: any }> = ({ schema }) => ( - + // The spec's `PageComponentSchema.dataSource` binding (objectstack#7121). No + // `dataSource` is passed: the gate falls back to `SchemaRendererContext`, which + // is the very adapter `LineItemsPanel` loads its rows through — resolving `view` + // against a different source than the rows come from could report a view as + // missing on a host that has it. + + {(bound) => } + ); ComponentRegistry.register('line_items', LineItemsPanelRenderer, { diff --git a/packages/plugin-gantt/src/ObjectGantt.elementDataSource.test.tsx b/packages/plugin-gantt/src/ObjectGantt.elementDataSource.test.tsx new file mode 100644 index 0000000000..a6cb7bd65d --- /dev/null +++ b/packages/plugin-gantt/src/ObjectGantt.elementDataSource.test.tsx @@ -0,0 +1,166 @@ +/** + * 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-gantt` consumes `PageComponentSchema.dataSource` (objectstack#7121). + * + * The gantt derives its whole data config from `schema.objectName` + * (`getDataConfig`), and nothing mapped the spec's `dataSource.object` onto it: + * a gantt authored with the binding the spec documents got a NULL data config, + * so `resolveDataSource` had nothing to read through — an empty chart, no + * request, no error. + * + * `filter` and `sort` DO map here (`$filter` / `$orderby` on the reload), while a + * column list and a row cap do not — a gantt projects the fields its `gantt` + * config names and its reload issues no `$top` at all. Those keys are left + * unmapped rather than parked on a key nothing reads. + */ + +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'; + +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })); + +// The bar canvas is irrelevant here — every assertion is about the QUERY. Same +// stub the sibling ObjectGantt tests use, for the same reason. +vi.mock('./GanttView', () => ({ + GanttView: ({ tasks }: any) =>
{tasks.length}
, +})); + +vi.mock('@object-ui/plugin-detail', () => ({ + RecordDetailDrawer: () => null, + deriveRecordPageHref: () => null, +})); + +// Registers `object-gantt` (and the ElementDataSourceGate wiring under test). +import './index'; + +const HOT_VIEW = { + name: 'hot', + label: 'Hot tasks', + columns: ['name', 'priority'], + filter: [['priority', '=', 'high']], + sort: [{ field: 'name', order: 'desc' }], + pagination: { pageSize: 7 }, +}; + +const GANTT = { startDateField: 'start_date', endDateField: 'end_date', titleField: 'name' }; + +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(), + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'task', + fields: { + name: { type: 'text' }, + priority: { type: 'text' }, + start_date: { type: 'datetime' }, + end_date: { type: 'datetime' }, + }, + listViews, + }), + }; +} + +const renderBlock = (schema: Record, adapter: ReturnType) => + render( + + + , + ); + +describe('object-gantt — dataSource: { object, view } (objectstack#7121)', () => { + it('queries the bound object with the saved view’s filter and sort', async () => { + const adapter = makeAdapter(); + renderBlock( + { type: 'object-gantt', gantt: GANTT, dataSource: { object: 'task', view: 'hot' } }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [object, params] = adapter.find.mock.calls[0] as [string, any]; + expect(object).toBe('task'); + expect(params.$filter).toEqual([['priority', '=', 'high']]); + expect(params.$orderby).toEqual({ name: 'desc' }); + }); + + it('narrows, never widens: the binding’s own filter ANDs with the view’s', async () => { + const adapter = makeAdapter(); + renderBlock( + { + type: 'object-gantt', + gantt: GANTT, + dataSource: { + object: 'task', + view: 'hot', + filter: [['owner', '=', 'me']], + }, + }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [, params] = adapter.find.mock.calls[0] as [string, any]; + // Both sources survive — the binding is "additional filter criteria", so it + // cannot drop the view's restriction. + expect(JSON.stringify(params.$filter)).toContain('priority'); + expect(JSON.stringify(params.$filter)).toContain('owner'); + }); + + it('reports an unresolvable `view` instead of fetching the whole object', async () => { + const adapter = makeAdapter(); + const { container } = renderBlock( + { type: 'object-gantt', gantt: GANTT, dataSource: { object: 'task', view: 'nope' } }, + adapter, + ); + + await waitFor(() => + expect(container.querySelector('[data-testid="object-gantt-datasource-error"]')).not.toBeNull(), + ); + expect(adapter.find).not.toHaveBeenCalled(); + }); + + it('writes NO row cap: the gantt reload issues no $top for one to land on', async () => { + const adapter = makeAdapter(); + renderBlock( + { type: 'object-gantt', gantt: GANTT, dataSource: { object: 'task', view: 'hot', limit: 3 } }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [, params] = adapter.find.mock.calls[0] as [string, any]; + // Not an oversight — `limit` is unmapped because there is no read site. The + // assertion is here so a future mapping addition has to be deliberate. + expect(params.$top).toBeUndefined(); + expect(params.options).toBeUndefined(); + }); + + it('leaves a gantt with NO dataSource exactly as it was', async () => { + const adapter = makeAdapter(); + renderBlock( + { + type: 'object-gantt', + objectName: 'task', + gantt: GANTT, + filter: [['owner', '=', 'me']], + sort: [{ field: 'end_date', order: 'asc' }], + }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [object, params] = adapter.find.mock.calls[0] as [string, any]; + expect(object).toBe('task'); + expect(params.$filter).toEqual([['owner', '=', 'me']]); + expect(params.$orderby).toEqual({ end_date: 'asc' }); + }); +}); diff --git a/packages/plugin-gantt/src/index.tsx b/packages/plugin-gantt/src/index.tsx index 1cf27c0db7..363867ee2d 100644 --- a/packages/plugin-gantt/src/index.tsx +++ b/packages/plugin-gantt/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 { ObjectGantt } from './ObjectGantt'; export { ObjectGantt }; @@ -53,10 +57,46 @@ export type { ResourceLoad, } from './workload'; +/** + * What `ObjectGantt` reads for its own query: `objectName` (via `getDataConfig`, + * which turns it into the `provider: 'object'` config the fetch resolves its + * `resource` from), `filter` and `sort` (`ObjectGantt.tsx` — + * `$filter: schema.filter`, `$orderby: convertSortToQueryParams(schema.sort)`). + * + * `columns` and a row cap are NOT mapped, because neither has a read site: a + * gantt projects the fields its `gantt` config names (start/end/title/progress/ + * dependencies/…) rather than a column list, and its reload issues no `$top` at + * all — it loads the whole bar set and lets `GanttView` window it. Writing a + * view's field list or page size onto either key would hand the block a value it + * ignores, which is the defect this wiring removes, one layer deeper. + * + * Inline data still wins, unchanged: `getDataConfig` prefers `schema.data` / + * `schema.staticData` over the object name, so a gantt authored with both a + * binding and inline rows renders the inline rows exactly as it did before. + */ +const OBJECT_GANTT_DATA_SOURCE: ElementDataSourceMapping = { + filter: true, + sort: true, +}; + // Register component export const ObjectGanttRenderer: React.FC<{ schema: any }> = ({ schema }) => { const { dataSource } = useSchemaContext() || {}; - return ; + // The spec's `PageComponentSchema.dataSource` binding (objectstack#7121). A + // gantt authored with the binding and no flat `objectName` produced no data + // config at all, so `resolveDataSource` had nothing to fetch through: an empty + // chart, no request, no diagnostic. + return ( + + {(bound) => } + + ); }; ComponentRegistry.register('object-gantt', ObjectGanttRenderer, { diff --git a/packages/plugin-map/src/ObjectMap.elementDataSource.test.tsx b/packages/plugin-map/src/ObjectMap.elementDataSource.test.tsx new file mode 100644 index 0000000000..2a3f3d2bee --- /dev/null +++ b/packages/plugin-map/src/ObjectMap.elementDataSource.test.tsx @@ -0,0 +1,152 @@ +/** + * 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-map` consumes `PageComponentSchema.dataSource` (objectstack#7121). + * + * The map derives its data config from `schema.objectName` (`getDataConfig`), and + * nothing mapped the spec's `dataSource.object` onto it: a map authored with the + * binding the spec documents got a null config and rendered with no markers — no + * request, no error. + * + * `filter` and `sort` DO map here (`$filter` / `$orderby` on the fetch); a column + * list and a row cap do not, a map projecting the fields its `map` config names + * and issuing no `$top`. + */ + +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'; + +// No WebGL in the test env — same stub the sibling ObjectMap tests use. Every +// assertion here is about the QUERY, not the canvas. +vi.mock('react-map-gl/maplibre', () => ({ + default: ({ children }: any) =>
{children}
, + Map: ({ children }: any) =>
{children}
, + NavigationControl: () =>
, + Marker: ({ children }: any) =>
{children}
, + Popup: ({ children }: any) =>
{children}
, +})); + +// Registers `object-map` (and the ElementDataSourceGate wiring under test). +import './index'; + +const HOT_VIEW = { + name: 'hot', + label: 'Hot stores', + columns: ['name', 'rating'], + filter: [['rating', '=', 'hot']], + sort: [{ field: 'name', order: 'desc' }], + pagination: { pageSize: 7 }, +}; + +const MAP = { latitudeField: 'lat', longitudeField: 'lng', titleField: 'name' }; + +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(), + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'store', + fields: { + name: { type: 'text' }, + rating: { type: 'text' }, + lat: { type: 'number' }, + lng: { type: 'number' }, + }, + listViews, + }), + }; +} + +const renderBlock = (schema: Record, adapter: ReturnType) => + render( + + + , + ); + +describe('object-map — dataSource: { object, view } (objectstack#7121)', () => { + it('queries the bound object with the saved view’s filter and sort', async () => { + const adapter = makeAdapter(); + renderBlock( + { type: 'object-map', map: MAP, dataSource: { object: 'store', view: 'hot' } }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [object, params] = adapter.find.mock.calls[0] as [string, any]; + expect(object).toBe('store'); + expect(params.$filter).toEqual([['rating', '=', 'hot']]); + expect(params.$orderby).toEqual({ name: 'desc' }); + }); + + it('narrows, never widens: the binding’s own filter ANDs with the view’s', async () => { + const adapter = makeAdapter(); + renderBlock( + { + type: 'object-map', + map: MAP, + dataSource: { object: 'store', view: 'hot', filter: [['owner', '=', 'me']] }, + }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [, params] = adapter.find.mock.calls[0] as [string, any]; + expect(JSON.stringify(params.$filter)).toContain('rating'); + expect(JSON.stringify(params.$filter)).toContain('owner'); + }); + + it('reports an unresolvable `view` instead of fetching the whole object', async () => { + const adapter = makeAdapter(); + const { container } = renderBlock( + { type: 'object-map', map: MAP, dataSource: { object: 'store', view: 'nope' } }, + adapter, + ); + + await waitFor(() => + expect(container.querySelector('[data-testid="object-map-datasource-error"]')).not.toBeNull(), + ); + expect(adapter.find).not.toHaveBeenCalled(); + }); + + it('writes NO row cap: the map fetch issues no $top for one to land on', async () => { + const adapter = makeAdapter(); + renderBlock( + { type: 'object-map', map: MAP, dataSource: { object: 'store', view: 'hot', limit: 3 } }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [, params] = adapter.find.mock.calls[0] as [string, any]; + expect(params.$top).toBeUndefined(); + }); + + it('leaves a map with NO dataSource exactly as it was', async () => { + const adapter = makeAdapter(); + renderBlock( + { + type: 'object-map', + objectName: 'store', + map: MAP, + filter: [['owner', '=', 'me']], + sort: [{ field: 'name', order: 'asc' }], + }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [object, params] = adapter.find.mock.calls[0] as [string, any]; + expect(object).toBe('store'); + expect(params.$filter).toEqual([['owner', '=', 'me']]); + expect(params.$orderby).toEqual({ name: 'asc' }); + }); +}); diff --git a/packages/plugin-map/src/index.tsx b/packages/plugin-map/src/index.tsx index 69b4f0731b..0a322e3741 100644 --- a/packages/plugin-map/src/index.tsx +++ b/packages/plugin-map/src/index.tsx @@ -8,17 +8,57 @@ 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 { ObjectMap } from './ObjectMap'; import type { ObjectMapProps } from './ObjectMap'; export { ObjectMap }; export type { ObjectMapProps }; +/** + * What `ObjectMap` reads for its own query: `objectName` (via `getDataConfig`), + * `filter` and `sort` (`ObjectMap.tsx` — `$filter: schema.filter`, + * `$orderby: convertSortToQueryParams(schema.sort)`). + * + * No `columns` and no row cap are mapped: a map projects the fields its `map` + * config names (latitude/longitude/title/description) and its fetch issues no + * `$top`, so neither key has a read site to write to. + * + * One caveat that belongs next to `filter: true` rather than in a changelog: + * `getMapConfig` ALSO accepts the map's own configuration stashed under + * `schema.filter.map` (a legacy shape predating the `map` input). A binding whose + * filter actually composes replaces that object with an AND node, so the legacy + * stash stops being found — authoring the map config under `map` (the declared + * input) is the supported spelling and the only one the binding is compatible + * with. Filed separately as the overload it is; nothing here relies on it, and a + * schema with no composing filter passes `schema.filter` through untouched. + */ +const OBJECT_MAP_DATA_SOURCE: ElementDataSourceMapping = { + filter: true, + sort: true, +}; + // Register component export const ObjectMapRenderer: React.FC = ({ schema, ...props }) => { const { dataSource } = useSchemaContext() || {}; - return ; + // The spec's `PageComponentSchema.dataSource` binding (objectstack#7121): a map + // authored with the binding and no flat `objectName` got a null data config, so + // it rendered an empty map — no markers, no request, no diagnostic. + return ( + + {(bound) => } + + ); }; console.log('Registering object-map...'); diff --git a/packages/plugin-timeline/src/ObjectTimeline.elementDataSource.test.tsx b/packages/plugin-timeline/src/ObjectTimeline.elementDataSource.test.tsx new file mode 100644 index 0000000000..07df313007 --- /dev/null +++ b/packages/plugin-timeline/src/ObjectTimeline.elementDataSource.test.tsx @@ -0,0 +1,134 @@ +/** + * 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-timeline` consumes `PageComponentSchema.dataSource` (objectstack#7121). + * + * Every branch of the timeline's fetch effect is gated on `schema.objectName`, + * and nothing mapped the spec's `dataSource.object` onto it: a timeline authored + * with the binding the spec documents never fetched and rendered an empty rail + * with no request and no error. + * + * This block maps `object` and NOTHING else, which the last two cases pin + * deliberately: the fetch is `find(objectName, { options: { $top: 100 } })` — no + * `$filter`, no `$orderby`, a hard-coded window — so a `filter` / `sort` / `limit` + * written onto the schema would be accepted and dropped, the very defect this + * wiring removes. A named view is still resolved (a typo reports) and then + * contributes nothing. + */ + +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-timeline` (and the ElementDataSourceGate wiring under test). +import './index'; + +const HOT_VIEW = { + name: 'hot', + label: 'Hot campaigns', + columns: ['name', 'stage'], + filter: [['stage', '=', 'live']], + sort: [{ field: 'name', order: 'desc' }], + pagination: { pageSize: 7 }, +}; + +const TIMELINE = { startDateField: 'start_date', endDateField: 'end_date', titleField: 'name' }; + +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(), + getObjectSchema: vi.fn().mockResolvedValue({ + name: 'campaign', + fields: { + name: { type: 'text' }, + stage: { type: 'text' }, + start_date: { type: 'datetime' }, + end_date: { type: 'datetime' }, + }, + listViews, + }), + }; +} + +const renderBlock = (schema: Record, adapter: ReturnType) => + render( + + + , + ); + +describe('object-timeline — dataSource: { object, view } (objectstack#7121)', () => { + it('queries the object named by the binding', async () => { + const adapter = makeAdapter(); + renderBlock( + { type: 'object-timeline', timeline: TIMELINE, dataSource: { object: 'campaign', view: 'hot' } }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [object] = adapter.find.mock.calls[0] as [string, any]; + expect(object).toBe('campaign'); + }); + + it('reports an unresolvable `view` instead of fetching the whole object', async () => { + const adapter = makeAdapter(); + const { container } = renderBlock( + { type: 'object-timeline', timeline: TIMELINE, dataSource: { object: 'campaign', view: 'nope' } }, + adapter, + ); + + await waitFor(() => + expect(container.querySelector('[data-testid="object-timeline-datasource-error"]')).not.toBeNull(), + ); + expect(adapter.find).not.toHaveBeenCalled(); + }); + + it('does NOT pretend to honour filter/sort/limit — there is no read site for them', async () => { + const adapter = makeAdapter(); + renderBlock( + { + type: 'object-timeline', + timeline: TIMELINE, + dataSource: { + object: 'campaign', + view: 'hot', + filter: [['owner', '=', 'me']], + sort: [{ field: 'end_date', order: 'asc' }], + limit: 3, + }, + }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [, params] = adapter.find.mock.calls[0] as [string, any]; + // The honest assertion: the timeline's query carries none of these, because + // it reads none of them. Recorded here (and in the data-source guide) rather + // than hidden behind a mapping that writes to keys nobody reads. When the + // fetch gains the read sites, this case is what has to be rewritten. + expect(params.$filter).toBeUndefined(); + expect(params.$orderby).toBeUndefined(); + expect(params.options).toEqual({ $top: 100 }); + }); + + it('leaves a timeline with NO dataSource exactly as it was', async () => { + const adapter = makeAdapter(); + renderBlock( + { type: 'object-timeline', objectName: 'campaign', timeline: TIMELINE }, + adapter, + ); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + const [object, params] = adapter.find.mock.calls[0] as [string, any]; + expect(object).toBe('campaign'); + expect(params.options).toEqual({ $top: 100 }); + }); +}); diff --git a/packages/plugin-timeline/src/index.tsx b/packages/plugin-timeline/src/index.tsx index 6c1eeb9722..a681dde2ba 100644 --- a/packages/plugin-timeline/src/index.tsx +++ b/packages/plugin-timeline/src/index.tsx @@ -305,12 +305,54 @@ export type { ObjectTimelineProps } from './ObjectTimeline'; import { ComponentRegistry } from '@object-ui/core'; import { ObjectTimeline } from './ObjectTimeline'; -import { useSchemaContext } from '@object-ui/react'; +import { + ElementDataSourceGate, + useSchemaContext, + type ElementDataSourceMapping, +} from '@object-ui/react'; + +/** + * `object-timeline` maps the binding's `object` and NOTHING ELSE, because + * `objectName` is the only query key this block has a read site for. + * + * `ObjectTimeline.tsx`'s fetch is literally + * `dataSource.find(schema.objectName, { options: { $top: 100 } })` — no + * `$filter`, no `$orderby`, and a HARD-CODED window rather than an authored cap. + * So `filter` / `sort` / `limit` are deliberately left unmapped: writing them + * onto schema keys the timeline never reads would look like the binding was + * honoured while changing nothing about the rows fetched, which is exactly the + * "declared and dropped" defect objectstack#7121 removes. `columns` is unmapped + * for the same reason a calendar's is — a timeline projects the fields its + * `timeline` config names (title/start/end/description/color/groupBy). + * + * Consequence, recorded rather than papered over: a saved `view` named here + * contributes NOTHING to the query (its filter and sort are dropped), so the + * timeline can be wider than the view it names. The name is still resolved, so a + * typo reports instead of silently widening. Tracked as the residual gap in + * `content/docs/guide/data-source.md`; when the timeline's fetch gains + * filter/sort read sites, this mapping gains the flags and the binding follows + * for free. + */ +const OBJECT_TIMELINE_DATA_SOURCE: ElementDataSourceMapping = {}; // Register object-timeline component export const ObjectTimelineRenderer: React.FC = ({ schema, ...props }) => { const { dataSource } = useSchemaContext() || {}; - return ; + // The spec's `PageComponentSchema.dataSource` binding (objectstack#7121): a + // timeline authored with the binding and no flat `objectName` never fetched — + // every branch of its effect is gated on `schema.objectName` — and rendered an + // empty rail with no request and no error. + return ( + + {(bound) => } + + ); }; ComponentRegistry.register('object-timeline', ObjectTimelineRenderer, {