Skip to content

Commit 968cacf

Browse files
fix(app-shell): the Studio canvas runs a report or widget over a draft dataset before the package publishes (#27758)
domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgP6XkfpjCfyzzG2KwQPUZfy)_ Fixes #27585 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:Studio 画布上的报表与仪表盘组件查询数据集时带上草稿标记,草稿数据集在包发布前就能出数;`queryDataset` 新增可选参数 `previewDrafts`。 - 为什么改:作者在 Studio 新建数据集并绑定报表或图表后,画布提示「数据集在此环境中未定义」,直到发布才显示,而旁边的选择器早已能读到这份草稿。 - 风险与回滚:只影响 Studio 内的报表/仪表盘预览;控制台运行时和 Setup 编辑器的请求一字节不变。回滚即还原本 PR。 - 先看:`canvasDataSource.ts`(画布数据源)、`data-objectstack/src/index.ts` 的 `queryDataset` 参数一段、`canvasDataSource-27585.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/previews/canvasDataSource-27585.test.tsx`,预期 5 个测试全过。 - 需要你做的:无,席位落地。 ## What changes **The defect.** In the Studio, a report or a dashboard widget bound to a dataset that is still a draft said the dataset "is not defined in this environment" until the package published. `queryDataset` decided the draft world only from the page URL's `?preview=draft`, and the Studio canvas is a draft window that does not carry that flag, so its query went to the published world, where a draft-only dataset does not exist. The dataset pickers beside the canvas already read the draft-overlaid catalog (#27518). **`@object-ui/data-objectstack`: `queryDataset(dataset, selection, options?)`.** A new optional `{ previewDrafts?: boolean }`. `true` puts `previewDrafts: true` on the one dataset-query request, exactly what the URL flag already put there; the server then resolves the dataset from the draft overlay. It is the same explicit option `listViews(objectName, { previewDrafts })` takes. Absent, the page URL decides as before; given, it wins over the URL. A caller that passes nothing sends a byte-identical request. **`@object-ui/app-shell`: the canvas data source.** `ReportPreview` and `DashboardPreview` render through `useCanvasDataSource()` (new, internal, `previews/canvasDataSource.ts`). In the Studio, keyed on the signal the dataset pickers read (`StudioDatasetAuthoringContext`, provided by the Interfaces pillar around the canvas), it hands the runtime renderers the adapter with its `queryDataset` asking for `previewDrafts`; every other member is the adapter's own, bound to it. Outside the Studio (Setup's editors) it hands back the adapter unchanged. The shape follows the Form tab preview's data source in `StudioDesignSurface.tsx` (`formPreviewSource`): the preview hands the runtime renderer a data source rather than threading a flag through `plugin-report` and `plugin-dashboard`, so every dataset query those renderers make (the report, a dataset widget, a dataset-bound chart, a filter bar's options) carries it with no change in either plugin. One source per adapter is kept in a module `WeakMap`, so its identity is the adapter's and never a `useMemo` result (ui/AGENTS.md #10). **The ruling.** "⛔ The canvas resolves draft datasets the same way the draft preview resolves other draft metadata. It does not add a second lookup." The draft preview resolves draft metadata with one request-level flag the server honours (`?preview=draft` on `/meta`, `previewDrafts` on the dataset query). The canvas now sends that same flag on the same request. The dataset is still named and only the server resolves it: no client-side read of the draft definition, no inline post of it. ## Measurements (the order's mechanism assumptions) - **(a) The server's dataset door honours the flag for a draft-only dataset: yes (code read, not run live).** `packages/rest/src/analytics-routes.ts`, the `POST /analytics/dataset/query` handler: `previewDrafts` is `body.previewDrafts === true` or `?preview=draft`, admitted only for a caller `mayReadPendingDrafts` admits; the by-name lookup reads `getMetaItems({ type: 'dataset', previewDrafts })`, the draft-overlaid list in which draft-only items surface, then hands `previewDrafts` on to `svc.queryDataset`. It is the one owner of that path in the tree. No server file changes. - **(b) The canvas's draft signal.** `useStudioDatasetAuthoring()` (`studio-design/datasetCreate.tsx`), the context the Interfaces pillar provides around both the inspector and the canvas. `DashboardWidgetInspector` and `ReportDefaultInspector` read it to ask `useDatasetCatalog` for `previewDrafts` since #27518; the canvas previews now read the same one. - **(c) The console runtime stays published.** `DashboardView` and `ReportView` render with the plain adapter and pass no option, so `queryDataset` still follows the page URL only: no flag on a runtime page, the flag inside the app draft preview, as before. ## Tests - `ui/packages/app-shell/src/views/metadata-admin/previews/canvasDataSource-27585.test.tsx` (new). Through the real `ObjectStackAdapter` (fetch stubbed) and the real `plugin-report` / `plugin-dashboard` renderers. The stub server answers the dataset by name only when the request carries `previewDrafts: true`, and otherwise answers the door's own `404 NOT_FOUND`, which is what the door does for a draft-only dataset. - In the Studio context, the report preview and the dashboard preview post `{ datasetName: 'draft_sales', previewDrafts: true }` on every dataset query and draw the draft's rows. - Without the context, the same previews post no `previewDrafts` and draw no rows. - `datasetDraftsSource` is one source per adapter, binds every other member to the adapter, and merges `previewDrafts` into a caller's own options. - `ui/packages/data-objectstack/src/queryDataset.test.ts`, new block "the draft world (objectstack#27585)": the option with no URL flag sends `previewDrafts: true`; neither sends nothing; the URL flag alone still sends it; an explicit `previewDrafts: false` wins over the URL flag. <details><summary>Targeted run, ablations and type-check (head 27e684e, before the main merge)</summary> - `pnpm --dir ui exec vitest run --reporter=verbose packages/data-objectstack/src/queryDataset.test.ts packages/app-shell/src/views/metadata-admin/previews/canvasDataSource-27585.test.tsx packages/app-shell/src/views/metadata-admin/previews/ReportPreview.dataset.test.tsx packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.loneWidget.test.tsx packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.i18nTitle-10847.test.tsx` · `VERDICT command-exit 0` · Test Files 5 passed (5) · Tests 56 passed (56). The three existing preview test files are unchanged and green: outside the Studio the previews call `queryDataset(name, selection)` exactly as before. - Ablation 1, through `scripts/ablation-replace.mjs`: `canvasDataSource.ts` anchor `return inStudio && adapter` replaced with a marker that always hands back the adapter (anchor 1 to 0, blob a3c4bd4 to 3f04dfb10d35). The two Studio tests went red (Tests 2 failed, 3 passed); restored to blob a3c4bd4. - Ablation 2: `index.ts` anchor `const previewDrafts = options?.previewDrafts !== undefined` replaced with a marker that ignores the option (blob a258fe1 to 36c53a39804e). Red: the two option tests in `queryDataset.test.ts` and the two Studio tests (Tests 4 failed, 36 passed); restored to blob a258fe1. - `pnpm --filter @object-ui/data-objectstack run type-check && pnpm --filter @object-ui/app-shell run type-check` (the latter also compiles the tests through `tsconfig.test.json`) · `VERDICT command-exit 0`. - The ui vitest resolves `@object-ui/*` to `src`, so neither ablation needed a dist build. </details> ## Acceptance notes - **Who gets drafts.** The server serves the draft world only to a caller it admits to drafts (`mayReadPendingDrafts`); anyone else gets the published dataset, as if the flag were absent. A Studio author is admitted by construction. - **Not in this PR, and not the same option.** The card's "related, code-read only" note (Setup's dataset designer field catalog, `useDatasetFields`, reading objects without the draft overlay) is a metadata read, not a dataset query; this option does not reach it. Left as the card noted it. - **Overlap.** #27713 also edits `ui/packages/data-objectstack/src/index.ts` (the record-page open request, another region). This diff in that file is the `queryDataset` docblock and its option only; whichever lands second rebases. - **Assignee and reviewers.** None set by this PR's author, by the dispatch rule: the seat asks once its review is in. ## Verification <details><summary>Derived gates (dispatch-gates --commands, on the merged head)</summary> Head `41685152` (the main merge). Each line is the command and the exit code captured before any pipe. ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` `node scripts/pm/dispatch-gates.mjs --ran` reconciliation: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN. After the merge, on the same head: the six dataset/preview test files (the five above plus `queryDataset.forbidden-10899.test.ts`) · `VERDICT command-exit 0` · Test Files 6 passed (6) · Tests 59 passed (59); `eslint` over the six touched source and test files: 0 errors (the warnings are pre-existing ones in the edited files; the two new files carry none). </details> CI runs every other family on this PR; its check-runs are the record. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CYWA2yJcJwFWLpBa7Q)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a7af1c1 commit 968cacf

