1 /*
  2  * Copyright (c) 2013-2019 LabKey Corporation
  3  *
  4  * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0
  5  */
  6 LABKEY.FilterDialog = Ext.extend(Ext.Window, {
  7 
  8     autoHeight: true,
  9 
 10     bbarCfg : {
 11         bodyStyle : 'border-top: 1px solid black;'
 12     },
 13 
 14     cls: 'labkey-filter-dialog',
 15 
 16     closeAction: 'destroy',
 17 
 18     defaults: {
 19         border: false,
 20         msgTarget: 'under'
 21     },
 22 
 23     itemId: 'filterWindow',
 24 
 25     modal: true,
 26 
 27     resizable: false,
 28 
 29     // 24846
 30     width: Ext.isGecko ? 425 : 410,
 31 
 32     allowFacet : undefined,
 33 
 34     cacheFacetResults: true,
 35 
 36     initComponent : function() {
 37 
 38         if (!this['dataRegionName']) {
 39             console.error('dataRegionName is required for a LABKEY.FilterDialog');
 40             return;
 41         }
 42 
 43         this.column = this.column || this.boundColumn; // backwards compat
 44         if (!this.configureColumn(this.column)) {
 45             return;
 46         }
 47 
 48         Ext.apply(this, {
 49             title: this.title || "Show Rows Where " + this.column.caption + "...",
 50 
 51             carryfilter : true, // whether filter state should try to be carried between views (e.g. when changing tabs)
 52 
 53             // buttons
 54             buttons: this.configureButtons(),
 55 
 56             // hook key events
 57             keys:[{
 58                 key: Ext.EventObject.ENTER,
 59                 handler: this.onApply,
 60                 scope: this
 61             },{
 62                 key: Ext.EventObject.ESC,
 63                 handler: this.closeDialog,
 64                 scope: this
 65             }],
 66 
 67             // listeners
 68             listeners: {
 69                 destroy: function() {
 70                     if (this.focusTask) {
 71                         Ext.TaskMgr.stop(this.focusTask);
 72                     }
 73                 },
 74                 resize : function(panel) {  panel.syncShadow(); },
 75                 scope : this
 76             }
 77         });
 78 
 79         this.items = [this.getContainer()];
 80 
 81         LABKEY.FilterDialog.superclass.initComponent.call(this);
 82     },
 83 
 84     allowFaceting : function() {
 85         if (Ext.isDefined(this.allowFacet))
 86             return this.allowFacet;
 87 
 88         var dr = this.getDataRegion();
 89         if (!this.isQueryDataRegion(dr)) {
 90             this.allowFacet = false;
 91             return this.allowFacet;
 92         }
 93 
 94         this.allowFacet = false;
 95         switch (this.column.facetingBehaviorType) {
 96 
 97             case 'ALWAYS_ON':
 98                 this.allowFacet = true;
 99                 break;
100             case 'ALWAYS_OFF':
101                 this.allowFacet = false;
102                 break;
103             case 'AUTOMATIC':
104                 // auto rules are if the column is a lookup or dimension
105                 // OR if it is of type : (boolean, int, date, text), multiline excluded
106                 if (this.column.lookup || this.column.dimension)
107                     this.allowFacet = true;
108                 else if (this.jsonType == 'boolean' || this.jsonType == 'int' ||
109                         (this.jsonType == 'string' && this.column.inputType != 'textarea'))
110                     this.allowFacet = true;
111                 break;
112         }
113 
114         return this.allowFacet;
115     },
116 
117     // Returns an Array of button configurations based on supported operations on this column
118     configureButtons : function() {
119         var buttons = [
120             {text: 'OK', handler: this.onApply, scope: this},
121             {text: 'Cancel', handler: this.closeDialog, scope: this}
122         ];
123 
124         if (this.getDataRegion()) {
125             buttons.push({text: 'Clear Filter', handler: this.clearFilter, scope: this});
126             buttons.push({text: 'Clear All Filters', handler: this.clearAllFilters, scope: this});
127         }
128 
129         return buttons;
130     },
131 
132     // Returns true if the initialization was a success
133     configureColumn : function(column) {
134         if (!column) {
135             console.error('A column is required for LABKEY.FilterDialog');
136             return false;
137         }
138 
139         Ext.apply(this, {
140             // DEPRECATED: Either invoked from GWT, which will handle the commit itself.
141             // Or invoked as part of a regular filter dialog on a grid
142             changeFilterCallback: this.confirmCallback,
143 
144             fieldCaption: column.caption,
145             fieldKey: column.lookup && column.displayField ? column.displayField : column.fieldKey, // terrible
146             jsonType: (column.displayFieldJsonType ? column.displayFieldJsonType : column.jsonType) || 'string'
147         });
148 
149         return true;
150     },
151 
152     onApply : function() {
153         if (this.apply())
154             this.closeDialog();
155     },
156 
157     // Validates and applies the current filter(s) to the DataRegion
158     apply : function() {
159         var view = this.getContainer().getActiveTab();
160         var isValid = true;
161 
162         if (!view.getForm().isValid())
163             isValid = false;
164 
165         if (isValid) {
166             isValid = view.checkValid();
167         }
168 
169         if (isValid) {
170 
171             var dr = this.getDataRegion(),
172                 filters = view.getFilters();
173 
174             if (Ext.isFunction(this.changeFilterCallback)) {
175 
176                 var filterParams = '', sep = '';
177                 for (var f=0; f < filters.length; f++) {
178                     filterParams += sep + encodeURIComponent(filters[f].getURLParameterName(this.dataRegionName)) + '=' + encodeURIComponent(filters[f].getURLParameterValue());
179                     sep = '&';
180                 }
181                 this.changeFilterCallback.call(this, null, null, filterParams);
182             }
183             else {
184                 if (filters.length > 0) {
185                     // add the current filter(s)
186                     if (view.supportsMultipleFilters) {
187                         dr.replaceFilters(filters, this.column);
188                     }
189                     else
190                         dr.replaceFilter(filters[0]);
191                 }
192                 else {
193                     this.clearFilter();
194                 }
195             }
196         }
197 
198         return isValid;
199     },
200 
201     clearFilter : function() {
202         var dr = this.getDataRegion();
203         if (!dr) { return; }
204         Ext.StoreMgr.clear();
205         dr.clearFilter(this.fieldKey);
206         this.closeDialog();
207     },
208 
209     clearAllFilters : function() {
210         var dr = this.getDataRegion();
211         if (!dr) { return; }
212         dr.clearAllFilters();
213         this.closeDialog();
214     },
215 
216     closeDialog : function() {
217         this.close();
218     },
219 
220     getDataRegion : function() {
221         return LABKEY.DataRegions[this.dataRegionName];
222     },
223 
224     isQueryDataRegion : function(dr) {
225         return dr && dr.schemaName && dr.queryName;
226     },
227 
228     // Returns a class instance of a class that extends Ext.Container.
229     // This container will hold all the views registered to this FilterDialog instance.
230     // For caching purposes assign to this.viewcontainer
231     getContainer : function() {
232 
233         if (!this.viewcontainer) {
234 
235             var views = this.getViews();
236             var type = 'TabPanel';
237 
238             if (views.length == 1) {
239                 views[0].title = false;
240                 type = 'Panel';
241             }
242 
243             var config = {
244                 defaults: this.defaults,
245                 deferredRender: false,
246                 monitorValid: true,
247 
248                 // sizing and styling
249                 autoHeight: true,
250                 bodyStyle: 'margin: 0 5px;',
251                 border: true,
252                 items: views
253             };
254 
255             if (type == 'TabPanel') {
256                 config.listeners = {
257                     beforetabchange : function(tp, newTab, oldTab) {
258                         if (this.carryfilter && newTab && oldTab && oldTab.isChanged()) {
259                             newTab.setFilters(oldTab.getFilters());
260                         }
261                     },
262                     tabchange : function() {
263                         this.syncShadow();
264                         this.viewcontainer.getActiveTab().doLayout(); // required when facets return while on another tab
265                     },
266                     scope : this
267                 };
268             }
269 
270             if (views.length > 1) {
271                 config.activeTab = (this.allowFaceting() ? 1 : 0);
272             }
273             else {
274                 views[0].title = false;
275             }
276 
277             this.viewcontainer = new Ext[type](config);
278 
279             if (!Ext.isFunction(this.viewcontainer.getActiveTab)) {
280                 var me = this;
281                 this.viewcontainer.getActiveTab = function() {
282                     return me.viewcontainer.items.items[0];
283                 };
284                 // views attempt to hook the 'activate' event but some panel types do not fire
285                 // force fire on the first view
286                 this.viewcontainer.items.items[0].on('afterlayout', function(p) {
287                     p.fireEvent('activate', p);
288                 }, this, {single: true});
289             }
290         }
291 
292         return this.viewcontainer;
293     },
294 
295     _getFilters : function() {
296         var filters = [];
297 
298         var dr = this.getDataRegion();
299         if (dr) {
300             Ext.each(dr.getUserFilterArray(), function(ff) {
301                 if (this.column.lookup && this.column.displayField && ff.getColumnName().toLowerCase() === this.column.displayField.toLowerCase()) {
302                     filters.push(ff);
303                 }
304                 else if (this.column.fieldKey && ff.getColumnName().toLowerCase() === this.column.fieldKey.toLowerCase()) {
305                     filters.push(ff);
306                 }
307             }, this);
308         }
309         else if (this.queryString) { // deprecated
310             filters = LABKEY.Filter.getFiltersFromUrl(this.queryString, this.dataRegionName);
311         }
312 
313         return filters;
314     },
315 
316     // Override to return your own filter views
317     getViews : function() {
318 
319         var filters = this._getFilters(), views = [];
320 
321         // default view
322         views.push({
323             xtype: 'filter-view-default',
324             column: this.column,
325             fieldKey: this.fieldKey, // should not have to hand this in bc the column should supply correctly
326             dataRegionName: this.dataRegionName,
327             jsonType : this.jsonType,
328             filters: filters
329         });
330 
331         // facet view
332         if (this.allowFaceting()) {
333             views.push({
334                 xtype: 'filter-view-faceted',
335                 column: this.column,
336                 fieldKey: this.fieldKey, // should not have to hand this in bc the column should supply correctly
337                 dataRegionName: this.dataRegionName,
338                 jsonType : this.jsonType,
339                 filters: filters,
340                 cacheResults: this.cacheFacetResults,
341                 listeners: {
342                     invalidfilter : function() {
343                         this.carryfilter = false;
344                         this.getContainer().setActiveTab(0);
345                         this.getContainer().getActiveTab().doLayout();
346                         this.carryfilter = true;
347                     },
348                     scope: this
349                 },
350                 scope: this
351             })
352         }
353 
354         return views;
355     }
356 });
357 
358 LABKEY.FilterDialog.ViewPanel = Ext.extend(Ext.form.FormPanel, {
359 
360     supportsMultipleFilters: false,
361 
362     filters : [],
363 
364     changed : false,
365 
366     initComponent : function() {
367         if (!this['dataRegionName']) {
368             console.error('dataRegionName is requied for a LABKEY.FilterDialog.ViewPanel');
369             return;
370         }
371         LABKEY.FilterDialog.ViewPanel.superclass.initComponent.call(this);
372     },
373 
374     // Override to provide own view validation
375     checkValid : function() {
376         return true;
377     },
378 
379     getDataRegion : function() {
380         return LABKEY.DataRegions[this.dataRegionName];
381     },
382 
383     getFilters : function() {
384         console.error('All classes which extend LABKEY.FilterDialog.ViewPanel must implement getFilters()');
385     },
386 
387     setFilters : function(filterArray) {
388         console.error('All classes which extend LABKEY.FilterDialog.ViewPanel must implement setFilters(filterArray)');
389     },
390 
391     getXtype : function() {
392         switch (this.jsonType) {
393             case "date":
394                 return "datefield";
395             case "int":
396             case "float":
397                 return "textfield";
398             case "boolean":
399                 return 'labkey-booleantextfield';
400             default:
401                 return "textfield";
402         }
403     },
404 
405     // Returns true if a view has been altered since the last time it was activated
406     isChanged : function() {
407         return this.changed;
408     }
409 });
410 
411 Ext.ns('LABKEY.FilterDialog.View');
412 
413 LABKEY.FilterDialog.View.Default = Ext.extend(LABKEY.FilterDialog.ViewPanel, {
414 
415     supportsMultipleFilters: true,
416 
417     itemDefaults: {
418         border: false,
419         msgTarget: 'under'
420     },
421 
422     initComponent : function() {
423 
424         Ext.apply(this, {
425             autoHeight: true,
426             title: this.title === false ? false : 'Choose Filters',
427             bodyStyle: 'padding: 5px;',
428             bubbleEvents: ['add', 'remove', 'clientvalidation'],
429             defaults: { border: false },
430             items: this.generateFilterDisplays(2)
431         });
432 
433         this.combos = [];
434         this.inputs = [];
435 
436         LABKEY.FilterDialog.View.Default.superclass.initComponent.call(this);
437 
438         this.on('activate', this.onViewReady, this, {single: true});
439     },
440 
441     onViewReady : function() {
442         if (this.filters.length == 0) {
443             for (var c=0; c < this.combos.length; c++) {
444                 // Update the input enabled/disabled status by using the 'select' event listener on the combobox.
445                 // However, ComboBox doesn't fire 'select' event when changed programatically so we fire it manually.
446                 this.combos[c].reset();
447                 this.combos[c].fireEvent('select', this.combos[c], null);
448                 this.inputs[c].reset();
449             }
450         }
451         else {
452             for (var f=0; f < this.filters.length; f++) {
453                 if (f < this.combos.length) {
454                     var filter = this.filters[f];
455                     var combo = this.combos[f];
456 
457                     // Update the input enabled/disabled status by using the 'select' event listener on the combobox.
458                     // However, ComboBox doesn't fire 'select' event when changed programatically so we fire it manually.
459                     var store = combo.getStore();
460                     if (store) {
461                         var rec = store.getAt(store.find('value', filter.getFilterType().getURLSuffix()));
462                         if (rec) {
463                             combo.setValue(filter.getFilterType().getURLSuffix());
464                             combo.fireEvent('select', combo, rec);
465                         }
466                     }
467 
468                     this.inputs[f].setValue(filter.getURLParameterValue());
469                 }
470             }
471         }
472 
473         //Issue 24550: always select the first filter field, and also select text if present
474         if (this.inputs[0]) {
475             this.inputs[0].focus(true, 100, this.inputs[0]);
476         }
477 
478         this.changed = false;
479     },
480 
481     checkValid : function() {
482         var combos = this.combos;
483         var inputs = this.inputs, input, value, f;
484 
485         var isValid = true;
486 
487         Ext.each(combos, function(c, i) {
488             if (!c.isValid()) {
489                 isValid = false;
490             }
491             else {
492                 input = inputs[i];
493                 value = input.getValue();
494 
495                 f = LABKEY.Filter.getFilterTypeForURLSuffix(c.getValue());
496 
497                 if (!f) {
498                     alert('filter not found: ' + c.getValue());
499                     return;
500                 }
501 
502                 if (f.isDataValueRequired() && Ext.isEmpty(value)) {
503                     input.markInvalid('You must enter a value');
504                     isValid = false;
505                 }
506             }
507         });
508 
509         return isValid;
510     },
511 
512     inputFieldValidator : function(input, combo) {
513 
514         var store = combo.getStore();
515         if (store) {
516             var rec = store.getAt(store.find('value', combo.getValue()));
517             var filter = LABKEY.Filter.getFilterTypeForURLSuffix(combo.getValue());
518 
519             if (rec) {
520                 if (filter.isMultiValued())
521                     return this.validateMultiValueInput(input.getValue(), filter.getMultiValueSeparator(), filter.getMultiValueMinOccurs(), filter.getMultiValueMaxOccurs());
522                 return this.validateInputField(input.getValue());
523             }
524         }
525         return true;
526     },
527 
528     generateFilterDisplays : function(quantity) {
529         var idx = this.nextIndex(), items = [], i=0;
530 
531         for(; i < quantity; i++) {
532             items.push({
533                 xtype: 'panel',
534                 layout: 'form',
535                 itemId: 'filterPair' + idx,
536                 border: false,
537                 defaults: this.itemDefaults,
538                 items: [this.getComboConfig(idx), this.getInputConfig(idx)],
539                 scope: this
540             });
541             idx++;
542         }
543         return items;
544     },
545 
546     getComboConfig : function(idx) {
547 
548         var val = idx === 0 ? LABKEY.Filter.getDefaultFilterForType(this.jsonType).getURLSuffix() : '';
549         return {
550             xtype: 'combo',
551             itemId: 'filterComboBox' + idx,
552             filterIndex: idx,
553             name: 'filterType_'+(idx + 1),   //for compatibility with tests...
554             listWidth: (this.jsonType == 'date' || this.jsonType == 'boolean') ? null : 380,
555             emptyText: idx === 0 ? 'Choose a filter:' : 'No other filter',
556             autoSelect: false,
557             width: 250,
558             minListWidth: 250,
559             triggerAction: 'all',
560             fieldLabel: (idx === 0 ?'Filter Type' : 'and'),
561             store: this.getSelectionStore(idx),
562             displayField: 'text',
563             valueField: 'value',
564             typeAhead: 'false',
565             forceSelection: true,
566             mode: 'local',
567             clearFilterOnReset: false,
568             editable: false,
569             value: val,
570             originalValue: val,
571             listeners : {
572                 render : function(combo) {
573                     this.combos.push(combo);
574                     // Update the associated inputField's enabled/disabled state on initial render
575                     this.enableInputField(combo);
576                 },
577                 select : function (combo) {
578                     this.changed = true;
579                     this.enableInputField(combo);
580                 },
581                 scope: this
582             },
583             scope: this
584         };
585     },
586 
587     enableInputField : function (combo) {
588 
589         var idx = combo.filterIndex;
590         var inputField = this.find('itemId', 'inputField'+idx)[0];
591 
592         var filter = LABKEY.Filter.getFilterTypeForURLSuffix(combo.getValue());
593         var selectedValue = filter ? filter.getURLSuffix() : '';
594 
595         var combos = this.combos;
596         var inputFields = this.inputs;
597 
598         if (filter && !filter.isDataValueRequired()) {
599             //Disable the field and allow it to be blank for values 'isblank' and 'isnonblank'.
600             inputField.disable();
601             inputField.setValue();
602         }
603         else {
604             inputField.enable();
605             inputField.validate();
606             inputField.focus('', 50)
607         }
608 
609         //if the value is null, this indicates no filter chosen.  if it lacks an operator (ie. isBlank)
610         //in either case, this means we should disable all other filters
611         if(selectedValue == '' || !filter.isDataValueRequired()){
612             //Disable all subsequent combos
613             Ext.each(combos, function(combo, idx){
614                 //we enable the next combo in the series
615                 if(combo.filterIndex == this.filterIndex + 1){
616                     combo.setValue();
617                     inputFields[idx].setValue();
618                     inputFields[idx].enable();
619                     inputFields[idx].validate();
620                 }
621                 else if (combo.filterIndex > this.filterIndex){
622                     combo.setValue();
623                     inputFields[idx].disable();
624                 }
625 
626             }, this);
627         }
628         else{
629             //enable the other filterComboBoxes.
630             Ext.each(combos, function(combo, i) { combo.enable(); }, this);
631 
632             if (combos.length) {
633                 combos[0].focus('', 50);
634             }
635         }
636     },
637 
638     getFilters : function() {
639 
640         var inputs = this.inputs;
641         var combos = this.combos;
642         var value, type, filters = [];
643 
644         Ext.each(combos, function(c, i) {
645             if (!inputs[i].disabled || (c.getRawValue() != 'No Other Filter')) {
646                 value = inputs[i].getValue();
647                 type = LABKEY.Filter.getFilterTypeForURLSuffix(c.getValue());
648 
649                 if (!type) {
650                     alert('Filter not found for suffix: ' + c.getValue());
651                 }
652 
653                 filters.push(LABKEY.Filter.create(this.fieldKey, value, type));
654             }
655         }, this);
656 
657         return filters;
658     },
659 
660     getInputConfig : function(idx) {
661         var me = this;
662         return {
663             xtype         : this.getXtype(),
664             itemId        : 'inputField' + idx,
665             filterIndex   : idx,
666             id            : 'value_'+(idx + 1),   //for compatibility with tests...
667             width         : 250,
668             blankText     : 'You must enter a value.',
669             validateOnBlur: true,
670             value         : null,
671             altFormats: (this.jsonType == "date" ? LABKEY.Utils.getDateAltFormats() : undefined),
672             validator : function(value) {
673 
674                 // support for filtering '∞'
675                 if (me.jsonType == 'float' && value.indexOf('∞') > -1) {
676                     value = value.replace('∞', 'Infinity');
677                     this.setRawValue(value); // does not fire validation
678                 }
679 
680                 var combos = me.combos;
681                 if (!combos.length) {
682                     return;
683                 }
684 
685                 return me.inputFieldValidator(this, combos[idx]);
686             },
687             listeners: {
688                 disable : function(field){
689                     //Call validate after disable so any pre-existing validation errors go away.
690                     if(field.rendered) {
691                         field.validate();
692                     }
693                 },
694                 focus : function(f) {
695                     if (this.focusTask) {
696                         Ext.TaskMgr.stop(this.focusTask);
697                     }
698                 },
699                 render : function(input) {
700                     me.inputs.push(input);
701                     if (!me.focusReady) {
702                         me.focusReady = true;
703                         // create a task to set the input focus that will get started after layout is complete,
704                         // the task will run for a max of 2000ms but will get stopped when the component receives focus
705                         this.focusTask = {interval:150, run: function(){
706                             input.focus(null, 50);
707                             Ext.TaskMgr.stop(this.focusTask);
708                         }, scope: this, duration: 2000};
709                     }
710                 },
711                 change : function(input, newVal, oldVal) {
712                     if (oldVal != newVal) {
713                         this.changed = true;
714                     }
715                 },
716                 scope : this
717             },
718             scope: this
719         };
720     },
721 
722     getSelectionStore : function(storeNum) {
723         var fields = ['text', 'value',
724             {name: 'isMulti', type: Ext.data.Types.BOOL},
725             {name: 'isOperatorOnly', type: Ext.data.Types.BOOL}
726         ];
727         var store = new Ext.data.ArrayStore({
728             fields: fields,
729             idIndex: 1
730         });
731         var comboRecord = Ext.data.Record.create(fields);
732 
733         var filters = LABKEY.Filter.getFilterTypesForType(this.jsonType, this.column.mvEnabled);
734         for (var i=0; i<filters.length; i++)
735         {
736             var filter = filters[i];
737             store.add(new comboRecord({
738                 text: filter.getLongDisplayText(),
739                 value: filter.getURLSuffix(),
740                 isMulti: filter.isMultiValued(),
741                 isOperatorOnly: filter.isDataValueRequired()
742             }));
743         }
744 
745         if (storeNum > 0) {
746             store.removeAt(0);
747             store.insert(0, new comboRecord({text:'No Other Filter', value: ''}));
748         }
749 
750         return store;
751     },
752 
753     setFilters : function(filterArray) {
754         this.filters = filterArray;
755         this.onViewReady();
756     },
757 
758     nextIndex : function() {
759         return 0;
760     },
761 
762     validateMultiValueInput : function(inputValues, multiValueSeparator, minOccurs, maxOccurs) {
763         // Used when "Equals One Of.." or "Between" is selected. Calls validateInputField on each value entered.
764         var values = inputValues.split(multiValueSeparator);
765         var isValid = "";
766         for(var i = 0; i < values.length; i++){
767             isValid = this.validateInputField(values[i]);
768             if(isValid !== true){
769                 return isValid;
770             }
771         }
772 
773         if (minOccurs !== undefined && minOccurs > 0)
774         {
775             if (values.length < minOccurs)
776                 return "At least " + minOccurs + " '" + multiValueSeparator + "' separated values are required";
777         }
778 
779         if (maxOccurs !== undefined && maxOccurs > 0)
780         {
781             if (values.length > maxOccurs)
782                 return "At most " + maxOccurs + " '" + multiValueSeparator + "' separated values are allowed";
783         }
784 
785         //If we make it out of the for loop we had no errors.
786         return true;
787     },
788 
789     // The fact that Ext3 ties validation to the editor is a little funny,
790     // but using this shifts the work to Ext
791     validateInputField : function(value) {
792         var map = {
793             'string': 'STRING',
794             'int': 'INT',
795             'float': 'FLOAT',
796             'date': 'DATE',
797             'boolean': 'BOOL'
798         };
799         var type = map[this.jsonType];
800         if (type) {
801             var field = new Ext.data.Field({
802                 type: Ext.data.Types[type],
803                 allowDecimals :  this.jsonType != "int",  //will be ignored by anything besides numberfield
804                 useNull: true
805             });
806 
807             var convertedVal = field.convert(value);
808             if (!Ext.isEmpty(value) && value != convertedVal) {
809                 return "Invalid value: " + value;
810             }
811         }
812         else {
813             console.log('Unrecognized type: ' + this.jsonType);
814         }
815 
816         return true;
817     }
818 });
819 
820 Ext.reg('filter-view-default', LABKEY.FilterDialog.View.Default);
821 
822 LABKEY.FilterDialog.View.Faceted = Ext.extend(LABKEY.FilterDialog.ViewPanel, {
823 
824     MAX_FILTER_CHOICES: 250, // This is the maximum number of filters that will be requested / shown
825 
826     applyContextFilters: true,
827 
828     /**
829      * Logically convert filters to try and optimize the query on the server.
830      * (e.g. using NOT IN when less than half the available values are checked)
831      */
832     filterOptimization: true,
833 
834     cacheResults: true,
835 
836     emptyDisplayValue: '[Blank]',
837 
838     gridID: Ext.id(),
839 
840     initComponent : function() {
841 
842         Ext.apply(this, {
843             title  : 'Choose Values',
844             border : false,
845             height : 200,
846             bodyStyle: 'overflow-x: hidden; overflow-y: auto',
847             bubbleEvents: ['add', 'remove', 'clientvalidation'],
848             defaults : {
849                 border : false
850             },
851             markDisabled : true,
852             items: [{
853                 layout: 'hbox',
854                 style: 'padding-bottom: 5px; overflow-x: hidden',
855                 defaults: {
856                     border: false
857                 },
858                 items: [{
859                     xtype: 'label',
860                     id: this.gridID + 'OverflowLabel',
861                     hidden: true,
862                     text: 'There are more than ' + this.MAX_FILTER_CHOICES + ' values. Showing a partial list.'
863                 }]
864             }]
865         });
866 
867         LABKEY.FilterDialog.View.Faceted.superclass.initComponent.call(this);
868 
869         this.on('render', this.onPanelRender, this, {single: true});
870     },
871 
872     formatValue : function(val) {
873         if(this.column) {
874             if (this.column.extFormatFn) {
875                 try {
876                     this.column.extFormatFn = eval(this.column.extFormatFn);
877                 }
878                 catch (error) {
879                     console.log('improper extFormatFn: ' + this.column.extFormatFn);
880                 }
881 
882                 if (Ext.isFunction(this.column.extFormatFn)) {
883                     val = this.column.extFormatFn(val);
884                 }
885             }
886             else if (this.jsonType == 'int') {
887                 val = parseInt(val);
888             }
889         }
890         return val;
891     },
892 
893     // copied from Ext 4 Ext.Array.difference
894     difference : function(arrayA, arrayB) {
895         var clone = arrayA.slice(),
896                 ln = clone.length,
897                 i, j, lnB;
898 
899         for (i = 0,lnB = arrayB.length; i < lnB; i++) {
900             for (j = 0; j < ln; j++) {
901                 if (clone[j] === arrayB[i]) {
902                     clone.splice(j, 1);
903                     j--;
904                     ln--;
905                 }
906             }
907         }
908 
909         return clone;
910     },
911 
912     constructFilter : function(selected, unselected) {
913         var filter = null;
914 
915         if (selected.length > 0) {
916 
917             var columnName = this.fieldKey;
918 
919             // one selection
920             if (selected.length == 1) {
921                 if (selected[0].get('displayValue') == this.emptyDisplayValue)
922                     filter = LABKEY.Filter.create(columnName, null, LABKEY.Filter.Types.ISBLANK);
923                 else
924                     filter = LABKEY.Filter.create(columnName, selected[0].get('value')); // default EQUAL
925             }
926             else if (this.filterOptimization && selected.length > unselected.length) {
927                 // Do the negation
928                 if (unselected.length == 1) {
929                     var val = unselected[0].get('value');
930                     var type = (val === "" ? LABKEY.Filter.Types.NONBLANK : LABKEY.Filter.Types.NOT_EQUAL_OR_MISSING);
931 
932                     // 18716: Check if 'unselected' contains empty value
933                     filter = LABKEY.Filter.create(columnName, val, type);
934                 }
935                 else
936                     filter = LABKEY.Filter.create(columnName, this.selectedToValues(unselected), LABKEY.Filter.Types.NOT_IN);
937             }
938             else {
939                 filter = LABKEY.Filter.create(columnName, this.selectedToValues(selected), LABKEY.Filter.Types.IN);
940             }
941         }
942 
943         return filter;
944     },
945 
946     // get array of values from the selected store item array
947     selectedToValues : function(valueArray) {
948         return valueArray.map(function (i) { return i.get('value'); });
949     },
950 
951     // Implement interface LABKEY.FilterDialog.ViewPanel
952     getFilters : function() {
953         var grid = Ext.getCmp(this.gridID);
954         var filters = [];
955 
956         if (grid) {
957             var store = grid.store;
958             var count = store.getCount(); // TODO: Check if store loaded
959             var selected = grid.getSelectionModel().getSelections();
960 
961             if (count == 0 || selected.length == 0 || selected.length == count) {
962                 filters = [];
963             }
964             else {
965                 var unselected = this.filterOptimization ? this.difference(store.getRange(), selected) : [];
966                 filters = [this.constructFilter(selected, unselected)];
967             }
968         }
969 
970         return filters;
971     },
972 
973     // Implement interface LABKEY.FilterDialog.ViewPanel
974     setFilters : function(filterArray) {
975         if (Ext.isArray(filterArray)) {
976             this.filters = filterArray;
977             this.onViewReady();
978         }
979     },
980 
981     getGridConfig : function(idx) {
982         var sm = new Ext.grid.CheckboxSelectionModel({
983             listeners: {
984                 selectionchange: {
985                     fn: function(sm) {
986                         // NOTE: this will manually set the checked state of the header checkbox.  it would be better
987                         // to make this a real tri-state (ie. selecting some records is different then none), but since this is still Ext3
988                         // and ext4 will be quite different it doesnt seem worth the effort right now
989                         var selections = sm.getSelections();
990                         var headerCell = Ext.fly(sm.grid.getView().getHeaderCell(0)).first('div');
991                         if(selections.length == sm.grid.store.getCount()){
992                             headerCell.addClass('x-grid3-hd-checker-on');
993                         }
994                         else {
995                             headerCell.removeClass('x-grid3-hd-checker-on');
996                         }
997 
998 
999                     },
1000                     buffer: 50
1001                 }
1002             }
1003         });
1004 
1005         var me = this;
1006 
1007         return {
1008             xtype: 'grid',
1009             id: this.gridID,
1010             border: true,
1011             bodyBorder: true,
1012             frame: false,
1013             autoHeight: true,
1014             itemId: 'inputField' + (idx || 0),
1015             filterIndex: idx || 0,
1016             msgTarget: 'title',
1017             store: this.getLookupStore(),
1018             headerClick: false,
1019             viewConfig: {
1020                 headerTpl: new Ext.Template(
1021                     '<table border="0" cellspacing="0" cellpadding="0" style="{tstyle}">',
1022                         '<thead>',
1023                             '<tr class="x-grid3-row-table">{cells}</tr>',
1024                         '</thead>',
1025                     '</table>'
1026                 )
1027             },
1028             sm: sm,
1029             cls: 'x-grid-noborder',
1030             columns: [
1031                 sm,
1032                 new Ext.grid.TemplateColumn({
1033                     header: '<a href="javascript:void(0);">[All]</a>',
1034                     dataIndex: 'value',
1035                     menuDisabled: true,
1036                     resizable: false,
1037                     width: 340,
1038                     tpl: new Ext.XTemplate('<tpl for=".">' +
1039                             '<span class="labkey-link" title="{[Ext.util.Format.htmlEncode(values["displayValue"])]}">' +
1040                             '{[Ext.util.Format.htmlEncode(values["displayValue"])]}' +
1041                             '</span></tpl>')
1042                 })
1043             ],
1044             listeners: {
1045                 afterrender : function(grid) {
1046                     grid.getSelectionModel().on('selectionchange', function() {
1047                         this.changed = true;
1048                     }, this);
1049 
1050                     grid.on('viewready', function(g) {
1051                         this.gridReady = true;
1052                         this.onViewReady();
1053                     }, this, {single: true});
1054                 },
1055                 scope : this
1056             },
1057             // extend toggle behavior to the header cell, not just the checkbox next to it
1058             onHeaderCellClick : function() {
1059                 var sm = this.getSelectionModel();
1060                 var selected = sm.getSelections();
1061                 selected.length == this.store.getCount() ? this.selectNone() : this.selectAll();
1062             },
1063             getValue : function() {
1064                 var vals = this.getValues();
1065                 if (vals.length == vals.max) {
1066                     return [];
1067                 }
1068                 return vals.values;
1069             },
1070             getValues : function() {
1071                 var values = [],
1072                         sels   = this.getSelectionModel().getSelections();
1073 
1074                 Ext.each(sels, function(rec){
1075                     values.push(rec.get('strValue'));
1076                 }, this);
1077 
1078                 if(values.indexOf('') != -1 && values.length == 1)
1079                     values.push(''); //account for null-only filtering
1080 
1081                 return {
1082                     values : values.join(';'),
1083                     length : values.length,
1084                     max    : this.getStore().getCount()
1085                 };
1086             },
1087             setValue : function(values, negated) {
1088                 if (!this.rendered) {
1089                     this.on('render', function() {
1090                         this.setValue(values, negated);
1091                     }, this, {single: true});
1092                 }
1093 
1094                 if (!Ext.isArray(values)) {
1095                     values = values.split(';');
1096                 }
1097 
1098                 if (this.store.isLoading) {
1099                     // need to wait for the store to load to ensure records
1100                     this.store.on('load', function() {
1101                         this._checkAndLoadValues(values, negated);
1102                     }, this, {single: true});
1103                 }
1104                 else {
1105                     this._checkAndLoadValues(values, negated);
1106                 }
1107             },
1108             _checkAndLoadValues : function(values, negated) {
1109                 var records = [],
1110                         recIdx,
1111                         recordNotFound = false;
1112 
1113                 Ext.each(values, function(val) {
1114                     recIdx = this.store.findBy(function(rec){
1115                         return rec.get('strValue') === val;
1116                     });
1117 
1118                     if (recIdx != -1) {
1119                         records.push(recIdx);
1120                     }
1121                     else {
1122                         // Issue 14710: if the record isnt found, we wont be able to select it, so should reject.
1123                         // If it's null/empty, ignore silently
1124                         if (!Ext.isEmpty(val)) {
1125                             recordNotFound = true;
1126                             return false;
1127                         }
1128                     }
1129                 }, this);
1130 
1131                 if (negated) {
1132                     var count = this.store.getCount(), found = false, negRecords = [];
1133                     for (var i=0; i < count; i++) {
1134                         found = false;
1135                         for (var j=0; j < records.length; j++) {
1136                             if (records[j] == i)
1137                                 found = true;
1138                         }
1139                         if (!found) {
1140                             negRecords.push(i);
1141                         }
1142                     }
1143                     records = negRecords;
1144                 }
1145 
1146                 if (recordNotFound) {
1147                     // cannot find any matching records
1148                     if (me.column.facetingBehaviorType != 'ALWAYS_ON')
1149                         me.fireEvent('invalidfilter');
1150                     return;
1151                 }
1152 
1153                 this.getSelectionModel().selectRows(records);
1154             },
1155             selectAll : function() {
1156                 if (this.rendered) {
1157                     var sm = this.getSelectionModel();
1158                     sm.selectAll.defer(10, sm);
1159                 }
1160                 else {
1161                     this.on('render', this.selectAll, this, {single: true});
1162                 }
1163             },
1164             selectNone : function() {
1165                 if (this.rendered) {
1166                     this.getSelectionModel().selectRows([]);
1167                 }
1168                 else {
1169                     this.on('render', this.selectNone, this, {single: true});
1170                 }
1171             },
1172             determineNegation: function(filter) {
1173                 var suffix = filter.getFilterType().getURLSuffix();
1174                 var negated = suffix == 'neqornull' || suffix == 'notin';
1175 
1176                 // negation of the null case is a bit different so check it as a special case.
1177                 var value = filter.getURLParameterValue();
1178                 if (value == "" && suffix != 'isblank') {
1179                     negated = true;
1180                 }
1181                 return negated;
1182             },
1183             selectFilter : function(filter) {
1184                 var negated = this.determineNegation(filter);
1185 
1186                 this.setValue(filter.getURLParameterValue(), negated);
1187 
1188                 if (!me.filterOptimization && negated) {
1189                     me.fireEvent('invalidfilter');
1190                 }
1191             },
1192             scope : this
1193         };
1194     },
1195 
1196     onViewReady : function() {
1197         if (this.gridReady && this.storeReady) {
1198             var grid = Ext.getCmp(this.gridID);
1199             this.hideMask();
1200 
1201             if (grid) {
1202 
1203                 var numFilters = this.filters.length;
1204                 var numFacets = grid.store.getCount();
1205 
1206                 // apply current filter
1207                 if (numFacets == 0)
1208                     grid.selectNone();
1209                 else if (numFilters == 0)
1210                     grid.selectAll();
1211                 else
1212                     grid.selectFilter(this.filters[0]);
1213 
1214                 if (!grid.headerClick) {
1215                     grid.headerClick = true;
1216                     var div = Ext.fly(grid.getView().getHeaderCell(1)).first('div');
1217                     div.on('click', grid.onHeaderCellClick, grid);
1218                 }
1219 
1220                 // Issue 39727 - show a message if we've capped the number of options shown
1221                 Ext.getCmp(this.gridID + 'OverflowLabel').setVisible(this.overflow);
1222 
1223                 if (this.overflow) {
1224                     this.fireEvent('invalidfilter');
1225                 }
1226             }
1227         }
1228 
1229         this.changed = false;
1230     },
1231 
1232     getLookupStore : function() {
1233         var dr = this.getDataRegion();
1234         var storeId = this.cacheResults ? [dr.schemaName, dr.queryName, this.fieldKey].join('||') : Ext.id();
1235 
1236         // cache
1237         var store = Ext.StoreMgr.get(storeId);
1238         if (store) {
1239             this.storeReady = true; // unsafe
1240             return store;
1241         }
1242 
1243         store = new Ext.data.ArrayStore({
1244             fields : ['value', 'strValue', 'displayValue'],
1245             storeId: storeId
1246         });
1247 
1248         var config = {
1249             schemaName: dr.schemaName,
1250             queryName: dr.queryName,
1251             dataRegionName: dr.name,
1252             viewName: dr.viewName,
1253             column: this.fieldKey,
1254             filterArray: dr.filters,
1255             containerPath: dr.container || dr.containerPath || LABKEY.container.path,
1256             containerFilter: dr.getContainerFilter(),
1257             parameters: dr.getParameters(),
1258             maxRows: this.MAX_FILTER_CHOICES+1,
1259             ignoreFilter: dr.ignoreFilter,
1260             success : function(d) {
1261                 if (d && d.values) {
1262                     var recs = [], v, i=0, hasBlank = false, isString, formattedValue;
1263 
1264                     // Issue 39727 - remember if we exceeded our cap so we can show a message
1265                     this.overflow = d.values.length > this.MAX_FILTER_CHOICES;
1266 
1267                     for (; i < Math.min(d.values.length, this.MAX_FILTER_CHOICES); i++) {
1268                         v = d.values[i];
1269                         formattedValue = this.formatValue(v);
1270                         isString = Ext.isString(formattedValue);
1271 
1272                         if (formattedValue == null || (isString && formattedValue.length == 0) || (!isString && isNaN(formattedValue))) {
1273                             hasBlank = true;
1274                         }
1275                         else if (Ext.isDefined(v)) {
1276                             recs.push([v, v.toString(), v.toString()]);
1277                         }
1278                     }
1279 
1280                     if (hasBlank)
1281                         recs.unshift(['', '', this.emptyDisplayValue]);
1282 
1283                     store.loadData(recs);
1284                     store.isLoading = false;
1285                     this.storeReady = true;
1286                     this.onViewReady();
1287                 }
1288             },
1289             scope: this
1290         };
1291 
1292         if (this.applyContextFilters) {
1293             var userFilters = dr.getUserFilterArray();
1294             if (userFilters && userFilters.length > 0) {
1295 
1296                 var uf = [];
1297 
1298                 // Remove filters for the current column
1299                 for (var i=0; i < userFilters.length; i++) {
1300                     if (userFilters[i].getColumnName() != this.fieldKey) {
1301                         uf.push(userFilters[i]);
1302                     }
1303                 }
1304 
1305                 config.filterArray = uf;
1306             }
1307         }
1308 
1309         // Use Select Distinct
1310         LABKEY.Query.selectDistinctRows(config);
1311 
1312         return Ext.StoreMgr.add(store);
1313     },
1314 
1315     onPanelRender : function(panel) {
1316         var toAdd = [{
1317             xtype: 'panel',
1318             width: this.width - 40, //prevent horizontal scroll
1319             bodyStyle: 'padding-left: 5px;',
1320             items: [ this.getGridConfig(0) ],
1321             listeners : {
1322                 afterrender : {
1323                     fn: this.showMask,
1324                     scope: this,
1325                     single: true
1326                 }
1327             }
1328         }];
1329         panel.add(toAdd);
1330     },
1331 
1332     showMask : function() {
1333         if (!this.gridReady && this.getEl()) {
1334             this.getEl().mask('Loading...');
1335         }
1336     },
1337 
1338     hideMask : function() {
1339         if (this.getEl()) {
1340             this.getEl().unmask();
1341         }
1342     }
1343 });
1344 
1345 Ext.reg('filter-view-faceted', LABKEY.FilterDialog.View.Faceted);
1346 
1347 Ext.ns('LABKEY.ext');
1348 
1349 LABKEY.ext.BooleanTextField = Ext.extend(Ext.form.TextField, {
1350     initComponent : function() {
1351         Ext.apply(this, {
1352             validator: function(val){
1353                 if(!val)
1354                     return true;
1355 
1356                 return LABKEY.Utils.isBoolean(val) ? true : val + " is not a valid boolean. Try true/false; yes/no; on/off; or 1/0.";
1357             }
1358         });
1359         LABKEY.ext.BooleanTextField.superclass.initComponent.call(this);
1360     }
1361 });
1362 
1363 Ext.reg('labkey-booleantextfield', LABKEY.ext.BooleanTextField);
1364 
1365