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