Upgrade to ExtJS 4.0.2 - Released 06/09/2011
[extjs.git] / examples / ux / grid / FiltersFeature.js
1 /*
2
3 This file is part of Ext JS 4
4
5 Copyright (c) 2011 Sencha Inc
6
7 Contact:  http://www.sencha.com/contact
8
9 GNU General Public License Usage
10 This file may be used under the terms of the GNU General Public License version 3.0 as published by the Free Software Foundation and appearing in the file LICENSE included in the packaging of this file.  Please review the following information to ensure the GNU General Public License version 3.0 requirements will be met: http://www.gnu.org/copyleft/gpl.html.
11
12 If you are unsure which license is appropriate for your use, please contact the sales department at http://www.sencha.com/contact.
13
14 */
15 /**
16  * @class Ext.ux.grid.FiltersFeature
17  * @extends Ext.grid.Feature
18
19 FiltersFeature is a grid {@link Ext.grid.Feature feature} that allows for a slightly more
20 robust representation of filtering than what is provided by the default store.
21
22 Filtering is adjusted by the user using the grid's column header menu (this menu can be
23 disabled through configuration). Through this menu users can configure, enable, and
24 disable filters for each column.
25
26 #Features#
27
28 ##Filtering implementations:##
29
30 Default filtering for Strings, Numeric Ranges, Date Ranges, Lists (which can be backed by a
31 {@link Ext.data.Store}), and Boolean. Additional custom filter types and menus are easily
32 created by extending {@link Ext.ux.grid.filter.Filter}.
33
34 ##Graphical Indicators:##
35
36 Columns that are filtered have {@link #filterCls a configurable css class} applied to the column headers.
37
38 ##Automatic Reconfiguration:##
39
40 Filters automatically reconfigure when the grid 'reconfigure' event fires.
41
42 ##Stateful:##
43
44 Filter information will be persisted across page loads by specifying a `stateId`
45 in the Grid configuration.
46
47 The filter collection binds to the {@link Ext.grid.Panel#beforestaterestore beforestaterestore}
48 and {@link Ext.grid.Panel#beforestatesave beforestatesave} events in order to be stateful.
49
50 ##GridPanel Changes:##
51
52 - A `filters` property is added to the GridPanel using this feature.
53 - A `filterupdate` event is added to the GridPanel and is fired upon onStateChange completion.
54
55 ##Server side code examples:##
56
57 - [PHP](http://www.vinylfox.com/extjs/grid-filter-php-backend-code.php) - (Thanks VinylFox)</li>
58 - [Ruby on Rails](http://extjs.com/forum/showthread.php?p=77326#post77326) - (Thanks Zyclops)</li>
59 - [Ruby on Rails](http://extjs.com/forum/showthread.php?p=176596#post176596) - (Thanks Rotomaul)</li>
60 - [Python](http://www.debatablybeta.com/posts/using-extjss-grid-filtering-with-django/) - (Thanks Matt)</li>
61 - [Grails](http://mcantrell.wordpress.com/2008/08/22/extjs-grids-and-grails/) - (Thanks Mike)</li>
62
63 #Example usage:#
64
65     var store = Ext.create('Ext.data.Store', {
66         pageSize: 15
67         ...
68     });
69
70     var filtersCfg = {
71         ftype: 'filters',
72         autoReload: false, //don't reload automatically
73         local: true, //only filter locally
74         // filters may be configured through the plugin,
75         // or in the column definition within the headers configuration
76         filters: [{
77             type: 'numeric',
78             dataIndex: 'id'
79         }, {
80             type: 'string',
81             dataIndex: 'name'
82         }, {
83             type: 'numeric',
84             dataIndex: 'price'
85         }, {
86             type: 'date',
87             dataIndex: 'dateAdded'
88         }, {
89             type: 'list',
90             dataIndex: 'size',
91             options: ['extra small', 'small', 'medium', 'large', 'extra large'],
92             phpMode: true
93         }, {
94             type: 'boolean',
95             dataIndex: 'visible'
96         }]
97     };
98
99     var grid = Ext.create('Ext.grid.Panel', {
100          store: store,
101          columns: ...,
102          filters: [filtersCfg],
103          height: 400,
104          width: 700,
105          bbar: Ext.create('Ext.PagingToolbar', {
106              store: store
107          })
108     });
109
110     // a filters property is added to the GridPanel
111     grid.filters
112
113  * @markdown
114  */
115 Ext.define('Ext.ux.grid.FiltersFeature', {
116     extend: 'Ext.grid.feature.Feature',
117     alias: 'feature.filters',
118     uses: [
119         'Ext.ux.grid.menu.ListMenu',
120         'Ext.ux.grid.menu.RangeMenu',
121         'Ext.ux.grid.filter.BooleanFilter',
122         'Ext.ux.grid.filter.DateFilter',
123         'Ext.ux.grid.filter.ListFilter',
124         'Ext.ux.grid.filter.NumericFilter',
125         'Ext.ux.grid.filter.StringFilter'
126     ],
127
128     /**
129      * @cfg {Boolean} autoReload
130      * Defaults to true, reloading the datasource when a filter change happens.
131      * Set this to false to prevent the datastore from being reloaded if there
132      * are changes to the filters.  See <code>{@link updateBuffer}</code>.
133      */
134     autoReload : true,
135     /**
136      * @cfg {Boolean} encode
137      * Specify true for {@link #buildQuery} to use Ext.util.JSON.encode to
138      * encode the filter query parameter sent with a remote request.
139      * Defaults to false.
140      */
141     /**
142      * @cfg {Array} filters
143      * An Array of filters config objects. Refer to each filter type class for
144      * configuration details specific to each filter type. Filters for Strings,
145      * Numeric Ranges, Date Ranges, Lists, and Boolean are the standard filters
146      * available.
147      */
148     /**
149      * @cfg {String} filterCls
150      * The css class to be applied to column headers with active filters.
151      * Defaults to <tt>'ux-filterd-column'</tt>.
152      */
153     filterCls : 'ux-filtered-column',
154     /**
155      * @cfg {Boolean} local
156      * <tt>true</tt> to use Ext.data.Store filter functions (local filtering)
157      * instead of the default (<tt>false</tt>) server side filtering.
158      */
159     local : false,
160     /**
161      * @cfg {String} menuFilterText
162      * defaults to <tt>'Filters'</tt>.
163      */
164     menuFilterText : 'Filters',
165     /**
166      * @cfg {String} paramPrefix
167      * The url parameter prefix for the filters.
168      * Defaults to <tt>'filter'</tt>.
169      */
170     paramPrefix : 'filter',
171     /**
172      * @cfg {Boolean} showMenu
173      * Defaults to true, including a filter submenu in the default header menu.
174      */
175     showMenu : true,
176     /**
177      * @cfg {String} stateId
178      * Name of the value to be used to store state information.
179      */
180     stateId : undefined,
181     /**
182      * @cfg {Integer} updateBuffer
183      * Number of milliseconds to defer store updates since the last filter change.
184      */
185     updateBuffer : 500,
186
187     // doesn't handle grid body events
188     hasFeatureEvent: false,
189
190
191     /** @private */
192     constructor : function (config) {
193         var me = this;
194
195         config = config || {};
196         Ext.apply(me, config);
197
198         me.deferredUpdate = Ext.create('Ext.util.DelayedTask', me.reload, me);
199
200         // Init filters
201         me.filters = me.createFiltersCollection();
202         me.filterConfigs = config.filters;
203     },
204
205     attachEvents: function() {
206         var me = this,
207             view = me.view,
208             headerCt = view.headerCt,
209             grid = me.getGridPanel();
210
211         me.bindStore(view.getStore(), true);
212
213         // Listen for header menu being created
214         headerCt.on('menucreate', me.onMenuCreate, me);
215
216         view.on('refresh', me.onRefresh, me);
217         grid.on({
218             scope: me,
219             beforestaterestore: me.applyState,
220             beforestatesave: me.saveState,
221             beforedestroy: me.destroy
222         });
223
224         // Add event and filters shortcut on grid panel
225         grid.filters = me;
226         grid.addEvents('filterupdate');
227     },
228
229     createFiltersCollection: function () {
230         return Ext.create('Ext.util.MixedCollection', false, function (o) {
231             return o ? o.dataIndex : null;
232         });
233     },
234
235     /**
236      * @private Create the Filter objects for the current configuration, destroying any existing ones first.
237      */
238     createFilters: function() {
239         var me = this,
240             hadFilters = me.filters.getCount(),
241             grid = me.getGridPanel(),
242             filters = me.createFiltersCollection(),
243             model = grid.store.model,
244             fields = model.prototype.fields,
245             field,
246             filter,
247             state;
248
249         if (hadFilters) {
250             state = {};
251             me.saveState(null, state);
252         }
253
254         function add (dataIndex, config, filterable) {
255             if (dataIndex && (filterable || config)) {
256                 field = fields.get(dataIndex);
257                 filter = {
258                     dataIndex: dataIndex,
259                     type: (field && field.type && field.type.type) || 'auto'
260                 };
261
262                 if (Ext.isObject(config)) {
263                     Ext.apply(filter, config);
264                 }
265
266                 filters.replace(filter);
267             }
268         }
269
270         // We start with filters from our config and then merge on filters from the columns
271         // in the grid. The Grid columns take precedence.
272         Ext.Array.each(me.filterConfigs, function (fc) {
273             add(fc.dataIndex, fc);
274         });
275
276         Ext.Array.each(grid.columns, function (column) {
277             if (column.filterable === false) {
278                 filters.removeAtKey(column.dataIndex);
279             } else {
280                 add(column.dataIndex, column.filter, column.filterable);
281             }
282         });
283
284         me.removeAll();
285         me.addFilters(filters.items);
286
287         if (hadFilters) {
288             me.applyState(null, state);
289         }
290     },
291
292     /**
293      * @private Handle creation of the grid's header menu. Initializes the filters and listens
294      * for the menu being shown.
295      */
296     onMenuCreate: function(headerCt, menu) {
297         var me = this;
298         me.createFilters();
299         menu.on('beforeshow', me.onMenuBeforeShow, me);
300     },
301
302     /**
303      * @private Handle showing of the grid's header menu. Sets up the filter item and menu
304      * appropriate for the target column.
305      */
306     onMenuBeforeShow: function(menu) {
307         var me = this,
308             menuItem, filter;
309
310         if (me.showMenu) {
311             menuItem = me.menuItem;
312             if (!menuItem || menuItem.isDestroyed) {
313                 me.createMenuItem(menu);
314                 menuItem = me.menuItem;
315             }
316
317             filter = me.getMenuFilter();
318
319             if (filter) {
320                 menuItem.menu = filter.menu;
321                 menuItem.setChecked(filter.active);
322                 // disable the menu if filter.disabled explicitly set to true
323                 menuItem.setDisabled(filter.disabled === true);
324             }
325             menuItem.setVisible(!!filter);
326             this.sep.setVisible(!!filter);
327         }
328     },
329
330
331     createMenuItem: function(menu) {
332         var me = this;
333         me.sep  = menu.add('-');
334         me.menuItem = menu.add({
335             checked: false,
336             itemId: 'filters',
337             text: me.menuFilterText,
338             listeners: {
339                 scope: me,
340                 checkchange: me.onCheckChange,
341                 beforecheckchange: me.onBeforeCheck
342             }
343         });
344     },
345
346     getGridPanel: function() {
347         return this.view.up('gridpanel');
348     },
349
350     /**
351      * @private
352      * Handler for the grid's beforestaterestore event (fires before the state of the
353      * grid is restored).
354      * @param {Object} grid The grid object
355      * @param {Object} state The hash of state values returned from the StateProvider.
356      */
357     applyState : function (grid, state) {
358         var key, filter;
359         this.applyingState = true;
360         this.clearFilters();
361         if (state.filters) {
362             for (key in state.filters) {
363                 filter = this.filters.get(key);
364                 if (filter) {
365                     filter.setValue(state.filters[key]);
366                     filter.setActive(true);
367                 }
368             }
369         }
370         this.deferredUpdate.cancel();
371         if (this.local) {
372             this.reload();
373         }
374         delete this.applyingState;
375         delete state.filters;
376     },
377
378     /**
379      * Saves the state of all active filters
380      * @param {Object} grid
381      * @param {Object} state
382      * @return {Boolean}
383      */
384     saveState : function (grid, state) {
385         var filters = {};
386         this.filters.each(function (filter) {
387             if (filter.active) {
388                 filters[filter.dataIndex] = filter.getValue();
389             }
390         });
391         return (state.filters = filters);
392     },
393
394     /**
395      * @private
396      * Handler called by the grid 'beforedestroy' event
397      */
398     destroy : function () {
399         var me = this;
400         Ext.destroyMembers(me, 'menuItem', 'sep');
401         me.removeAll();
402         me.clearListeners();
403     },
404
405     /**
406      * Remove all filters, permanently destroying them.
407      */
408     removeAll : function () {
409         if(this.filters){
410             Ext.destroy.apply(Ext, this.filters.items);
411             // remove all items from the collection
412             this.filters.clear();
413         }
414     },
415
416
417     /**
418      * Changes the data store bound to this view and refreshes it.
419      * @param {Store} store The store to bind to this view
420      */
421     bindStore : function(store, initial){
422         if(!initial && this.store){
423             if (this.local) {
424                 store.un('load', this.onLoad, this);
425             } else {
426                 store.un('beforeload', this.onBeforeLoad, this);
427             }
428         }
429         if(store){
430             if (this.local) {
431                 store.on('load', this.onLoad, this);
432             } else {
433                 store.on('beforeload', this.onBeforeLoad, this);
434             }
435         }
436         this.store = store;
437     },
438
439
440     /**
441      * @private
442      * Get the filter menu from the filters MixedCollection based on the clicked header
443      */
444     getMenuFilter : function () {
445         var header = this.view.headerCt.getMenu().activeHeader;
446         return header ? this.filters.get(header.dataIndex) : null;
447     },
448
449     /** @private */
450     onCheckChange : function (item, value) {
451         this.getMenuFilter().setActive(value);
452     },
453
454     /** @private */
455     onBeforeCheck : function (check, value) {
456         return !value || this.getMenuFilter().isActivatable();
457     },
458
459     /**
460      * @private
461      * Handler for all events on filters.
462      * @param {String} event Event name
463      * @param {Object} filter Standard signature of the event before the event is fired
464      */
465     onStateChange : function (event, filter) {
466         if (event !== 'serialize') {
467             var me = this,
468                 grid = me.getGridPanel();
469
470             if (filter == me.getMenuFilter()) {
471                 me.menuItem.setChecked(filter.active, false);
472             }
473
474             if ((me.autoReload || me.local) && !me.applyingState) {
475                 me.deferredUpdate.delay(me.updateBuffer);
476             }
477             me.updateColumnHeadings();
478
479             if (!me.applyingState) {
480                 grid.saveState();
481             }
482             grid.fireEvent('filterupdate', me, filter);
483         }
484     },
485
486     /**
487      * @private
488      * Handler for store's beforeload event when configured for remote filtering
489      * @param {Object} store
490      * @param {Object} options
491      */
492     onBeforeLoad : function (store, options) {
493         options.params = options.params || {};
494         this.cleanParams(options.params);
495         var params = this.buildQuery(this.getFilterData());
496         Ext.apply(options.params, params);
497     },
498
499     /**
500      * @private
501      * Handler for store's load event when configured for local filtering
502      * @param {Object} store
503      * @param {Object} options
504      */
505     onLoad : function (store, options) {
506         store.filterBy(this.getRecordFilter());
507     },
508
509     /**
510      * @private
511      * Handler called when the grid's view is refreshed
512      */
513     onRefresh : function () {
514         this.updateColumnHeadings();
515     },
516
517     /**
518      * Update the styles for the header row based on the active filters
519      */
520     updateColumnHeadings : function () {
521         var me = this,
522             headerCt = me.view.headerCt;
523         if (headerCt) {
524             headerCt.items.each(function(header) {
525                 var filter = me.getFilter(header.dataIndex);
526                 header[filter && filter.active ? 'addCls' : 'removeCls'](me.filterCls);
527             });
528         }
529     },
530
531     /** @private */
532     reload : function () {
533         var me = this,
534             store = me.view.getStore(),
535             start;
536
537         if (me.local) {
538             store.clearFilter(true);
539             store.filterBy(me.getRecordFilter());
540         } else {
541             me.deferredUpdate.cancel();
542             store.loadPage(1);
543         }
544     },
545
546     /**
547      * Method factory that generates a record validator for the filters active at the time
548      * of invokation.
549      * @private
550      */
551     getRecordFilter : function () {
552         var f = [], len, i;
553         this.filters.each(function (filter) {
554             if (filter.active) {
555                 f.push(filter);
556             }
557         });
558
559         len = f.length;
560         return function (record) {
561             for (i = 0; i < len; i++) {
562                 if (!f[i].validateRecord(record)) {
563                     return false;
564                 }
565             }
566             return true;
567         };
568     },
569
570     /**
571      * Adds a filter to the collection and observes it for state change.
572      * @param {Object/Ext.ux.grid.filter.Filter} config A filter configuration or a filter object.
573      * @return {Ext.ux.grid.filter.Filter} The existing or newly created filter object.
574      */
575     addFilter : function (config) {
576         var Cls = this.getFilterClass(config.type),
577             filter = config.menu ? config : (new Cls(config));
578         this.filters.add(filter);
579
580         Ext.util.Observable.capture(filter, this.onStateChange, this);
581         return filter;
582     },
583
584     /**
585      * Adds filters to the collection.
586      * @param {Array} filters An Array of filter configuration objects.
587      */
588     addFilters : function (filters) {
589         if (filters) {
590             var i, len, filter;
591             for (i = 0, len = filters.length; i < len; i++) {
592                 filter = filters[i];
593                 // if filter config found add filter for the column
594                 if (filter) {
595                     this.addFilter(filter);
596                 }
597             }
598         }
599     },
600
601     /**
602      * Returns a filter for the given dataIndex, if one exists.
603      * @param {String} dataIndex The dataIndex of the desired filter object.
604      * @return {Ext.ux.grid.filter.Filter}
605      */
606     getFilter : function (dataIndex) {
607         return this.filters.get(dataIndex);
608     },
609
610     /**
611      * Turns all filters off. This does not clear the configuration information
612      * (see {@link #removeAll}).
613      */
614     clearFilters : function () {
615         this.filters.each(function (filter) {
616             filter.setActive(false);
617         });
618     },
619
620     /**
621      * Returns an Array of the currently active filters.
622      * @return {Array} filters Array of the currently active filters.
623      */
624     getFilterData : function () {
625         var filters = [], i, len;
626
627         this.filters.each(function (f) {
628             if (f.active) {
629                 var d = [].concat(f.serialize());
630                 for (i = 0, len = d.length; i < len; i++) {
631                     filters.push({
632                         field: f.dataIndex,
633                         data: d[i]
634                     });
635                 }
636             }
637         });
638         return filters;
639     },
640
641     /**
642      * Function to take the active filters data and build it into a query.
643      * The format of the query depends on the <code>{@link #encode}</code>
644      * configuration:
645      * <div class="mdetail-params"><ul>
646      *
647      * <li><b><tt>false</tt></b> : <i>Default</i>
648      * <div class="sub-desc">
649      * Flatten into query string of the form (assuming <code>{@link #paramPrefix}='filters'</code>:
650      * <pre><code>
651 filters[0][field]="someDataIndex"&
652 filters[0][data][comparison]="someValue1"&
653 filters[0][data][type]="someValue2"&
654 filters[0][data][value]="someValue3"&
655      * </code></pre>
656      * </div></li>
657      * <li><b><tt>true</tt></b> :
658      * <div class="sub-desc">
659      * JSON encode the filter data
660      * <pre><code>
661 filters[0][field]="someDataIndex"&
662 filters[0][data][comparison]="someValue1"&
663 filters[0][data][type]="someValue2"&
664 filters[0][data][value]="someValue3"&
665      * </code></pre>
666      * </div></li>
667      * </ul></div>
668      * Override this method to customize the format of the filter query for remote requests.
669      * @param {Array} filters A collection of objects representing active filters and their configuration.
670      *    Each element will take the form of {field: dataIndex, data: filterConf}. dataIndex is not assured
671      *    to be unique as any one filter may be a composite of more basic filters for the same dataIndex.
672      * @return {Object} Query keys and values
673      */
674     buildQuery : function (filters) {
675         var p = {}, i, f, root, dataPrefix, key, tmp,
676             len = filters.length;
677
678         if (!this.encode){
679             for (i = 0; i < len; i++) {
680                 f = filters[i];
681                 root = [this.paramPrefix, '[', i, ']'].join('');
682                 p[root + '[field]'] = f.field;
683
684                 dataPrefix = root + '[data]';
685                 for (key in f.data) {
686                     p[[dataPrefix, '[', key, ']'].join('')] = f.data[key];
687                 }
688             }
689         } else {
690             tmp = [];
691             for (i = 0; i < len; i++) {
692                 f = filters[i];
693                 tmp.push(Ext.apply(
694                     {},
695                     {field: f.field},
696                     f.data
697                 ));
698             }
699             // only build if there is active filter
700             if (tmp.length > 0){
701                 p[this.paramPrefix] = Ext.JSON.encode(tmp);
702             }
703         }
704         return p;
705     },
706
707     /**
708      * Removes filter related query parameters from the provided object.
709      * @param {Object} p Query parameters that may contain filter related fields.
710      */
711     cleanParams : function (p) {
712         // if encoding just delete the property
713         if (this.encode) {
714             delete p[this.paramPrefix];
715         // otherwise scrub the object of filter data
716         } else {
717             var regex, key;
718             regex = new RegExp('^' + this.paramPrefix + '\[[0-9]+\]');
719             for (key in p) {
720                 if (regex.test(key)) {
721                     delete p[key];
722                 }
723             }
724         }
725     },
726
727     /**
728      * Function for locating filter classes, overwrite this with your favorite
729      * loader to provide dynamic filter loading.
730      * @param {String} type The type of filter to load ('Filter' is automatically
731      * appended to the passed type; eg, 'string' becomes 'StringFilter').
732      * @return {Class} The Ext.ux.grid.filter.Class
733      */
734     getFilterClass : function (type) {
735         // map the supported Ext.data.Field type values into a supported filter
736         switch(type) {
737             case 'auto':
738               type = 'string';
739               break;
740             case 'int':
741             case 'float':
742               type = 'numeric';
743               break;
744             case 'bool':
745               type = 'boolean';
746               break;
747         }
748         return Ext.ClassManager.getByAlias('gridfilter.' + type);
749     }
750 });
751