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
48 changes: 48 additions & 0 deletions .changeset/dashboard-dataset-authoring-3251.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@object-ui/types": minor
"@object-ui/plugin-dashboard": minor
"@object-ui/app-shell": minor
---

feat(dashboard): Studio authors the ADR-0021 dataset shape only (framework#3251)

Finishes the dashboard analytics migration on the authoring side so the
framework can enable `DashboardWidgetSchema.strict()`. Both Studio surfaces now
emit only the semantic-layer shape (`dataset` + `dimensions` + `values`); no
surface authors the removed pre-ADR-0021 inline query.

**FROM → TO** (authoring)

- charts: `object` + `categoryField` + `valueField` + `aggregate`
→ `dataset` + `dimensions` + `values`
- pivots: `object` + `rowField` + `columnField` + `valueField` + `aggregation`
→ `dataset` + `dimensions` + `values` (last dimension spreads across columns)

**Changes**

- `@object-ui/types` — `DashboardWidgetSchema` gains `dataset` / `dimensions` /
`values`; the inline analytics keys (`object`, `categoryField`,
`categoryGranularity`, `valueField`, `aggregate`, `measures`) are marked
`@deprecated` (retained only so the renderer can still read legacy/static
metadata during the transition).
- `@object-ui/plugin-dashboard` — `WidgetConfigPanel` is rewritten as a dataset
picker (chart AND pivot). **Breaking prop change:** the unused
`availableObjects` / `availableFields` props are replaced by a new
`datasets?: WidgetDatasetCatalogEntry[]` (+ `datasetsLoading?`) catalog prop,
also forwarded by `DashboardWithConfig`. Hosts resolve the catalog (e.g. via
the metadata client's `list('dataset')`); without it the panel falls back to
free-text authoring. New exports: `WidgetDatasetCatalogEntry` and
`sanitizeDraftForType`.
- `@object-ui/app-shell` — the metadata-admin `DashboardWidgetInspector` drops
the legacy inline fields (object / value field / category field / aggregate);
the dataset section is now the primary (and only) analytics binding, and the
filter-binding field picker sources options from the bound dataset's
dimensions. The "Add widget" catalog drops `list` / `custom` — neither is a
member of `@objectstack/spec` `ChartTypeSchema`, so a widget authored with
them could never publish.

**Not changed:** `DashboardRenderer` keeps its legacy/static read branches and
the `ObjectPivotTable` / `PivotTable` blocks (still public SDUI blocks and the
backward-compat path for stored/static widgets) — only the dashboard authoring
flow stops emitting the legacy keys. Retiring those renderer branches is a
follow-up gated on migrating stored dashboards.
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@

/**
* DashboardWidgetInspector — dataset binding (ADR-0021). The widget inspector
* binds a governed dataset and picks its dimensions/measures from the bound
* dataset's semantic layer (the same control the Report inspector uses), and
* the inline object query picks object/fields from the live schema — instead
* of free-text the author has to recall. These tests stub the catalog hooks so
* the pickers render network-free.
* authors the single semantic-layer shape: it binds a governed `dataset` and
* picks its dimensions/measures from the bound dataset's semantic layer (the
* same control the Report inspector uses) — instead of free-text the author has
* to recall. The pre-ADR-0021 inline object query (object/valueField/
* categoryField/aggregate) was removed (framework#3251), so no Studio surface
* can author the dead shape. These tests stub the catalog hook so the pickers
* render network-free.
*/

import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, within } from '@testing-library/react';

// Network-free catalogs.
// Network-free catalog.
vi.mock('../previews/useDatasetCatalog', () => ({
useDatasetCatalog: () => ({
datasets: [{ name: 'sales_pipeline', label: 'Sales Pipeline', dimensions: [], measures: [] }],
Expand All @@ -27,12 +29,6 @@ vi.mock('../previews/useDatasetCatalog', () => ({
error: null,
}),
}));
vi.mock('./useDatasetFields', () => ({
useObjectOptions: () => ({ options: [{ name: 'crm_opportunity', label: 'Opportunity' }], loading: false }),
}));
vi.mock('../previews/useObjectFields', () => ({
useObjectFields: () => ({ fields: [{ name: 'amount', label: 'Amount', type: 'currency', hidden: false }], loading: false, error: null }),
}));

import { DashboardWidgetInspector } from './DashboardWidgetInspector';

Expand Down Expand Up @@ -84,13 +80,13 @@ describe('DashboardWidgetInspector — dataset binding', () => {
expect(screen.getByText('revenue')).toBeInTheDocument();
});

it('renders object + field bindings as pickers (inline single-object query)', () => {
renderWidget({ object: 'crm_opportunity', valueField: 'amount' });
expect(screen.getByText('Data Source (Object)')).toBeInTheDocument();
expect(screen.getByText('Value Field')).toBeInTheDocument();
// The bound object/field resolve to their catalog labels on the combo triggers.
expect(screen.getAllByText('Opportunity').length).toBeGreaterThan(0);
expect(screen.getAllByText('Amount').length).toBeGreaterThan(0);
it('no longer renders the removed inline object query fields', () => {
// Legacy inline analytics fields were removed (framework#3251) — the
// inspector authors only the dataset shape now.
renderWidget({ dataset: 'sales_pipeline' });
expect(screen.queryByText('Data Source (Object)')).not.toBeInTheDocument();
expect(screen.queryByText('Value Field')).not.toBeInTheDocument();
expect(screen.queryByText('Category Field')).not.toBeInTheDocument();
});

it('renders Chinese labels under zh-CN', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,20 +37,26 @@ import { InspectorCheckboxField, InspectorReorderButtons, moveArray } from './_s
import { InspectorComboField, type InspectorComboOption } from './InspectorComboField';
import { DatasetNamesEditor } from './ReportDefaultInspector';
import { useDatasetCatalog, useDatasetSemantics } from '../previews/useDatasetCatalog';
import { useObjectFields, type ObjectFieldInfo } from '../previews/useObjectFields';
import { useObjectOptions } from './useDatasetFields';
import type { ObjectFieldInfo } from '../previews/useObjectFields';

// ADR-0021: dashboard widgets author the semantic-layer dataset shape only
// (dataset + dimensions + values). The pre-ADR-0021 inline single-object query
// (object / valueField / categoryField / aggregate) was removed from the spec
// at @objectstack/spec 9.0.0 and is no longer authored here — its fields are
// gone so no Studio surface can emit the dead shape (framework#3251).
const WIDGET_TYPES = [
{ value: 'metric', label: 'KPI Metric' },
{ value: 'bar', label: 'Bar Chart' },
{ value: 'horizontal-bar', label: 'Horizontal Bar' },
{ value: 'line', label: 'Line Chart' },
{ value: 'area', label: 'Area Chart' },
{ value: 'pie', label: 'Pie Chart' },
{ value: 'donut', label: 'Donut Chart' },
{ value: 'funnel', label: 'Funnel' },
{ value: 'table', label: 'Table' },
{ value: 'grid', label: 'Grid' },
{ value: 'pivot', label: 'Pivot Table' },
];

const AGGREGATES = ['count', 'sum', 'avg', 'min', 'max'];

const COLORS = [
'default',
'blue',
Expand Down Expand Up @@ -95,21 +101,18 @@ export function DashboardWidgetInspector({
// objectui bumps `@objectstack/spec`. Same accessor pattern as DatasetWidget.
const w = (hit?.widget ?? {}) as any;
const datasetName = typeof w.dataset === 'string' ? (w.dataset as string) : '';
const objectName = typeof w.object === 'string' ? (w.object as string) : '';
const dimensions: string[] = Array.isArray(w.dimensions)
? (w.dimensions as unknown[]).filter((x): x is string => typeof x === 'string')
: [];
const values: string[] = Array.isArray(w.values)
? (w.values as unknown[]).filter((x): x is string => typeof x === 'string')
: [];

// Catalogs — called unconditionally (stable hook order) BEFORE any early
// return, so the dataset/object/field pickers below bind to the live schema
// instead of free-text the author has to recall.
// Catalog — called unconditionally (stable hook order) BEFORE any early
// return, so the dataset / dimensions / values pickers below bind to the
// live schema instead of free-text the author has to recall.
const catalog = useDatasetCatalog();
const semantics = useDatasetSemantics(datasetName || undefined, catalog);
const { options: objectOptions, loading: objectsLoading } = useObjectOptions();
const { fields: objectFields } = useObjectFields(objectName || undefined);

const datasetComboOptions: InspectorComboOption[] = React.useMemo(() => {
const opts = catalog.datasets.map((d) => ({
Expand All @@ -121,14 +124,6 @@ export function DashboardWidgetInspector({
}
return opts;
}, [catalog.datasets, datasetName]);
const objectComboOptions: InspectorComboOption[] = React.useMemo(
() => objectOptions.map((o) => ({ value: o.name, label: o.label })),
[objectOptions],
);
const fieldComboOptions: InspectorComboOption[] = React.useMemo(
() => objectFields.map((f) => ({ value: f.name, label: f.label, hint: f.type })),
[objectFields],
);
const measureOptions: ObjectFieldInfo[] = React.useMemo(
() => semantics.measures.map((m) => ({ name: m.name, label: m.aggregate ? `${m.name} · ${m.aggregate}` : m.name, type: 'number', hidden: false })),
[semantics.measures],
Expand All @@ -137,6 +132,13 @@ export function DashboardWidgetInspector({
() => semantics.dimensions.map((d) => ({ name: d.name, label: d.name, type: d.type ?? 'text', hidden: false })),
[semantics.dimensions],
);
// Filter-binding field picker options come from the bound dataset's
// dimensions (the fields a widget filter can target), replacing the removed
// object-field source.
const fieldComboOptions: InspectorComboOption[] = React.useMemo(
() => semantics.dimensions.map((d) => ({ value: d.name, label: d.name, hint: d.type })),
[semantics.dimensions],
);

// ── Dashboard filter bindings (framework#2501) ─────────────────────────
// The dashboard's own dateRange + globalFilters declarations, normalized
Expand Down Expand Up @@ -247,14 +249,12 @@ export function DashboardWidgetInspector({
</Select>
</Field>

{/* Dataset binding (ADR-0021) — governed cross-object semantic layer.
When `dataset` is set, DashboardRenderer renders this widget via
<DatasetWidget> (consistent numbers, cross-object, RLS-enforced),
taking precedence over the inline single-object query below. The
inline fields are kept visible so existing widgets stay editable
(additive dual-form, mirroring report's dataset binding). */}
<div className="space-y-3 rounded-md border border-dashed border-primary/40 bg-primary/5 p-3">
<div className="text-[10px] font-semibold uppercase tracking-wider text-primary/80">
{/* Dataset binding (ADR-0021) — the single author-facing analytics
shape. The widget binds a governed cross-object `dataset` and selects
its dimensions/measures by name; DashboardRenderer renders it via
<DatasetWidget> (consistent numbers, cross-object, RLS-enforced). */}
<div className="space-y-3 rounded-md border p-3">
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{t('engine.inspector.widget.datasetSection', locale)}
</div>
<Field id="widget-dataset" label={t('engine.inspector.widget.dataset', locale)}>
Expand Down Expand Up @@ -301,62 +301,6 @@ export function DashboardWidgetInspector({
)}
</div>

<Field id="widget-object" label={t('engine.inspector.widget.object', locale)}>
<InspectorComboField
value={widget.object ?? ''}
onCommit={(v) => patchWidget({ object: v || undefined })}
options={objectComboOptions}
loading={objectsLoading}
placeholder="Select an object…"
searchPlaceholder="Search objects…"
disabled={readOnly}
mono
/>
</Field>

<Field id="widget-value-field" label={t('engine.inspector.widget.valueField', locale)}>
<InspectorComboField
value={widget.valueField ?? ''}
onCommit={(v) => patchWidget({ valueField: v || undefined })}
options={fieldComboOptions}
placeholder={widget.object ? 'Select a field…' : 'e.g. amount'}
searchPlaceholder="Search fields…"
disabled={readOnly}
mono
/>
</Field>

<Field id="widget-category-field" label={t('engine.inspector.widget.categoryField', locale)}>
<InspectorComboField
value={widget.categoryField ?? ''}
onCommit={(v) => patchWidget({ categoryField: v || undefined })}
options={fieldComboOptions}
placeholder={widget.object ? 'Select a field…' : 'e.g. status'}
searchPlaceholder="Search fields…"
disabled={readOnly}
mono
/>
</Field>

<Field id="widget-aggregate" label={t('engine.inspector.widget.aggregate', locale)}>
<Select
value={widget.aggregate ?? 'count'}
onValueChange={(v) => patchWidget({ aggregate: v })}
disabled={readOnly}
>
<SelectTrigger id="widget-aggregate">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AGGREGATES.map((a) => (
<SelectItem key={a} value={a}>
{a}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>

{/* Dashboard filter bindings (framework#2501) — one row per dashboard
filter: an Apply toggle (unchecked writes `false` = opt out) and a
field picker re-targeting the filter to THIS widget's field (empty =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,19 @@ import {
AreaChart,
BarChart,
BarChart2,
Code2,
Database,
Donut,
Filter,
Hash,
LineChart,
List,
PieChart,
ScatterChart,
Table2,
TrendingDown,
type LucideIcon,
} from 'lucide-react';

export type WidgetCategory = 'kpi' | 'chart' | 'data' | 'custom';
export type WidgetCategory = 'kpi' | 'chart' | 'data';

export interface WidgetTypeMeta {
id: string;
Expand Down Expand Up @@ -54,19 +52,20 @@ export const WIDGET_TYPE_META: Record<string, WidgetTypeMeta> = {
funnel: { id: 'funnel', label: 'Funnel', category: 'chart', icon: TrendingDown },
table: { id: 'table', label: 'Data table', category: 'data', icon: Table2 },
pivot: { id: 'pivot', label: 'Pivot table', category: 'data', icon: Database },
list: { id: 'list', label: 'Record list', category: 'data', icon: List },
custom: { id: 'custom', label: 'Custom widget', category: 'custom', icon: Code2 },
// NOTE: `list` and `custom` are intentionally absent — they are not members
// of @objectstack/spec ChartTypeSchema, so a widget authored with them can
// never publish (framework#3251). Keep this catalog in lockstep with the spec
// enum so the "Add widget" picker only offers publishable types.
};

export const WIDGET_CATEGORY_LABEL: Record<WidgetCategory, string> = {
kpi: 'Single value',
chart: 'Charts',
data: 'Tabular',
custom: 'Custom',
};

export const WIDGETS_BY_CATEGORY: Array<{ category: WidgetCategory; types: WidgetTypeMeta[] }> = (
['kpi', 'chart', 'data', 'custom'] as WidgetCategory[]
['kpi', 'chart', 'data'] as WidgetCategory[]
).map((category) => ({
category,
types: Object.values(WIDGET_TYPE_META).filter((m) => m.category === category),
Expand Down
33 changes: 19 additions & 14 deletions packages/plugin-dashboard/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,19 @@ Server-driven dashboard renderer. Consumes `DashboardSchema` (from
`@objectstack/spec`) and renders a grid of widgets (metric, gauge, chart,
table, pivot, etc.) with drag/resize, drill-down, and async data binding.

> **Authoring shape (ADR-0021).** Dashboard widgets bind a semantic-layer
> `dataset` and select its `dimensions` + `values` by name — that is the only
> author-facing analytics shape. The pre-ADR-0021 inline query
> (`object` + `categoryField` + `valueField` + `aggregate`, pivot
> `rowField`/`columnField`) was removed at `@objectstack/spec` 9.0.0 and is a
> hard error under `DashboardWidgetSchema.strict()` (framework#3251). Examples
> below use the dataset shape.

## Period-over-period comparison (`compareTo`)

Any widget that binds to an `object` (metric / gauge / chart) can opt into a
Any dataset-bound widget (metric / gauge / chart) can opt into a
period-over-period comparison by adding a `compareTo` field. The renderer
issues a second aggregate against the comparison-period filter and:
issues a second dataset query against the comparison-period filter and:

- For **metric** & **gauge** widgets, computes a delta percentage and surfaces
it as a `trend` indicator (overrides any static `trend` prop).
Expand Down Expand Up @@ -46,35 +54,32 @@ without per-card configuration:
{
"id": "revenue",
"type": "metric",
"object": "Order",
"aggregate": "sum",
"valueField": "amount",
"dataset": "order_metrics",
"values": ["revenue"],
"filter": {
"created_at": {
"$gte": "{current_quarter_start}",
"$lte": "{current_quarter_end}"
}
},
"compareTo": "previousPeriod",
"label": "Revenue (Q2 2026)",
"format": "currency",
"currency": "USD"
"compareTo": "previousPeriod"
}
```

Renders a KPI card showing this quarter's revenue with a `↑ 12.5% vs last quarter`
delta sourced from the same aggregate run against Q1 2026.
delta sourced from the same dataset query run against Q1 2026. (The `revenue`
measure — its aggregate, field, format, and currency — is declared once on the
`order_metrics` dataset, not inline on the widget.)

### Chart example (year-over-year line)

```json
{
"id": "orders-trend",
"type": "line",
"object": "Order",
"aggregate": "count",
"valueField": "id",
"categoryField": "created_at",
"dataset": "order_metrics",
"dimensions": ["created_at"],
"values": ["order_count"],
"filter": {
"created_at": {
"$gte": "{current_year_start}",
Expand Down
Loading
Loading