1 /*
  2  * Copyright (c) 2013-2018 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.getValue());
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     initComponent : function() {
839 
840         Ext.apply(this, {
841             title  : 'Choose Values',
842             border : false,
843             height : 200,
844             bodyStyle: 'overflow-x: hidden; overflow-y: auto',
845             bubbleEvents: ['add', 'remove', 'clientvalidation'],
846             defaults : {
847                 border : false
848             },
849             markDisabled : true,
850             items: [{
851                 layout: 'hbox',
852                 style: 'padding-bottom: 5px; overflow-x: hidden',
853                 width: 100,
854                 defaults: {
855                     border: false
856                 },
857                 items: []
858             }]
859         });
860 
861         LABKEY.FilterDialog.View.Faceted.superclass.initComponent.call(this);
862 
863         this.on('render', this.onPanelRender, this, {single: true});
864     },
865 
866     formatValue : function(val) {
867         if(this.column) {
868             if (this.column.extFormatFn) {
869                 try {
870                     this.column.extFormatFn = eval(this.column.extFormatFn);
871                 }
872                 catch (error) {
873                     console.log('improper extFormatFn: ' + this.column.extFormatFn);
874                 }
875 
876                 if (Ext.isFunction(this.column.extFormatFn)) {
877                     val = this.column.extFormatFn(val);
878                 }
879             }
880             else if (this.jsonType == 'int') {
881                 val = parseInt(val);
882             }
883         }
884         return val;
885     },
886 
887     // copied from Ext 4 Ext.Array.difference
888     difference : function(arrayA, arrayB) {
889         var clone = arrayA.slice(),
890                 ln = clone.length,
891                 i, j, lnB;
892 
893         for (i = 0,lnB = arrayB.length; i < lnB; i++) {
894             for (j = 0; j < ln; j++) {
895                 if (clone[j] === arrayB[i]) {
896                     clone.splice(j, 1);
897                     j--;
898                     ln--;
899                 }
900             }
901         }
902 
903         return clone;
904     },
905 
906     constructFilter : function(selected, unselected) {
907         var filter = null;
908 
909         if (selected.length > 0) {
910 
911             var columnName = this.fieldKey;
912 
913             // one selection
914             if (selected.length == 1) {
915                 if (selected[0].get('displayValue') == this.emptyDisplayValue)
916                     filter = LABKEY.Filter.create(columnName, null, LABKEY.Filter.Types.ISBLANK);
917                 else
918                     filter = LABKEY.Filter.create(columnName, selected[0].get('value')); // default EQUAL
919             }
920             else if (this.filterOptimization && selected.length > unselected.length) {
921                 // Do the negation
922                 if (unselected.length == 1) {
923                     var val = unselected[0].get('value');
924                     var type = (val === "" ? LABKEY.Filter.Types.NONBLANK : LABKEY.Filter.Types.NOT_EQUAL_OR_MISSING);
925 
926                     // 18716: Check if 'unselected' contains empty value
927                     filter = LABKEY.Filter.create(columnName, val, type);
928                 }
929                 else
930                     filter = LABKEY.Filter.create(columnName, this.delimitValues(unselected), LABKEY.Filter.Types.NOT_IN);
931             }
932             else {
933                 filter = LABKEY.Filter.create(columnName, this.delimitValues(selected), LABKEY.Filter.Types.IN);
934             }
935         }
936 
937         return filter;
938     },
939 
940     delimitValues : function(valueArray) {
941         var value = '', sep = '';
942         for (var s=0; s < valueArray.length; s++) {
943             value += sep + valueArray[s].get('value');
944             sep = ';';
945         }
946         return value;
947     },
948 
949     // Implement interface LABKEY.FilterDialog.ViewPanel
950     getFilters : function() {
951         var grid = Ext.getCmp(this.gridID);
952         var filters = [];
953 
954         if (grid) {
955             var store = grid.store;
956             var count = store.getCount(); // TODO: Check if store loaded
957             var selected = grid.getSelectionModel().getSelections();
958 
959             if (count == 0 || selected.length == 0 || selected.length == count) {
960                 filters = [];
961             }
962             else {
963                 var unselected = this.filterOptimization ? this.difference(store.getRange(), selected) : [];
964                 filters = [this.constructFilter(selected, unselected)];
965             }
966         }
967 
968         return filters;
969     },
970 
971     // Implement interface LABKEY.FilterDialog.ViewPanel
972     setFilters : function(filterArray) {
973         if (Ext.isArray(filterArray)) {
974             this.filters = filterArray;
975             this.onViewReady();
976         }
977     },
978 
979     getGridConfig : function(idx) {
980         var sm = new Ext.grid.CheckboxSelectionModel({
981             listeners: {
982                 selectionchange: {
983                     fn: function(sm) {
984                         // NOTE: this will manually set the checked state of the header checkbox.  it would be better
985                         // to make this a real tri-state (ie. selecting some records is different then none), but since this is still Ext3
986                         // and ext4 will be quite different it doesnt seem worth the effort right now
987                         var selections = sm.getSelections();
988                         var headerCell = Ext.fly(sm.grid.getView().getHeaderCell(0)).first('div');
989                         if(selections.length == sm.grid.store.getCount()){
990                             headerCell.addClass('x-grid3-hd-checker-on');
991                         }
992                         else {
993                             headerCell.removeClass('x-grid3-hd-checker-on');
994                         }
995 
996 
997                     },
998                     buffer: 50
999                 }
1000             }
1001         });
1002 
1003         this.gridID = Ext.id();
1004         var me = this;
1005 
1006         return {
1007             xtype: 'grid',
1008             id: this.gridID,
1009             border: true,
1010             bodyBorder: true,
1011             frame: false,
1012             autoHeight: true,
1013             itemId: 'inputField' + (idx || 0),
1014             filterIndex: idx || 0,
1015             msgTarget: 'title',
1016             store: this.getLookupStore(),
1017             headerClick: false,
1018             viewConfig: {
1019                 headerTpl: new Ext.Template(
1020                     '<table border="0" cellspacing="0" cellpadding="0" style="{tstyle}">',
1021                         '<thead>',
1022                             '<tr class="x-grid3-row-table">{cells}</tr>',
1023                         '</thead>',
1024                     '</table>'
1025                 )
1026             },
1027             sm: sm,
1028             cls: 'x-grid-noborder',
1029             columns: [
1030                 sm,
1031                 new Ext.grid.TemplateColumn({
1032                     header: '<a href="javascript:void(0);">[All]</a>',
1033                     dataIndex: 'value',
1034                     menuDisabled: true,
1035                     resizable: false,
1036                     width: 340,
1037                     tpl: new Ext.XTemplate('<tpl for=".">' +
1038                             '<span class="labkey-link" title="{[Ext.util.Format.htmlEncode(values["displayValue"])]}">' +
1039                             '{[Ext.util.Format.htmlEncode(values["displayValue"])]}' +
1040                             '</span></tpl>')
1041                 })
1042             ],
1043             listeners: {
1044                 afterrender : function(grid) {
1045                     grid.getSelectionModel().on('selectionchange', function() {
1046                         this.changed = true;
1047                     }, this);
1048 
1049                     grid.on('viewready', function(g) {
1050                         this.gridReady = true;
1051                         this.onViewReady();
1052                     }, this, {single: true});
1053                 },
1054                 scope : this
1055             },
1056             // extend toggle behavior to the header cell, not just the checkbox next to it
1057             onHeaderCellClick : function() {
1058                 var sm = this.getSelectionModel();
1059                 var selected = sm.getSelections();
1060                 selected.length == this.store.getCount() ? this.selectNone() : this.selectAll();
1061             },
1062             getValue : function() {
1063                 var vals = this.getValues();
1064                 if (vals.length == vals.max) {
1065                     return [];
1066                 }
1067                 return vals.values;
1068             },
1069             getValues : function() {
1070                 var values = [],
1071                         sels   = this.getSelectionModel().getSelections();
1072 
1073                 Ext.each(sels, function(rec){
1074                     values.push(rec.get('strValue'));
1075                 }, this);
1076 
1077                 if(values.indexOf('') != -1 && values.length == 1)
1078                     values.push(''); //account for null-only filtering
1079 
1080                 return {
1081                     values : values.join(';'),
1082                     length : values.length,
1083                     max    : this.getStore().getCount()
1084                 };
1085             },
1086             setValue : function(values, negated) {
1087                 if (!this.rendered) {
1088                     this.on('render', function() {
1089                         this.setValue(values, negated);
1090                     }, this, {single: true});
1091                 }
1092 
1093                 if (!Ext.isArray(values)) {
1094                     values = values.split(';');
1095                 }
1096 
1097                 if (this.store.isLoading) {
1098                     // need to wait for the store to load to ensure records
1099                     this.store.on('load', function() {
1100                         this._checkAndLoadValues(values, negated);
1101                     }, this, {single: true});
1102                 }
1103                 else {
1104                     this._checkAndLoadValues(values, negated);
1105                 }
1106             },
1107             _checkAndLoadValues : function(values, negated) {
1108                 var records = [],
1109                         recIdx,
1110                         recordNotFound = false;
1111 
1112                 Ext.each(values, function(val) {
1113                     recIdx = this.store.findBy(function(rec){
1114                         return rec.get('strValue') === val;
1115                     });
1116 
1117                     if (recIdx != -1) {
1118                         records.push(recIdx);
1119                     }
1120                     else {
1121                         // Issue 14710: if the record isnt found, we wont be able to select it, so should reject.
1122                         // If it's null/empty, ignore silently
1123                         if (!Ext.isEmpty(val)) {
1124                             recordNotFound = true;
1125                             return false;
1126                         }
1127                     }
1128                 }, this);
1129 
1130                 if (negated) {
1131                     var count = this.store.getCount(), found = false, negRecords = [];
1132                     for (var i=0; i < count; i++) {
1133                         found = false;
1134                         for (var j=0; j < records.length; j++) {
1135                             if (records[j] == i)
1136                                 found = true;
1137                         }
1138                         if (!found) {
1139                             negRecords.push(i);
1140                         }
1141                     }
1142                     records = negRecords;
1143                 }
1144 
1145                 if (recordNotFound) {
1146                     // cannot find any matching records
1147                     if (me.column.facetingBehaviorType != 'ALWAYS_ON')
1148                         me.fireEvent('invalidfilter');
1149                     return;
1150                 }
1151 
1152                 this.getSelectionModel().selectRows(records);
1153             },
1154             selectAll : function() {
1155                 if (this.rendered) {
1156                     var sm = this.getSelectionModel();
1157                     sm.selectAll.defer(10, sm);
1158                 }
1159                 else {
1160                     this.on('render', this.selectAll, this, {single: true});
1161                 }
1162             },
1163             selectNone : function() {
1164                 if (this.rendered) {
1165                     this.getSelectionModel().selectRows([]);
1166                 }
1167                 else {
1168                     this.on('render', this.selectNone, this, {single: true});
1169                 }
1170             },
1171             determineNegation: function(filter) {
1172                 var suffix = filter.getFilterType().getURLSuffix();
1173                 var negated = suffix == 'neqornull' || suffix == 'notin';
1174 
1175                 // negation of the null case is a bit different so check it as a special case.
1176                 var value = filter.getURLParameterValue();
1177                 if (value == "" && suffix != 'isblank') {
1178                     negated = true;
1179                 }
1180                 return negated;
1181             },
1182             selectFilter : function(filter) {
1183                 var negated = this.determineNegation(filter);
1184 
1185                 this.setValue(filter.getURLParameterValue(), negated);
1186 
1187                 if (!me.filterOptimization && negated) {
1188                     me.fireEvent('invalidfilter');
1189                 }
1190             },
1191             scope : this
1192         };
1193     },
1194 
1195     onViewReady : function() {
1196         if (this.gridReady && this.storeReady) {
1197             var grid = Ext.getCmp(this.gridID);
1198             this.hideMask();
1199 
1200             if (grid) {
1201 
1202                 var numFilters = this.filters.length;
1203                 var numFacets = grid.store.getCount();
1204 
1205                 // apply current filter
1206                 if (numFacets == 0)
1207                     grid.selectNone();
1208                 else if (numFilters == 0)
1209                     grid.selectAll();
1210                 else
1211                     grid.selectFilter(this.filters[0]);
1212 
1213                 if (!grid.headerClick) {
1214                     grid.headerClick = true;
1215                     var div = Ext.fly(grid.getView().getHeaderCell(1)).first('div');
1216                     div.on('click', grid.onHeaderCellClick, grid);
1217                 }
1218 
1219                 if (numFacets > this.MAX_FILTER_CHOICES) {
1220                     this.fireEvent('invalidfilter');
1221                 }
1222             }
1223         }
1224 
1225         this.changed = false;
1226     },
1227 
1228     getLookupStore : function() {
1229         var dr = this.getDataRegion();
1230         var storeId = this.cacheResults ? [dr.schemaName, dr.queryName, this.fieldKey].join('||') : Ext.id();
1231 
1232         // cache
1233         var store = Ext.StoreMgr.get(storeId);
1234         if (store) {
1235             this.storeReady = true; // unsafe
1236             return store;
1237         }
1238 
1239         store = new Ext.data.ArrayStore({
1240             fields : ['value', 'strValue', 'displayValue'],
1241             storeId: storeId
1242         });
1243 
1244         var config = {
1245             schemaName: dr.schemaName,
1246             queryName: dr.queryName,
1247             dataRegionName: dr.name,
1248             viewName: dr.viewName,
1249             column: this.fieldKey,
1250             filterArray: dr.filters,
1251             containerPath: dr.container || dr.containerPath || LABKEY.container.path,
1252             containerFilter: dr.getContainerFilter(),
1253             parameters: dr.getParameters(),
1254             maxRows: this.MAX_FILTER_CHOICES+1,
1255             ignoreFilter: dr.ignoreFilter,
1256             success : function(d) {
1257                 if (d && d.values) {
1258                     var recs = [], v, i=0, hasBlank = false, isString, formattedValue;
1259                     for (; i < d.values.length; i++) {
1260                         v = d.values[i];
1261                         formattedValue = this.formatValue(v);
1262                         isString = Ext.isString(formattedValue);
1263 
1264                         if (formattedValue == null || (isString && formattedValue.length == 0) || (!isString && isNaN(formattedValue))) {
1265                             hasBlank = true;
1266                         }
1267                         else if (Ext.isDefined(v)) {
1268                             recs.push([v, v.toString(), v.toString()]);
1269                         }
1270                     }
1271 
1272                     if (hasBlank)
1273                         recs.unshift(['', '', this.emptyDisplayValue]);
1274 
1275                     store.loadData(recs);
1276                     store.isLoading = false;
1277                     this.storeReady = true;
1278                     this.onViewReady();
1279                 }
1280             },
1281             scope: this
1282         };
1283 
1284         if (this.applyContextFilters) {
1285             var userFilters = dr.getUserFilterArray();
1286             if (userFilters && userFilters.length > 0) {
1287 
1288                 var uf = [];
1289 
1290                 // Remove filters for the current column
1291                 for (var i=0; i < userFilters.length; i++) {
1292                     if (userFilters[i].getColumnName() != this.fieldKey) {
1293                         uf.push(userFilters[i]);
1294                     }
1295                 }
1296 
1297                 config.filterArray = uf;
1298             }
1299         }
1300 
1301         // Use Select Distinct
1302         LABKEY.Query.selectDistinctRows(config);
1303 
1304         return Ext.StoreMgr.add(store);
1305     },
1306 
1307     onPanelRender : function(panel) {
1308         var toAdd = [{
1309             xtype: 'panel',
1310             width: this.width - 40, //prevent horizontal scroll
1311             bodyStyle: 'padding-left: 5px;',
1312             items: [ this.getGridConfig(0) ],
1313             listeners : {
1314                 afterrender : {
1315                     fn: this.showMask,
1316                     scope: this,
1317                     single: true
1318                 }
1319             }
1320         }];
1321         panel.add(toAdd);
1322     },
1323 
1324     showMask : function() {
1325         if (!this.gridReady && this.getEl()) {
1326             this.getEl().mask('Loading...');
1327         }
1328     },
1329 
1330     hideMask : function() {
1331         if (this.getEl()) {
1332             this.getEl().unmask();
1333         }
1334     }
1335 });
1336 
1337 Ext.reg('filter-view-faceted', LABKEY.FilterDialog.View.Faceted);
1338 
1339 Ext.ns('LABKEY.ext');
1340 
1341 LABKEY.ext.BooleanTextField = Ext.extend(Ext.form.TextField, {
1342     initComponent : function() {
1343         Ext.apply(this, {
1344             validator: function(val){
1345                 if(!val)
1346                     return true;
1347 
1348                 return LABKEY.Utils.isBoolean(val) ? true : val + " is not a valid boolean. Try true/false; yes/no; on/off; or 1/0.";
1349             }
1350         });
1351         LABKEY.ext.BooleanTextField.superclass.initComponent.call(this);
1352     }
1353 });
1354 
1355 Ext.reg('labkey-booleantextfield', LABKEY.ext.BooleanTextField);
1356 
1357