From 3d275b5bfc3aac253ec6c1a63e389e5f084f0f5a Mon Sep 17 00:00:00 2001 From: Jack Date: Sun, 7 Jun 2026 18:33:59 +0800 Subject: [PATCH] feat(metadata-admin): render dataset-bound reports (ADR-0021 B-P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A report that binds to a semantic-layer `dataset` (instead of an inline object query) now renders in ReportPreview: it maps the report's `rows`→dimensions and `values`→measures and runs them through `adapter.queryDataset` — the same path the dataset preview uses — so numbers match every other surface on that dataset. Additive/dual-form: inline-object reports are unchanged (early return only when `draft.dataset` is set). Errors surface instead of silently falling back. Tests: ReportPreview.dataset (4) — table render, empty-state, error surfacing, runtimeFilter forwarding. Pairs with framework report.zod dual-form (dataset/rows/values). Co-Authored-By: Claude Opus 4.8 --- .../previews/ReportPreview.dataset.test.tsx | 60 ++++++++++++ .../metadata-admin/previews/ReportPreview.tsx | 94 ++++++++++++++++++- 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 packages/app-shell/src/views/metadata-admin/previews/ReportPreview.dataset.test.tsx diff --git a/packages/app-shell/src/views/metadata-admin/previews/ReportPreview.dataset.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/ReportPreview.dataset.test.tsx new file mode 100644 index 000000000..ff094eba9 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/previews/ReportPreview.dataset.test.tsx @@ -0,0 +1,60 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import { ReportPreview } from './ReportPreview'; + +// Mock the data adapter the preview pulls from AdapterProvider. +const { queryDataset } = vi.hoisted(() => ({ queryDataset: vi.fn() })); +vi.mock('../../../providers/AdapterProvider', () => ({ + useAdapter: () => ({ queryDataset }), +})); + +afterEach(() => { + cleanup(); + queryDataset.mockReset(); +}); + +const baseProps = { type: 'report', name: 'revenue_by_region', locale: 'en-US' as const }; + +const datasetReport = { + name: 'revenue_by_region', + label: 'Revenue by Region', + dataset: 'sales', + rows: ['region'], + values: ['revenue'], +}; + +describe('ReportPreview — dataset-bound report (ADR-0021 dual-form)', () => { + it('runs the bound dataset (rows→dimensions, values→measures) and renders a table', async () => { + queryDataset.mockResolvedValue({ rows: [{ region: 'finance', revenue: 450000 }, { region: 'tech', revenue: 260000 }], fields: [] }); + render(); + + await waitFor(() => + expect(queryDataset).toHaveBeenCalledWith('sales', { dimensions: ['region'], measures: ['revenue'], runtimeFilter: undefined }), + ); + expect(await screen.findByText('finance')).toBeInTheDocument(); + expect(screen.getByText('450000')).toBeInTheDocument(); + expect(screen.getByText('tech')).toBeInTheDocument(); + }); + + it('shows an empty state when no measures are selected', () => { + render(); + expect(screen.getByText(/Pick measures to show/)).toBeInTheDocument(); + expect(queryDataset).not.toHaveBeenCalled(); + }); + + it('surfaces a dataset compile/RLS error instead of wrong numbers', async () => { + queryDataset.mockRejectedValue(new Error('relationship "account" is not declared in the dataset')); + render(); + expect(await screen.findByText(/not declared in the dataset/)).toBeInTheDocument(); + }); + + it('forwards runtimeFilter to the dataset query', async () => { + queryDataset.mockResolvedValue({ rows: [], fields: [] }); + render(); + await waitFor(() => + expect(queryDataset).toHaveBeenCalledWith('sales', { dimensions: ['region'], measures: ['revenue'], runtimeFilter: { stage: 'won' } }), + ); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/previews/ReportPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/ReportPreview.tsx index f7cb4ebea..eed9f3521 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/ReportPreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/ReportPreview.tsx @@ -9,7 +9,7 @@ */ import * as React from 'react'; -import { Loader2, Database, Columns3, Plus } from 'lucide-react'; +import { Loader2, Database, Columns3, Plus, Table2, AlertTriangle } from 'lucide-react'; import { useAdapter } from '../../../providers/AdapterProvider'; import type { MetadataPreviewProps } from '../preview-registry'; import { PreviewShell, PreviewErrorBoundary, PreviewEmptyState } from './PreviewShell'; @@ -20,8 +20,100 @@ const ReportRenderer = React.lazy(() => import('@object-ui/plugin-report').then((m) => ({ default: m.ReportRenderer })), ); +/** + * DatasetBoundReport — renders a report that binds to a semantic-layer + * `dataset` (ADR-0021 dual-form) instead of an inline object query. The report + * picks dimensions (`rows`) and measures (`values`) by NAME from the dataset; + * we run them through the same `adapter.queryDataset` path the dataset preview + * uses, so the numbers match every other surface on that dataset. + */ +function DatasetBoundReport({ draft }: { draft: Record }) { + const adapter = useAdapter(); + const datasetName = String((draft as any).dataset); + const rows = React.useMemo( + () => (Array.isArray((draft as any).rows) ? ((draft as any).rows as string[]).filter(Boolean) : []), + [draft], + ); + const values = React.useMemo( + () => (Array.isArray((draft as any).values) ? ((draft as any).values as string[]).filter(Boolean) : []), + [draft], + ); + const runtimeFilter = (draft as any).runtimeFilter; + + const [state, setState] = React.useState<{ status: 'idle' | 'loading' | 'ok' | 'error'; rows: Array>; error?: string }>({ status: 'idle', rows: [] }); + + const signature = `${datasetName}|${rows.join(',')}|${values.join(',')}`; + React.useEffect(() => { + if (values.length === 0) { setState({ status: 'idle', rows: [] }); return; } + let cancelled = false; + setState({ status: 'loading', rows: [] }); + (adapter as unknown as { queryDataset: (d: string, s: unknown) => Promise<{ rows: Array> }> }) + .queryDataset(datasetName, { dimensions: rows, measures: values, runtimeFilter }) + .then((res) => { if (!cancelled) setState({ status: 'ok', rows: Array.isArray(res?.rows) ? res.rows : [] }); }) + .catch((e) => { if (!cancelled) setState({ status: 'error', rows: [], error: String((e as Error)?.message ?? e) }); }); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [signature]); + + if (values.length === 0) { + return ( + + } + title="Pick measures to show" + description={`This report binds the "${datasetName}" dataset — choose at least one measure (values) to render.`} + /> + + ); + } + + const columns = [...rows, ...values]; + return ( + +
+ {state.status === 'loading' && ( +
Running report…
+ )} + {state.status === 'error' && ( +
+ {state.error} +
+ )} + {state.status === 'ok' && state.rows.length === 0 && ( + } title="No rows" description="The dataset returned no rows for this report's scope." /> + )} + {state.rows.length > 0 && ( +
+ + + {columns.map((c) => )} + + + {state.rows.map((row, i) => ( + + {columns.map((c) => { + const v = row[c]; + const text = v == null ? '—' : typeof v === 'number' ? (Number.isInteger(v) ? String(v) : v.toLocaleString(undefined, { maximumFractionDigits: 2 })) : String(v); + return ; + })} + + ))} + +
{c}
{text}
+
+ )} +
+
+ ); +} + export function ReportPreview({ draft, editing, selection, onSelectionChange, onPatch, locale }: MetadataPreviewProps) { const adapter = useAdapter(); + // ADR-0021 dual-form: a report bound to a semantic-layer dataset renders + // through the dataset query path rather than the inline-object ReportRenderer. + if (typeof (draft as any).dataset === 'string' && (draft as any).dataset) { + return } />; + } // Different fixture sets use different keys for the source object: // • new schema: `object` // • legacy: `objectName`