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
Original file line number Diff line number Diff line change
@@ -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(<ReportPreview {...baseProps} draft={datasetReport} />);

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(<ReportPreview {...baseProps} draft={{ ...datasetReport, values: [] }} />);
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(<ReportPreview {...baseProps} draft={datasetReport} />);
expect(await screen.findByText(/not declared in the dataset/)).toBeInTheDocument();
});

it('forwards runtimeFilter to the dataset query', async () => {
queryDataset.mockResolvedValue({ rows: [], fields: [] });
render(<ReportPreview {...baseProps} draft={{ ...datasetReport, runtimeFilter: { stage: 'won' } }} />);
await waitFor(() =>
expect(queryDataset).toHaveBeenCalledWith('sales', { dimensions: ['region'], measures: ['revenue'], runtimeFilter: { stage: 'won' } }),
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string, unknown> }) {
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<Record<string, unknown>>; 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<Record<string, unknown>> }> })
.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 (
<PreviewShell>
<PreviewEmptyState
icon={<Table2 className="h-8 w-8" />}
title="Pick measures to show"
description={`This report binds the "${datasetName}" dataset — choose at least one measure (values) to render.`}
/>
</PreviewShell>
);
}

const columns = [...rows, ...values];
return (
<PreviewShell hint={`report · dataset "${datasetName}"${rows.length ? ' · by ' + rows.join(', ') : ''}`}>
<div className="p-3">
{state.status === 'loading' && (
<div className="flex items-center gap-2 p-4 text-xs text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" /> Running report…</div>
)}
{state.status === 'error' && (
<div role="alert" className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" /><span className="break-words">{state.error}</span>
</div>
)}
{state.status === 'ok' && state.rows.length === 0 && (
<PreviewEmptyState icon={<Table2 className="h-8 w-8" />} title="No rows" description="The dataset returned no rows for this report's scope." />
)}
{state.rows.length > 0 && (
<div className="overflow-auto max-h-[70vh] rounded-md border">
<table className="w-full text-xs">
<thead className="bg-muted/40">
<tr>{columns.map((c) => <th key={c} className="px-2 py-1.5 text-left font-medium whitespace-nowrap">{c}</th>)}</tr>
</thead>
<tbody>
{state.rows.map((row, i) => (
<tr key={i} className="border-t">
{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 <td key={c} className="px-2 py-1 tabular-nums whitespace-nowrap">{text}</td>;
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</PreviewShell>
);
}

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 <DatasetBoundReport draft={draft as Record<string, unknown>} />;
}
// Different fixture sets use different keys for the source object:
// • new schema: `object`
// • legacy: `objectName`
Expand Down
Loading