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 ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ The following renames are planned for packages that implement core service contr
Protocol enhancements and core component implementations for dashboard feature parity.

**Spec Protocol Changes:**
- [ ] Add `colorVariant`, `actionUrl`, `description`, `actionType`, `actionIcon` to `DashboardWidgetSchema` ([#713](https://github.com/objectstack-ai/spec/issues/713))
- [x] Add `colorVariant`, `actionUrl`, `description`, `actionType`, `actionIcon` to `DashboardWidgetSchema` ([#713](https://github.com/objectstack-ai/spec/issues/713))
- [ ] 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))
Expand Down
242 changes: 242 additions & 0 deletions packages/spec/src/ui/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
DashboardSchema,
DashboardWidgetSchema,
Dashboard,
WidgetColorVariantSchema,
WidgetActionTypeSchema,
type Dashboard as DashboardType,
type DashboardWidget,
} from './dashboard.zod';
Expand Down Expand Up @@ -610,3 +612,243 @@ describe('DashboardSchema - dateRange', () => {
expect(result.dateRange).toBeUndefined();
});
});

// ============================================================================
// Protocol Enhancement Tests: colorVariant, description, actionUrl/actionType
// ============================================================================

describe('WidgetColorVariantSchema', () => {
it('should accept all color variants', () => {
const variants = ['default', 'blue', 'teal', 'orange', 'purple', 'success', 'warning', 'danger'];
variants.forEach(variant => {
expect(() => WidgetColorVariantSchema.parse(variant)).not.toThrow();
});
});

it('should reject invalid color variants', () => {
expect(() => WidgetColorVariantSchema.parse('red')).toThrow();
expect(() => WidgetColorVariantSchema.parse('unknown')).toThrow();
});
});

describe('WidgetActionTypeSchema', () => {
it('should accept all action types', () => {
const types = ['url', 'modal', 'flow'];
types.forEach(type => {
expect(() => WidgetActionTypeSchema.parse(type)).not.toThrow();
});
});

it('should reject invalid action types', () => {
expect(() => WidgetActionTypeSchema.parse('script')).toThrow();
expect(() => WidgetActionTypeSchema.parse('invalid')).toThrow();
});
});

describe('DashboardWidgetSchema - colorVariant', () => {
it('should accept widget with colorVariant', () => {
const widget: DashboardWidget = {
title: 'Total Revenue',
type: 'metric',
colorVariant: 'teal',
layout: { x: 0, y: 0, w: 3, h: 2 },
};
const result = DashboardWidgetSchema.parse(widget);
expect(result.colorVariant).toBe('teal');
});

it('should accept widget without colorVariant (optional)', () => {
const result = DashboardWidgetSchema.parse({
type: 'metric',
layout: { x: 0, y: 0, w: 3, h: 2 },
});
expect(result.colorVariant).toBeUndefined();
});

it('should reject invalid colorVariant', () => {
expect(() => DashboardWidgetSchema.parse({
type: 'metric',
colorVariant: 'neon',
layout: { x: 0, y: 0, w: 3, h: 2 },
})).toThrow();
});
});

describe('DashboardWidgetSchema - description', () => {
it('should accept widget with string description', () => {
const result = DashboardWidgetSchema.parse({
title: 'Revenue',
description: 'Year-to-date total revenue',
type: 'metric',
layout: { x: 0, y: 0, w: 3, h: 2 },
});
expect(result.description).toBe('Year-to-date total revenue');
});

it('should accept widget with i18n description', () => {
const result = DashboardWidgetSchema.parse({
title: 'Revenue',
description: { key: 'widgets.revenue.desc', defaultValue: 'Total revenue' },
type: 'metric',
layout: { x: 0, y: 0, w: 3, h: 2 },
});
expect(result.description).toEqual({ key: 'widgets.revenue.desc', defaultValue: 'Total revenue' });
});

it('should accept widget without description (optional)', () => {
const result = DashboardWidgetSchema.parse({
type: 'metric',
layout: { x: 0, y: 0, w: 3, h: 2 },
});
expect(result.description).toBeUndefined();
});
});

