Skip to content

Commit b4853a8

Browse files
authored
Merge pull request #718 from objectstack-ai/copilot/enhance-global-filters-options
2 parents 8555b62 + 320708c commit b4853a8

File tree

3 files changed

+365
-6
lines changed

3 files changed

+365
-6
lines changed

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,7 @@ Protocol enhancements and core component implementations for dashboard feature p
402402

403403
**Spec Protocol Changes:**
404404
- [x] Add `colorVariant`, `actionUrl`, `description`, `actionType`, `actionIcon` to `DashboardWidgetSchema` ([#713](https://github.com/objectstack-ai/spec/issues/713))
405-
- [ ] Enhance `globalFilters` with `options`, `optionsFrom`, `defaultValue`, `scope`, `targetWidgets` ([#712](https://github.com/objectstack-ai/spec/issues/712))
405+
- [x] Enhance `globalFilters` with `options`, `optionsFrom`, `defaultValue`, `scope`, `targetWidgets` ([#712](https://github.com/objectstack-ai/spec/issues/712))
406406
- [ ] Add `header` configuration to `DashboardSchema` with `showTitle`, `showDescription`, `actions` ([#714](https://github.com/objectstack-ai/spec/issues/714))
407407
- [ ] Add `pivotConfig` and `measures` array to `DashboardWidgetSchema` for multi-measure pivots ([#714](https://github.com/objectstack-ai/spec/issues/714))
408408

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

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ import {
55
Dashboard,
66
WidgetColorVariantSchema,
77
WidgetActionTypeSchema,
8+
GlobalFilterSchema,
9+
GlobalFilterOptionsFromSchema,
810
type Dashboard as DashboardType,
911
type DashboardWidget,
12+
type GlobalFilter,
13+
type GlobalFilterOptionsFrom,
1014
} from './dashboard.zod';
1115
import { ChartTypeSchema } from './chart.zod';
1216

@@ -852,3 +856,309 @@ describe('DashboardWidgetSchema - combined new fields', () => {
852856
expect(dashboard.widgets[3].colorVariant).toBe('blue');
853857
});
854858
});
859+
860+
// ============================================================================
861+
// Protocol Enhancement Tests: GlobalFilterSchema — options, optionsFrom,
862+
// defaultValue, scope, targetWidgets (#712)
863+
// ============================================================================
864+
865+
describe('GlobalFilterOptionsFromSchema', () => {
866+
it('should accept valid optionsFrom config', () => {
867+
const result = GlobalFilterOptionsFromSchema.parse({
868+
object: 'account',
869+
valueField: 'id',
870+
labelField: 'name',
871+
});
872+
expect(result.object).toBe('account');
873+
expect(result.valueField).toBe('id');
874+
expect(result.labelField).toBe('name');
875+
});
876+
877+
it('should accept optionsFrom with filter', () => {
878+
const result = GlobalFilterOptionsFromSchema.parse({
879+
object: 'account',
880+
valueField: 'id',
881+
labelField: 'name',
882+
filter: { is_active: true },
883+
});
884+
expect(result.filter).toEqual({ is_active: true });
885+
});
886+
887+
it('should reject optionsFrom without required fields', () => {
888+
expect(() => GlobalFilterOptionsFromSchema.parse({ object: 'account' })).toThrow();
889+
expect(() => GlobalFilterOptionsFromSchema.parse({ valueField: 'id' })).toThrow();
890+
expect(() => GlobalFilterOptionsFromSchema.parse({})).toThrow();
891+
});
892+
});
893+
894+
describe('GlobalFilterSchema', () => {
895+
it('should accept minimal filter (backward compat)', () => {
896+
const result = GlobalFilterSchema.parse({
897+
field: 'status',
898+
});
899+
expect(result.field).toBe('status');
900+
expect(result.scope).toBe('dashboard');
901+
});
902+
903+
it('should accept old-style filter with label and type', () => {
904+
const result = GlobalFilterSchema.parse({
905+
field: 'status',
906+
label: 'Status',
907+
type: 'select',
908+
});
909+
expect(result.field).toBe('status');
910+
expect(result.label).toBe('Status');
911+
expect(result.type).toBe('select');
912+
});
913+
914+
it('should accept all filter types including lookup', () => {
915+
const types = ['text', 'select', 'date', 'number', 'lookup'] as const;
916+
types.forEach(type => {
917+
expect(() => GlobalFilterSchema.parse({ field: 'f', type })).not.toThrow();
918+
});
919+
});
920+
921+
it('should reject invalid filter type', () => {
922+
expect(() => GlobalFilterSchema.parse({ field: 'f', type: 'checkbox' })).toThrow();
923+
});
924+
925+
it('should accept filter with static options', () => {
926+
const result = GlobalFilterSchema.parse({
927+
field: 'priority',
928+
type: 'select',
929+
options: [
930+
{ value: 'high', label: 'High' },
931+
{ value: 'medium', label: 'Medium' },
932+
{ value: 'low', label: 'Low' },
933+
],
934+
});
935+
expect(result.options).toHaveLength(3);
936+
expect(result.options![0].value).toBe('high');
937+
expect(result.options![0].label).toBe('High');
938+
});
939+
940+
it('should accept filter with i18n option labels', () => {
941+
const result = GlobalFilterSchema.parse({
942+
field: 'priority',
943+
type: 'select',
944+
options: [
945+
{ value: 'high', label: { key: 'filter.priority.high', defaultValue: 'High' } },
946+
],
947+
});
948+
expect(result.options![0].label).toEqual({ key: 'filter.priority.high', defaultValue: 'High' });
949+
});
950+
951+
it('should accept filter with optionsFrom (dynamic binding)', () => {
952+
const result = GlobalFilterSchema.parse({
953+
field: 'account_id',
954+
type: 'lookup',
955+
optionsFrom: {
956+
object: 'account',
957+
valueField: 'id',
958+
labelField: 'name',
959+
},
960+
});
961+
expect(result.optionsFrom).toBeDefined();
962+
expect(result.optionsFrom!.object).toBe('account');
963+
expect(result.optionsFrom!.valueField).toBe('id');
964+
expect(result.optionsFrom!.labelField).toBe('name');
965+
});
966+
967+
it('should accept filter with optionsFrom and filter', () => {
968+
const result = GlobalFilterSchema.parse({
969+
field: 'owner_id',
970+
type: 'lookup',
971+
optionsFrom: {
972+
object: 'user',
973+
valueField: 'id',
974+
labelField: 'full_name',
975+
filter: { is_active: true },
976+
},
977+
});
978+
expect(result.optionsFrom!.filter).toEqual({ is_active: true });
979+
});
980+
981+
it('should accept filter with defaultValue', () => {
982+
const result = GlobalFilterSchema.parse({
983+
field: 'status',
984+
type: 'select',
985+
defaultValue: 'open',
986+
});
987+
expect(result.defaultValue).toBe('open');
988+
});
989+
990+
it('should default scope to dashboard', () => {
991+
const result = GlobalFilterSchema.parse({ field: 'status' });
992+
expect(result.scope).toBe('dashboard');
993+
});
994+
995+
it('should accept scope widget', () => {
996+
const result = GlobalFilterSchema.parse({
997+
field: 'status',
998+
scope: 'widget',
999+
});
1000+
expect(result.scope).toBe('widget');
1001+
});
1002+
1003+
it('should reject invalid scope', () => {
1004+
expect(() => GlobalFilterSchema.parse({ field: 'f', scope: 'global' })).toThrow();
1005+
});
1006+
1007+
it('should accept targetWidgets', () => {
1008+
const result = GlobalFilterSchema.parse({
1009+
field: 'region',
1010+
scope: 'widget',
1011+
targetWidgets: ['revenue_chart', 'pipeline_table'],
1012+
});
1013+
expect(result.targetWidgets).toEqual(['revenue_chart', 'pipeline_table']);
1014+
});
1015+
1016+
it('should accept filter without targetWidgets (optional)', () => {
1017+
const result = GlobalFilterSchema.parse({ field: 'status' });
1018+
expect(result.targetWidgets).toBeUndefined();
1019+
});
1020+
});
1021+
1022+
describe('DashboardSchema - enhanced globalFilters', () => {
1023+
it('should still accept old-style globalFilters (backward compat)', () => {
1024+
const result = DashboardSchema.parse({
1025+
name: 'compat_dash',
1026+
label: 'Compat Dashboard',
1027+
widgets: [],
1028+
globalFilters: [
1029+
{ field: 'status', label: 'Status', type: 'select' },
1030+
{ field: 'created_at', type: 'date' },
1031+
],
1032+
});
1033+
expect(result.globalFilters).toHaveLength(2);
1034+
expect(result.globalFilters![0].scope).toBe('dashboard');
1035+
});
1036+
1037+
it('should accept globalFilters with optionsFrom', () => {
1038+
const result = DashboardSchema.parse({
1039+
name: 'dynamic_filters_dash',
1040+
label: 'Dynamic Filters',
1041+
widgets: [],
1042+
globalFilters: [
1043+
{
1044+
field: 'account_id',
1045+
label: 'Account',
1046+
type: 'lookup',
1047+
optionsFrom: {
1048+
object: 'account',
1049+
valueField: 'id',
1050+
labelField: 'name',
1051+
},
1052+
},
1053+
],
1054+
});
1055+
expect(result.globalFilters![0].optionsFrom!.object).toBe('account');
1056+
});
1057+
1058+
it('should accept globalFilters with static options', () => {
1059+
const result = DashboardSchema.parse({
1060+
name: 'static_options_dash',
1061+
label: 'Static Options',
1062+
widgets: [],
1063+
globalFilters: [
1064+
{
1065+
field: 'priority',
1066+
label: 'Priority',
1067+
type: 'select',
1068+
options: [
1069+
{ value: 'high', label: 'High' },
1070+
{ value: 'medium', label: 'Medium' },
1071+
{ value: 'low', label: 'Low' },
1072+
],
1073+
defaultValue: 'medium',
1074+
},
1075+
],
1076+
});
1077+
expect(result.globalFilters![0].options).toHaveLength(3);
1078+
expect(result.globalFilters![0].defaultValue).toBe('medium');
1079+
});
1080+
1081+
it('should accept globalFilters with targetWidgets', () => {
1082+
const result = DashboardSchema.parse({
1083+
name: 'targeted_filter_dash',
1084+
label: 'Targeted Filters',
1085+
widgets: [
1086+
{ title: 'Chart A', type: 'bar', layout: { x: 0, y: 0, w: 6, h: 4 } },
1087+
{ title: 'Chart B', type: 'line', layout: { x: 6, y: 0, w: 6, h: 4 } },
1088+
],
1089+
globalFilters: [
1090+
{
1091+
field: 'region',
1092+
label: 'Region',
1093+
type: 'select',
1094+
scope: 'widget',
1095+
targetWidgets: ['chart_a'],
1096+
options: [
1097+
{ value: 'na', label: 'North America' },
1098+
{ value: 'eu', label: 'Europe' },
1099+
],
1100+
},
1101+
],
1102+
});
1103+
expect(result.globalFilters![0].scope).toBe('widget');
1104+
expect(result.globalFilters![0].targetWidgets).toEqual(['chart_a']);
1105+
});
1106+
1107+
it('should accept Airtable-style dashboard with full filter bar config', () => {
1108+
const dashboard = Dashboard.create({
1109+
name: 'airtable_style_dash',
1110+
label: 'Airtable Style Dashboard',
1111+
widgets: [
1112+
{
1113+
title: 'Revenue by Region',
1114+
type: 'bar',
1115+
object: 'opportunity',
1116+
categoryField: 'region',
1117+
valueField: 'amount',
1118+
aggregate: 'sum',
1119+
layout: { x: 0, y: 0, w: 12, h: 4 },
1120+
},
1121+
],
1122+
globalFilters: [
1123+
{
1124+
field: 'owner_id',
1125+
label: 'Owner',
1126+
type: 'lookup',
1127+
optionsFrom: {
1128+
object: 'user',
1129+
valueField: 'id',
1130+
labelField: 'full_name',
1131+
filter: { is_active: true },
1132+
},
1133+
},
1134+
{
1135+
field: 'status',
1136+
label: 'Status',
1137+
type: 'select',
1138+
options: [
1139+
{ value: 'open', label: 'Open' },
1140+
{ value: 'closed', label: 'Closed' },
1141+
],
1142+
defaultValue: 'open',
1143+
},
1144+
{
1145+
field: 'region',
1146+
label: 'Region',
1147+
type: 'select',
1148+
scope: 'widget',
1149+
targetWidgets: ['revenue_chart'],
1150+
optionsFrom: {
1151+
object: 'region',
1152+
valueField: 'code',
1153+
labelField: 'name',
1154+
},
1155+
},
1156+
],
1157+
});
1158+
1159+
expect(dashboard.globalFilters).toHaveLength(3);
1160+
expect(dashboard.globalFilters![0].optionsFrom!.object).toBe('user');
1161+
expect(dashboard.globalFilters![1].defaultValue).toBe('open');
1162+
expect(dashboard.globalFilters![2].targetWidgets).toEqual(['revenue_chart']);
1163+
});
1164+
});

0 commit comments

Comments
 (0)