Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/27585-studio-canvas-draft-dataset.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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';
Expand All @@ -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
: [];
Expand Down Expand Up @@ -194,7 +195,7 @@ export function DashboardPreview({
<div className={loneWidget ? undefined : 'min-w-[768px]'}>
<DashboardRenderer
schema={renderedSchema as any}
dataSource={adapter as any}
dataSource={dataSource as any}
designMode={designMode}
selectedWidgetId={selectedWidgetId}
onWidgetClick={designMode ? handleWidgetClick : undefined}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
* render through plugin-report's `ReportRenderer` (→ DatasetReportRenderer),
* keeping the studio preview pixel-equal with the runtime — including the
* matrix cross-tab and the joined block stack — and the numbers consistent
* with every other surface on the same dataset (`adapter.queryDataset`).
* with every other surface on the same dataset (`adapter.queryDataset`). In
* the Studio the query reads the draft world, so a draft dataset runs before
* the package publishes (`useCanvasDataSource`, objectstack#27585).
* Drill-down stays inert here: the preview passes no `onDrill` sink.
*
* A draft with neither a dataset nor any dataset-bound block gets an
Expand All @@ -20,7 +22,7 @@

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

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`
Expand Down Expand Up @@ -61,7 +63,7 @@ export function ReportPreview({ draft, locale }: MetadataPreviewProps) {
}
>
<div className="p-3 min-h-[200px] max-h-[70vh] overflow-auto">
<ReportRenderer schema={draft as any} dataSource={adapter as any} />
<ReportRenderer schema={draft as any} dataSource={dataSource as any} />
</div>
</React.Suspense>
</PreviewErrorBoundary>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof import('../../../providers/AdapterProvider')>();
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<Record<string, unknown>> = [];

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<string, unknown>;
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 }) => (
<StudioDatasetAuthoringContext.Provider value={authoring}>{children}</StudioDatasetAuthoringContext.Provider>
);

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(<ReportPreview type="report" name="by_region" draft={report} locale="en-US" />, wrapper ? { wrapper } : undefined);
}

function renderDashboard(wrapper?: typeof inStudio) {
return render(
<DashboardPreview type="dashboard" name="sales_board" draft={dashboard} selection={null} locale="en-US" />,
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 }] },
]);
});
});
Original file line number Diff line number Diff line change
@@ -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<object, object>();

/**
* `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<T extends object>(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<string, unknown>) =>
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;
}
17 changes: 13 additions & 4 deletions ui/packages/data-objectstack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6809,10 +6809,17 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
* `timeDimensions` to `unknown[]` and `runtimeFilter` to
* `Record<string, unknown>`, 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, unknown> | string,
selection: DatasetSelection,
options?: { previewDrafts?: boolean },
): Promise<{
rows: Array<Record<string, unknown>>;
/**
Expand Down Expand Up @@ -6873,10 +6880,12 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
// 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 } : {}) };
Expand Down
Loading
Loading