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'], + }); + } })); /**