describe('DashboardWidgetSchema - actionUrl/actionType/actionIcon', () => {
it('should accept widget with actionUrl and actionType', () => {
const result = DashboardWidgetSchema.parse({
title: 'Open Tickets',
type: 'metric',
actionUrl: 'https://example.com/tickets',
actionType: 'url',
layout: { x: 0, y: 0, w: 3, h: 2 },
});
expect(result.actionUrl).toBe('https://example.com/tickets');
expect(result.actionType).toBe('url');
});

it('should accept widget with actionIcon', () => {
const result = DashboardWidgetSchema.parse({
title: 'Details',
type: 'metric',
actionUrl: '/details',
actionType: 'url',
actionIcon: 'external-link',
layout: { x: 0, y: 0, w: 3, h: 2 },
});
expect(result.actionIcon).toBe('external-link');
});

it('should accept widget with modal action type', () => {
const result = DashboardWidgetSchema.parse({
title: 'Breakdown',
type: 'metric',
actionUrl: 'revenue_breakdown',
actionType: 'modal',
layout: { x: 0, y: 0, w: 3, h: 2 },
});
expect(result.actionType).toBe('modal');
});

it('should accept widget with flow action type', () => {
const result = DashboardWidgetSchema.parse({
title: 'Refresh Data',
type: 'metric',
actionUrl: 'refresh_pipeline_flow',
actionType: 'flow',
layout: { x: 0, y: 0, w: 3, h: 2 },
});
expect(result.actionType).toBe('flow');
});

it('should accept widget without action fields (optional)', () => {
const result = DashboardWidgetSchema.parse({
type: 'metric',
layout: { x: 0, y: 0, w: 3, h: 2 },
});
expect(result.actionUrl).toBeUndefined();
expect(result.actionType).toBeUndefined();
expect(result.actionIcon).toBeUndefined();
});

it('should reject invalid actionType', () => {
expect(() => DashboardWidgetSchema.parse({
type: 'metric',
actionType: 'invalid',
layout: { x: 0, y: 0, w: 3, h: 2 },
})).toThrow();
});
});

describe('DashboardWidgetSchema - combined new fields', () => {
it('should accept KPI widget with all new fields', () => {
const widget: DashboardWidget = {
title: 'Revenue',
description: 'Q4 total revenue across all regions',
type: 'metric',
colorVariant: 'success',
actionUrl: 'https://reports.example.com/revenue',
actionType: 'url',
actionIcon: 'external-link',
object: 'opportunity',
valueField: 'amount',
aggregate: 'sum',
layout: { x: 0, y: 0, w: 3, h: 2 },
};

const result = DashboardWidgetSchema.parse(widget);
expect(result.description).toBe('Q4 total revenue across all regions');
expect(result.colorVariant).toBe('success');
expect(result.actionUrl).toBe('https://reports.example.com/revenue');
expect(result.actionType).toBe('url');
expect(result.actionIcon).toBe('external-link');
});

it('should work in a full dashboard with color-coded KPI cards', () => {
const dashboard = Dashboard.create({
name: 'kpi_dashboard',
label: 'KPI Dashboard',
widgets: [
{
title: 'Revenue',
description: 'Total quarterly revenue',
type: 'metric',
colorVariant: 'success',
object: 'opportunity',
valueField: 'amount',
aggregate: 'sum',
layout: { x: 0, y: 0, w: 3, h: 2 },
},
{
title: 'Open Issues',
description: 'Unresolved support tickets',
type: 'metric',
colorVariant: 'warning',
actionUrl: '/issues',
actionType: 'url',
object: 'case',
aggregate: 'count',
layout: { x: 3, y: 0, w: 3, h: 2 },
},
{
title: 'Critical Bugs',
description: 'P0/P1 bugs requiring attention',
type: 'metric',
colorVariant: 'danger',
actionUrl: 'bug_triage_flow',
actionType: 'flow',
actionIcon: 'alert-triangle',
object: 'bug',
aggregate: 'count',
layout: { x: 6, y: 0, w: 3, h: 2 },
},
{
title: 'Team Velocity',
type: 'bar',
colorVariant: 'blue',
object: 'sprint',
categoryField: 'sprint_name',
valueField: 'story_points',
aggregate: 'sum',
layout: { x: 0, y: 2, w: 12, h: 4 },
},
],
});

expect(dashboard.widgets).toHaveLength(4);
expect(dashboard.widgets[0].colorVariant).toBe('success');
expect(dashboard.widgets[1].actionUrl).toBe('/issues');
expect(dashboard.widgets[2].description).toBe('P0/P1 bugs requiring attention');
expect(dashboard.widgets[3].colorVariant).toBe('blue');
});
});
40 changes: 40 additions & 0 deletions packages/spec/src/ui/dashboard.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,57 @@ import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod';
import { ResponsiveConfigSchema, PerformanceConfigSchema } from './responsive.zod';

