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
2 changes: 1 addition & 1 deletion .objectui-sha
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3d275b5bfc3aac253ec6c1a63e389e5f084f0f5a
028ad35533474594abf29f68e8e5bedac5e5160b
25 changes: 25 additions & 0 deletions packages/spec/src/ui/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
27 changes: 25 additions & 2 deletions packages/spec/src/ui/dashboard.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'],
});
}
}));

/**
Expand Down
Loading