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
15 changes: 13 additions & 2 deletions packages/plugin-dashboard/src/DashboardRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { isObjectProvider } from './utils';
import { DatasetWidget } from './DatasetWidget';

interface SortableWidgetWrapperProps {
id: string;
Expand Down Expand Up @@ -402,6 +403,12 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
? { ...widget.layout, w: Math.min(widget.layout.w, columns) }
: undefined;

// ADR-0021 — a widget bound to a semantic-layer dataset renders through
// the governed queryDataset path (DatasetWidget) instead of the inline
// object-aggregate schema. `as any` because the bundled DashboardWidget
// type gains `dataset` only after objectui bumps @objectstack/spec.
const datasetBound = !!(widget as any).dataset;

const getComponentSchema = () => {
if (widget.component) return widget.component;

Expand Down Expand Up @@ -786,7 +793,9 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
style={innerGridSpanStyle}
{...designModeProps}
>
<SchemaRenderer schema={componentSchema} className={cn("h-full w-full", designMode && "pointer-events-none")} dataSource={dataSource} />
{datasetBound
? <div className={cn("h-full w-full", designMode && "pointer-events-none")}><DatasetWidget widget={widget} dataSource={dataSource} /></div>
: <SchemaRenderer schema={componentSchema} className={cn("h-full w-full", designMode && "pointer-events-none")} dataSource={dataSource} />}
{designMode && <div className="absolute inset-0 z-10" aria-hidden="true" data-testid="widget-click-overlay" />}
</div>
) : (
Expand All @@ -813,7 +822,9 @@ export const DashboardRenderer = forwardRef<HTMLDivElement, DashboardRendererPro
)}
<CardContent className="p-0">
<div className={cn("h-full w-full", "p-3 sm:p-4 md:p-6", designMode && "pointer-events-none")}>
<SchemaRenderer schema={componentSchema} dataSource={dataSource} />
{datasetBound
? <DatasetWidget widget={widget} dataSource={dataSource} />
: <SchemaRenderer schema={componentSchema} dataSource={dataSource} />}
</div>
</CardContent>
{designMode && <div className="absolute inset-0 z-10" aria-hidden="true" data-testid="widget-click-overlay" />}
Expand Down
98 changes: 98 additions & 0 deletions packages/plugin-dashboard/src/DatasetWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* DatasetWidget — renders a dashboard widget that binds to a semantic-layer
* `dataset` (ADR-0021) instead of an inline `object` + `valueField` query.
*
* It selects the dataset's dimensions/measures BY NAME and runs them through
* `dataSource.queryDataset` — the same governed path the dataset preview and
* dataset-bound reports use — so the numbers match everywhere. A `metric`
* widget shows the single measure value; other types render a bar chart via the
* shared chart registry (`bar-chart`). Errors surface instead of silently
* showing wrong/empty numbers.
*
* Field access goes through `as any` because the bundled `@object-ui/types`
* `DashboardWidgetSchema` only gains `dataset`/`dimensions`/`values` once
* objectui bumps its `@objectstack/spec` dependency (cross-repo spec skew).
*/

import { useEffect, useMemo, useState } from 'react';
import { SchemaRenderer } from '@object-ui/react';
import { cn } from '@object-ui/components';
import { Loader2, BarChart3, AlertTriangle } from 'lucide-react';

type Row = Record<string, unknown>;
interface DatasetCapableSource {
queryDataset?: (dataset: string, selection: unknown) => Promise<{ rows: Row[] }>;
}

function formatValue(v: unknown): string {
if (v == null) return '—';
if (typeof v === 'number') return Number.isInteger(v) ? String(v) : v.toLocaleString(undefined, { maximumFractionDigits: 2 });
return String(v);
}

