Skip to content

Commit 94d4876

Browse files
os-zhuangclaude
andauthored
feat(dashboard): Studio authors the ADR-0021 dataset shape only (framework#3251) (#2703)
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). - types: DashboardWidgetSchema gains dataset/dimensions/values; the inline analytics keys (object/categoryField/valueField/aggregate/measures) are deprecated (kept only for the renderer's legacy/static read path). - plugin-dashboard: WidgetConfigPanel rewritten as a dataset picker (chart AND pivot). Replaces the unused availableObjects/availableFields props with a prop-injected `datasets` catalog (WidgetDatasetCatalogEntry), also forwarded by DashboardWithConfig. Free-text fallback when no catalog. New exports: WidgetDatasetCatalogEntry, sanitizeDraftForType. - app-shell: DashboardWidgetInspector drops the legacy inline fields; dataset binding is now the only analytics shape; filter-binding field picker sources from the bound dataset's dimensions. Add-widget catalog drops list/custom (not members of spec ChartTypeSchema). - Renderer legacy/static branches and ObjectPivotTable/PivotTable blocks kept (backward-compat for stored/static widgets); their retirement is a follow-up. - Tests: new WidgetConfigPanel dataset-authoring + sanitize suite; inspector test updated. Docs: SKILL.md examples use the dataset shape. Claude-Session: https://claude.ai/code/session_01T4qmiXd4wjnJMLt18Cir1Y Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1c8935a commit 94d4876

12 files changed

Lines changed: 574 additions & 611 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@object-ui/types": minor
3+
"@object-ui/plugin-dashboard": minor
4+
"@object-ui/app-shell": minor
5+
---
6+
7+
feat(dashboard): Studio authors the ADR-0021 dataset shape only (framework#3251)
8+
9+
Finishes the dashboard analytics migration on the authoring side so the
10+
framework can enable `DashboardWidgetSchema.strict()`. Both Studio surfaces now
11+
emit only the semantic-layer shape (`dataset` + `dimensions` + `values`); no
12+
surface authors the removed pre-ADR-0021 inline query.
13+
14+
**FROM → TO** (authoring)
15+
16+
- charts: `object` + `categoryField` + `valueField` + `aggregate`
17+
`dataset` + `dimensions` + `values`
18+
- pivots: `object` + `rowField` + `columnField` + `valueField` + `aggregation`
19+
`dataset` + `dimensions` + `values` (last dimension spreads across columns)
20+
21+
**Changes**
22+
23+
- `@object-ui/types``DashboardWidgetSchema` gains `dataset` / `dimensions` /
24+
`values`; the inline analytics keys (`object`, `categoryField`,
25+
`categoryGranularity`, `valueField`, `aggregate`, `measures`) are marked
26+
`@deprecated` (retained only so the renderer can still read legacy/static
27+
metadata during the transition).
28+
- `@object-ui/plugin-dashboard``WidgetConfigPanel` is rewritten as a dataset
29+
picker (chart AND pivot). **Breaking prop change:** the unused
30+
`availableObjects` / `availableFields` props are replaced by a new
31+
`datasets?: WidgetDatasetCatalogEntry[]` (+ `datasetsLoading?`) catalog prop,
32+
also forwarded by `DashboardWithConfig`. Hosts resolve the catalog (e.g. via
33+
the metadata client's `list('dataset')`); without it the panel falls back to
34+
free-text authoring. New exports: `WidgetDatasetCatalogEntry` and
35+
`sanitizeDraftForType`.
36+
- `@object-ui/app-shell` — the metadata-admin `DashboardWidgetInspector` drops
37+
the legacy inline fields (object / value field / category field / aggregate);
38+
the dataset section is now the primary (and only) analytics binding, and the
39+
filter-binding field picker sources options from the bound dataset's
40+
dimensions. The "Add widget" catalog drops `list` / `custom` — neither is a
41+
member of `@objectstack/spec` `ChartTypeSchema`, so a widget authored with
42+
them could never publish.
43+
44+
**Not changed:** `DashboardRenderer` keeps its legacy/static read branches and
45+
the `ObjectPivotTable` / `PivotTable` blocks (still public SDUI blocks and the
46+
backward-compat path for stored/static widgets) — only the dashboard authoring
47+
flow stops emitting the legacy keys. Retiring those renderer branches is a
48+
follow-up gated on migrating stored dashboards.

packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.test.tsx

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,20 @@
22

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

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

16-
// Network-free catalogs.
18+
// Network-free catalog.
1719
vi.mock('../previews/useDatasetCatalog', () => ({
1820
useDatasetCatalog: () => ({
1921
datasets: [{ name: 'sales_pipeline', label: 'Sales Pipeline', dimensions: [], measures: [] }],
@@ -27,12 +29,6 @@ vi.mock('../previews/useDatasetCatalog', () => ({
2729
error: null,
2830
}),
2931
}));
30-
vi.mock('./useDatasetFields', () => ({
31-
useObjectOptions: () => ({ options: [{ name: 'crm_opportunity', label: 'Opportunity' }], loading: false }),
32-
}));
33-
vi.mock('../previews/useObjectFields', () => ({
34-
useObjectFields: () => ({ fields: [{ name: 'amount', label: 'Amount', type: 'currency', hidden: false }], loading: false, error: null }),
35-
}));
3632

3733
import { DashboardWidgetInspector } from './DashboardWidgetInspector';
3834

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

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

9692
it('renders Chinese labels under zh-CN', () => {

packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx

Lines changed: 27 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -37,20 +37,26 @@ import { InspectorCheckboxField, InspectorReorderButtons, moveArray } from './_s
3737
import { InspectorComboField, type InspectorComboOption } from './InspectorComboField';
3838
import { DatasetNamesEditor } from './ReportDefaultInspector';
3939
import { useDatasetCatalog, useDatasetSemantics } from '../previews/useDatasetCatalog';
40-
import { useObjectFields, type ObjectFieldInfo } from '../previews/useObjectFields';
41-
import { useObjectOptions } from './useDatasetFields';
40+
import type { ObjectFieldInfo } from '../previews/useObjectFields';
4241

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

52-
const AGGREGATES = ['count', 'sum', 'avg', 'min', 'max'];
53-
5460
const COLORS = [
5561
'default',
5662
'blue',
@@ -95,21 +101,18 @@ export function DashboardWidgetInspector({
95101
// objectui bumps `@objectstack/spec`. Same accessor pattern as DatasetWidget.
96102
const w = (hit?.widget ?? {}) as any;
97103
const datasetName = typeof w.dataset === 'string' ? (w.dataset as string) : '';
98-
const objectName = typeof w.object === 'string' ? (w.object as string) : '';
99104
const dimensions: string[] = Array.isArray(w.dimensions)
100105
? (w.dimensions as unknown[]).filter((x): x is string => typeof x === 'string')
101106
: [];
102107
const values: string[] = Array.isArray(w.values)
103108
? (w.values as unknown[]).filter((x): x is string => typeof x === 'string')
104109
: [];
105110

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

114117
const datasetComboOptions: InspectorComboOption[] = React.useMemo(() => {
115118
const opts = catalog.datasets.map((d) => ({
@@ -121,14 +124,6 @@ export function DashboardWidgetInspector({
121124
}
122125
return opts;
123126
}, [catalog.datasets, datasetName]);
124-
const objectComboOptions: InspectorComboOption[] = React.useMemo(
125-
() => objectOptions.map((o) => ({ value: o.name, label: o.label })),
126-
[objectOptions],
127-
);
128-
const fieldComboOptions: InspectorComboOption[] = React.useMemo(
129-
() => objectFields.map((f) => ({ value: f.name, label: f.label, hint: f.type })),
130-
[objectFields],
131-
);
132127
const measureOptions: ObjectFieldInfo[] = React.useMemo(
133128
() => semantics.measures.map((m) => ({ name: m.name, label: m.aggregate ? `${m.name} · ${m.aggregate}` : m.name, type: 'number', hidden: false })),
134129
[semantics.measures],
@@ -137,6 +132,13 @@ export function DashboardWidgetInspector({
137132
() => semantics.dimensions.map((d) => ({ name: d.name, label: d.name, type: d.type ?? 'text', hidden: false })),
138133
[semantics.dimensions],
139134
);
135+
// Filter-binding field picker options come from the bound dataset's
136+
// dimensions (the fields a widget filter can target), replacing the removed
137+
// object-field source.
138+
const fieldComboOptions: InspectorComboOption[] = React.useMemo(
139+
() => semantics.dimensions.map((d) => ({ value: d.name, label: d.name, hint: d.type })),
140+
[semantics.dimensions],
141+
);
140142

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

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

304-
<Field id="widget-object" label={t('engine.inspector.widget.object', locale)}>
305-
<InspectorComboField
306-
value={widget.object ?? ''}
307-
onCommit={(v) => patchWidget({ object: v || undefined })}
308-
options={objectComboOptions}
309-
loading={objectsLoading}
310-
placeholder="Select an object…"
311-
searchPlaceholder="Search objects…"
312-
disabled={readOnly}
313-
mono
314-
/>
315-
</Field>
316-
317-
<Field id="widget-value-field" label={t('engine.inspector.widget.valueField', locale)}>
318-
<InspectorComboField
319-
value={widget.valueField ?? ''}
320-
onCommit={(v) => patchWidget({ valueField: v || undefined })}
321-
options={fieldComboOptions}
322-
placeholder={widget.object ? 'Select a field…' : 'e.g. amount'}
323-
searchPlaceholder="Search fields…"
324-
disabled={readOnly}
325-
mono
326-
/>
327-
</Field>
328-
329-
<Field id="widget-category-field" label={t('engine.inspector.widget.categoryField', locale)}>
330-
<InspectorComboField
331-
value={widget.categoryField ?? ''}
332-
onCommit={(v) => patchWidget({ categoryField: v || undefined })}
333-
options={fieldComboOptions}
334-
placeholder={widget.object ? 'Select a field…' : 'e.g. status'}
335-
searchPlaceholder="Search fields…"
336-
disabled={readOnly}
337-
mono
338-
/>
339-
</Field>
340-
341-
<Field id="widget-aggregate" label={t('engine.inspector.widget.aggregate', locale)}>
342-
<Select
343-
value={widget.aggregate ?? 'count'}
344-
onValueChange={(v) => patchWidget({ aggregate: v })}
345-
disabled={readOnly}
346-
>
347-
<SelectTrigger id="widget-aggregate">
348-
<SelectValue />
349-
</SelectTrigger>
350-
<SelectContent>
351-
{AGGREGATES.map((a) => (
352-
<SelectItem key={a} value={a}>
353-
{a}
354-
</SelectItem>
355-
))}
356-
</SelectContent>
357-
</Select>
358-
</Field>
359-
360304
{/* Dashboard filter bindings (framework#2501) — one row per dashboard
361305
filter: an Apply toggle (unchecked writes `false` = opt out) and a
362306
field picker re-targeting the filter to THIS widget's field (empty =

packages/app-shell/src/views/metadata-admin/previews/widget-types.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,19 @@ import {
1212
AreaChart,
1313
BarChart,
1414
BarChart2,
15-
Code2,
1615
Database,
1716
Donut,
1817
Filter,
1918
Hash,
2019
LineChart,
21-
List,
2220
PieChart,
2321
ScatterChart,
2422
Table2,
2523
TrendingDown,
2624
type LucideIcon,
2725
} from 'lucide-react';
2826

29-
export type WidgetCategory = 'kpi' | 'chart' | 'data' | 'custom';
27+
export type WidgetCategory = 'kpi' | 'chart' | 'data';
3028

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

6161
export const WIDGET_CATEGORY_LABEL: Record<WidgetCategory, string> = {
6262
kpi: 'Single value',
6363
chart: 'Charts',
6464
data: 'Tabular',
65-
custom: 'Custom',
6665
};
6766

6867
export const WIDGETS_BY_CATEGORY: Array<{ category: WidgetCategory; types: WidgetTypeMeta[] }> = (
69-
['kpi', 'chart', 'data', 'custom'] as WidgetCategory[]
68+
['kpi', 'chart', 'data'] as WidgetCategory[]
7069
).map((category) => ({
7170
category,
7271
types: Object.values(WIDGET_TYPE_META).filter((m) => m.category === category),

packages/plugin-dashboard/SKILL.md

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,19 @@ Server-driven dashboard renderer. Consumes `DashboardSchema` (from
44
`@objectstack/spec`) and renders a grid of widgets (metric, gauge, chart,
55
table, pivot, etc.) with drag/resize, drill-down, and async data binding.
66

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

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

1321
- For **metric** & **gauge** widgets, computes a delta percentage and surfaces
1422
it as a `trend` indicator (overrides any static `trend` prop).
@@ -46,35 +54,32 @@ without per-card configuration:
4654
{
4755
"id": "revenue",
4856
"type": "metric",
49-
"object": "Order",
50-
"aggregate": "sum",
51-
"valueField": "amount",
57+
"dataset": "order_metrics",
58+
"values": ["revenue"],
5259
"filter": {
5360
"created_at": {
5461
"$gte": "{current_quarter_start}",
5562
"$lte": "{current_quarter_end}"
5663
}
5764
},
58-
"compareTo": "previousPeriod",
59-
"label": "Revenue (Q2 2026)",
60-
"format": "currency",
61-
"currency": "USD"
65+
"compareTo": "previousPeriod"
6266
}
6367
```
6468

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

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

7076
```json
7177
{
7278
"id": "orders-trend",
7379
"type": "line",
74-
"object": "Order",
75-
"aggregate": "count",
76-
"valueField": "id",
77-
"categoryField": "created_at",
80+
"dataset": "order_metrics",
81+
"dimensions": ["created_at"],
82+
"values": ["order_count"],
7883
"filter": {
7984
"created_at": {
8085
"$gte": "{current_year_start}",

0 commit comments

Comments
 (0)