@@ -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' ;
1115import { 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