From 721cf2c0b7e34e45c526115fb0603eea5179314f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 17:47:31 +0000 Subject: [PATCH] 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). - 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. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01T4qmiXd4wjnJMLt18Cir1Y --- .../dashboard-dataset-authoring-3251.md | 48 ++ .../DashboardWidgetInspector.test.tsx | 34 +- .../inspectors/DashboardWidgetInspector.tsx | 110 +-- .../metadata-admin/previews/widget-types.ts | 13 +- packages/plugin-dashboard/SKILL.md | 33 +- .../src/DashboardWithConfig.tsx | 81 +-- .../src/WidgetConfigPanel.tsx | 653 +++++++----------- .../src/__tests__/WidgetConfigPanel.test.tsx | 103 +++ .../plugin-dashboard/src/dataset-catalog.ts | 49 ++ packages/plugin-dashboard/src/index.tsx | 6 + packages/types/src/complex.ts | 51 +- packages/types/src/zod/complex.zod.ts | 4 + 12 files changed, 574 insertions(+), 611 deletions(-) create mode 100644 .changeset/dashboard-dataset-authoring-3251.md create mode 100644 packages/plugin-dashboard/src/__tests__/WidgetConfigPanel.test.tsx create mode 100644 packages/plugin-dashboard/src/dataset-catalog.ts diff --git a/.changeset/dashboard-dataset-authoring-3251.md b/.changeset/dashboard-dataset-authoring-3251.md new file mode 100644 index 000000000..88497d281 --- /dev/null +++ b/.changeset/dashboard-dataset-authoring-3251.md @@ -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. diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.test.tsx index e0e8e0171..bbbe26437 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.test.tsx @@ -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: [] }], @@ -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'; @@ -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', () => { diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx index df63d1bf7..c5262f762 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx @@ -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', @@ -95,7 +101,6 @@ 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') : []; @@ -103,13 +108,11 @@ export function DashboardWidgetInspector({ ? (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) => ({ @@ -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], @@ -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 @@ -247,14 +249,12 @@ export function DashboardWidgetInspector({ - {/* Dataset binding (ADR-0021) — governed cross-object semantic layer. - When `dataset` is set, DashboardRenderer renders this widget via - (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). */} -
-
+ {/* 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 + (consistent numbers, cross-object, RLS-enforced). */} +
+
{t('engine.inspector.widget.datasetSection', locale)}
@@ -301,62 +301,6 @@ export function DashboardWidgetInspector({ )}
- - patchWidget({ object: v || undefined })} - options={objectComboOptions} - loading={objectsLoading} - placeholder="Select an object…" - searchPlaceholder="Search objects…" - disabled={readOnly} - mono - /> - - - - patchWidget({ valueField: v || undefined })} - options={fieldComboOptions} - placeholder={widget.object ? 'Select a field…' : 'e.g. amount'} - searchPlaceholder="Search fields…" - disabled={readOnly} - mono - /> - - - - patchWidget({ categoryField: v || undefined })} - options={fieldComboOptions} - placeholder={widget.object ? 'Select a field…' : 'e.g. status'} - searchPlaceholder="Search fields…" - disabled={readOnly} - mono - /> - - - - - - {/* 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 = diff --git a/packages/app-shell/src/views/metadata-admin/previews/widget-types.ts b/packages/app-shell/src/views/metadata-admin/previews/widget-types.ts index a33794a50..8aa7f0b2e 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/widget-types.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/widget-types.ts @@ -12,13 +12,11 @@ import { AreaChart, BarChart, BarChart2, - Code2, Database, Donut, Filter, Hash, LineChart, - List, PieChart, ScatterChart, Table2, @@ -26,7 +24,7 @@ import { type LucideIcon, } from 'lucide-react'; -export type WidgetCategory = 'kpi' | 'chart' | 'data' | 'custom'; +export type WidgetCategory = 'kpi' | 'chart' | 'data'; export interface WidgetTypeMeta { id: string; @@ -54,19 +52,20 @@ export const WIDGET_TYPE_META: Record = { 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 = { 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), diff --git a/packages/plugin-dashboard/SKILL.md b/packages/plugin-dashboard/SKILL.md index 8f0575dfa..804af0a69 100644 --- a/packages/plugin-dashboard/SKILL.md +++ b/packages/plugin-dashboard/SKILL.md @@ -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). @@ -46,24 +54,22 @@ 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) @@ -71,10 +77,9 @@ delta sourced from the same aggregate run against Q1 2026. { "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}", diff --git a/packages/plugin-dashboard/src/DashboardWithConfig.tsx b/packages/plugin-dashboard/src/DashboardWithConfig.tsx index 5b2c875bc..555d3d469 100644 --- a/packages/plugin-dashboard/src/DashboardWithConfig.tsx +++ b/packages/plugin-dashboard/src/DashboardWithConfig.tsx @@ -15,6 +15,7 @@ import type { DashboardSchema, DashboardWidgetSchema } from '@object-ui/types'; import { DashboardRenderer } from './DashboardRenderer'; import { DashboardConfigPanel } from './DashboardConfigPanel'; import { WidgetConfigPanel } from './WidgetConfigPanel'; +import type { WidgetDatasetCatalogEntry } from './dataset-catalog'; // --------------------------------------------------------------------------- // Props @@ -37,6 +38,15 @@ export interface DashboardWithConfigProps { defaultConfigOpen?: boolean; /** Additional CSS class name for the container */ className?: string; + /** + * Analytics dataset catalog (ADR-0021), forwarded to the widget config + * panel so its dataset / dimensions / values pickers bind to the live + * schema. Hosts resolve it (e.g. via the metadata client's + * `list('dataset')`); absent → free-text authoring still works. + */ + datasets?: WidgetDatasetCatalogEntry[]; + /** Whether the dataset catalog is still loading. */ + datasetsLoading?: boolean; } // --------------------------------------------------------------------------- @@ -63,6 +73,8 @@ export function DashboardWithConfig({ recordCount, defaultConfigOpen = false, className, + datasets, + datasetsLoading, }: DashboardWithConfigProps) { const [configOpen, setConfigOpen] = useState(defaultConfigOpen); const [selectedWidgetId, setSelectedWidgetId] = useState(null); @@ -86,40 +98,20 @@ export function DashboardWithConfig({ (w) => (w.id || w.title) === selectedWidgetId, ); if (!widget) return null; - // Spread widget.options first so explicit top-level widget keys win on - // collision. This surfaces type-specific fields (pivot rowField/columnField, - // table searchable/pagination, chart xAxis/yAxis, etc.) that are stored - // under `options` in the persisted metadata. - const options = (widget.options || {}) as Record; + // ADR-0021 dataset shape — the only authoring shape the panel edits. + // `dataset`/`dimensions`/`values` are read through casts: the bundled + // `@object-ui/types` gains them once objectui bumps `@objectstack/spec`. + const w = widget as any; return { - ...options, id: widget.id ?? '', title: widget.title ?? '', description: widget.description ?? '', type: widget.type ?? '', - object: widget.object ?? '', - categoryField: widget.categoryField ?? options.categoryField ?? '', - valueField: widget.valueField ?? options.valueField ?? '', - aggregate: widget.aggregate ?? options.aggregate ?? '', - colorVariant: widget.colorVariant ?? options.colorVariant ?? 'default', - actionUrl: widget.actionUrl ?? options.actionUrl ?? '', - // Drill-down lives under options.drillDown — flatten so the panel - // can edit it as plain top-level switches. Default ON for object- - // backed pivot widgets and for object-backed drillable chart types - // (mirrors DashboardRenderer policy). - drillDownEnabled: options.drillDown?.enabled - ?? (!!widget.object && ( - widget.type === 'pivot' || - widget.type === 'metric' || - widget.type === 'bar' || - widget.type === 'horizontal-bar' || - widget.type === 'line' || - widget.type === 'area' || - widget.type === 'pie' || - widget.type === 'donut' || - widget.type === 'funnel' - ) ? true : false), - drillDownTarget: options.drillDown?.target ?? 'drawer', + dataset: typeof w.dataset === 'string' ? w.dataset : '', + dimensions: Array.isArray(w.dimensions) ? w.dimensions : [], + values: Array.isArray(w.values) ? w.values : [], + colorVariant: widget.colorVariant ?? 'default', + actionUrl: widget.actionUrl ?? '', layoutW: widget.layout?.w ?? 1, layoutH: widget.layout?.h ?? 1, }; @@ -154,16 +146,6 @@ export function DashboardWithConfig({ if (field === 'layoutH') { return { ...w, layout: { ...(w.layout || {}), h: value } as DashboardWidgetSchema['layout'] }; } - // Drill-down toggles map into options.drillDown.{enabled,target} - // so the renderer (which reads options.drillDown) picks them up. - if (field === 'drillDownEnabled' || field === 'drillDownTarget') { - const prevDrill = (w.options as any)?.drillDown ?? {}; - const nextDrill = - field === 'drillDownEnabled' - ? { ...prevDrill, enabled: !!value } - : { ...prevDrill, target: value }; - return { ...w, options: { ...(w.options || {}), drillDown: nextDrill } } as DashboardWidgetSchema; - } return { ...w, [field]: value }; }), }; @@ -175,21 +157,10 @@ export function DashboardWithConfig({ const handleWidgetSave = useCallback( (widgetConfig: Record) => { if (selectedWidgetId && onWidgetSave) { - // Re-nest drill-down flat keys back under options.drillDown so - // persistence callers receive the canonical widget shape. - const { drillDownEnabled, drillDownTarget, ...rest } = widgetConfig; - const persisted: Record = { ...rest }; - if (drillDownEnabled || drillDownTarget) { - persisted.options = { - ...(rest.options || {}), - drillDown: { - ...((rest.options as any)?.drillDown || {}), - ...(drillDownEnabled !== undefined ? { enabled: !!drillDownEnabled } : {}), - ...(drillDownTarget !== undefined ? { target: drillDownTarget } : {}), - }, - }; - } - onWidgetSave(selectedWidgetId, persisted); + // WidgetConfigPanel already emits the canonical ADR-0021 shape + // (dataset / dimensions / values) with legacy keys scrubbed, so the + // config is persisted verbatim. + onWidgetSave(selectedWidgetId, widgetConfig); } setSelectedWidgetId(null); setConfigVersion((v) => v + 1); @@ -242,6 +213,8 @@ export function DashboardWithConfig({ config={selectedWidgetConfig} onSave={handleWidgetSave} onFieldChange={handleWidgetFieldChange} + datasets={datasets} + datasetsLoading={datasetsLoading} /> ) : (