diff --git a/ROADMAP.md b/ROADMAP.md index 4f357c6a03..c4d56b4b25 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -402,7 +402,7 @@ 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)) -- [ ] Enhance `globalFilters` with `options`, `optionsFrom`, `defaultValue`, `scope`, `targetWidgets` ([#712](https://github.com/objectstack-ai/spec/issues/712)) +- [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)) diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index 4f04edc1ed..4b705cbf0c 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -5,8 +5,12 @@ import { Dashboard, WidgetColorVariantSchema, WidgetActionTypeSchema, + GlobalFilterSchema, + GlobalFilterOptionsFromSchema, type Dashboard as DashboardType, type DashboardWidget, + type GlobalFilter, + type GlobalFilterOptionsFrom, } from './dashboard.zod'; import { ChartTypeSchema } from './chart.zod'; @@ -852,3 +856,309 @@ describe('DashboardWidgetSchema - combined new fields', () => { expect(dashboard.widgets[3].colorVariant).toBe('blue'); }); }); + +// ============================================================================ +// Protocol Enhancement Tests: GlobalFilterSchema — options, optionsFrom, +// defaultValue, scope, targetWidgets (#712) +// ============================================================================ + +describe('GlobalFilterOptionsFromSchema', () => { + it('should accept valid optionsFrom config', () => { + const result = GlobalFilterOptionsFromSchema.parse({ + object: 'account', + valueField: 'id', + labelField: 'name', + }); + expect(result.object).toBe('account'); + expect(result.valueField).toBe('id'); + expect(result.labelField).toBe('name'); + }); + + it('should accept optionsFrom with filter', () => { + const result = GlobalFilterOptionsFromSchema.parse({ + object: 'account', + valueField: 'id', + labelField: 'name', + filter: { is_active: true }, + }); + expect(result.filter).toEqual({ is_active: true }); + }); + + it('should reject optionsFrom without required fields', () => { + expect(() => GlobalFilterOptionsFromSchema.parse({ object: 'account' })).toThrow(); + expect(() => GlobalFilterOptionsFromSchema.parse({ valueField: 'id' })).toThrow(); + expect(() => GlobalFilterOptionsFromSchema.parse({})).toThrow(); + }); +}); + +describe('GlobalFilterSchema', () => { + it('should accept minimal filter (backward compat)', () => { + const result = GlobalFilterSchema.parse({ + field: 'status', + }); + expect(result.field).toBe('status'); + expect(result.scope).toBe('dashboard'); + }); + + it('should accept old-style filter with label and type', () => { + const result = GlobalFilterSchema.parse({ + field: 'status', + label: 'Status', + type: 'select', + }); + expect(result.field).toBe('status'); + expect(result.label).toBe('Status'); + expect(result.type).toBe('select'); + }); + + it('should accept all filter types including lookup', () => { + const types = ['text', 'select', 'date', 'number', 'lookup'] as const; + types.forEach(type => { + expect(() => GlobalFilterSchema.parse({ field: 'f', type })).not.toThrow(); + }); + }); + + it('should reject invalid filter type', () => { + expect(() => GlobalFilterSchema.parse({ field: 'f', type: 'checkbox' })).toThrow(); + }); + + it('should accept filter with static options', () => { + const result = GlobalFilterSchema.parse({ + field: 'priority', + type: 'select', + options: [ + { value: 'high', label: 'High' }, + { value: 'medium', label: 'Medium' }, + { value: 'low', label: 'Low' }, + ], + }); + expect(result.options).toHaveLength(3); + expect(result.options![0].value).toBe('high'); + expect(result.options![0].label).toBe('High'); + }); + + it('should accept filter with i18n option labels', () => { + const result = GlobalFilterSchema.parse({ + field: 'priority', + type: 'select', + options: [ + { value: 'high', label: { key: 'filter.priority.high', defaultValue: 'High' } }, + ], + }); + expect(result.options![0].label).toEqual({ key: 'filter.priority.high', defaultValue: 'High' }); + }); + + it('should accept filter with optionsFrom (dynamic binding)', () => { + const result = GlobalFilterSchema.parse({ + field: 'account_id', + type: 'lookup', + optionsFrom: { + object: 'account', + valueField: 'id', + labelField: 'name', + }, + }); + expect(result.optionsFrom).toBeDefined(); + expect(result.optionsFrom!.object).toBe('account'); + expect(result.optionsFrom!.valueField).toBe('id'); + expect(result.optionsFrom!.labelField).toBe('name'); + }); + + it('should accept filter with optionsFrom and filter', () => { + const result = GlobalFilterSchema.parse({ + field: 'owner_id', + type: 'lookup', + optionsFrom: { + object: 'user', + valueField: 'id', + labelField: 'full_name', + filter: { is_active: true }, + }, + }); + expect(result.optionsFrom!.filter).toEqual({ is_active: true }); + }); + + it('should accept filter with defaultValue', () => { + const result = GlobalFilterSchema.parse({ + field: 'status', + type: 'select', + defaultValue: 'open', + }); + expect(result.defaultValue).toBe('open'); + }); + + it('should default scope to dashboard', () => { + const result = GlobalFilterSchema.parse({ field: 'status' }); + expect(result.scope).toBe('dashboard'); + }); + + it('should accept scope widget', () => { + const result = GlobalFilterSchema.parse({ + field: 'status', + scope: 'widget', + }); + expect(result.scope).toBe('widget'); + }); + + it('should reject invalid scope', () => { + expect(() => GlobalFilterSchema.parse({ field: 'f', scope: 'global' })).toThrow(); + }); + + it('should accept targetWidgets', () => { + const result = GlobalFilterSchema.parse({ + field: 'region', + scope: 'widget', + targetWidgets: ['revenue_chart', 'pipeline_table'], + }); + expect(result.targetWidgets).toEqual(['revenue_chart', 'pipeline_table']); + }); + + it('should accept filter without targetWidgets (optional)', () => { + const result = GlobalFilterSchema.parse({ field: 'status' }); + expect(result.targetWidgets).toBeUndefined(); + }); +}); + +describe('DashboardSchema - enhanced globalFilters', () => { + it('should still accept old-style globalFilters (backward compat)', () => { + const result = DashboardSchema.parse({ + name: 'compat_dash', + label: 'Compat Dashboard', + widgets: [], + globalFilters: [ + { field: 'status', label: 'Status', type: 'select' }, + { field: 'created_at', type: 'date' }, + ], + }); + expect(result.globalFilters).toHaveLength(2); + expect(result.globalFilters![0].scope).toBe('dashboard'); + }); + + it('should accept globalFilters with optionsFrom', () => { + const result = DashboardSchema.parse({ + name: 'dynamic_filters_dash', + label: 'Dynamic Filters', + widgets: [], + globalFilters: [ + { + field: 'account_id', + label: 'Account', + type: 'lookup', + optionsFrom: { + object: 'account', + valueField: 'id', + labelField: 'name', + }, + }, + ], + }); + expect(result.globalFilters![0].optionsFrom!.object).toBe('account'); + }); + + it('should accept globalFilters with static options', () => { + const result = DashboardSchema.parse({ + name: 'static_options_dash', + label: 'Static Options', + widgets: [], + globalFilters: [ + { + field: 'priority', + label: 'Priority', + type: 'select', + options: [ + { value: 'high', label: 'High' }, + { value: 'medium', label: 'Medium' }, + { value: 'low', label: 'Low' }, + ], + defaultValue: 'medium', + }, + ], + }); + expect(result.globalFilters![0].options).toHaveLength(3); + expect(result.globalFilters![0].defaultValue).toBe('medium'); + }); + + it('should accept globalFilters with targetWidgets', () => { + const result = DashboardSchema.parse({ + name: 'targeted_filter_dash', + label: 'Targeted Filters', + widgets: [ + { title: 'Chart A', type: 'bar', layout: { x: 0, y: 0, w: 6, h: 4 } }, + { title: 'Chart B', type: 'line', layout: { x: 6, y: 0, w: 6, h: 4 } }, + ], + globalFilters: [ + { + field: 'region', + label: 'Region', + type: 'select', + scope: 'widget', + targetWidgets: ['chart_a'], + options: [ + { value: 'na', label: 'North America' }, + { value: 'eu', label: 'Europe' }, + ], + }, + ], + }); + expect(result.globalFilters![0].scope).toBe('widget'); + expect(result.globalFilters![0].targetWidgets).toEqual(['chart_a']); + }); + + it('should accept Airtable-style dashboard with full filter bar config', () => { + const dashboard = Dashboard.create({ + name: 'airtable_style_dash', + label: 'Airtable Style Dashboard', + widgets: [ + { + title: 'Revenue by Region', + type: 'bar', + object: 'opportunity', + categoryField: 'region', + valueField: 'amount', + aggregate: 'sum', + layout: { x: 0, y: 0, w: 12, h: 4 }, + }, + ], + globalFilters: [ + { + field: 'owner_id', + label: 'Owner', + type: 'lookup', + optionsFrom: { + object: 'user', + valueField: 'id', + labelField: 'full_name', + filter: { is_active: true }, + }, + }, + { + field: 'status', + label: 'Status', + type: 'select', + options: [ + { value: 'open', label: 'Open' }, + { value: 'closed', label: 'Closed' }, + ], + defaultValue: 'open', + }, + { + field: 'region', + label: 'Region', + type: 'select', + scope: 'widget', + targetWidgets: ['revenue_chart'], + optionsFrom: { + object: 'region', + valueField: 'code', + labelField: 'name', + }, + }, + ], + }); + + expect(dashboard.globalFilters).toHaveLength(3); + expect(dashboard.globalFilters![0].optionsFrom!.object).toBe('user'); + expect(dashboard.globalFilters![1].defaultValue).toBe('open'); + expect(dashboard.globalFilters![2].targetWidgets).toEqual(['revenue_chart']); + }); +}); diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 689d564e85..f33c850bd5 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -98,6 +98,57 @@ export const DashboardWidgetSchema = z.object({ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), }); +/** + * Dynamic options binding for global filters. + * Allows dropdown options to be fetched from an object at runtime. + */ +export const GlobalFilterOptionsFromSchema = z.object({ + /** Source object name to fetch options from */ + object: z.string().describe('Source object name'), + + /** Field to use as option value */ + valueField: z.string().describe('Field to use as option value'), + + /** Field to use as option label */ + labelField: z.string().describe('Field to use as option label'), + + /** Optional filter to apply when fetching options */ + filter: FilterConditionSchema.optional().describe('Filter to apply to source object'), +}).describe('Dynamic filter options from object'); + +/** + * Global Filter Schema + * Defines a single global filter control for the dashboard filter bar. + */ +export const GlobalFilterSchema = z.object({ + /** Field name to filter on */ + field: z.string().describe('Field name to filter on'), + + /** Display label for the filter */ + label: I18nLabelSchema.optional().describe('Display label for the filter'), + + /** Filter input type */ + type: z.enum(['text', 'select', 'date', 'number', 'lookup']).optional().describe('Filter input type'), + + /** Static options for select/lookup filters */ + options: z.array(z.object({ + value: z.any(), + label: I18nLabelSchema, + })).optional().describe('Static filter options'), + + /** Dynamic data binding for filter options */ + optionsFrom: GlobalFilterOptionsFromSchema.optional().describe('Dynamic filter options from object'), + + /** Default filter value */ + defaultValue: z.any().optional().describe('Default filter value'), + + /** Filter application scope */ + scope: z.enum(['dashboard', 'widget']).default('dashboard').describe('Filter application scope'), + + /** Widget IDs to apply this filter to (when scope is widget) */ + targetWidgets: z.array(z.string()).optional().describe('Widget IDs to apply this filter to'), +}); + /** * Dashboard Schema * Represents a page containing multiple visualizations. @@ -151,11 +202,7 @@ export const DashboardSchema = z.object({ }).optional().describe('Global dashboard date range filter configuration'), /** Global Filters */ - globalFilters: z.array(z.object({ - field: z.string().describe('Field name to filter on'), - label: I18nLabelSchema.optional().describe('Display label for the filter'), - type: z.enum(['text', 'select', 'date', 'number']).optional().describe('Filter input type'), - })).optional().describe('Global filters that apply to all widgets in the dashboard'), + globalFilters: z.array(GlobalFilterSchema).optional().describe('Global filters that apply to all widgets in the dashboard'), /** ARIA accessibility attributes */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), @@ -169,6 +216,8 @@ export type DashboardInput = z.input; export type DashboardWidget = z.infer; export type WidgetColorVariant = z.infer; export type WidgetActionType = z.infer; +export type GlobalFilter = z.infer; +export type GlobalFilterOptionsFrom = z.infer; /** * Dashboard Factory Helper