From 80868d45b4227ba3ba6d18ba55709c011ff5a1e9 Mon Sep 17 00:00:00 2001 From: Jack Date: Sun, 7 Jun 2026 19:18:19 +0800 Subject: [PATCH] feat(spec): dashboard widget can bind to a dataset (ADR-0021 B-P1, dual-form) Additive dual-form for DashboardWidgetSchema: a widget may bind a semantic-layer `dataset` and select `dimensions` + `values` (measure names) by name, instead of the inline `object`+`categoryField`+`valueField`+`aggregate` query. A superRefine requires `values` when `dataset` is set. Existing inline widgets are unchanged. Completes the symmetric half of B-P1 (report + dashboard now both consume the governed dataset). Bumps .objectui-sha to the matching objectui commit (DatasetWidget renders dataset-bound widgets via queryDataset). Tests: dashboard.test +3 (dataset-bound valid; missing values rejected; legacy inline still valid). Co-Authored-By: Claude Opus 4.8 --- .objectui-sha | 2 +- packages/spec/src/ui/dashboard.test.ts | 25 ++++++++++++++++++++++++ packages/spec/src/ui/dashboard.zod.ts | 27 ++++++++++++++++++++++++-- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/.objectui-sha b/.objectui-sha index 24cbfd904..ccd613957 100644 --- a/.objectui-sha +++ b/.objectui-sha @@ -1 +1 @@ -3d275b5bfc3aac253ec6c1a63e389e5f084f0f5a +028ad35533474594abf29f68e8e5bedac5e5160b diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index fb9adcf58..ce1827ef1 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -1821,3 +1821,28 @@ describe('WidgetActionTypeSchema - unified action types', () => { })).not.toThrow(); }); }); + +describe('DashboardWidgetSchema — dataset binding (ADR-0021 dual-form)', () => { + it('accepts a dataset-bound widget (dataset + dimensions + values)', () => { + expect(() => DashboardWidgetSchema.parse({ + id: 'revenue_by_region', type: 'bar', dataset: 'sales', + dimensions: ['region'], values: ['revenue'], + layout: { x: 0, y: 0, w: 6, h: 4 }, + })).not.toThrow(); + }); + + it('rejects a dataset-bound widget with no values', () => { + expect(() => DashboardWidgetSchema.parse({ + id: 'bad', type: 'bar', dataset: 'sales', dimensions: ['region'], + layout: { x: 0, y: 0, w: 6, h: 4 }, + })).toThrowError(/needs `values`/); + }); + + it('still accepts a legacy inline widget (object + valueField + aggregate)', () => { + expect(() => DashboardWidgetSchema.parse({ + id: 'legacy', type: 'metric', object: 'crm_opportunity', + valueField: 'amount', aggregate: 'sum', + layout: { x: 0, y: 0, w: 3, h: 2 }, + })).not.toThrow(); + }); +}); diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 6bc409ac5..e689b4802 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -197,8 +197,22 @@ export const DashboardWidgetSchema = lazySchema(() => z.object({ /** Multi-measure definitions for pivot/matrix widgets */ measures: z.array(WidgetMeasureSchema).optional().describe('Multiple measures for pivot/matrix analysis'), - - /** + + /** + * ADR-0021 — bind this widget to a semantic-layer `dataset` (governed, + * consistent metrics) instead of the inline `object` + `categoryField` + + * `valueField` + `aggregate` query. A dataset-bound widget selects + * dimensions/measures BY NAME from the dataset; numbers stay consistent with + * every other surface that uses the same dataset. Additive / dual-form: + * inline widgets are unchanged. + */ + dataset: SnakeCaseIdentifierSchema.optional().describe('Dataset name to bind (ADR-0021)'), + /** Dimension names (from the dataset) for X / group / split. Dataset-bound only. */ + dimensions: z.array(z.string()).optional().describe('Dimension names — X/group/split (dataset-bound)'), + /** Measure names (from the dataset) for the value axis. Dataset-bound only. */ + values: z.array(z.string()).optional().describe('Measure names — Y (dataset-bound)'), + + /** * Layout Position (React-Grid-Layout style) * x: column (0-11) * y: row @@ -220,6 +234,15 @@ export const DashboardWidgetSchema = lazySchema(() => z.object({ /** ARIA accessibility attributes */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), +}).superRefine((w, ctx) => { + // ADR-0021 dual-form: a dataset-bound widget needs at least one measure name. + if (w.dataset && (!w.values || w.values.length === 0)) { + ctx.addIssue({ + code: 'custom', + message: 'a dataset-bound widget needs `values` (measure names from the dataset).', + path: ['values'], + }); + } })); /**