export function DatasetWidget({ widget, dataSource }: { widget: any; dataSource: unknown }) {
const datasetName = String(widget?.dataset ?? '');
const dimensions: string[] = useMemo(() => (Array.isArray(widget?.dimensions) ? widget.dimensions.filter(Boolean) : []), [widget]);
const values: string[] = useMemo(() => (Array.isArray(widget?.values) ? widget.values.filter(Boolean) : []), [widget]);
const compareTo = widget?.compareTo;
const isMetric = widget?.type === 'metric' || dimensions.length === 0;

const [state, setState] = useState<{ status: 'idle' | 'loading' | 'ok' | 'error'; rows: Row[]; error?: string }>({ status: 'idle', rows: [] });

const signature = `${datasetName}|${dimensions.join(',')}|${values.join(',')}`;
useEffect(() => {
const src = dataSource as DatasetCapableSource | undefined;
if (!src || typeof src.queryDataset !== 'function') {
setState({ status: 'error', rows: [], error: 'This data source does not support dataset queries.' });
return;
}
if (values.length === 0) { setState({ status: 'idle', rows: [] }); return; }
let cancelled = false;
setState({ status: 'loading', rows: [] });
src.queryDataset(datasetName, { dimensions, measures: values, ...(compareTo ? { compareTo } : {}) })
.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 <div className="flex h-full w-full items-center justify-center rounded border border-dashed bg-muted/20 p-4 text-xs text-muted-foreground">Pick measures (values) for this dataset widget.</div>;
}
if (state.status === 'loading' || state.status === 'idle') {
return <div className="flex h-full w-full items-center justify-center p-4 text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" /></div>;
}
if (state.status === 'error') {
return (
<div role="alert" className="flex h-full w-full items-start gap-2 rounded border border-destructive/40 bg-destructive/5 p-3 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>
);
}
if (state.rows.length === 0) {
return <div className="flex h-full w-full items-center justify-center rounded border border-dashed bg-muted/20 p-4 text-xs text-muted-foreground"><BarChart3 className="mr-2 h-4 w-4" />No rows</div>;
}

// Metric / KPI — show the single measure value of the first row.
if (isMetric) {
const value = state.rows[0]?.[values[0]];
return (
<div className="flex h-full w-full flex-col items-start justify-center gap-1 p-2">
<span className="text-2xl font-semibold tabular-nums">{formatValue(value)}</span>
<span className="text-xs text-muted-foreground">{values[0]}</span>
</div>
);
}

// Chart — bar chart of the first measure over the first dimension, via the
// shared chart registry (`bar-chart`).
return (
<div className={cn('h-full w-full min-h-[220px]')}>
<SchemaRenderer
schema={{ type: 'bar-chart', data: state.rows, xAxisKey: dimensions[0], dataKey: values[0] } as any}
/>
</div>
);
}
48 changes: 48 additions & 0 deletions packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// 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 { DatasetWidget } from '../DatasetWidget';

afterEach(cleanup);

const makeSource = (impl: (d: string, s: any) => Promise<{ rows: any[] }>) => ({ queryDataset: vi.fn(impl) });

describe('DatasetWidget', () => {
it('renders a KPI value for a metric widget', async () => {
const src = makeSource(async () => ({ rows: [{ revenue: 510000 }] }));
render(<DatasetWidget widget={{ type: 'metric', dataset: 'sales', values: ['revenue'] }} dataSource={src} />);
expect(await screen.findByText('510000')).toBeInTheDocument();
expect(src.queryDataset).toHaveBeenCalledWith('sales', { dimensions: [], measures: ['revenue'] });
});

it('runs the dataset query for a dimensioned (chart) widget — rows→dimensions, values→measures', async () => {
const src = makeSource(async () => ({ rows: [{ stage: 'won', revenue: 100 }, { stage: 'lost', revenue: 20 }] }));
render(<DatasetWidget widget={{ type: 'bar', dataset: 'sales', dimensions: ['stage'], values: ['revenue'] }} dataSource={src} />);
await waitFor(() => expect(src.queryDataset).toHaveBeenCalledWith('sales', { dimensions: ['stage'], measures: ['revenue'] }));
});

it('forwards compareTo when set', async () => {
const src = makeSource(async () => ({ rows: [{ revenue: 1 }] }));
render(<DatasetWidget widget={{ type: 'metric', dataset: 'sales', values: ['revenue'], compareTo: 'previousPeriod' }} dataSource={src} />);
await waitFor(() => expect(src.queryDataset).toHaveBeenCalledWith('sales', { dimensions: [], measures: ['revenue'], compareTo: 'previousPeriod' }));
});

it('surfaces a dataset error instead of wrong numbers', async () => {
const src = makeSource(async () => { throw new Error('relationship "account" not declared'); });
render(<DatasetWidget widget={{ type: 'metric', dataset: 'sales', values: ['revenue'] }} dataSource={src} />);
expect(await screen.findByText(/not declared/)).toBeInTheDocument();
});

it('errors when the data source cannot run dataset queries', async () => {
render(<DatasetWidget widget={{ type: 'metric', dataset: 'sales', values: ['revenue'] }} dataSource={{}} />);
await waitFor(() => expect(screen.getByText(/does not support dataset queries/)).toBeInTheDocument());
});

it('prompts when no measures are selected (no query run)', () => {
const src = makeSource(async () => ({ rows: [] }));
render(<DatasetWidget widget={{ type: 'bar', dataset: 'sales', dimensions: ['stage'], values: [] }} dataSource={src} />);
expect(screen.getByText(/Pick measures/)).toBeInTheDocument();
expect(src.queryDataset).not.toHaveBeenCalled();
});
});
Loading