diff --git a/.changeset/27585-studio-canvas-draft-dataset.md b/.changeset/27585-studio-canvas-draft-dataset.md new file mode 100644 index 00000000000..a965184b037 --- /dev/null +++ b/.changeset/27585-studio-canvas-draft-dataset.md @@ -0,0 +1,14 @@ +--- +'@object-ui/data-objectstack': patch +'@object-ui/app-shell': patch +--- + +fix(app-shell): the Studio canvas runs a report or a dashboard widget over a draft dataset before the package publishes (#27585) + +Clause-②: yes (widening) +Metadata-protocol: none + +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. + +- **`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. +- **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. diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx index eae5909f5dd..a7f08cf3f9e 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx @@ -7,7 +7,8 @@ * * Uses the same DashboardRenderer the runtime DashboardView uses, with * the adapter from app-shell's AdapterProvider so widgets can query - * live data. `designMode` is ON whenever the host is editing — + * live data; in the Studio its dataset queries read the draft world + * (`useCanvasDataSource`, objectstack#27585). `designMode` is ON whenever the host is editing — * read-only / view mode falls back to a plain runtime preview so the * canvas looks identical to what end users see. * @@ -18,7 +19,7 @@ import * as React from 'react'; import { Loader2, Pencil, X, Check } from 'lucide-react'; import type { DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types'; -import { useAdapter } from '../../../providers/AdapterProvider.js'; +import { useCanvasDataSource } from './canvasDataSource.js'; import type { MetadataPreviewProps } from '../preview-registry.js'; import { PreviewShell, PreviewErrorBoundary, PreviewMessage } from './PreviewShell.js'; import { uniqueId, appendArray } from '../inspectors/_shared.js'; @@ -42,7 +43,7 @@ export function DashboardPreview({ onSelectionChange, locale, }: MetadataPreviewProps) { - const adapter = useAdapter(); + const dataSource = useCanvasDataSource(); const widgets: DashboardWidgetSchema[] = Array.isArray((draft as any).widgets) ? (draft as any).widgets : []; @@ -194,7 +195,7 @@ export function DashboardPreview({
); export function ReportPreview({ draft, locale }: MetadataPreviewProps) { - const adapter = useAdapter(); + const dataSource = useCanvasDataSource(); const d = draft as any; // ADR-0021 single-form: a report binds a semantic-layer dataset; a `joined` @@ -61,7 +63,7 @@ export function ReportPreview({ draft, locale }: MetadataPreviewProps) { } >
- +
diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/canvasDataSource-27585.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/canvasDataSource-27585.test.tsx new file mode 100644 index 00000000000..d2edbb9cc27 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/canvasDataSource-27585.test.tsx @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#27585 — the Studio canvas runs a report or a dataset widget over + * a dataset that is still a draft. + * + * Through the REAL `ObjectStackAdapter` (its fetch stubbed) and the real + * runtime renderers: the stub server answers the dataset by name only when the + * request asks for the draft world, as the dataset door does for a draft-only + * dataset, and a published-world request gets the door's own 404. Pinned: + * - in the Studio (its dataset-authoring context present) the report preview + * and the dashboard preview send `previewDrafts: true` and draw the rows; + * - outside it (Setup's editors) the same previews send no flag; + * - the draft source is the adapter for every other member, one per adapter. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +// Module-scope imports of the lazily loaded renderers, so the previews' +// `React.lazy` factories resolve at once instead of racing the wait window +// (ui/AGENTS.md, flaky-test discipline). Specifiers identical to the previews'. +import '@object-ui/plugin-report'; +import '@object-ui/plugin-dashboard'; +import { ObjectStackAdapter, clearSharedDiscoveryCache } from '@object-ui/data-objectstack'; +import { StudioDatasetAuthoringContext } from '../../studio-design/datasetCreate'; + +const current = vi.hoisted(() => ({ adapter: null as unknown })); +vi.mock('../../../providers/AdapterProvider', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => current.adapter }; +}); + +import { ReportPreview } from './ReportPreview'; +import { DashboardPreview } from './DashboardPreview'; +import { datasetDraftsSource } from './canvasDataSource'; + +/** The dataset-query bodies the adapter posted. */ +let posted: Array> = []; + +function draftOnlyServer() { + return vi.fn(async (url: unknown, init?: { body?: string }) => { + const u = String(url); + if (u.includes('/api/v1/discovery')) { + return { ok: true, status: 200, statusText: 'OK', json: async () => ({ success: true, data: { version: 'v1', routes: {} } }) }; + } + if (u.includes('/api/v1/analytics/dataset/query')) { + const body = JSON.parse(init?.body ?? '{}') as Record; + posted.push(body); + if (body.previewDrafts !== true) { + return { + ok: false, + status: 404, + statusText: 'Not Found', + json: async () => ({ code: 'NOT_FOUND', message: `Dataset "${String(body.datasetName)}" not found.` }), + }; + } + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ rows: [{ region: 'north_draft_row', revenue: 7 }], fields: [] }), + }; + } + return { ok: true, status: 200, statusText: 'OK', json: async () => ({}) }; + }); +} + +beforeEach(() => { + clearSharedDiscoveryCache(); + posted = []; + current.adapter = new ObjectStackAdapter({ + baseUrl: 'http://localhost:3000', + autoReconnect: false, + fetch: draftOnlyServer() as never, + }); +}); +afterEach(cleanup); + +const authoring = { packageId: 'com.acme.sales' }; +const inStudio = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +const report = { name: 'by_region', label: 'By region', dataset: 'draft_sales', rows: ['region'], values: ['revenue'] }; +const dashboard = { + name: 'sales_board', + label: 'Sales', + widgets: [ + { id: 'w1', type: 'table', title: 'By region', dataset: 'draft_sales', dimensions: ['region'], values: ['revenue'], layout: { x: 0, y: 0, w: 6, h: 4 } }, + ], +}; + +function renderReport(wrapper?: typeof inStudio) { + return render(, wrapper ? { wrapper } : undefined); +} + +function renderDashboard(wrapper?: typeof inStudio) { + return render( + , + wrapper ? { wrapper } : undefined, + ); +} + +describe('Studio canvas — a draft-only dataset draws (objectstack#27585)', () => { + it('the report preview asks for the draft world and draws its rows', async () => { + renderReport(inStudio); + expect(await screen.findByText('north_draft_row')).toBeInTheDocument(); + expect(posted).not.toHaveLength(0); + for (const body of posted) expect(body).toMatchObject({ datasetName: 'draft_sales', previewDrafts: true }); + }); + + it('the dashboard preview asks for the draft world and draws its rows', async () => { + renderDashboard(inStudio); + expect(await screen.findByText('north_draft_row')).toBeInTheDocument(); + expect(posted).not.toHaveLength(0); + for (const body of posted) expect(body).toMatchObject({ datasetName: 'draft_sales', previewDrafts: true }); + }); +}); + +describe('outside the Studio the previews query the published world', () => { + it('the report preview sends no draft flag', async () => { + renderReport(); + await waitFor(() => expect(posted).not.toHaveLength(0)); + for (const body of posted) expect(body).not.toHaveProperty('previewDrafts'); + expect(screen.queryByText('north_draft_row')).not.toBeInTheDocument(); + }); + + it('the dashboard preview sends no draft flag', async () => { + renderDashboard(); + await waitFor(() => expect(posted).not.toHaveLength(0)); + for (const body of posted) expect(body).not.toHaveProperty('previewDrafts'); + expect(screen.queryByText('north_draft_row')).not.toBeInTheDocument(); + }); +}); + +describe('datasetDraftsSource', () => { + it('is one source per adapter, and the adapter itself for every other member', () => { + const calls: unknown[] = []; + const adapter = { + name: 'stub', + queryDataset(this: unknown, ...args: unknown[]) { + calls.push({ self: this, args }); + return Promise.resolve({ rows: [], fields: [] }); + }, + whoAmI(this: unknown) { + return this; + }, + }; + const source = datasetDraftsSource(adapter); + expect(datasetDraftsSource(adapter)).toBe(source); + expect(source.name).toBe('stub'); + expect(source.whoAmI()).toBe(adapter); + void source.queryDataset('draft_sales', { measures: ['revenue'] }, { limit: 5 } as never); + expect(calls).toEqual([ + { self: adapter, args: ['draft_sales', { measures: ['revenue'] }, { limit: 5, previewDrafts: true }] }, + ]); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/canvasDataSource.ts b/ui/packages/app-shell/src/views/metadata-admin/previews/canvasDataSource.ts new file mode 100644 index 00000000000..a3c4bd40de4 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/canvasDataSource.ts @@ -0,0 +1,65 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#27585 — the data source a report or dashboard preview hands the + * runtime renderer, and the world its dataset queries read. + * + * In the Studio, a dataset the author binds is a draft until the package + * publishes (objectstack#27518), and the pickers beside the canvas read the + * draft-overlaid catalog. The canvas runs the same binding in the same world: + * every `queryDataset` the renderers make (a report, a dataset widget, a + * dataset-bound chart, a filter bar's options) carries `previewDrafts`, the + * request's draft flag the draft preview sends as `?preview=draft`, and the + * server resolves the named dataset from the draft overlay. The query still + * names the dataset; nothing here looks a draft up a second way. Every other + * member is the adapter's own. + * + * The signal is the one the pickers read: the Studio's + * {@link StudioDatasetAuthoringContext}. Without it (Setup's editors) the + * adapter is handed back as it is, and so is every runtime page, which never + * renders these previews: both query the published world, or the one the page + * URL asks for. + * + * Internal to app-shell: not exported from the package root. + */ + +import { useAdapter } from '../../../providers/AdapterProvider.js'; +import { useStudioDatasetAuthoring } from '../../studio-design/datasetCreate.js'; + +/** + * One draft source per adapter, kept outside React so its identity is the + * adapter's (ui/AGENTS.md #10): the renderers key their dataset fetches on the + * data source they are handed. + */ +const draftSources = new WeakMap(); + +/** + * `adapter`, with its `queryDataset` asking for the draft world. The answer is + * the adapter itself for every other member, bound to it, so the adapter's own + * state (its connection, its caches) is the one read and written. + */ +export function datasetDraftsSource(adapter: T): T { + let source = draftSources.get(adapter); + if (!source) { + source = new Proxy(adapter, { + get(target, prop) { + const value = Reflect.get(target, prop, target); + if (typeof value !== 'function') return value; + if (prop === 'queryDataset') { + return (dataset: unknown, selection: unknown, options?: Record) => + value.call(target, dataset, selection, { ...options, previewDrafts: true }); + } + return value.bind(target); + }, + }); + draftSources.set(adapter, source); + } + return source as T; +} + +/** The data source a report or dashboard preview renders through (see the file header). */ +export function useCanvasDataSource(): unknown { + const adapter: unknown = useAdapter(); + const inStudio = useStudioDatasetAuthoring() !== null; + return inStudio && adapter && typeof adapter === 'object' ? datasetDraftsSource(adapter) : adapter; +} diff --git a/ui/packages/data-objectstack/src/index.ts b/ui/packages/data-objectstack/src/index.ts index ee740118abb..a258fe17a1e 100644 --- a/ui/packages/data-objectstack/src/index.ts +++ b/ui/packages/data-objectstack/src/index.ts @@ -6809,10 +6809,17 @@ export class ObjectStackAdapter implements DataSource { * `timeDimensions` to `unknown[]` and `runtimeFilter` to * `Record`, and it had never grown `dateGranularity` at * all. Pinned in `queryDataset.test.ts`. + * @param options - `previewDrafts` asks for the draft world explicitly (the + * draft-overlaid dataset definitions and the pending seed drafts' rows, as + * `?preview=draft` does), the way `listViews({ previewDrafts })` asks for + * draft views. A surface that is a draft window without carrying the URL + * flag passes it: the Studio canvas. When the option is absent, the page + * URL decides (see the body). */ async queryDataset( dataset: Record | string, selection: DatasetSelection, + options?: { previewDrafts?: boolean }, ): Promise<{ rows: Array>; /** @@ -6873,10 +6880,12 @@ export class ObjectStackAdapter implements DataSource { // iframe), so the adapter reads it straight off the location rather than // threading a React context down through every widget package. When set, // the server overlays the pending seed draft's rows on the dataset query - // and resolves draft-overlaid dataset definitions. - const previewDrafts = - typeof window !== 'undefined' && - new URLSearchParams(window.location.search).get('preview') === 'draft'; + // and resolves draft-overlaid dataset definitions. An explicit + // `options.previewDrafts` is the caller's answer and wins over the URL. + const previewDrafts = options?.previewDrafts !== undefined + ? options.previewDrafts === true + : typeof window !== 'undefined' && + new URLSearchParams(window.location.search).get('preview') === 'draft'; const requestBody = typeof dataset === 'string' ? { datasetName: dataset, selection, ...(previewDrafts ? { previewDrafts: true } : {}) } : { dataset, selection, ...(previewDrafts ? { previewDrafts: true } : {}) }; diff --git a/ui/packages/data-objectstack/src/queryDataset.test.ts b/ui/packages/data-objectstack/src/queryDataset.test.ts index d6b178383ee..516b33fb705 100644 --- a/ui/packages/data-objectstack/src/queryDataset.test.ts +++ b/ui/packages/data-objectstack/src/queryDataset.test.ts @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import type { AnalyticsQuery as SpecAnalyticsQuery, AnalyticsResult as SpecAnalyticsResult, @@ -163,6 +163,58 @@ describe('ObjectStackAdapter.queryDataset', () => { /* The first two and the third SHARE HTTP 404. That collision is the defect, */ /* so the pins below are written to fail if anyone reintroduces a status test. */ /* -------------------------------------------------------------------------- */ +/** + * objectstack#27585 — which world a dataset query reads. The page URL's + * `?preview=draft` decides when the caller says nothing (the draft preview of + * an app); `previewDrafts` is the caller saying it, for a surface that is a + * draft window without the URL flag (the Studio canvas). Either way the draft + * world is one flag on the one request, the server resolves the dataset. + */ +describe('ObjectStackAdapter.queryDataset — the draft world (objectstack#27585)', () => { + beforeEach(() => clearSharedDiscoveryCache()); + afterEach(() => vi.unstubAllGlobals()); + + async function postedBody( + search: string, + options?: { previewDrafts?: boolean }, + ): Promise> { + vi.stubGlobal('window', { location: { search } }); + const { fetchImpl, calls } = makeFetch({ ok: true, body: { rows: [], fields: [] } }); + const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any }); + await adapter.queryDataset('draft_sales', selection, options); + const posts = calls.filter((c) => c.url.includes('/analytics/dataset/query')); + expect(posts).toHaveLength(1); + return JSON.parse(posts[0].init.body); + } + + it('asks for the draft world when the caller passes previewDrafts, with no URL flag', async () => { + expect(await postedBody('', { previewDrafts: true })).toEqual({ + datasetName: 'draft_sales', + selection, + previewDrafts: true, + }); + }); + + it('reads the published world when neither the caller nor the URL asks', async () => { + expect(await postedBody('')).toEqual({ datasetName: 'draft_sales', selection }); + }); + + it('still follows the page URL when the caller says nothing', async () => { + expect(await postedBody('?preview=draft')).toEqual({ + datasetName: 'draft_sales', + selection, + previewDrafts: true, + }); + }); + + it("takes the caller's previewDrafts: false over the URL flag", async () => { + expect(await postedBody('?preview=draft', { previewDrafts: false })).toEqual({ + datasetName: 'draft_sales', + selection, + }); + }); +}); + describe('ObjectStackAdapter.queryDataset — error branch mapping (objectui#5663)', () => { beforeEach(() => clearSharedDiscoveryCache());