From e26c9289e035425da4e56895c5ca7c776bdc74c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 07:08:41 +0000 Subject: [PATCH 1/2] Initial plan From 355e592ea2fed45147fb2cfca21c0f1f495d2727 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 07:12:57 +0000 Subject: [PATCH 2/2] feat(dashboard): add header config and multi-measure pivot support (#714) Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 4 +- packages/spec/src/ui/dashboard.test.ts | 275 +++++++++++++++++++++++++ packages/spec/src/ui/dashboard.zod.ts | 60 ++++++ 3 files changed, 337 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c4d56b4b25..b4ad5d2c94 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -403,8 +403,8 @@ Protocol enhancements and core component implementations for dashboard feature p **Spec Protocol Changes:** - [x] Add `colorVariant`, `actionUrl`, `description`, `actionType`, `actionIcon` to `DashboardWidgetSchema` ([#713](https://github.com/objectstack-ai/spec/issues/713)) - [x] Enhance `globalFilters` with `options`, `optionsFrom`, `defaultValue`, `scope`, `targetWidgets` ([#712](https://github.com/objectstack-ai/spec/issues/712)) -- [ ] Add `header` configuration to `DashboardSchema` with `showTitle`, `showDescription`, `actions` ([#714](https://github.com/objectstack-ai/spec/issues/714)) -- [ ] Add `pivotConfig` and `measures` array to `DashboardWidgetSchema` for multi-measure pivots ([#714](https://github.com/objectstack-ai/spec/issues/714)) +- [x] Add `header` configuration to `DashboardSchema` with `showTitle`, `showDescription`, `actions` ([#714](https://github.com/objectstack-ai/spec/issues/714)) +- [x] Add `pivotConfig` and `measures` array to `DashboardWidgetSchema` for multi-measure pivots ([#714](https://github.com/objectstack-ai/spec/issues/714)) **ObjectUI Component Implementations:** - [ ] Implement `DashboardFilterBar` component for global filters ([objectui#588](https://github.com/objectstack-ai/objectui/issues/588)) diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index 4b705cbf0c..52c50c1de5 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -2,6 +2,9 @@ import { describe, it, expect } from 'vitest'; import { DashboardSchema, DashboardWidgetSchema, + DashboardHeaderSchema, + DashboardHeaderActionSchema, + WidgetMeasureSchema, Dashboard, WidgetColorVariantSchema, WidgetActionTypeSchema, @@ -9,6 +12,9 @@ import { GlobalFilterOptionsFromSchema, type Dashboard as DashboardType, type DashboardWidget, + type DashboardHeader, + type DashboardHeaderAction, + type WidgetMeasure, type GlobalFilter, type GlobalFilterOptionsFrom, } from './dashboard.zod'; @@ -1162,3 +1168,272 @@ describe('DashboardSchema - enhanced globalFilters', () => { expect(dashboard.globalFilters![2].targetWidgets).toEqual(['revenue_chart']); }); }); + +// ============================================================================ +// Protocol Enhancement Tests: DashboardHeaderSchema (#714) +// ============================================================================ + +describe('DashboardHeaderActionSchema', () => { + it('should accept valid header action', () => { + const result = DashboardHeaderActionSchema.parse({ + label: 'Export PDF', + actionUrl: '/export/pdf', + }); + expect(result.label).toBe('Export PDF'); + expect(result.actionUrl).toBe('/export/pdf'); + }); + + it('should accept action with all fields', () => { + const result = DashboardHeaderActionSchema.parse({ + label: 'Run Report', + actionUrl: 'generate_report_flow', + actionType: 'flow', + icon: 'play', + }); + expect(result.actionType).toBe('flow'); + expect(result.icon).toBe('play'); + }); + + it('should accept i18n label', () => { + const result = DashboardHeaderActionSchema.parse({ + label: { key: 'actions.export', defaultValue: 'Export' }, + actionUrl: '/export', + }); + expect(result.label).toEqual({ key: 'actions.export', defaultValue: 'Export' }); + }); + + it('should reject action without required fields', () => { + expect(() => DashboardHeaderActionSchema.parse({ label: 'Test' })).toThrow(); + expect(() => DashboardHeaderActionSchema.parse({ actionUrl: '/test' })).toThrow(); + expect(() => DashboardHeaderActionSchema.parse({})).toThrow(); + }); +}); + +describe('DashboardHeaderSchema', () => { + it('should accept empty header with defaults', () => { + const result = DashboardHeaderSchema.parse({}); + expect(result.showTitle).toBe(true); + expect(result.showDescription).toBe(true); + expect(result.actions).toBeUndefined(); + }); + + it('should accept header with showTitle/showDescription overrides', () => { + const result = DashboardHeaderSchema.parse({ + showTitle: false, + showDescription: false, + }); + expect(result.showTitle).toBe(false); + expect(result.showDescription).toBe(false); + }); + + it('should accept header with actions', () => { + const result = DashboardHeaderSchema.parse({ + actions: [ + { label: 'Export', actionUrl: '/export/pdf', icon: 'download' }, + { label: 'Share', actionUrl: 'share_modal', actionType: 'modal', icon: 'share' }, + ], + }); + expect(result.actions).toHaveLength(2); + expect(result.actions![0].label).toBe('Export'); + expect(result.actions![1].actionType).toBe('modal'); + }); +}); + +describe('DashboardSchema - header', () => { + it('should accept dashboard with header configuration', () => { + const result = DashboardSchema.parse({ + name: 'sales_dashboard', + label: 'Sales Dashboard', + description: 'Q4 sales performance', + header: { + showTitle: true, + showDescription: true, + actions: [ + { label: 'Export PDF', actionUrl: '/export/pdf', icon: 'download' }, + ], + }, + widgets: [], + }); + expect(result.header).toBeDefined(); + expect(result.header!.showTitle).toBe(true); + expect(result.header!.actions).toHaveLength(1); + }); + + it('should accept dashboard without header (backward compat)', () => { + const result = DashboardSchema.parse({ + name: 'simple_dash', + label: 'Simple', + widgets: [], + }); + expect(result.header).toBeUndefined(); + }); + + it('should accept dashboard with header hiding title/description', () => { + const result = DashboardSchema.parse({ + name: 'minimal_header_dash', + label: 'Minimal', + header: { + showTitle: false, + showDescription: false, + }, + widgets: [], + }); + expect(result.header!.showTitle).toBe(false); + expect(result.header!.showDescription).toBe(false); + }); + + it('should work with Dashboard factory', () => { + const dashboard = Dashboard.create({ + name: 'executive_dash', + label: 'Executive Dashboard', + description: 'Key business metrics', + header: { + actions: [ + { label: 'Export', actionUrl: '/export', actionType: 'url', icon: 'download' }, + { label: 'Refresh', actionUrl: 'refresh_flow', actionType: 'flow', icon: 'refresh' }, + ], + }, + widgets: [ + { title: 'Revenue', type: 'metric', layout: { x: 0, y: 0, w: 3, h: 2 } }, + ], + }); + expect(dashboard.header!.showTitle).toBe(true); + expect(dashboard.header!.actions).toHaveLength(2); + expect(dashboard.header!.actions![1].actionType).toBe('flow'); + }); +}); + +// ============================================================================ +// Protocol Enhancement Tests: WidgetMeasureSchema / multi-measure pivot (#714) +// ============================================================================ + +describe('WidgetMeasureSchema', () => { + it('should accept minimal measure', () => { + const result = WidgetMeasureSchema.parse({ + valueField: 'amount', + }); + expect(result.valueField).toBe('amount'); + expect(result.aggregate).toBe('count'); + }); + + it('should accept measure with all fields', () => { + const result = WidgetMeasureSchema.parse({ + valueField: 'amount', + aggregate: 'sum', + label: 'Total Amount', + format: '$0,0.00', + }); + expect(result.aggregate).toBe('sum'); + expect(result.label).toBe('Total Amount'); + expect(result.format).toBe('$0,0.00'); + }); + + it('should accept measure with i18n label', () => { + const result = WidgetMeasureSchema.parse({ + valueField: 'quantity', + aggregate: 'avg', + label: { key: 'measures.avg_qty', defaultValue: 'Average Quantity' }, + }); + expect(result.label).toEqual({ key: 'measures.avg_qty', defaultValue: 'Average Quantity' }); + }); + + it('should accept all aggregate functions', () => { + const aggregates = ['count', 'sum', 'avg', 'min', 'max'] as const; + aggregates.forEach(aggregate => { + expect(() => WidgetMeasureSchema.parse({ valueField: 'f', aggregate })).not.toThrow(); + }); + }); + + it('should reject measure without valueField', () => { + expect(() => WidgetMeasureSchema.parse({})).toThrow(); + expect(() => WidgetMeasureSchema.parse({ aggregate: 'sum' })).toThrow(); + }); +}); + +describe('DashboardWidgetSchema - measures (multi-measure pivot)', () => { + it('should accept pivot widget with measures', () => { + const widget = DashboardWidgetSchema.parse({ + title: 'Sales by Region and Product', + type: 'pivot', + object: 'opportunity', + categoryField: 'region', + measures: [ + { valueField: 'amount', aggregate: 'sum', label: 'Total Amount', format: '$0,0' }, + { valueField: 'amount', aggregate: 'avg', label: 'Avg Deal Size', format: '$0,0.00' }, + { valueField: 'amount', aggregate: 'count', label: 'Deal Count' }, + ], + layout: { x: 0, y: 0, w: 12, h: 6 }, + }); + expect(widget.measures).toHaveLength(3); + expect(widget.measures![0].aggregate).toBe('sum'); + expect(widget.measures![1].aggregate).toBe('avg'); + expect(widget.measures![2].aggregate).toBe('count'); + }); + + it('should accept widget without measures (backward compat)', () => { + const result = DashboardWidgetSchema.parse({ + type: 'bar', + object: 'opportunity', + valueField: 'amount', + aggregate: 'sum', + layout: { x: 0, y: 0, w: 6, h: 4 }, + }); + expect(result.measures).toBeUndefined(); + }); + + it('should accept table widget with measures for multi-aggregate', () => { + const widget = DashboardWidgetSchema.parse({ + title: 'Regional Summary', + type: 'table', + object: 'order', + categoryField: 'region', + measures: [ + { valueField: 'revenue', aggregate: 'sum', label: 'Revenue' }, + { valueField: 'quantity', aggregate: 'sum', label: 'Units Sold' }, + { valueField: 'revenue', aggregate: 'avg', label: 'Avg Order Value' }, + ], + layout: { x: 0, y: 0, w: 12, h: 6 }, + }); + expect(widget.measures).toHaveLength(3); + }); + + it('should work in full dashboard with pivot multi-measure', () => { + const dashboard = Dashboard.create({ + name: 'analytics_dashboard', + label: 'Analytics Dashboard', + header: { + actions: [ + { label: 'Export CSV', actionUrl: '/export/csv', icon: 'download' }, + ], + }, + widgets: [ + { + title: 'Revenue', + type: 'metric', + object: 'order', + valueField: 'amount', + aggregate: 'sum', + layout: { x: 0, y: 0, w: 4, h: 2 }, + }, + { + title: 'Sales Pivot Analysis', + type: 'pivot', + object: 'opportunity', + categoryField: 'region', + measures: [ + { valueField: 'amount', aggregate: 'sum', label: 'Total Revenue', format: '$0,0' }, + { valueField: 'amount', aggregate: 'count', label: 'Deals' }, + { valueField: 'amount', aggregate: 'avg', label: 'Avg Deal', format: '$0,0.00' }, + { valueField: 'margin', aggregate: 'avg', label: 'Avg Margin', format: '0.0%' }, + ], + layout: { x: 0, y: 2, w: 12, h: 6 }, + }, + ], + }); + + expect(dashboard.header!.actions).toHaveLength(1); + expect(dashboard.widgets[1].measures).toHaveLength(4); + expect(dashboard.widgets[1].measures![0].format).toBe('$0,0'); + expect(dashboard.widgets[1].measures![3].valueField).toBe('margin'); + }); +}); diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index f33c850bd5..a6d6462a6c 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -30,6 +30,57 @@ export const WidgetActionTypeSchema = z.enum([ 'flow', ]).describe('Widget action type'); +/** + * Dashboard Header Action Schema + * An action button displayed in the dashboard header area. + */ +export const DashboardHeaderActionSchema = z.object({ + /** Action label */ + label: I18nLabelSchema.describe('Action button label'), + + /** Action URL or target */ + actionUrl: z.string().describe('URL or target for the action'), + + /** Action type */ + actionType: WidgetActionTypeSchema.optional().describe('Type of action'), + + /** Icon identifier */ + icon: z.string().optional().describe('Icon identifier for the action button'), +}).describe('Dashboard header action'); + +/** + * Dashboard Header Schema + * Structured header configuration for the dashboard. + */ +export const DashboardHeaderSchema = z.object({ + /** Whether to show the dashboard title in the header */ + showTitle: z.boolean().default(true).describe('Show dashboard title in header'), + + /** Whether to show the dashboard description in the header */ + showDescription: z.boolean().default(true).describe('Show dashboard description in header'), + + /** Action buttons displayed in the header */ + actions: z.array(DashboardHeaderActionSchema).optional().describe('Header action buttons'), +}).describe('Dashboard header configuration'); + +/** + * Widget Measure Schema + * A single measure definition for multi-measure pivot/matrix widgets. + */ +export const WidgetMeasureSchema = z.object({ + /** Value field to aggregate */ + valueField: z.string().describe('Field to aggregate'), + + /** Aggregate function */ + aggregate: z.enum(['count', 'sum', 'avg', 'min', 'max']).default('count').describe('Aggregate function'), + + /** Display label for the measure */ + label: I18nLabelSchema.optional().describe('Measure display label'), + + /** Number format string (e.g., "$0,0.00", "0.0%") */ + format: z.string().optional().describe('Number format string'), +}).describe('Widget measure definition'); + /** * Dashboard Widget Schema * A single component on the dashboard grid. @@ -74,6 +125,9 @@ export const DashboardWidgetSchema = z.object({ /** Aggregate operation */ aggregate: z.enum(['count', 'sum', 'avg', 'min', 'max']).optional().default('count').describe('Aggregate function'), + /** Multi-measure definitions for pivot/matrix widgets */ + measures: z.array(WidgetMeasureSchema).optional().describe('Multiple measures for pivot/matrix analysis'), + /** * Layout Position (React-Grid-Layout style) * x: column (0-11) @@ -187,6 +241,9 @@ export const DashboardSchema = z.object({ /** Description */ description: I18nLabelSchema.optional().describe('Dashboard description'), + + /** Structured header configuration */ + header: DashboardHeaderSchema.optional().describe('Dashboard header configuration'), /** Collection of widgets */ widgets: z.array(DashboardWidgetSchema).describe('Widgets to display'), @@ -214,6 +271,9 @@ export const DashboardSchema = z.object({ export type Dashboard = z.infer; export type DashboardInput = z.input; export type DashboardWidget = z.infer; +export type DashboardHeader = z.infer; +export type DashboardHeaderAction = z.infer; +export type WidgetMeasure = z.infer; export type WidgetColorVariant = z.infer; export type WidgetActionType = z.infer; export type GlobalFilter = z.infer;