/**
* Color variant for dashboard widgets (e.g., KPI cards).
*/
export const WidgetColorVariantSchema = z.enum([
'default',
'blue',
'teal',
'orange',
'purple',
'success',
'warning',
'danger',
]).describe('Widget color variant');

/**
* Action type for widget action buttons.
*/
export const WidgetActionTypeSchema = z.enum([
'url',
'modal',
'flow',
]).describe('Widget action type');

/**
* Dashboard Widget Schema
* A single component on the dashboard grid.
*/
export const DashboardWidgetSchema = z.object({
/** Widget Title */
title: I18nLabelSchema.optional().describe('Widget title'),

/** Widget Description (displayed below the title) */
description: I18nLabelSchema.optional().describe('Widget description text below the header'),

/** Visualization Type */
type: ChartTypeSchema.default('metric').describe('Visualization type'),

/** Chart Configuration */
chartConfig: ChartConfigSchema.optional().describe('Chart visualization configuration'),

/** Color variant for the widget (e.g., KPI card accent color) */
colorVariant: WidgetColorVariantSchema.optional().describe('Widget color variant for theming'),

/** Action URL for the widget header action button */
actionUrl: z.string().optional().describe('URL or target for the widget action button'),
Comment on lines +53 to +54

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actionUrl is used as a generic action target (URL path, modal id, or flow name) but the current description still reads like it is always a URL. Consider updating the field description to explicitly document the expected format for each actionType (e.g., url => URL/path, modal => modalId, flow => flowName) to avoid client ambiguity.

Suggested change
/** Action URL for the widget header action button */
actionUrl: z.string().optional().describe('URL or target for the widget action button'),
/**
* Action target for the widget header action button.
* When actionType = "url", this should be a URL or relative path.
* When actionType = "modal", this should be a modalId.
* When actionType = "flow", this should be a flowName (machine name of the flow).
*/
actionUrl: z
.string()
.optional()
.describe('Action target: url → URL/path, modal → modalId, flow → flowName'),

Copilot uses AI. Check for mistakes.

/** Action type for the widget header action button */
actionType: WidgetActionTypeSchema.optional().describe('Type of action for the widget action button'),

Comment on lines +53 to +58

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actionUrl and actionType are independent optionals right now, so the schema will accept partial configs (e.g., actionType without actionUrl, or actionUrl without actionType). If the action button requires both, consider adding a cross-field validation (e.g., via .superRefine) or defaulting actionType to 'url' when actionUrl is provided.

Copilot uses AI. Check for mistakes.
/** Icon for the widget header action button */
actionIcon: z.string().optional().describe('Icon identifier for the widget action button'),

/** Data Source Object */
object: z.string().optional().describe('Data source object name'),
Expand Down Expand Up @@ -129,6 +167,8 @@ export const DashboardSchema = z.object({
export type Dashboard = z.infer<typeof DashboardSchema>;
export type DashboardInput = z.input<typeof DashboardSchema>;
export type DashboardWidget = z.infer<typeof DashboardWidgetSchema>;
export type WidgetColorVariant = z.infer<typeof WidgetColorVariantSchema>;
export type WidgetActionType = z.infer<typeof WidgetActionTypeSchema>;

/**
* Dashboard Factory Helper
Expand Down