From 08d365094dd367966f4849be186a510851c7c1a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 01:38:29 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(plugin-dashboard):=20the=20editable=20g?= =?UTF-8?q?rid=20renders=20dataset-bound=20widgets=20=E2=80=94=20and=20say?= =?UTF-8?q?s=20so=20visibly=20when=20it=20cannot=20(#4614)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DashboardGridLayout had no dataset path at all: it never read `widget.dataset`, never imported DatasetWidget, and took no `dataSource` prop. A widget authored in the current ADR-0021 shape fell through to the static-data branch and rendered nothing — measured three ways: `{type:'chart',data:[]}` for a chart, `{type:'metric',value:'—'}` for a metric, `{type:'data-table',data:[]}` for a table. No data, no diagnostic, no path to fix. The cure is the sibling's own mechanics, not a second dispatch idiom: the `datasetBound` predicate decided per widget, DatasetWidget picked at the render site (DashboardRenderer.tsx:524 / :849-851), and a dataset-bound metric taking the shared Card wrapper (:777-782). A dataset-bound widget with NO dataSource renders DatasetWidget's own no-capability alert rather than a blank tile — measured visible before choosing it, so no third diagnostic surface is declared. That is the `dashboard-grid` SDUI path, which passes no adapter. The #4612 legacy sentinel keeps its position and verdict: the two conditions are mutually exclusive by construction (legacyRetiredWidget.ts:107). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .changeset/gridlayout-dataset-path-4614.md | 15 ++ .../src/DashboardGridLayout.tsx | 89 ++++++- .../DashboardGridLayout.datasetPath.test.tsx | 250 ++++++++++++++++++ 3 files changed, 352 insertions(+), 2 deletions(-) create mode 100644 .changeset/gridlayout-dataset-path-4614.md create mode 100644 packages/plugin-dashboard/src/__tests__/DashboardGridLayout.datasetPath.test.tsx diff --git a/.changeset/gridlayout-dataset-path-4614.md b/.changeset/gridlayout-dataset-path-4614.md new file mode 100644 index 000000000..0b78111c6 --- /dev/null +++ b/.changeset/gridlayout-dataset-path-4614.md @@ -0,0 +1,15 @@ +--- +'@object-ui/plugin-dashboard': minor +--- + +The editable dashboard grid renders dataset-bound widgets — and says so visibly when it cannot + +`DashboardGridLayout` had no dataset path at all. It never read `widget.dataset`, never imported `DatasetWidget`, and took no `dataSource` prop — so a widget authored the way ADR-0021 says to author them (`{ id, type: 'bar', dataset: 'invoices', values: ['count'] }`) fell straight through to the static-data branch and rendered nothing. Measured on the node the grid handed `SchemaRenderer`, the silence had three flavours rather than the one reported: a `bar` became `{ type: 'chart', data: [] }` (a chart drawn over nothing), a `metric` became `{ type: 'metric', value: '—' }` (an em dash, which reads as a rendered value rather than an error), and a `table` became `{ type: 'data-table', data: [] }`. No data, no diagnostic, no path to fix — on the surface registered as the `dashboard-grid` SDUI component and exported by name from the package entry. + +This is the defect objectui#4612 fixed for the RETIRED authoring shape, one level up: same surface, same silence, but the shape that is current. The sibling `DashboardRenderer` has routed these widgets through the governed `queryDataset` path since ADR-0021, so the cure is that surface's own mechanics rather than a second dispatch idiom — the `datasetBound` predicate decided per widget, and `DatasetWidget` picked at the render site. + +`DashboardGridLayout` therefore gains an optional `dataSource` prop, forwarded to `DatasetWidget` for dataset-bound widgets. A dataset-bound metric now also takes the shared `Card` wrapper, matching the sibling: `DatasetWidget` renders just the value, so without the card it would show as bare text with no title beside its neighbours. + +A dataset-bound widget arriving with NO data source renders a visible state, never a blank. No new placeholder was declared for it: `DatasetWidget`'s own no-capability rendering — an alert reading "This data source does not support dataset queries." — was measured to render visibly when handed no adapter, so routing through it unconditionally cures both halves with one diagnostic and one wording. That case is not hypothetical: `dashboard-grid`'s SDUI registration declares only `title` and `className` inputs, so schema-driven hosts render this component with no adapter at all, and every such host keeps working exactly as before. + +Nothing else moves. The objectui#4612 legacy sentinel keeps its position and its verdict — the two conditions are mutually exclusive by construction, since the shared detector returns false the moment a widget carries `dataset` — and static-data widgets, `options.data` provider widgets and legacy-retired widgets all render as they did and never reach the dataset query. The new prop is additive and optional, so existing call sites are untouched. diff --git a/packages/plugin-dashboard/src/DashboardGridLayout.tsx b/packages/plugin-dashboard/src/DashboardGridLayout.tsx index 34944a22b..b66295835 100644 --- a/packages/plugin-dashboard/src/DashboardGridLayout.tsx +++ b/packages/plugin-dashboard/src/DashboardGridLayout.tsx @@ -9,6 +9,7 @@ import type { BaseSchema, DashboardComponentSchema, DashboardWidgetSchema } from import { isObjectProvider } from './utils'; import { classifyWidgetType } from './widgetDispatch'; import { LEGACY_RETIRED_WIDGET_SCHEMA, isLegacyRetiredWidget } from './legacyRetiredWidget'; +import { DatasetWidget } from './DatasetWidget'; /** Bridges editMode transitions to the ObjectUI DnD system when a DndProvider is present. */ function DndEditModeBridge({ editMode }: { editMode: boolean }) { @@ -37,6 +38,33 @@ const CHART_COLORS = [ export interface DashboardGridLayoutProps { schema: DashboardComponentSchema; className?: string; + /** + * Data-source adapter for the widgets this grid renders, handed to + * `DatasetWidget` for ADR-0021 dataset-bound widgets (objectui#4614). + * + * Typed `unknown`, not `any`. The sibling declares `dataSource?: any` + * (`DashboardRenderer.tsx:198`) for a reason that is explicitly historical — + * "that is precisely what it resolved to before", via the index signature that + * used to answer for it — and this is a NEW declaration with no prior + * resolution to preserve. `unknown` is what the consumer itself declares + * (`DatasetWidget.tsx:655`, `dataSource: unknown`), so the value is forwarded + * to exactly the type that receives it, and no call site is held to anything + * new: every value is assignable to `unknown`. Narrowing it further to a real + * adapter type is a separate change with its own consumer sweep, on both + * surfaces at once. + * + * Optional, and the omitted case is a SUPPORTED one rather than an oversight: + * `dashboard-grid`'s SDUI registration declares only `title` / `className` + * inputs, so schema-driven hosts render this component with no adapter at all. + * A dataset-bound widget arriving that way renders `DatasetWidget`'s own + * no-capability diagnostic — a visible state, never a blank tile. + * + * `SchemaRenderer` forwards this as a React prop (its `...props` spread, last: + * `SchemaRenderer.tsx:632`). It cannot be shadowed by the spec's per-element + * `dataSource` BINDING, which is stripped from the schema before that spread + * for exactly this reason (`SchemaRenderer.tsx:564-575`, objectstack#5576). + */ + dataSource?: unknown; /** * Fires on every drag/resize tick with the raw react-grid-layout payload. * Useful for live previews; NOT a persistence hook. @@ -91,6 +119,7 @@ function buildDefaultLayouts(schema: DashboardComponentSchema): { lg: RGLLayout[ export const DashboardGridLayout: React.FC = ({ schema, className, + dataSource, onLayoutChange, onSchemaChange, onRefresh, @@ -415,7 +444,42 @@ export const DashboardGridLayout: React.FC = ({ // truthfully, so the narrowing is named here once (objectui#4548) // instead of being spread across the two render sites below. const componentSchema = getComponentSchema(widget) as BaseSchema | string | null | undefined; - const isSelfContained = widget.type === 'metric'; + // ADR-0021 — a widget bound to a semantic-layer dataset renders + // through the governed queryDataset path (DatasetWidget) instead of + // the inline object-aggregate schema. Decided per widget AT THE + // RENDER SITE, which is `DashboardRenderer`'s own mechanic + // (`DashboardRenderer.tsx:524` for the predicate, `:849-851` for the + // fork) rather than a second dispatch idiom invented here: this + // surface had NO dataset path at all, so every current-shape widget + // fell through `getComponentSchema` to the static-data branch and + // drew `data: []` — a blank chart, an em-dash metric or an empty + // table depending on the family, with no diagnostic (objectui#4614). + // The cast names the ONE key it reads, rather than reaching for + // `as any` the way the sibling does (`DashboardRenderer.tsx:524`): + // the bundled DashboardWidget type gains `dataset` only after + // objectui bumps @objectstack/spec, and `legacyRetiredWidget.ts` + // already answers that same problem in this package by naming the + // undeclared keys in a shape of its own — "what keeps `as any` out + // of both call sites" (`legacyRetiredWidget.ts:64-80`). One key is + // read here, so the shape is stated inline. + // + // Position relative to the objectui#4612 legacy sentinel (which + // stays where it is, inside `getComponentSchema` BEFORE the dispatch + // branches): the two conditions are MUTUALLY EXCLUSIVE by + // construction, because `isLegacyRetiredWidget` returns false the + // moment a widget carries `dataset` (`legacyRetiredWidget.ts:107`). + // Neither can capture the other's widget, so their relative order + // cannot change a verdict on either surface — the same arrangement + // `DashboardRenderer` has carried since #4612, and the reason that + // module states step 1 on its own terms instead of inheriting it + // from a caller's fork (`legacyRetiredWidget.ts:97-102`). + const datasetBound = !!(widget as { dataset?: unknown }).dataset; + // A `metric` widget renders its own card chrome ONLY in the inline + // path. A dataset-bound metric uses DatasetWidget, which renders just + // the value — so it must take the shared Card wrapper to get a title + // and border like its neighbours, instead of showing as bare text + // (`DashboardRenderer.tsx:777-782`, same rule, same reason). + const isSelfContained = widget.type === 'metric' && !datasetBound; // `DashboardWidget.title` is the spec's `I18nLabel`: since // 17.0.0-rc.6 an author may inline a per-locale map // (`{ en: 'Pipeline', 'zh-CN': '销售漏斗' }`) instead of a string. @@ -455,7 +519,28 @@ export const DashboardGridLayout: React.FC = ({ )}
- + {/* + The fork itself, mirroring `DashboardRenderer.tsx:849-851`. + `widget` is passed whole: DatasetWidget reads + `widget.filter` and forwards it to the query as + `runtimeFilter`, so an authored per-widget filter still + applies. The sibling wraps it as `effectiveWidget` only + to merge in the dashboard FILTER BAR's scoped filter, + which this surface does not have — there is no + `scopedFilter` here to merge, so re-creating the wrapper + would state a dependency that does not exist. + + This is the ONLY fork: the self-contained branch above + cannot be reached by a dataset-bound widget, since + `isSelfContained` now requires `!datasetBound`. The + sibling carries a second, identical fork inside its + self-contained branch (`:820-822`) that is unreachable + for that same reason; the reachable mechanics are what + is mirrored here, not the stranded limb. + */} + {datasetBound + ? + : }
diff --git a/packages/plugin-dashboard/src/__tests__/DashboardGridLayout.datasetPath.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardGridLayout.datasetPath.test.tsx new file mode 100644 index 000000000..6037475c7 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/DashboardGridLayout.datasetPath.test.tsx @@ -0,0 +1,250 @@ +/** + * 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. + */ + +/** + * objectui#4614 — the editable grid gains the ADR-0021 dataset path it never had. + * + * `DashboardRenderer` has routed a dataset-bound widget through `DatasetWidget` + * — the governed `queryDataset` path — since ADR-0021. `DashboardGridLayout`, + * separately exported and registered as the `dashboard-grid` SDUI component, + * never read `widget.dataset` at all and took no `dataSource` prop, so a widget + * authored in the CURRENT shape fell through to the static-data branch. Measured + * on `origin/main` (8640cec19) by spying on the node handed to `SchemaRenderer`, + * the silence had three flavours, not the one the issue reported: + * + * bar → { type: 'chart', chartType: 'bar', data: [], … } → blank chart + * metric → { type: 'metric', label: 'metric', value: '—' } → em dash + * table → { type: 'data-table', data: [], … } → empty table + * + * No data, no diagnostic, no path to fix — objectui#4612's defect one level up. + * #4612 was the RETIRED authoring shape falling through this same surface; this + * is the CURRENT one. + * + * ## Why the component registries are imported here + * + * At module scope, never in a hook (the cold transform must not be billed to a + * bounded test budget — AGENTS.md 测试纪律, objectui#3010), and for a reason this + * suite cannot do without: with `chart` unregistered, `SchemaRenderer` renders a + * red "Unknown component type" box carrying `role="alert"`, and an assertion for + * the dataset diagnostic's alert would have passed on that box — pre-fix, with + * no dataset path in the file at all. `chart` lives in `@object-ui/plugin-charts` + * (a declared devDependency), not in `@object-ui/components`, so BOTH are needed + * to reproduce what a real host renders. With them, the pre-fix tile for a + * dataset-bound widget is exactly what the issue says: empty — no alert, no + * chart, nothing. + * + * ## Why no new placeholder for the no-dataSource half + * + * The ruling asked for a VISIBLE state — never a blank — when a dataset-bound + * widget arrives with no data source, and asked to measure `DatasetWidget`'s own + * no-capability rendering before inventing a second one. Measured: with + * `dataSource={undefined}` it renders `role="alert"` carrying "This data source + * does not support dataset queries." (`DatasetWidget.tsx:766-771` sets the error + * state BEFORE the measures check; `:929-934` renders it). It is visible in this + * context, so routing through `DatasetWidget` UNCONDITIONALLY is the whole fix + * and no third diagnostic surface is declared. `dashboard-grid`'s SDUI + * registration passes no `dataSource`, so that path IS this case — pinned below. + * + * The negative controls are the binding half: static-data, `options.data` + * provider, and the #4613 legacy-retired widget must all keep rendering exactly + * as before AND must never reach the dataset query. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import type { DashboardComponentSchema } from '@object-ui/types'; +import '@object-ui/components'; +import '@object-ui/plugin-charts'; +import { DashboardGridLayout } from '../DashboardGridLayout'; + +afterEach(cleanup); + +/** + * `dataset` / `values` / `dimensions` are the ADR-0021 authoring keys; `object` + * + `categoryField` (the legacy control) were removed from the widget vocabulary + * by framework#3320. The cast mirrors `DashboardGridLayout.legacyRetired.test.tsx` + * — stored metadata is what a renderer actually receives. + */ +const dash = (widget: Record): DashboardComponentSchema => + ({ type: 'dashboard', widgets: [widget] }) as unknown as DashboardComponentSchema; + +/** The same stub shape `DatasetWidget`'s own suite uses for a capable source. */ +const makeSource = (impl: () => Promise>) => ({ queryDataset: vi.fn(impl) }); + +/** + * Negative-control discipline, carried over from the #4613 suite: every control + * asserts BOTH the absence of the dataset path AND that the widget really + * rendered — otherwise "no dataset query" would also be satisfied by a grid that + * threw or drew nothing, and the control would pass for the wrong reason. + */ +const renderOne = (widget: Record, dataSource?: unknown) => { + const { container } = render(); + expect(container.querySelector('[data-testid="grid-layout"]')).toBeInTheDocument(); + return container; +}; + +describe('DashboardGridLayout dataset-bound widgets (#4614)', () => { + // ── positives: the path that did not exist ────────────────────────────── + it('renders the KPI value a dataset-bound metric widget queried for', async () => { + const src = makeSource(async () => ({ + rows: [{ invoice_count: 42 }], + fields: [{ name: 'invoice_count', type: 'number', label: 'Invoices' }], + })); + render( + , + ); + // The real DatasetWidget outcome from the canned rows. Before this change + // the widget rendered the static-data branch's em dash — `value: '—'`. + expect(await screen.findByText('42')).toBeInTheDocument(); + expect(screen.queryByText('—')).not.toBeInTheDocument(); + expect(src.queryDataset).toHaveBeenCalledWith('invoices', { dimensions: [], measures: ['invoice_count'] }); + }); + + it('renders real table content (resolved header labels + a cell) for a dataset-bound table widget', async () => { + const src = makeSource(async () => ({ + rows: [{ status: 'Open', invoice_count: 5 }], + fields: [ + { name: 'status', type: 'string', label: 'Status' }, + { name: 'invoice_count', type: 'number', label: 'Invoices' }, + ], + })); + render( + , + ); + // Header labels come from the dataset result's fields — the `data: []` + // data-table it used to build has no headers and no cells at all. + expect(await screen.findByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Invoices')).toBeInTheDocument(); + expect(screen.getByText('Open')).toBeInTheDocument(); + }); + + it('runs the governed dataset query for a dataset-bound chart widget — dimensions→dimensions, values→measures', async () => { + const src = makeSource(async () => ({ + rows: [{ status: 'Open', invoice_count: 5 }, { status: 'Paid', invoice_count: 9 }], + fields: [ + { name: 'status', type: 'string', label: 'Status' }, + { name: 'invoice_count', type: 'number', label: 'Invoices' }, + ], + })); + render( + , + ); + // The query IS the observable outcome for a chart under jsdom (recharts + // draws nothing at zero width), and it is precisely what the grid skipped: + // `data: []` never called anything. + await waitFor(() => + expect(src.queryDataset).toHaveBeenCalledWith('invoices', { dimensions: ['status'], measures: ['invoice_count'] }), + ); + }); + + it('mounts DatasetWidget itself — a pending query shows ITS loading skeleton, which no other branch of this grid can produce', () => { + // The `pendingSource` idiom from DatasetWidget's own suite. `dataset-loading` + // is DatasetWidget's testid and nothing else on this surface emits it, so + // this pins WHICH component renders the tile, independently of what the + // query eventually resolves to. + const src = { queryDataset: vi.fn(() => new Promise>(() => {})) }; + render( + , + ); + expect(screen.getByTestId('dataset-loading')).toBeInTheDocument(); + }); + + it('says so VISIBLY when a dataset-bound widget arrives with NO dataSource — never a blank (the SDUI registration path)', async () => { + // `dashboard-grid` is registered with inputs `title` / `className` only and + // is rendered by hosts that pass no adapter, so this is the shape the SDUI + // component type actually renders in. It must state the problem rather than + // draw nothing — measured pre-fix, the tile held no alert, no chart and no + // text at all. + render(); + // Wording pinned verbatim — the message IS the diagnostic, and it is + // DatasetWidget's own (one condition, one wording, no third surface). + const message = await screen.findByText('This data source does not support dataset queries.'); + expect(message).toBeInTheDocument(); + // Rendered AS an alert — asserted on the element carrying the message, not + // by a bare role query, which `SchemaRenderer`'s own "Unknown component + // type" box would also satisfy. + expect(message.closest('[role="alert"]')).not.toBeNull(); + }); + + it.each([ + ['metric', { id: 'w1', type: 'metric', dataset: 'invoices', values: ['count'] }], + ['table', { id: 'w1', type: 'table', dataset: 'invoices', dimensions: ['status'], values: ['count'] }], + ['pivot', { id: 'w1', type: 'pivot', dataset: 'invoices', dimensions: ['status'], values: ['count'] }], + ])('cures the %s flavour of the same silence — no dataSource still says something', async (_kind, widget) => { + // The pre-fix measurement found three different empty artifacts, one per + // family (blank chart / em-dash metric / empty table). All three are the + // same defect and all three take the same cure, so all three are pinned — + // the em dash in particular reads as a rendered value, not as an error. + render(); + expect(await screen.findByText('This data source does not support dataset queries.')).toBeInTheDocument(); + expect(screen.queryByText('—')).not.toBeInTheDocument(); + }); + + // ── negatives: everything that must render exactly as it did ──────────── + it('does NOT take the dataset path for a static-data widget', () => { + const src = makeSource(async () => ({ rows: [] })); + renderOne({ id: 'w1', type: 'bar', options: { data: [{ name: 'A', value: 1 }] } }, src); + expect(src.queryDataset).not.toHaveBeenCalled(); + expect(screen.queryByText(/does not support dataset queries/)).not.toBeInTheDocument(); + }); + + it('does NOT take the dataset path for an options.data provider widget', () => { + // The nested `{ provider: 'object', … }` config is a separate, LIVE surface + // that carries its own `object`/`aggregate` and renders through + // `object-chart`. Conflating it with a dataset binding would retire it. + const src = makeSource(async () => ({ rows: [] })); + renderOne( + { + id: 'w1', + type: 'bar', + options: { data: { provider: 'object', object: 'invoices', aggregate: { field: 'amount', function: 'sum' } } }, + }, + src, + ); + expect(src.queryDataset).not.toHaveBeenCalled(); + expect(screen.queryByText(/does not support dataset queries/)).not.toBeInTheDocument(); + }); + + it('does NOT take the dataset path for a static-data pivot widget', () => { + const src = makeSource(async () => ({ rows: [] })); + renderOne({ id: 'w1', type: 'pivot', options: { data: [{ region: 'EMEA', amount: 1 }] } }, src); + expect(src.queryDataset).not.toHaveBeenCalled(); + }); + + it('still shows the #4613 retired-format placeholder for a legacy widget, and runs no dataset query', () => { + // The shared sentinel keeps its verdict: `isLegacyRetiredWidget` returns + // false the moment a widget carries `dataset`, so the two conditions are + // mutually exclusive and neither can capture the other's widget. + const src = makeSource(async () => ({ rows: [] })); + renderOne({ id: 'w1', type: 'bar', object: 'invoices', categoryField: 'month', valueField: 'amount', aggregate: 'sum' }, src); + expect( + screen.getByText('This widget uses a retired data format. Edit it to bind a dataset.'), + ).toBeInTheDocument(); + expect(src.queryDataset).not.toHaveBeenCalled(); + }); + + it('renders a legacy widget’s placeholder even when a dataSource IS supplied — the sentinel is not a data question', () => { + const src = makeSource(async () => ({ rows: [{ x: 1 }] })); + renderOne({ id: 'w1', type: 'metric', object: 'invoices', aggregate: 'count' }, src); + expect(screen.getByText(/retired data format/i)).toBeInTheDocument(); + expect(src.queryDataset).not.toHaveBeenCalled(); + }); +}); From e5c6ccc8cb89e44a43161a2daa58044323fc9880 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 01:51:20 +0000 Subject: [PATCH 2/2] test(plugin-dashboard): pin the dataset path through the dashboard-grid SDUI registration (#4614) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The component-level suite renders DashboardGridLayout directly. This one renders it the way a schema-driven host does — as the registered `dashboard-grid` component type resolved by SchemaRenderer — because that registration is the surface #4614 was filed against. Two directions, neither visible from the component-level suite: - no `dataSource` passed (what the registration actually declares: `title` and `className` only) still yields the visible dataset diagnostic inside a rendered grid, so adding an optional prop did not break schema-driven usage; - a `dataSource` handed to SchemaRenderer reaches DatasetWidget, which works only because SchemaRenderer forwards unread props to the resolved component (`...props`, spread last) and strips the spec's same-named per-element binding before that spread (objectstack#5576). Nothing else pinned that interaction for this component. Reverse-verified: with the fix reverted, the two positives go red and the static-data control stays green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- ...shboardGridLayout.sduiDatasetPath.test.tsx | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 packages/plugin-dashboard/src/__tests__/DashboardGridLayout.sduiDatasetPath.test.tsx diff --git a/packages/plugin-dashboard/src/__tests__/DashboardGridLayout.sduiDatasetPath.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardGridLayout.sduiDatasetPath.test.tsx new file mode 100644 index 000000000..36836ec62 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/DashboardGridLayout.sduiDatasetPath.test.tsx @@ -0,0 +1,105 @@ +/** + * 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. + */ + +/** + * objectui#4614 — the same guarantee, one level out: through the SDUI registry. + * + * `DashboardGridLayout.datasetPath.test.tsx` renders the component directly. + * This file renders it the way a schema-driven host does — as the registered + * `dashboard-grid` component type, resolved by `SchemaRenderer` from the schema + * (`index.tsx:271-289`) — because that registration is the surface the card was + * filed against, and the new `dataSource` prop is only useful if it survives the + * trip through the renderer loop. + * + * Two directions, both load-bearing and neither visible from the component-level + * suite: + * + * 1. **No `dataSource` passed.** `dashboard-grid` declares only `title` and + * `className` inputs, so this is what schema-driven hosts actually render. + * Adding an optional prop must not break them, and a dataset-bound widget + * arriving this way must still say something visible rather than draw a blank + * tile. + * 2. **`dataSource` passed to `SchemaRenderer`.** It reaches `DatasetWidget` + * only because `SchemaRenderer` forwards every prop it does not itself read + * to the resolved component (`...props`, spread last — `SchemaRenderer.tsx:632`). + * That is also why the spec's per-element `dataSource` BINDING is stripped + * from the schema before that spread (`SchemaRenderer.tsx:564-575`, + * objectstack#5576): the binding and the adapter share a name, and the + * adapter is the one that must win. Nothing else in this repo pins that + * interaction for this component. + * + * The registries are imported at module scope, never in a hook (AGENTS.md + * 测试纪律, objectui#3010). `../index` is what registers `dashboard-grid` itself. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import { SchemaRenderer } from '@object-ui/react'; +import '@object-ui/components'; +import '@object-ui/plugin-charts'; +import '../index'; + +afterEach(cleanup); + +/** + * `dataset` / `values` are ADR-0021 authoring keys the bundled + * `DashboardComponentSchema` does not carry yet, and `dashboard-grid` is + * resolved by type name at runtime — so the node is built as the stored + * metadata it represents. + */ +const gridSchema = (widget: Record) => + ({ type: 'dashboard-grid', widgets: [widget] }) as unknown as Parameters[0]['schema']; + +describe('dashboard-grid via the SDUI registry (#4614)', () => { + it('renders the visible dataset diagnostic when the host passes no dataSource', async () => { + // The registration's own shape: no adapter input is declared, so this is + // the default schema-driven path — and it must not be a blank tile. + render(); + const message = await screen.findByText('This data source does not support dataset queries.'); + expect(message).toBeInTheDocument(); + expect(message.closest('[role="alert"]')).not.toBeNull(); + // The grid itself still rendered — the diagnostic is inside it, not instead + // of it, so schema-driven usage is intact rather than merely failing loudly. + expect(screen.getByTestId('grid-layout')).toBeInTheDocument(); + }); + + it('forwards a dataSource given to SchemaRenderer all the way to the dataset query', async () => { + const src = { + queryDataset: vi.fn(async () => ({ + rows: [{ invoice_count: 42 }], + fields: [{ name: 'invoice_count', type: 'number', label: 'Invoices' }], + })), + }; + render( + , + ); + await waitFor(() => + expect(src.queryDataset).toHaveBeenCalledWith('invoices', { dimensions: [], measures: ['invoice_count'] }), + ); + expect(await screen.findByText('42')).toBeInTheDocument(); + }); + + it('leaves a static-data widget on its existing path when rendered through the registry', async () => { + // The registration must keep behaving exactly as it did for every widget + // shape that already worked. + const src = { queryDataset: vi.fn(async () => ({ rows: [] })) }; + render( + , + ); + expect(await screen.findByTestId('grid-layout')).toBeInTheDocument(); + expect(src.queryDataset).not.toHaveBeenCalled(); + expect(screen.queryByText(/does not support dataset queries/)).not.toBeInTheDocument(); + }); +});