1 /*
  2  * Copyright (c) 2013-2016 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: 'padding: 5px;',
251                 border: true,
252                 width: this.width - 5,
253                 items: views
254             };
255 
256             if (type == 'TabPanel') {
257                 config.listeners = {
258                     beforetabchange : function(tp, newTab, oldTab) {
259                         if (this.carryfilter && newTab && oldTab && oldTab.isChanged()) {
260                             newTab.setFilters(oldTab.getFilters());
261                         }
262                     },
263                     tabchange : function() {
264                         this.syncShadow();
265                         this.viewcontainer.getActiveTab().doLayout(); // required when facets return while on another tab
266                     },
267                     scope : this
268                 };
269             }
270 
271             if (views.length > 1) {
272                 config.activeTab = (this.allowFaceting() ? 1 : 0);
273             }
274             else {
275                 views[0].title = false;
276             }
277 
278             this.viewcontainer = new Ext[type](config);
279 
280             if (!Ext.isFunction(this.viewcontainer.getActiveTab)) {
281                 var me = this;
282                 this.viewcontainer.getActiveTab = function() {
283                     return me.viewcontainer.items.items[0];
284                 };
285                 // views attempt to hook the 'activate' event but some panel types do not fire
286                 // force fire on the first view
287                 this.viewcontainer.items.items[0].on('afterlayout', function(p) {
288                     p.fireEvent('activate', p);
289                 }, this, {single: true});
290             }
291         }
292 
293         return this.viewcontainer;
294     },
295 
296     _getFilters : function() {
297         var filters = [];
298 
299         var dr = this.getDataRegion();
300         if (dr) {
301             Ext.each(dr.getUserFilterArray(), function(ff) {
302                 if (this.column.lookup && this.column.displayField && ff.getColumnName() == this.column.displayField) {
303                     filters.push(ff);
304                 }
305                 else if (this.column.fieldKey && ff.getColumnName() == this.column.fieldKey) {
306                     filters.push(ff);
307                 }
308             }, this);
309         }
310         else if (this.queryString) { // deprecated
311             filters = LABKEY.Filter.getFiltersFromUrl(this.queryString, this.dataRegionName);
312         }
313 
314         return filters;
315     },
316 
317     // Override to return your own filter views
318     getViews : function() {
319 
320         var filters = this._getFilters(), views = [];
321 
322         // default view
323         views.push({
324             xtype: 'filter-view-default',
325             column: this.column,
326             fieldKey: this.fieldKey, // should not have to hand this in bc the column should supply correctly
327             dataRegionName: this.dataRegionName,
328             jsonType : this.jsonType,
329             filters: filters
330         });
331 
332         // facet view
333         if (this.allowFaceting()) {
334             views.push({
335                 xtype: 'filter-view-faceted',
336                 column: this.column,
337                 fieldKey: this.fieldKey, // should not have to hand this in bc the column should supply correctly
338                 dataRegionName: this.dataRegionName,
339                 jsonType : this.jsonType,
340                 filters: filters,
341                 cacheResults: this.cacheFacetResults,
342                 listeners: {
343                     invalidfilter : function() {
344                         this.carryfilter = false;
345                         this.getContainer().setActiveTab(0);
346                         this.getContainer().getActiveTab().doLayout();
347                         this.carryfilter = true;
348                     },
349                     scope: this
350                 },
351                 scope: this
352             })
353         }
354 
355         return views;
356     }
357 });
358 
359 LABKEY.FilterDialog.ViewPanel = Ext.extend(Ext.form.FormPanel, {
360 
361     supportsMultipleFilters: false,
362 
363     filters : [],
364 
365     changed : false,
366 
367     initComponent : function() {
368         if (!this['dataRegionName']) {
369             console.error('dataRegionName is requied for a LABKEY.FilterDialog.ViewPanel');
370             return;
371         }
372         LABKEY.FilterDialog.ViewPanel.superclass.initComponent.call(this);
373     },
374 
375     // Override to provide own view validation
376     checkValid : function() {
377         return true;
378     },
379 
380     getDataRegion : function() {
381         return LABKEY.DataRegions[this.dataRegionName];
382     },
383 
384     getFilters : function() {
385         console.error('All classes which extend LABKEY.FilterDialog.ViewPanel must implement getFilters()');
386     },
387 
388     setFilters : function(filterArray) {
389         console.error('All classes which extend LABKEY.FilterDialog.ViewPanel must implement setFilters(filterArray)');
390     },
391 
392     getXtype : function() {
393         switch (this.jsonType) {
394             case "date":
395                 return "datefield";
396             case "int":
397             case "float":
398                 return "textfield";
399             case "boolean":
400                 return 'labkey-booleantextfield';
401             default:
402                 return "textfield";
403         }
404     },
405 
406     // Returns true if a view has been altered since the last time it was activated
407     isChanged : function() {
408         return this.changed;
409     }
410 });
411 
412 Ext.ns('LABKEY.FilterDialog.View');
413 
414 LABKEY.FilterDialog.View.Default = Ext.extend(LABKEY.FilterDialog.ViewPanel, {
415 
416     supportsMultipleFilters: true,
417 
418     itemDefaults: {
419         border: false,
420         msgTarget: 'under'
421     },
422 
423     initComponent : function() {
424 
425         Ext.apply(this, {
426             autoHeight: true,
427             title: this.title === false ? false : 'Choose Filters',
428             bodyStyle: 'padding: 5px;',
429             bubbleEvents: ['add', 'remove', 'clientvalidation'],
430             defaults: { border: false },
431             items: this.generateFilterDisplays(2)
432         });
433 
434         this.combos = [];
435         this.inputs = [];
436 
437         LABKEY.FilterDialog.View.Default.superclass.initComponent.call(this);
438 
439         this.on('activate', this.onViewReady, this, {single: true});
440     },
441 
442     onViewReady : function() {
443         if (this.filters.length == 0) {
444             for (var c=0; c < this.combos.length; c++) {
445                 // Update the input enabled/disabled status by using the 'select' event listener on the combobox.
446                 // However, ComboBox doesn't fire 'select' event when changed programatically so we fire it manually.
447                 this.combos[c].reset();
448                 this.combos[c].fireEvent('select', this.combos[c], null);
449                 this.inputs[c].reset();
450             }
451         }
452         else {
453             for (var f=0; f < this.filters.length; f++) {
454                 if (f < this.combos.length) {
455                     var filter = this.filters[f];
456                     var combo = this.combos[f];
457 
458                     // Update the input enabled/disabled status by using the 'select' event listener on the combobox.
459                     // However, ComboBox doesn't fire 'select' event when changed programatically so we fire it manually.
460                     var store = combo.getStore();
461                     if (store) {
462                         var rec = store.getAt(store.find('value', filter.getFilterType().getURLSuffix()));
463                         if (rec) {
464                             combo.setValue(filter.getFilterType().getURLSuffix());
465                             combo.fireEvent('select', combo, rec);
466                         }
467                     }
468 
469                     this.inputs[f].setValue(filter.getValue());
470                 }
471             }
472         }
473 
474         //Issue 24550: always select the first filter field, and also select text if present
475         if (this.inputs[0]) {
476             this.inputs[0].focus(true, 100, this.inputs[0]);
477         }
478 
479         this.changed = false;
480     },
481 
482     checkValid : function() {
483         var combos = this.combos;
484         var inputs = this.inputs, input, value, f;
485 
486         var isValid = true;
487 
488         Ext.each(combos, function(c, i) {
489             if (!c.isValid()) {
490                 isValid = false;
491             }
492             else {
493                 input = inputs[i];
494                 value = input.getValue();
495 
496                 f = LABKEY.Filter.getFilterTypeForURLSuffix(c.getValue());
497 
498                 if (!f) {
499                     alert('filter not found: ' + c.getValue());
500                     return;
501                 }
502 
503                 if (f.isDataValueRequired() && Ext.isEmpty(value)) {
504                     input.markInvalid('You must enter a value');
505                     isValid = false;
506                 }
507             }
508         });
509 
510         return isValid;
511     },
512 
513     inputFieldValidator : function(input, combo) {
514 
515         var store = combo.getStore();
516         if (store) {
517             var rec = store.getAt(store.find('value', combo.getValue()));
518             var filter = LABKEY.Filter.getFilterTypeForURLSuffix(combo.getValue());
519 
520             if (rec) {
521                 if (filter.isMultiValued())
522                     return this.validateMultiValueInput(input.getValue(), filter.getMultiValueSeparator(), filter.getMultiValueMinOccurs(), filter.getMultiValueMaxOccurs());
523                 return this.validateInputField(input.getValue());
524             }
525         }
526         return true;
527     },
528 
529     generateFilterDisplays : function(quantity) {
530         var idx = this.nextIndex(), items = [], i=0;
531 
532         for(; i < quantity; i++) {
533             items.push({
534                 xtype: 'panel',
535                 layout: 'form',
536                 itemId: 'filterPair' + idx,
537                 border: false,
538                 defaults: this.itemDefaults,
539                 items: [this.getComboConfig(idx), this.getInputConfig(idx)],
540                 scope: this
541             });
542             idx++;
543         }
544         return items;
545     },
546 
547     getComboConfig : function(idx) {
548 
549         var val = idx === 0 ? LABKEY.Filter.getDefaultFilterForType(this.jsonType).getURLSuffix() : '';
550         return {
551             xtype: 'combo',
552             itemId: 'filterComboBox' + idx,
553             filterIndex: idx,
554             name: 'filterType_'+(idx + 1),   //for compatibility with tests...
555             listWidth: (this.jsonType == 'date' || this.jsonType == 'boolean') ? null : 380,
556             emptyText: idx === 0 ? 'Choose a filter:' : 'No other filter',
557             autoSelect: false,
558             width: 250,
559             minListWidth: 250,
560             triggerAction: 'all',
561             fieldLabel: (idx === 0 ?'Filter Type' : 'and'),
562             store: this.getSelectionStore(idx),
563             displayField: 'text',
564             valueField: 'value',
565             typeAhead: 'false',
566             forceSelection: true,
567             mode: 'local',
568             clearFilterOnReset: false,
569             editable: false,
570             value: val,
571             originalValue: val,
572             listeners : {
573                 render : function(combo) {
574                     this.combos.push(combo);
575                     // Update the associated inputField's enabled/disabled state on initial render
576                     this.enableInputField(combo);
577                 },
578                 select : function (combo) {
579                     this.changed = true;
580                     this.enableInputField(combo);
581                 },
582                 scope: this
583             },
584             scope: this
585         };
586     },
587 
588     enableInputField : function (combo) {
589 
590         var idx = combo.filterIndex;
591         var inputField = this.find('itemId', 'inputField'+idx)[0];
592 
593         var filter = LABKEY.Filter.getFilterTypeForURLSuffix(combo.getValue());
594         var selectedValue = filter ? filter.getURLSuffix() : '';
595 
596         var combos = this.combos;
597         var inputFields = this.inputs;
598 
599         if (filter && !filter.isDataValueRequired()) {
600             //Disable the field and allow it to be blank for values 'isblank' and 'isnonblank'.
601             inputField.disable();
602             inputField.setValue();
603         }
604         else {
605             inputField.enable();
606             inputField.validate();
607             inputField.focus('', 50)
608         }
609 
610         //if the value is null, this indicates no filter chosen.  if it lacks an operator (ie. isBlank)
611         //in either case, this means we should disable all other filters
612         if(selectedValue == '' || !filter.isDataValueRequired()){
613             //Disable all subsequent combos
614             Ext.each(combos, function(combo, idx){
615                 //we enable the next combo in the series
616                 if(combo.filterIndex == this.filterIndex + 1){
617                     combo.setValue();
618                     inputFields[idx].setValue();
619                     inputFields[idx].enable();
620                     inputFields[idx].validate();
621                 }
622                 else if (combo.filterIndex > this.filterIndex){
623                     combo.setValue();
624                     inputFields[idx].disable();
625                 }
626 
627             }, this);
628         }
629         else{
630             //enable the other filterComboBoxes.
631             Ext.each(combos, function(combo, i) { combo.enable(); }, this);
632 
633             if (combos.length) {
634                 combos[0].focus('', 50);
635             }
636         }
637     },
638 
639     getFilters : function() {
640 
641         var inputs = this.inputs;
642         var combos = this.combos;
643         var value, type, filters = [];
644 
645         Ext.each(combos, function(c, i) {
646             if (!inputs[i].disabled || (c.getRawValue() != 'No Other Filter')) {
647                 value = inputs[i].getValue();
648                 type = LABKEY.Filter.getFilterTypeForURLSuffix(c.getValue());
649 
650                 if (!type) {
651                     alert('Filter not found for suffix: ' + c.getValue());
652                 }
653 
654                 filters.push(LABKEY.Filter.create(this.fieldKey, value, type));
655             }
656         }, this);
657 
658         return filters;
659     },
660 
661     getInputConfig : function(idx) {
662         var me = this;
663         return {
664             xtype         : this.getXtype(),
665             itemId        : 'inputField' + idx,
666             filterIndex   : idx,
667             id            : 'value_'+(idx + 1),   //for compatibility with tests...
668             width         : 250,
669             blankText     : 'You must enter a value.',
670             validateOnBlur: true,
671             value         : null,
672             altFormats: (this.jsonType == "date" ? LABKEY.Utils.getDateAltFormats() : undefined),
673             validator : function(value) {
674 
675                 // support for filtering '∞'
676                 if (me.jsonType == 'float' && value.indexOf('∞') > -1) {
677                     value = value.replace('∞', 'Infinity');
678                     this.setRawValue(value); // does not fire validation
679                 }
680 
681                 var combos = me.combos;
682                 if (!combos.length) {
683                     return;
684                 }
685 
686                 return me.inputFieldValidator(this, combos[idx]);
687             },
688             listeners: {
689                 disable : function(field){
690                     //Call validate after disable so any pre-existing validation errors go away.
691                     if(field.rendered) {
692                         field.validate();
693                     }
694                 },
695                 focus : function(f) {
696                     if (this.focusTask) {
697                         Ext.TaskMgr.stop(this.focusTask);
698                     }
699                 },
700                 render : function(input) {
701                     me.inputs.push(input);
702                     if (!me.focusReady) {
703                         me.focusReady = true;
704                         // create a task to set the input focus that will get started after layout is complete,
705                         // the task will run for a max of 2000ms but will get stopped when the component receives focus
706                         this.focusTask = {interval:150, run: function(){
707                             input.focus(null, 50);
708                             Ext.TaskMgr.stop(this.focusTask);
709                         }, scope: this, duration: 2000};
710                     }
711                 },
712                 change : function(input, newVal, oldVal) {
713                     if (oldVal != newVal) {
714                         this.changed = true;
715                     }
716                 },
717                 scope : this
718             },
719             scope: this
720         };
721     },
722 
723     getSelectionStore : function(storeNum) {
724         var fields = ['text', 'value',
725             {name: 'isMulti', type: Ext.data.Types.BOOL},
726             {name: 'isOperatorOnly', type: Ext.data.Types.BOOL}
727         ];
728         var store = new Ext.data.ArrayStore({
729             fields: fields,
730             idIndex: 1
731         });
732         var comboRecord = Ext.data.Record.create(fields);
733 
734         var filters = LABKEY.Filter.getFilterTypesForType(this.jsonType, this.column.mvEnabled);
735         for (var i=0; i<filters.length; i++)
736         {
737             var filter = filters[i];
738             store.add(new comboRecord({
739                 text: filter.getLongDisplayText(),
740                 value: filter.getURLSuffix(),
741                 isMulti: filter.isMultiValued(),
742                 isOperatorOnly: filter.isDataValueRequired()
743             }));
744         }
745 
746         if (storeNum > 0) {
747             store.removeAt(0);
748             store.insert(0, new comboRecord({text:'No Other Filter', value: ''}));
749         }
750 
751         return store;
752     },
753 
754     setFilters : function(filterArray) {
755         this.filters = filterArray;
756         this.onViewReady();
757     },
758 
759     nextIndex : function() {
760         return 0;
761     },
762 
763     validateMultiValueInput : function(inputValues, multiValueSeparator, minOccurs, maxOccurs) {
764         // Used when "Equals One Of.." or "Between" is selected. Calls validateInputField on each value entered.
765         var values = inputValues.split(multiValueSeparator);
766         var isValid = "";
767         for(var i = 0; i < values.length; i++){
768             isValid = this.validateInputField(values[i]);
769             if(isValid !== true){
770                 return isValid;
771             }
772         }
773 
774         if (minOccurs !== undefined && minOccurs > 0)
775         {
776             if (values.length < minOccurs)
777                 return "At least " + minOccurs + " '" + multiValueSeparator + "' separated values are required";
778         }
779 
780         if (maxOccurs !== undefined && maxOccurs > 0)
781         {
782             if (values.length > maxOccurs)
783                 return "At most " + maxOccurs + " '" + multiValueSeparator + "' separated values are allowed";
784         }
785 
786         //If we make it out of the for loop we had no errors.
787         return true;
788     },
789 
790     // The fact that Ext3 ties validation to the editor is a little funny,
791     // but using this shifts the work to Ext
792     validateInputField : function(value) {
793         var map = {
794             'string': 'STRING',
795             'int': 'INT',
796             'float': 'FLOAT',
797             'date': 'DATE',
798             'boolean': 'BOOL'
799         };
800         var type = map[this.jsonType];
801         if (type) {
802             var field = new Ext.data.Field({
803                 type: Ext.data.Types[type],
804                 allowDecimals :  this.jsonType != "int",  //will be ignored by anything besides numberfield
805                 useNull: true
806             });
807 
808             var convertedVal = field.convert(value);
809             if (!Ext.isEmpty(value) && value != convertedVal) {
810                 return "Invalid value: " + value;
811             }
812         }
813         else {
814             console.log('Unrecognized type: ' + this.jsonType);
815         }
816 
817         return true;
818     }
819 });
820 
821 Ext.reg('filter-view-default', LABKEY.FilterDialog.View.Default);
822 
823 LABKEY.FilterDialog.View.Faceted = Ext.extend(LABKEY.FilterDialog.ViewPanel, {
824 
825     MAX_FILTER_CHOICES: 250, // This is the maximum number of filters that will be requested / shown
826 
827     applyContextFilters: true,
828 
829     /**
830      * Logically convert filters to try and optimize the query on the server.
831      * (e.g. using NOT IN when less than half the available values are checked)
832      */
833     filterOptimization: true,
834 
835     cacheResults: true,
836 
837     emptyDisplayValue: '[Blank]',
838 
839     initComponent : function() {
840 
841         Ext.apply(this, {
842             title  : 'Choose Values',
843             border : false,
844             height : 200,
845             bodyStyle: 'overflow-x: hidden; overflow-y: auto',
846             bubbleEvents: ['add', 'remove', 'clientvalidation'],
847             defaults : {
848                 border : false
849             },
850             markDisabled : true,
851             items: [{
852                 layout: 'hbox',
853                 style: 'padding-bottom: 5px; overflow-x: hidden',
854                 width: 100,
855                 defaults: {
856                     border: false
857                 },
858                 items: []
859             }]
860         });
861 
862         LABKEY.FilterDialog.View.Faceted.superclass.initComponent.call(this);
863 
864         this.on('render', this.onPanelRender, this, {single: true});
865     },
866 
867     formatValue : function(val) {
868         if(this.column) {
869             if (this.column.extFormatFn) {
870                 try {
871                     this.column.extFormatFn = eval(this.column.extFormatFn);
872                 }
873                 catch (error) {
874                     console.log('improper extFormatFn: ' + this.column.extFormatFn);
875                 }
876 
877                 if (Ext.isFunction(this.column.extFormatFn)) {
878                     val = this.column.extFormatFn(val);
879                 }
880             }
881             else if (this.jsonType == 'int') {
882                 val = parseInt(val);
883             }
884         }
885         return val;
886     },
887 
888     // copied from Ext 4 Ext.Array.difference
889     difference : function(arrayA, arrayB) {
890         var clone = arrayA.slice(),
891                 ln = clone.length,
892                 i, j, lnB;
893 
894         for (i = 0,lnB = arrayB.length; i < lnB; i++) {
895             for (j = 0; j < ln; j++) {
896                 if (clone[j] === arrayB[i]) {
897                     clone.splice(j, 1);
898                     j--;
899                     ln--;
900                 }
901             }
902         }
903 
904         return clone;
905     },
906 
907     constructFilter : function(selected, unselected) {
908         var filter = null;
909 
910         if (selected.length > 0) {
911 
912             var columnName = this.fieldKey;
913 
914             // one selection
915             if (selected.length == 1) {
916                 if (selected[0].get('displayValue') == this.emptyDisplayValue)
917                     filter = LABKEY.Filter.create(columnName, null, LABKEY.Filter.Types.ISBLANK);
918                 else
919                     filter = LABKEY.Filter.create(columnName, selected[0].get('value')); // default EQUAL
920             }
921             else if (this.filterOptimization && selected.length > unselected.length) {
922                 // Do the negation
923                 if (unselected.length == 1) {
924                     var val = unselected[0].get('value');
925                     var type = (val === "" ? LABKEY.Filter.Types.NONBLANK : LABKEY.Filter.Types.NOT_EQUAL_OR_MISSING);
926 
927                     // 18716: Check if 'unselected' contains empty value
928                     filter = LABKEY.Filter.create(columnName, val, type);
929                 }
930                 else
931                     filter = LABKEY.Filter.create(columnName, this.delimitValues(unselected), LABKEY.Filter.Types.NOT_IN);
932             }
933             else {
934                 filter = LABKEY.Filter.create(columnName, this.delimitValues(selected), LABKEY.Filter.Types.IN);
935             }
936         }
937 
938         return filter;
939     },
940 
941     delimitValues : function(valueArray) {
942         var value = '', sep = '';
943         for (var s=0; s < valueArray.length; s++) {
944             value += sep + valueArray[s].get('value');
945             sep = ';';
946         }
947         return value;
948     },
949 
950     // Implement interface LABKEY.FilterDialog.ViewPanel
951     getFilters : function() {
952         var grid = Ext.getCmp(this.gridID);
953         var filters = [];
954 
955         if (grid) {
956             var store = grid.store;
957             var count = store.getCount(); // TODO: Check if store loaded
958             var selected = grid.getSelectionModel().getSelections();
959 
960             if (count == 0 || selected.length == 0 || selected.length == count) {
961                 filters = [];
962             }
963             else {
964                 var unselected = this.filterOptimization ? this.difference(store.getRange(), selected) : [];
965                 filters = [this.constructFilter(selected, unselected)];
966             }
967         }
968 
969         return filters;
970     },
971 
972     // Implement interface LABKEY.FilterDialog.ViewPanel
973     setFilters : function(filterArray) {
974         if (Ext.isArray(filterArray)) {
975             this.filters = filterArray;
976             this.onViewReady();
977         }
978     },
979 
980     getGridConfig : function(idx) {
981         var sm = new Ext.grid.CheckboxSelectionModel({
982             listeners: {
983                 selectionchange: {
984                     fn: function(sm) {
985                         // NOTE: this will manually set the checked state of the header checkbox.  it would be better
986                         // to make this a real tri-state (ie. selecting some records is different then none), but since this is still Ext3
987                         // and ext4 will be quite different it doesnt seem worth the effort right now
988                         var selections = sm.getSelections();
989                         var headerCell = Ext.fly(sm.grid.getView().getHeaderCell(0)).first('div');
990                         if(selections.length == sm.grid.store.getCount()){
991                             headerCell.addClass('x-grid3-hd-checker-on');
992                         }
993                         else {
994                             headerCell.removeClass('x-grid3-hd-checker-on');
995                         }
996 
997 
998                     },
999                     buffer: 50
1000                 }
1001             }
1002         });
1003 
1004         this.gridID = Ext.id();
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" ext:qtip="Click the label to select only this row.  ' +
1040                             'Click the checkbox to toggle this row and preserve other selections.">' +
1041                             '{[Ext.util.Format.htmlEncode(values["displayValue"])]}' +
1042                             '</span></tpl>')
1043                 })
1044             ],
1045             listeners: {
1046                 afterrender : function(grid) {
1047                     grid.getSelectionModel().on('selectionchange', function() {
1048                         this.changed = true;
1049                     }, this);
1050 
1051                     grid.on('viewready', function(g) {
1052                         this.gridReady = true;
1053                         this.onViewReady();
1054                     }, this, {single: true});
1055                 },
1056                 scope : this
1057             },
1058             // extend toggle behavior to the header cell, not just the checkbox next to it
1059             onHeaderCellClick : function() {
1060                 var sm = this.getSelectionModel();
1061                 var selected = sm.getSelections();
1062                 selected.length == this.store.getCount() ? this.selectNone() : this.selectAll();
1063             },
1064             getValue : function() {
1065                 var vals = this.getValues();
1066                 if (vals.length == vals.max) {
1067                     return [];
1068                 }
1069                 return vals.values;
1070             },
1071             getValues : function() {
1072                 var values = [],
1073                         sels   = this.getSelectionModel().getSelections();
1074 
1075                 Ext.each(sels, function(rec){
1076                     values.push(rec.get('strValue'));
1077                 }, this);
1078 
1079                 if(values.indexOf('') != -1 && values.length == 1)
1080                     values.push(''); //account for null-only filtering
1081 
1082                 return {
1083                     values : values.join(';'),
1084                     length : values.length,
1085                     max    : this.getStore().getCount()
1086                 };
1087             },
1088             setValue : function(values, negated) {
1089                 if (!this.rendered) {
1090                     this.on('render', function() {
1091                         this.setValue(values, negated);
1092                     }, this, {single: true});
1093                 }
1094 
1095                 if (!Ext.isArray(values)) {
1096                     values = values.split(';');
1097                 }
1098 
1099                 if (this.store.isLoading) {
1100                     // need to wait for the store to load to ensure records
1101                     this.store.on('load', function() {
1102                         this._checkAndLoadValues(values, negated);
1103                     }, this, {single: true});
1104                 }
1105                 else {
1106                     this._checkAndLoadValues(values, negated);
1107                 }
1108             },
1109             _checkAndLoadValues : function(values, negated) {
1110                 var records = [],
1111                         recIdx,
1112                         recordNotFound = false;
1113 
1114                 Ext.each(values, function(val) {
1115                     recIdx = this.store.findBy(function(rec){
1116                         return rec.get('strValue') === val;
1117                     });
1118 
1119                     if (recIdx != -1) {
1120                         records.push(recIdx);
1121                     }
1122                     else {
1123                         // Issue 14710: if the record isnt found, we wont be able to select it, so should reject.
1124                         // If it's null/empty, ignore silently
1125                         if (!Ext.isEmpty(val)) {
1126                             recordNotFound = true;
1127                             return false;
1128                         }
1129                     }
1130                 }, this);
1131 
1132                 if (negated) {
1133                     var count = this.store.getCount(), found = false, negRecords = [];
1134                     for (var i=0; i < count; i++) {
1135                         found = false;
1136                         for (var j=0; j < records.length; j++) {
1137                             if (records[j] == i)
1138                                 found = true;
1139                         }
1140                         if (!found) {
1141                             negRecords.push(i);
1142                         }
1143                     }
1144                     records = negRecords;
1145                 }
1146 
1147                 if (recordNotFound) {
1148                     // cannot find any matching records
1149                     if (me.column.facetingBehaviorType != 'ALWAYS_ON')
1150                         me.fireEvent('invalidfilter');
1151                     return;
1152                 }
1153 
1154                 this.getSelectionModel().selectRows(records);
1155             },
1156             selectAll : function() {
1157                 if (this.rendered) {
1158                     var sm = this.getSelectionModel();
1159                     sm.selectAll.defer(10, sm);
1160                 }
1161                 else {
1162                     this.on('render', this.selectAll, this, {single: true});
1163                 }
1164             },
1165             selectNone : function() {
1166                 if (this.rendered) {
1167                     this.getSelectionModel().selectRows([]);
1168                 }
1169                 else {
1170                     this.on('render', this.selectNone, this, {single: true});
1171                 }
1172             },
1173             determineNegation: function(filter) {
1174                 var suffix = filter.getFilterType().getURLSuffix();
1175                 var negated = suffix == 'neqornull' || suffix == 'notin';
1176 
1177                 // negation of the null case is a bit different so check it as a special case.
1178                 var value = filter.getURLParameterValue();
1179                 if (value == "" && suffix != 'isblank') {
1180                     negated = true;
1181                 }
1182                 return negated;
1183             },
1184             selectFilter : function(filter) {
1185                 var negated = this.determineNegation(filter);
1186 
1187                 this.setValue(filter.getURLParameterValue(), negated);
1188 
1189                 if (!me.filterOptimization && negated) {
1190                     me.fireEvent('invalidfilter');
1191                 }
1192             },
1193             scope : this
1194         };
1195     },
1196 
1197     onViewReady : function() {
1198         if (this.gridReady && this.storeReady) {
1199             var grid = Ext.getCmp(this.gridID);
1200             this.hideMask();
1201 
1202             if (grid) {
1203 
1204                 var numFilters = this.filters.length;
1205                 var numFacets = grid.store.getCount();
1206 
1207                 // apply current filter
1208                 if (numFacets == 0)
1209                     grid.selectNone();
1210                 else if (numFilters == 0)
1211                     grid.selectAll();
1212                 else
1213                     grid.selectFilter(this.filters[0]);
1214 
1215                 if (!grid.headerClick) {
1216                     grid.headerClick = true;
1217                     var div = Ext.fly(grid.getView().getHeaderCell(1)).first('div');
1218                     div.on('click', grid.onHeaderCellClick, grid);
1219                 }
1220 
1221                 if (numFacets > this.MAX_FILTER_CHOICES) {
1222                     this.fireEvent('invalidfilter');
1223                 }
1224             }
1225         }
1226 
1227         this.changed = false;
1228     },
1229 
1230     getLookupStore : function() {
1231         var dr = this.getDataRegion();
1232         var storeId = this.cacheResults ? [dr.schemaName, dr.queryName, this.fieldKey].join('||') : Ext.id();
1233 
1234         // cache
1235         var store = Ext.StoreMgr.get(storeId);
1236         if (store) {
1237             this.storeReady = true; // unsafe
1238             return store;
1239         }
1240 
1241         store = new Ext.data.ArrayStore({
1242             fields : ['value', 'strValue', 'displayValue'],
1243             storeId: storeId
1244         });
1245 
1246         var config = {
1247             schemaName: dr.schemaName,
1248             queryName: dr.queryName,
1249             dataRegionName: dr.name,
1250             viewName: dr.viewName,
1251             column: this.fieldKey,
1252             filterArray: dr.filters,
1253             containerPath: dr.container || dr.containerPath || LABKEY.container.path,
1254             containerFilter: dr.getContainerFilter(),
1255             parameters: dr.getParameters(),
1256             maxRows: this.MAX_FILTER_CHOICES+1,
1257             ignoreFilter: dr.ignoreFilter,
1258             success : function(d) {
1259                 if (d && d.values) {
1260                     var recs = [], v, i=0, hasBlank = false, isString, formattedValue;
1261                     for (; i < d.values.length; i++) {
1262                         v = d.values[i];
1263                         formattedValue = this.formatValue(v);
1264                         isString = Ext.isString(formattedValue);
1265 
1266                         if (formattedValue == null || (isString && formattedValue.length == 0) || (!isString && isNaN(formattedValue))) {
1267                             hasBlank = true;
1268                         }
1269                         else if (Ext.isDefined(v)) {
1270                             recs.push([v, v.toString(), v.toString()]);
1271                         }
1272                     }
1273 
1274                     if (hasBlank)
1275                         recs.unshift(['', '', this.emptyDisplayValue]);
1276 
1277                     store.loadData(recs);
1278                     store.isLoading = false;
1279                     this.storeReady = true;
1280                     this.onViewReady();
1281                 }
1282             },
1283             scope: this
1284         };
1285 
1286         if (this.applyContextFilters) {
1287             var userFilters = dr.getUserFilterArray();
1288             if (userFilters && userFilters.length > 0) {
1289 
1290                 var uf = [];
1291 
1292                 // Remove filters for the current column
1293                 for (var i=0; i < userFilters.length; i++) {
1294                     if (userFilters[i].getColumnName() != this.fieldKey) {
1295                         uf.push(userFilters[i]);
1296                     }
1297                 }
1298 
1299                 config.filterArray = uf;
1300             }
1301         }
1302 
1303         // Use Select Distinct
1304         LABKEY.Query.selectDistinctRows(config);
1305 
1306         return Ext.StoreMgr.add(store);
1307     },
1308 
1309     onPanelRender : function(panel) {
1310         var toAdd = [{
1311             xtype: 'panel',
1312             width: this.width - 40, //prevent horizontal scroll
1313             bodyStyle: 'padding-left: 5px;',
1314             items: [ this.getGridConfig(0) ],
1315             listeners : {
1316                 afterrender : {
1317                     fn: this.showMask,
1318                     scope: this,
1319                     single: true
1320                 }
1321             }
1322         }];
1323         panel.add(toAdd);
1324     },
1325 
1326     showMask : function() {
1327         if (!this.gridReady && this.getEl()) {
1328             this.getEl().mask('Loading...');
1329         }
1330     },
1331 
1332     hideMask : function() {
1333         if (this.getEl()) {
1334             this.getEl().unmask();
1335         }
1336     }
1337 });
1338 
1339 Ext.reg('filter-view-faceted', LABKEY.FilterDialog.View.Faceted);
1340 
1341 Ext.ns('LABKEY.ext');
1342 
1343 LABKEY.ext.BooleanTextField = Ext.extend(Ext.form.TextField, {
1344     initComponent : function() {
1345         Ext.apply(this, {
1346             validator: function(val){
1347                 if(!val)
1348                     return true;
1349 
1350                 return LABKEY.Utils.isBoolean(val) ? true : val + " is not a valid boolean. Try true/false; yes/no; on/off; or 1/0.";
1351             }
1352         });
1353         LABKEY.ext.BooleanTextField.superclass.initComponent.call(this);
1354     }
1355 });
1356 
1357 Ext.reg('labkey-booleantextfield', LABKEY.ext.BooleanTextField);
1358 
1359