7 files changed

Lines changed: 316 additions & 13 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/data-objectstack': patch
3+
'@object-ui/app-shell': patch
4+
---
5+
6+
fix(app-shell): the Studio canvas runs a report or a dashboard widget over a draft dataset before the package publishes (#27585)
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
A Studio author who created a dataset and bound a report or a chart to it saw "Dataset … is not defined in this environment" on the canvas until the package published, while the dataset pickers beside it already offered the draft. The canvas's dataset query went to the published world, where a draft-only dataset does not exist.
12+
13+
- **`queryDataset(dataset, selection, options?)`** in `@object-ui/data-objectstack` takes an optional `{ previewDrafts?: boolean }`. `true` asks for the draft world on that one request (the server resolves draft-overlaid dataset definitions and the pending seed drafts' rows, as `?preview=draft` does), the way `listViews(objectName, { previewDrafts })` asks for draft views. When the option is absent, the page URL's `?preview=draft` decides, as before; when it is given, it wins over the URL. A caller that passes nothing sends exactly the request it sent before.
14+
- **The Studio's report and dashboard previews** hand the runtime renderers a data source whose dataset queries carry `previewDrafts`, keyed on the signal the Studio's dataset pickers read (the dataset-authoring context). The query still names the dataset and the server resolves it; there is no second lookup. Setup's editors and every runtime page are unchanged and query the published world, or the one the page URL asks for. The server answers the draft world only to a caller who may read drafts; anyone else gets the published dataset, as if the flag were absent.

‎ui/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
*
88
* Uses the same DashboardRenderer the runtime DashboardView uses, with
99
* the adapter from app-shell's AdapterProvider so widgets can query
10-
* live data. `designMode` is ON whenever the host is editing —
10+
* live data; in the Studio its dataset queries read the draft world
11+
* (`useCanvasDataSource`, objectstack#27585). `designMode` is ON whenever the host is editing —
1112
* read-only / view mode falls back to a plain runtime preview so the
1213
* canvas looks identical to what end users see.
1314
*
@@ -18,7 +19,7 @@
1819
import * as React from 'react';
1920
import { Loader2, Pencil, X, Check } from 'lucide-react';
2021
import type { DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types';
21-
import { useAdapter } from '../../../providers/AdapterProvider.js';
22+
import { useCanvasDataSource } from './canvasDataSource.js';
2223
import type { MetadataPreviewProps } from '../preview-registry.js';
2324
import { PreviewShell, PreviewErrorBoundary, PreviewMessage } from './PreviewShell.js';
2425
import { uniqueId, appendArray } from '../inspectors/_shared.js';
@@ -42,7 +43,7 @@ export function DashboardPreview({
4243
onSelectionChange,
4344
locale,
4445
}: MetadataPreviewProps) {
45-
const adapter = useAdapter();
46+
const dataSource = useCanvasDataSource();
4647
const widgets: DashboardWidgetSchema[] = Array.isArray((draft as any).widgets)
4748
? (draft as any).widgets
4849
: [];
@@ -194,7 +195,7 @@ export function DashboardPreview({
194195
<div className={loneWidget ? undefined : 'min-w-[768px]'}>
195196
<DashboardRenderer
196197
schema={renderedSchema as any}
197-
dataSource={adapter as any}
198+
dataSource={dataSource as any}
198199
designMode={designMode}
199200
selectedWidgetId={selectedWidgetId}
200201
onWidgetClick={designMode ? handleWidgetClick : undefined}

‎ui/packages/app-shell/src/views/metadata-admin/previews/ReportPreview.tsx‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
* render through plugin-report's `ReportRenderer` (→ DatasetReportRenderer),
1111
* keeping the studio preview pixel-equal with the runtime — including the
1212
* matrix cross-tab and the joined block stack — and the numbers consistent
13-
* with every other surface on the same dataset (`adapter.queryDataset`).
13+
* with every other surface on the same dataset (`adapter.queryDataset`). In
14+
* the Studio the query reads the draft world, so a draft dataset runs before
15+
* the package publishes (`useCanvasDataSource`, objectstack#27585).
1416
* Drill-down stays inert here: the preview passes no `onDrill` sink.
1517
*
1618
* A draft with neither a dataset nor any dataset-bound block gets an
@@ -20,7 +22,7 @@
2022

2123
import * as React from 'react';
2224
import { Database, Loader2 } from 'lucide-react';
23-
import { useAdapter } from '../../../providers/AdapterProvider.js';
25+
import { useCanvasDataSource } from './canvasDataSource.js';
2426
import type { MetadataPreviewProps } from '../preview-registry.js';
2527
import { PreviewShell, PreviewErrorBoundary, PreviewEmptyState } from './PreviewShell.js';
2628
import { t as tr } from '../i18n.js';
@@ -30,7 +32,7 @@ const ReportRenderer = React.lazy(() =>
3032
);
3133

3234
export function ReportPreview({ draft, locale }: MetadataPreviewProps) {
33-
const adapter = useAdapter();
35+
const dataSource = useCanvasDataSource();
3436
const d = draft as any;
3537

3638
// ADR-0021 single-form: a report binds a semantic-layer dataset; a `joined`
@@ -61,7 +63,7 @@ export function ReportPreview({ draft, locale }: MetadataPreviewProps) {
6163
}
6264
>
6365
<div className="p-3 min-h-[200px] max-h-[70vh] overflow-auto">
64-
<ReportRenderer schema={draft as any} dataSource={adapter as any} />
66+
<ReportRenderer schema={draft as any} dataSource={dataSource as any} />
6567
</div>
6668
</React.Suspense>
6769
</PreviewErrorBoundary>
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#27585 — the Studio canvas runs a report or a dataset widget over
5+
* a dataset that is still a draft.
6+
*
7+
* Through the REAL `ObjectStackAdapter` (its fetch stubbed) and the real
8+
* runtime renderers: the stub server answers the dataset by name only when the
9+
* request asks for the draft world, as the dataset door does for a draft-only
10+
* dataset, and a published-world request gets the door's own 404. Pinned:
11+
* - in the Studio (its dataset-authoring context present) the report preview
12+
* and the dashboard preview send `previewDrafts: true` and draw the rows;
13+
* - outside it (Setup's editors) the same previews send no flag;
14+
* - the draft source is the adapter for every other member, one per adapter.
15+
*/
16+
17+
import '@testing-library/jest-dom/vitest';
18+
import * as React from 'react';
19+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
20+
import { cleanup, render, screen, waitFor } from '@testing-library/react';
21+
// Module-scope imports of the lazily loaded renderers, so the previews'
22+
// `React.lazy` factories resolve at once instead of racing the wait window
23+
// (ui/AGENTS.md, flaky-test discipline). Specifiers identical to the previews'.
24+
import '@object-ui/plugin-report';
25+
import '@object-ui/plugin-dashboard';
26+
import { ObjectStackAdapter, clearSharedDiscoveryCache } from '@object-ui/data-objectstack';
27+
import { StudioDatasetAuthoringContext } from '../../studio-design/datasetCreate';
28+
29+
const current = vi.hoisted(() => ({ adapter: null as unknown }));
30+
vi.mock('../../../providers/AdapterProvider', async (importOriginal) => {
31+
const mod = await importOriginal<typeof import('../../../providers/AdapterProvider')>();
32+
return { ...mod, useAdapter: () => current.adapter };
33+
});
34+
35+
import { ReportPreview } from './ReportPreview';
36+
import { DashboardPreview } from './DashboardPreview';
37+
import { datasetDraftsSource } from './canvasDataSource';
38+
39+
/** The dataset-query bodies the adapter posted. */
40+
let posted: Array<Record<string, unknown>> = [];
41+
42+
function draftOnlyServer() {
43+
return vi.fn(async (url: unknown, init?: { body?: string }) => {
44+
const u = String(url);
45+
if (u.includes('/api/v1/discovery')) {
46+
return { ok: true, status: 200, statusText: 'OK', json: async () => ({ success: true, data: { version: 'v1', routes: {} } }) };
47+
}
48+
if (u.includes('/api/v1/analytics/dataset/query')) {
49+
const body = JSON.parse(init?.body ?? '{}') as Record<string, unknown>;
50+
posted.push(body);
51+
if (body.previewDrafts !== true) {
52+
return {
53+
ok: false,
54+
status: 404,
55+
statusText: 'Not Found',
56+
json: async () => ({ code: 'NOT_FOUND', message: `Dataset "${String(body.datasetName)}" not found.` }),
57+
};
58+
}
59+
return {
60+
ok: true,
61+
status: 200,
62+
statusText: 'OK',
63+
json: async () => ({ rows: [{ region: 'north_draft_row', revenue: 7 }], fields: [] }),
64+
};
65+
}
66+
return { ok: true, status: 200, statusText: 'OK', json: async () => ({}) };
67+
});
68+
}
69+
70+
beforeEach(() => {
71+
clearSharedDiscoveryCache();
72+
posted = [];
73+
current.adapter = new ObjectStackAdapter({
74+
baseUrl: 'http://localhost:3000',
75+
autoReconnect: false,
76+
fetch: draftOnlyServer() as never,
77+
});
78+
});
79+
afterEach(cleanup);
80+
81+
const authoring = { packageId: 'com.acme.sales' };
82+
const inStudio = ({ children }: { children: React.ReactNode }) => (
83+
<StudioDatasetAuthoringContext.Provider value={authoring}>{children}</StudioDatasetAuthoringContext.Provider>
84+
);
85+
86+
const report = { name: 'by_region', label: 'By region', dataset: 'draft_sales', rows: ['region'], values: ['revenue'] };
87+
const dashboard = {
88+
name: 'sales_board',
89+
label: 'Sales',
90+
widgets: [
91+
{ id: 'w1', type: 'table', title: 'By region', dataset: 'draft_sales', dimensions: ['region'], values: ['revenue'], layout: { x: 0, y: 0, w: 6, h: 4 } },
92+
],
93+
};
94+
95+
function renderReport(wrapper?: typeof inStudio) {
96+
return render(<ReportPreview type="report" name="by_region" draft={report} locale="en-US" />, wrapper ? { wrapper } : undefined);
97+
}
98+
99+
function renderDashboard(wrapper?: typeof inStudio) {
100+
return render(
101+
<DashboardPreview type="dashboard" name="sales_board" draft={dashboard} selection={null} locale="en-US" />,
102+
wrapper ? { wrapper } : undefined,
103+
);
104+
}
105+
106+
describe('Studio canvas — a draft-only dataset draws (objectstack#27585)', () => {
107+
it('the report preview asks for the draft world and draws its rows', async () => {
108+
renderReport(inStudio);
109+
expect(await screen.findByText('north_draft_row')).toBeInTheDocument();
110+
expect(posted).not.toHaveLength(0);
111+
for (const body of posted) expect(body).toMatchObject({ datasetName: 'draft_sales', previewDrafts: true });
112+
});
113+
114+
it('the dashboard preview asks for the draft world and draws its rows', async () => {
115+
renderDashboard(inStudio);
116+
expect(await screen.findByText('north_draft_row')).toBeInTheDocument();
117+
expect(posted).not.toHaveLength(0);
118+
for (const body of posted) expect(body).toMatchObject({ datasetName: 'draft_sales', previewDrafts: true });
119+
});
120+
});
121+
122+
describe('outside the Studio the previews query the published world', () => {
123+
it('the report preview sends no draft flag', async () => {
124+
renderReport();
125+
await waitFor(() => expect(posted).not.toHaveLength(0));
126+
for (const body of posted) expect(body).not.toHaveProperty('previewDrafts');
127+
expect(screen.queryByText('north_draft_row')).not.toBeInTheDocument();
128+
});
129+
130+
it('the dashboard preview sends no draft flag', async () => {
131+
renderDashboard();
132+
await waitFor(() => expect(posted).not.toHaveLength(0));
133+
for (const body of posted) expect(body).not.toHaveProperty('previewDrafts');
134+
expect(screen.queryByText('north_draft_row')).not.toBeInTheDocument();
135+
});
136+
});
137+
138+
describe('datasetDraftsSource', () => {
139+
it('is one source per adapter, and the adapter itself for every other member', () => {
140+
const calls: unknown[] = [];
141+
const adapter = {
142+
name: 'stub',
143+
queryDataset(this: unknown, ...args: unknown[]) {
144+
calls.push({ self: this, args });
145+
return Promise.resolve({ rows: [], fields: [] });
146+
},
147+
whoAmI(this: unknown) {
148+
return this;
149+
},
150+
};
151+
const source = datasetDraftsSource(adapter);
152+
expect(datasetDraftsSource(adapter)).toBe(source);
153+
expect(source.name).toBe('stub');
154+
expect(source.whoAmI()).toBe(adapter);
155+
void source.queryDataset('draft_sales', { measures: ['revenue'] }, { limit: 5 } as never);
156+
expect(calls).toEqual([
157+
{ self: adapter, args: ['draft_sales', { measures: ['revenue'] }, { limit: 5, previewDrafts: true }] },
158+
]);
159+
});
160+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#27585 — the data source a report or dashboard preview hands the
5+
* runtime renderer, and the world its dataset queries read.
6+
*
7+
* In the Studio, a dataset the author binds is a draft until the package
8+
* publishes (objectstack#27518), and the pickers beside the canvas read the
9+
* draft-overlaid catalog. The canvas runs the same binding in the same world:
10+
* every `queryDataset` the renderers make (a report, a dataset widget, a
11+
* dataset-bound chart, a filter bar's options) carries `previewDrafts`, the
12+
* request's draft flag the draft preview sends as `?preview=draft`, and the
13+
* server resolves the named dataset from the draft overlay. The query still
14+
* names the dataset; nothing here looks a draft up a second way. Every other
15+
* member is the adapter's own.
16+
*
17+
* The signal is the one the pickers read: the Studio's
18+
* {@link StudioDatasetAuthoringContext}. Without it (Setup's editors) the
19+
* adapter is handed back as it is, and so is every runtime page, which never
20+
* renders these previews: both query the published world, or the one the page
21+
* URL asks for.
22+
*
23+
* Internal to app-shell: not exported from the package root.
24+
*/
25+
26+
import { useAdapter } from '../../../providers/AdapterProvider.js';
27+
import { useStudioDatasetAuthoring } from '../../studio-design/datasetCreate.js';
28+
29+
/**
30+
* One draft source per adapter, kept outside React so its identity is the
31+
* adapter's (ui/AGENTS.md #10): the renderers key their dataset fetches on the
32+
* data source they are handed.
33+
*/
34+
const draftSources = new WeakMap<object, object>();
35+
36+
/**
37+
* `adapter`, with its `queryDataset` asking for the draft world. The answer is
38+
* the adapter itself for every other member, bound to it, so the adapter's own
39+
* state (its connection, its caches) is the one read and written.
40+
*/
41+
export function datasetDraftsSource<T extends object>(adapter: T): T {
42+
let source = draftSources.get(adapter);
43+
if (!source) {
44+
source = new Proxy(adapter, {
45+
get(target, prop) {
46+
const value = Reflect.get(target, prop, target);
47+
if (typeof value !== 'function') return value;
48+
if (prop === 'queryDataset') {
49+
return (dataset: unknown, selection: unknown, options?: Record<string, unknown>) =>
50+
value.call(target, dataset, selection, { ...options, previewDrafts: true });
51+
}
52+
return value.bind(target);
53+
},
54+
});
55+
draftSources.set(adapter, source);
56+
}
57+
return source as T;
58+
}
59+
60+
/** The data source a report or dashboard preview renders through (see the file header). */
61+
export function useCanvasDataSource(): unknown {
62+
const adapter: unknown = useAdapter();
63+
const inStudio = useStudioDatasetAuthoring() !== null;
64+
return inStudio && adapter && typeof adapter === 'object' ? datasetDraftsSource(adapter) : adapter;
65+
}

‎ui/packages/data-objectstack/src/index.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6809,10 +6809,17 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
68096809
* `timeDimensions` to `unknown[]` and `runtimeFilter` to
68106810
* `Record<string, unknown>`, and it had never grown `dateGranularity` at
68116811
* all. Pinned in `queryDataset.test.ts`.
6812+
* @param options - `previewDrafts` asks for the draft world explicitly (the
6813+
* draft-overlaid dataset definitions and the pending seed drafts' rows, as
6814+
* `?preview=draft` does), the way `listViews({ previewDrafts })` asks for
6815+
* draft views. A surface that is a draft window without carrying the URL
6816+
* flag passes it: the Studio canvas. When the option is absent, the page
6817+
* URL decides (see the body).
68126818
*/
68136819
async queryDataset(
68146820
dataset: Record<string, unknown> | string,
68156821
selection: DatasetSelection,
6822+
options?: { previewDrafts?: boolean },
68166823
): Promise<{
68176824
rows: Array<Record<string, unknown>>;
68186825
/**
@@ -6873,10 +6880,12 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
68736880
// iframe), so the adapter reads it straight off the location rather than
68746881
// threading a React context down through every widget package. When set,
68756882
// the server overlays the pending seed draft's rows on the dataset query
6876-
// and resolves draft-overlaid dataset definitions.
6877-
const previewDrafts =
6878-
typeof window !== 'undefined' &&
6879-
new URLSearchParams(window.location.search).get('preview') === 'draft';
6883+
// and resolves draft-overlaid dataset definitions. An explicit
6884+
// `options.previewDrafts` is the caller's answer and wins over the URL.
6885+
const previewDrafts = options?.previewDrafts !== undefined
6886+
? options.previewDrafts === true
6887+
: typeof window !== 'undefined' &&
6888+
new URLSearchParams(window.location.search).get('preview') === 'draft';
68806889
const requestBody = typeof dataset === 'string'
68816890
? { datasetName: dataset, selection, ...(previewDrafts ? { previewDrafts: true } : {}) }
68826891
: { dataset, selection, ...(previewDrafts ? { previewDrafts: true } : {}) };

0 commit comments

Comments
 (0)