Skip to content

Commit 355e592

Browse files
Copilothotlong
andcommitted
feat(dashboard): add header config and multi-measure pivot support (#714)
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent e26c928 commit 355e592

3 files changed

Lines changed: 337 additions & 2 deletions

File tree

ROADMAP.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -403,8 +403,8 @@ Protocol enhancements and core component implementations for dashboard feature p
403403
**Spec Protocol Changes:**
404404
- [x] Add `colorVariant`, `actionUrl`, `description`, `actionType`, `actionIcon` to `DashboardWidgetSchema` ([#713](https://github.com/objectstack-ai/spec/issues/713))
405405
- [x] Enhance `globalFilters` with `options`, `optionsFrom`, `defaultValue`, `scope`, `targetWidgets` ([#712](https://github.com/objectstack-ai/spec/issues/712))
406-
- [ ] Add `header` configuration to `DashboardSchema` with `showTitle`, `showDescription`, `actions` ([#714](https://github.com/objectstack-ai/spec/issues/714))
407-
- [ ] Add `pivotConfig` and `measures` array to `DashboardWidgetSchema` for multi-measure pivots ([#714](https://github.com/objectstack-ai/spec/issues/714))
406+
- [x] Add `header` configuration to `DashboardSchema` with `showTitle`, `showDescription`, `actions` ([#714](https://github.com/objectstack-ai/spec/issues/714))
407+
- [x] Add `pivotConfig` and `measures` array to `DashboardWidgetSchema` for multi-measure pivots ([#714](https://github.com/objectstack-ai/spec/issues/714))
408408

409409
**ObjectUI Component Implementations:**
410410
- [ ] Implement `DashboardFilterBar` component for global filters ([objectui#588](https://github.com/objectstack-ai/objectui/issues/588))

packages/spec/src/ui/dashboard.test.ts

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@ import { describe, it, expect } from 'vitest';
22
import {
33
DashboardSchema,
44
DashboardWidgetSchema,
5+
DashboardHeaderSchema,
6+
DashboardHeaderActionSchema,
7+
WidgetMeasureSchema,
58
Dashboard,
69
WidgetColorVariantSchema,
710
WidgetActionTypeSchema,
811
GlobalFilterSchema,
912
GlobalFilterOptionsFromSchema,
1013
type Dashboard as DashboardType,
1114
type DashboardWidget,
15+
type DashboardHeader,
16+
type DashboardHeaderAction,
17+
type WidgetMeasure,
1218
type GlobalFilter,
1319
type GlobalFilterOptionsFrom,
1420
} from './dashboard.zod';
@@ -1162,3 +1168,272 @@ describe('DashboardSchema - enhanced globalFilters', () => {
11621168
expect(dashboard.globalFilters![2].targetWidgets).toEqual(['revenue_chart']);
11631169
});
11641170
});
1171+
1172+
// ============================================================================
1173+
// Protocol Enhancement Tests: DashboardHeaderSchema (#714)
1174+
// ============================================================================
1175+
1176+
describe('DashboardHeaderActionSchema', () => {
1177+
it('should accept valid header action', () => {
1178+
const result = DashboardHeaderActionSchema.parse({
1179+
label: 'Export PDF',
1180+
actionUrl: '/export/pdf',
1181+
});
1182+
expect(result.label).toBe('Export PDF');
1183+
expect(result.actionUrl).toBe('/export/pdf');
1184+
});
1185+
1186+
it('should accept action with all fields', () => {
1187+
const result = DashboardHeaderActionSchema.parse({
1188+
label: 'Run Report',
1189+
actionUrl: 'generate_report_flow',
1190+
actionType: 'flow',
1191+
icon: 'play',
1192+
});
1193+
expect(result.actionType).toBe('flow');
1194+
expect(result.icon).toBe('play');
1195+
});
1196+
1197+
it('should accept i18n label', () => {
1198+
const result = DashboardHeaderActionSchema.parse({
1199+
label: { key: 'actions.export', defaultValue: 'Export' },
1200+
actionUrl: '/export',
1201+
});
1202+
expect(result.label).toEqual({ key: 'actions.export', defaultValue: 'Export' });
1203+
});
1204+
1205+
it('should reject action without required fields', () => {
1206+
expect(() => DashboardHeaderActionSchema.parse({ label: 'Test' })).toThrow();
1207+
expect(() => DashboardHeaderActionSchema.parse({ actionUrl: '/test' })).toThrow();
1208+
expect(() => DashboardHeaderActionSchema.parse({})).toThrow();
1209+
});
1210+
});
1211+
1212+
describe('DashboardHeaderSchema', () => {
1213+
it('should accept empty header with defaults', () => {
1214+
const result = DashboardHeaderSchema.parse({});
1215+
expect(result.showTitle).toBe(true);
1216+
expect(result.showDescription).toBe(true);
1217+
expect(result.actions).toBeUndefined();
1218+
});
1219+
1220+
it('should accept header with showTitle/showDescription overrides', () => {
1221+
const result = DashboardHeaderSchema.parse({
1222+
showTitle: false,
1223+
showDescription: false,
1224+
});
1225+
expect(result.showTitle).toBe(false);
1226+
expect(result.showDescription).toBe(false);
1227+
});
1228+
1229+
it('should accept header with actions', () => {
1230+
const result = DashboardHeaderSchema.parse({
1231+
actions: [
1232+
{ label: 'Export', actionUrl: '/export/pdf', icon: 'download' },
1233+
{ label: 'Share', actionUrl: 'share_modal', actionType: 'modal', icon: 'share' },
1234+
],
1235+
});
1236+
expect(result.actions).toHaveLength(2);
1237+
expect(result.actions![0].label).toBe('Export');
1238+
expect(result.actions![1].actionType).toBe('modal');
1239+
});
1240+
});
1241+
1242+
describe('DashboardSchema - header', () => {
1243+
it('should accept dashboard with header configuration', () => {
1244+
const result = DashboardSchema.parse({
1245+
name: 'sales_dashboard',
1246+
label: 'Sales Dashboard',
1247+
description: 'Q4 sales performance',
1248+
header: {
1249+
showTitle: true,
1250+
showDescription: true,
1251+
actions: [
1252+
{ label: 'Export PDF', actionUrl: '/export/pdf', icon: 'download' },
1253+
],
1254+
},
1255+
widgets: [],
1256+
});
1257+
expect(result.header).toBeDefined();
1258+
expect(result.header!.showTitle).toBe(true);
1259+
expect(result.header!.actions).toHaveLength(1);
1260+
});
1261+
1262+
it('should accept dashboard without header (backward compat)', () => {
1263+
const result = DashboardSchema.parse({
1264+
name: 'simple_dash',
1265+
label: 'Simple',
1266+
widgets: [],
1267+
});
1268+
expect(result.header).toBeUndefined();
1269+
});
1270+
1271+
it('should accept dashboard with header hiding title/description', () => {
1272+
const result = DashboardSchema.parse({
1273+
name: 'minimal_header_dash',
1274+
label: 'Minimal',
1275+
header: {
1276+
showTitle: false,
1277+
showDescription: false,
1278+
},
1279+
widgets: [],
1280+
});
1281+
expect(result.header!.showTitle).toBe(false);
1282+
expect(result.header!.showDescription).toBe(false);
1283+
});
1284+
1285+
it('should work with Dashboard factory', () => {
1286+
const dashboard = Dashboard.create({
1287+
name: 'executive_dash',
1288+
label: 'Executive Dashboard',
1289+
description: 'Key business metrics',
1290+
header: {
1291+
actions: [
1292+
{ label: 'Export', actionUrl: '/export', actionType: 'url', icon: 'download' },
1293+
{ label: 'Refresh', actionUrl: 'refresh_flow', actionType: 'flow', icon: 'refresh' },
1294+
],
1295+
},
1296+
widgets: [
1297+
{ title: 'Revenue', type: 'metric', layout: { x: 0, y: 0, w: 3, h: 2 } },
1298+
],
1299+
});
1300+
expect(dashboard.header!.showTitle).toBe(true);
1301+
expect(dashboard.header!.actions).toHaveLength(2);
1302+
expect(dashboard.header!.actions![1].actionType).toBe('flow');
1303+
});
1304+
});
1305+
1306+
// ============================================================================
1307+
// Protocol Enhancement Tests: WidgetMeasureSchema / multi-measure pivot (#714)
1308+
// ============================================================================
1309+
1310+
describe('WidgetMeasureSchema', () => {
1311+
it('should accept minimal measure', () => {
1312+
const result = WidgetMeasureSchema.parse({
1313+
valueField: 'amount',
1314+
});
1315+
expect(result.valueField).toBe('amount');
1316+
expect(result.aggregate).toBe('count');
1317+
});
1318+
1319+
it('should accept measure with all fields', () => {
1320+
const result = WidgetMeasureSchema.parse({
1321+
valueField: 'amount',
1322+
aggregate: 'sum',
1323+
label: 'Total Amount',
1324+
format: '$0,0.00',
1325+
});
1326+
expect(result.aggregate).toBe('sum');
1327+
expect(result.label).toBe('Total Amount');
1328+
expect(result.format).toBe('$0,0.00');
1329+
});
1330+
1331+
it('should accept measure with i18n label', () => {
1332+
const result = WidgetMeasureSchema.parse({
1333+
valueField: 'quantity',
1334+
aggregate: 'avg',
1335+
label: { key: 'measures.avg_qty', defaultValue: 'Average Quantity' },
1336+
});
1337+
expect(result.label).toEqual({ key: 'measures.avg_qty', defaultValue: 'Average Quantity' });
1338+
});
1339+
1340+
it('should accept all aggregate functions', () => {
1341+
const aggregates = ['count', 'sum', 'avg', 'min', 'max'] as const;
1342+
aggregates.forEach(aggregate => {
1343+
expect(() => WidgetMeasureSchema.parse({ valueField: 'f', aggregate })).not.toThrow();
1344+
});
1345+
});
1346+
1347+
it('should reject measure without valueField', () => {
1348+
expect(() => WidgetMeasureSchema.parse({})).toThrow();
1349+
expect(() => WidgetMeasureSchema.parse({ aggregate: 'sum' })).toThrow();
1350+
});
1351+
});
1352+
1353+
describe('DashboardWidgetSchema - measures (multi-measure pivot)', () => {
1354+
it('should accept pivot widget with measures', () => {
1355+
const widget = DashboardWidgetSchema.parse({
1356+
title: 'Sales by Region and Product',
1357+
type: 'pivot',
1358+
object: 'opportunity',
1359+
categoryField: 'region',
1360+
measures: [
1361+
{ valueField: 'amount', aggregate: 'sum', label: 'Total Amount', format: '$0,0' },
1362+
{ valueField: 'amount', aggregate: 'avg', label: 'Avg Deal Size', format: '$0,0.00' },
1363+
{ valueField: 'amount', aggregate: 'count', label: 'Deal Count' },
1364+
],
1365+
layout: { x: 0, y: 0, w: 12, h: 6 },
1366+
});
1367+
expect(widget.measures).toHaveLength(3);
1368+
expect(widget.measures![0].aggregate).toBe('sum');
1369+
expect(widget.measures![1].aggregate).toBe('avg');
1370+
expect(widget.measures![2].aggregate).toBe('count');
1371+
});
1372+
1373+
it('should accept widget without measures (backward compat)', () => {
1374+
const result = DashboardWidgetSchema.parse({
1375+
type: 'bar',
1376+
object: 'opportunity',
1377+
valueField: 'amount',
1378+
aggregate: 'sum',
1379+
layout: { x: 0, y: 0, w: 6, h: 4 },
1380+
});
1381+
expect(result.measures).toBeUndefined();
1382+
});
1383+
1384+
it('should accept table widget with measures for multi-aggregate', () => {
1385+
const widget = DashboardWidgetSchema.parse({
1386+
title: 'Regional Summary',
1387+
type: 'table',
1388+
object: 'order',
1389+
categoryField: 'region',
1390+
measures: [
1391+
{ valueField: 'revenue', aggregate: 'sum', label: 'Revenue' },
1392+
{ valueField: 'quantity', aggregate: 'sum', label: 'Units Sold' },
1393+
{ valueField: 'revenue', aggregate: 'avg', label: 'Avg Order Value' },
1394+
],
1395+
layout: { x: 0, y: 0, w: 12, h: 6 },
1396+
});
1397+
expect(widget.measures).toHaveLength(3);
1398+
});
1399+
1400+
it('should work in full dashboard with pivot multi-measure', () => {
1401+
const dashboard = Dashboard.create({
1402+
name: 'analytics_dashboard',
1403+
label: 'Analytics Dashboard',
1404+
header: {
1405+
actions: [
1406+
{ label: 'Export CSV', actionUrl: '/export/csv', icon: 'download' },
1407+
],
1408+
},
1409+
widgets: [
1410+
{
1411+
title: 'Revenue',
1412+
type: 'metric',
1413+
object: 'order',
1414+
valueField: 'amount',
1415+
aggregate: 'sum',
1416+
layout: { x: 0, y: 0, w: 4, h: 2 },
1417+
},
1418+
{
1419+
title: 'Sales Pivot Analysis',
1420+
type: 'pivot',
1421+
object: 'opportunity',
1422+
categoryField: 'region',
1423+
measures: [
1424+
{ valueField: 'amount', aggregate: 'sum', label: 'Total Revenue', format: '$0,0' },
1425+
{ valueField: 'amount', aggregate: 'count', label: 'Deals' },
1426+
{ valueField: 'amount', aggregate: 'avg', label: 'Avg Deal', format: '$0,0.00' },
1427+
{ valueField: 'margin', aggregate: 'avg', label: 'Avg Margin', format: '0.0%' },
1428+
],
1429+
layout: { x: 0, y: 2, w: 12, h: 6 },
1430+
},
1431+
],
1432+
});
1433+
1434+
expect(dashboard.header!.actions).toHaveLength(1);
1435+
expect(dashboard.widgets[1].measures).toHaveLength(4);
1436+
expect(dashboard.widgets[1].measures![0].format).toBe('$0,0');
1437+
expect(dashboard.widgets[1].measures![3].valueField).toBe('margin');
1438+
});
1439+
});

packages/spec/src/ui/dashboard.zod.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,57 @@ export const WidgetActionTypeSchema = z.enum([
3030
'flow',
3131
]).describe('Widget action type');
3232

33+
/**
34+
* Dashboard Header Action Schema
35+
* An action button displayed in the dashboard header area.
36+
*/
37+
export const DashboardHeaderActionSchema = z.object({
38+
/** Action label */
39+
label: I18nLabelSchema.describe('Action button label'),
40+
41+
/** Action URL or target */
42+
actionUrl: z.string().describe('URL or target for the action'),
43+
44+
/** Action type */
45+
actionType: WidgetActionTypeSchema.optional().describe('Type of action'),
46+
47+
/** Icon identifier */
48+
icon: z.string().optional().describe('Icon identifier for the action button'),
49+
}).describe('Dashboard header action');
50+
51+
/**
52+
* Dashboard Header Schema
53+
* Structured header configuration for the dashboard.
54+
*/
55+
export const DashboardHeaderSchema = z.object({
56+
/** Whether to show the dashboard title in the header */
57+
showTitle: z.boolean().default(true).describe('Show dashboard title in header'),
58+
59+
/** Whether to show the dashboard description in the header */
60+
showDescription: z.boolean().default(true).describe('Show dashboard description in header'),
61+
62+
/** Action buttons displayed in the header */
63+
actions: z.array(DashboardHeaderActionSchema).optional().describe('Header action buttons'),
64+
}).describe('Dashboard header configuration');
65+
66+
/**
67+
* Widget Measure Schema
68+
* A single measure definition for multi-measure pivot/matrix widgets.
69+
*/
70+
export const WidgetMeasureSchema = z.object({
71+
/** Value field to aggregate */
72+
valueField: z.string().describe('Field to aggregate'),
73+
74+
/** Aggregate function */
75+
aggregate: z.enum(['count', 'sum', 'avg', 'min', 'max']).default('count').describe('Aggregate function'),
76+
77+
/** Display label for the measure */
78+
label: I18nLabelSchema.optional().describe('Measure display label'),
79+
80+
/** Number format string (e.g., "$0,0.00", "0.0%") */
81+
format: z.string().optional().describe('Number format string'),
82+
}).describe('Widget measure definition');
83+
3384
/**
3485
* Dashboard Widget Schema
3586
* A single component on the dashboard grid.
@@ -74,6 +125,9 @@ export const DashboardWidgetSchema = z.object({
74125
/** Aggregate operation */
75126
aggregate: z.enum(['count', 'sum', 'avg', 'min', 'max']).optional().default('count').describe('Aggregate function'),
76127

128+
/** Multi-measure definitions for pivot/matrix widgets */
129+
measures: z.array(WidgetMeasureSchema).optional().describe('Multiple measures for pivot/matrix analysis'),
130+
77131
/**
78132
* Layout Position (React-Grid-Layout style)
79133
* x: column (0-11)
@@ -187,6 +241,9 @@ export const DashboardSchema = z.object({
187241

188242
/** Description */
189243
description: I18nLabelSchema.optional().describe('Dashboard description'),
244+
245+
/** Structured header configuration */
246+
header: DashboardHeaderSchema.optional().describe('Dashboard header configuration'),
190247

191248
/** Collection of widgets */
192249
widgets: z.array(DashboardWidgetSchema).describe('Widgets to display'),
@@ -214,6 +271,9 @@ export const DashboardSchema = z.object({
214271
export type Dashboard = z.infer<typeof DashboardSchema>;
215272
export type DashboardInput = z.input<typeof DashboardSchema>;
216273
export type DashboardWidget = z.infer<typeof DashboardWidgetSchema>;
274+
export type DashboardHeader = z.infer<typeof DashboardHeaderSchema>;
275+
export type DashboardHeaderAction = z.infer<typeof DashboardHeaderActionSchema>;
276+
export type WidgetMeasure = z.infer<typeof WidgetMeasureSchema>;
217277
export type WidgetColorVariant = z.infer<typeof WidgetColorVariantSchema>;
218278
export type WidgetActionType = z.infer<typeof WidgetActionTypeSchema>;
219279
export type GlobalFilter = z.infer<typeof GlobalFilterSchema>;

0 commit comments

Comments
 (0)