Upgrade to ExtJS 4.0.0 - Released 04/26/2011
[extjs.git] / examples / ux / grid / FiltersFeature.js
diff --git a/examples/ux/grid/FiltersFeature.js b/examples/ux/grid/FiltersFeature.js
new file mode 100644 (file)
index 0000000..ad27e6e
--- /dev/null
@@ -0,0 +1,695 @@
+/**
+ * @class Ext.ux.grid.FiltersFeature
+ * @extends Ext.grid.Feature
+
+FiltersFeature is a grid {@link Ext.grid.Feature feature} that allows for a slightly more
+robust representation of filtering than what is provided by the default store.
+
+Filtering is adjusted by the user using the grid's column header menu (this menu can be
+disabled through configuration). Through this menu users can configure, enable, and
+disable filters for each column.
+
+#Features#
+
+##Filtering implementations:##
+
+Default filtering for Strings, Numeric Ranges, Date Ranges, Lists (which can be backed by a
+{@link Ext.data.Store}), and Boolean. Additional custom filter types and menus are easily
+created by extending {@link Ext.ux.grid.filter.Filter}.
+
+##Graphical Indicators:##
+
+Columns that are filtered have {@link #filterCls a configurable css class} applied to the column headers.
+
+##Automatic Reconfiguration:##
+
+Filters automatically reconfigure when the grid 'reconfigure' event fires.
+
+##Stateful:##
+
+Filter information will be persisted across page loads by specifying a `stateId`
+in the Grid configuration.
+
+The filter collection binds to the {@link Ext.grid.Panel#beforestaterestore beforestaterestore}
+and {@link Ext.grid.Panel#beforestatesave beforestatesave} events in order to be stateful.
+
+##GridPanel Changes:##
+
+- A `filters` property is added to the GridPanel using this feature.
+- A `filterupdate` event is added to the GridPanel and is fired upon onStateChange completion.
+
+##Server side code examples:##
+
+- [PHP](http://www.vinylfox.com/extjs/grid-filter-php-backend-code.php) - (Thanks VinylFox)</li>
+- [Ruby on Rails](http://extjs.com/forum/showthread.php?p=77326#post77326) - (Thanks Zyclops)</li>
+- [Ruby on Rails](http://extjs.com/forum/showthread.php?p=176596#post176596) - (Thanks Rotomaul)</li>
+- [Python](http://www.debatablybeta.com/posts/using-extjss-grid-filtering-with-django/) - (Thanks Matt)</li>
+- [Grails](http://mcantrell.wordpress.com/2008/08/22/extjs-grids-and-grails/) - (Thanks Mike)</li>
+
+#Example usage:#
+
+    var store = Ext.create('Ext.data.Store', {
+        pageSize: 15
+        ...
+    });
+
+    var filtersCfg = {
+        ftype: 'filters',
+        autoReload: false, //don't reload automatically
+        local: true, //only filter locally
+        // filters may be configured through the plugin,
+        // or in the column definition within the headers configuration
+        filters: [{
+            type: 'numeric',
+            dataIndex: 'id'
+        }, {
+            type: 'string',
+            dataIndex: 'name'
+        }, {
+            type: 'numeric',
+            dataIndex: 'price'
+        }, {
+            type: 'date',
+            dataIndex: 'dateAdded'
+        }, {
+            type: 'list',
+            dataIndex: 'size',
+            options: ['extra small', 'small', 'medium', 'large', 'extra large'],
+            phpMode: true
+        }, {
+            type: 'boolean',
+            dataIndex: 'visible'
+        }]
+    };
+
+    var grid = Ext.create('Ext.grid.Panel', {
+         store: store,
+         columns: ...,
+         filters: [filtersCfg],
+         height: 400,
+         width: 700,
+         bbar: Ext.create('Ext.PagingToolbar', {
+             store: store
+         })
+    });
+
+    // a filters property is added to the GridPanel
+    grid.filters
+
+ * @markdown
+ */
+Ext.define('Ext.ux.grid.FiltersFeature', {
+    extend: 'Ext.grid.feature.Feature',
+    alias: 'feature.filters',
+    uses: [
+        'Ext.ux.grid.menu.ListMenu',
+        'Ext.ux.grid.menu.RangeMenu',
+        'Ext.ux.grid.filter.BooleanFilter',
+        'Ext.ux.grid.filter.DateFilter',
+        'Ext.ux.grid.filter.ListFilter',
+        'Ext.ux.grid.filter.NumericFilter',
+        'Ext.ux.grid.filter.StringFilter'
+    ],
+
+    /**
+     * @cfg {Boolean} autoReload
+     * Defaults to true, reloading the datasource when a filter change happens.
+     * Set this to false to prevent the datastore from being reloaded if there
+     * are changes to the filters.  See <code>{@link updateBuffer}</code>.
+     */
+    autoReload : true,
+    /**
+     * @cfg {Boolean} encode
+     * Specify true for {@link #buildQuery} to use Ext.util.JSON.encode to
+     * encode the filter query parameter sent with a remote request.
+     * Defaults to false.
+     */
+    /**
+     * @cfg {Array} filters
+     * An Array of filters config objects. Refer to each filter type class for
+     * configuration details specific to each filter type. Filters for Strings,
+     * Numeric Ranges, Date Ranges, Lists, and Boolean are the standard filters
+     * available.
+     */
+    /**
+     * @cfg {String} filterCls
+     * The css class to be applied to column headers with active filters.
+     * Defaults to <tt>'ux-filterd-column'</tt>.
+     */
+    filterCls : 'ux-filtered-column',
+    /**
+     * @cfg {Boolean} local
+     * <tt>true</tt> to use Ext.data.Store filter functions (local filtering)
+     * instead of the default (<tt>false</tt>) server side filtering.
+     */
+    local : false,
+    /**
+     * @cfg {String} menuFilterText
+     * defaults to <tt>'Filters'</tt>.
+     */
+    menuFilterText : 'Filters',
+    /**
+     * @cfg {String} paramPrefix
+     * The url parameter prefix for the filters.
+     * Defaults to <tt>'filter'</tt>.
+     */
+    paramPrefix : 'filter',
+    /**
+     * @cfg {Boolean} showMenu
+     * Defaults to true, including a filter submenu in the default header menu.
+     */
+    showMenu : true,
+    /**
+     * @cfg {String} stateId
+     * Name of the value to be used to store state information.
+     */
+    stateId : undefined,
+    /**
+     * @cfg {Integer} updateBuffer
+     * Number of milliseconds to defer store updates since the last filter change.
+     */
+    updateBuffer : 500,
+
+    // doesn't handle grid body events
+    hasFeatureEvent: false,
+
+
+    /** @private */
+    constructor : function (config) {
+        var me = this;
+
+        config = config || {};
+        Ext.apply(me, config);
+
+        me.deferredUpdate = Ext.create('Ext.util.DelayedTask', me.reload, me);
+
+        // Init filters
+        me.filters = Ext.create('Ext.util.MixedCollection', false, function (o) {
+            return o ? o.dataIndex : null;
+        });
+        me.filterConfigs = config.filters;
+    },
+
+
+    attachEvents: function() {
+        var me = this,
+            view = me.view,
+            headerCt = view.headerCt,
+            grid = me.getGridPanel();
+
+        me.bindStore(view.getStore(), true);
+
+        // Listen for header menu being created
+        headerCt.on('menucreate', me.onMenuCreate, me);
+
+        view.on('refresh', me.onRefresh, me);
+        grid.on({
+            scope: me,
+            beforestaterestore: me.applyState,
+            beforestatesave: me.saveState,
+            beforedestroy: me.destroy
+        });
+
+        // Add event and filters shortcut on grid panel
+        grid.filters = me;
+        grid.addEvents('filterupdate');
+    },
+
+    /**
+     * @private Create the Filter objects for the current configuration, destroying any existing ones first.
+     */
+    createFilters: function() {
+        var me = this,
+            filterConfigs = me.filterConfigs,
+            hadFilters = me.filters.getCount(),
+            state;
+        if (hadFilters) {
+            state = {};
+            me.saveState(null, state);
+        }
+        me.removeAll();
+        me.addFilters(Ext.isEmpty(filterConfigs) ? me.view.headerCt.items.items : filterConfigs);
+        if (hadFilters) {
+            me.applyState(null, state);
+        }
+    },
+
+    /**
+     * @private Handle creation of the grid's header menu. Initializes the filters and listens
+     * for the menu being shown.
+     */
+    onMenuCreate: function(headerCt, menu) {
+        var me = this;
+        me.createFilters();
+        menu.on('beforeshow', me.onMenuBeforeShow, me);
+    },
+
+    /**
+     * @private Handle showing of the grid's header menu. Sets up the filter item and menu
+     * appropriate for the target column.
+     */
+    onMenuBeforeShow: function(menu) {
+        var me = this,
+            menuItem, filter;
+
+        if (me.showMenu) {
+            menuItem = me.menuItem;
+            if (!menuItem || menuItem.isDestroyed) {
+                me.createMenuItem(menu);
+                menuItem = me.menuItem;
+            }
+
+            filter = me.getMenuFilter();
+
+            if (filter) {
+                menuItem.menu = filter.menu;
+                menuItem.setChecked(filter.active);
+                // disable the menu if filter.disabled explicitly set to true
+                menuItem.setDisabled(filter.disabled === true);
+            }
+            menuItem.setVisible(!!filter);
+            this.sep.setVisible(!!filter);
+        }
+    },
+
+
+    createMenuItem: function(menu) {
+        var me = this;
+        me.sep  = menu.add('-');
+        me.menuItem = menu.add({
+            checked: false,
+            itemId: 'filters',
+            text: me.menuFilterText,
+            listeners: {
+                scope: me,
+                checkchange: me.onCheckChange,
+                beforecheckchange: me.onBeforeCheck
+            }
+        });
+    },
+
+    getGridPanel: function() {
+        return this.view.up('gridpanel');
+    },
+
+    /**
+     * @private
+     * Handler for the grid's beforestaterestore event (fires before the state of the
+     * grid is restored).
+     * @param {Object} grid The grid object
+     * @param {Object} state The hash of state values returned from the StateProvider.
+     */
+    applyState : function (grid, state) {
+        var key, filter;
+        this.applyingState = true;
+        this.clearFilters();
+        if (state.filters) {
+            for (key in state.filters) {
+                filter = this.filters.get(key);
+                if (filter) {
+                    filter.setValue(state.filters[key]);
+                    filter.setActive(true);
+                }
+            }
+        }
+        this.deferredUpdate.cancel();
+        if (this.local) {
+            this.reload();
+        }
+        delete this.applyingState;
+        delete state.filters;
+    },
+
+    /**
+     * Saves the state of all active filters
+     * @param {Object} grid
+     * @param {Object} state
+     * @return {Boolean}
+     */
+    saveState : function (grid, state) {
+        var filters = {};
+        this.filters.each(function (filter) {
+            if (filter.active) {
+                filters[filter.dataIndex] = filter.getValue();
+            }
+        });
+        return (state.filters = filters);
+    },
+
+    /**
+     * @private
+     * Handler called by the grid 'beforedestroy' event
+     */
+    destroy : function () {
+        var me = this;
+        Ext.destroyMembers(me, 'menuItem', 'sep');
+        me.removeAll();
+        me.clearListeners();
+    },
+
+    /**
+     * Remove all filters, permanently destroying them.
+     */
+    removeAll : function () {
+        if(this.filters){
+            Ext.destroy.apply(Ext, this.filters.items);
+            // remove all items from the collection
+            this.filters.clear();
+        }
+    },
+
+
+    /**
+     * Changes the data store bound to this view and refreshes it.
+     * @param {Store} store The store to bind to this view
+     */
+    bindStore : function(store, initial){
+        if(!initial && this.store){
+            if (this.local) {
+                store.un('load', this.onLoad, this);
+            } else {
+                store.un('beforeload', this.onBeforeLoad, this);
+            }
+        }
+        if(store){
+            if (this.local) {
+                store.on('load', this.onLoad, this);
+            } else {
+                store.on('beforeload', this.onBeforeLoad, this);
+            }
+        }
+        this.store = store;
+    },
+
+
+    /**
+     * @private
+     * Get the filter menu from the filters MixedCollection based on the clicked header
+     */
+    getMenuFilter : function () {
+        var header = this.view.headerCt.getMenu().activeHeader;
+        return header ? this.filters.get(header.dataIndex) : null;
+    },
+
+    /** @private */
+    onCheckChange : function (item, value) {
+        this.getMenuFilter().setActive(value);
+    },
+
+    /** @private */
+    onBeforeCheck : function (check, value) {
+        return !value || this.getMenuFilter().isActivatable();
+    },
+
+    /**
+     * @private
+     * Handler for all events on filters.
+     * @param {String} event Event name
+     * @param {Object} filter Standard signature of the event before the event is fired
+     */
+    onStateChange : function (event, filter) {
+        if (event !== 'serialize') {
+            var me = this,
+                grid = me.getGridPanel();
+
+            if (filter == me.getMenuFilter()) {
+                me.menuItem.setChecked(filter.active, false);
+            }
+
+            if ((me.autoReload || me.local) && !me.applyingState) {
+                me.deferredUpdate.delay(me.updateBuffer);
+            }
+            me.updateColumnHeadings();
+
+            if (!me.applyingState) {
+                grid.saveState();
+            }
+            grid.fireEvent('filterupdate', me, filter);
+        }
+    },
+
+    /**
+     * @private
+     * Handler for store's beforeload event when configured for remote filtering
+     * @param {Object} store
+     * @param {Object} options
+     */
+    onBeforeLoad : function (store, options) {
+        options.params = options.params || {};
+        this.cleanParams(options.params);
+        var params = this.buildQuery(this.getFilterData());
+        Ext.apply(options.params, params);
+    },
+
+    /**
+     * @private
+     * Handler for store's load event when configured for local filtering
+     * @param {Object} store
+     * @param {Object} options
+     */
+    onLoad : function (store, options) {
+        store.filterBy(this.getRecordFilter());
+    },
+
+    /**
+     * @private
+     * Handler called when the grid's view is refreshed
+     */
+    onRefresh : function () {
+        this.updateColumnHeadings();
+    },
+
+    /**
+     * Update the styles for the header row based on the active filters
+     */
+    updateColumnHeadings : function () {
+        var me = this,
+            headerCt = me.view.headerCt;
+        if (headerCt) {
+            headerCt.items.each(function(header) {
+                var filter = me.getFilter(header.dataIndex);
+                header[filter && filter.active ? 'addCls' : 'removeCls'](me.filterCls);
+            });
+        }
+    },
+
+    /** @private */
+    reload : function () {
+        var me = this,
+            store = me.view.getStore(),
+            start;
+
+        if (me.local) {
+            store.clearFilter(true);
+            store.filterBy(me.getRecordFilter());
+        } else {
+            me.deferredUpdate.cancel();
+            store.loadPage(1);
+        }
+    },
+
+    /**
+     * Method factory that generates a record validator for the filters active at the time
+     * of invokation.
+     * @private
+     */
+    getRecordFilter : function () {
+        var f = [], len, i;
+        this.filters.each(function (filter) {
+            if (filter.active) {
+                f.push(filter);
+            }
+        });
+
+        len = f.length;
+        return function (record) {
+            for (i = 0; i < len; i++) {
+                if (!f[i].validateRecord(record)) {
+                    return false;
+                }
+            }
+            return true;
+        };
+    },
+
+    /**
+     * Adds a filter to the collection and observes it for state change.
+     * @param {Object/Ext.ux.grid.filter.Filter} config A filter configuration or a filter object.
+     * @return {Ext.ux.grid.filter.Filter} The existing or newly created filter object.
+     */
+    addFilter : function (config) {
+        var Cls = this.getFilterClass(config.type),
+            filter = config.menu ? config : (new Cls(config));
+        this.filters.add(filter);
+
+        Ext.util.Observable.capture(filter, this.onStateChange, this);
+        return filter;
+    },
+
+    /**
+     * Adds filters to the collection.
+     * @param {Array} filters An Array of filter configuration objects.
+     */
+    addFilters : function (filters) {
+        if (filters) {
+            var i, len, filter;
+            for (i = 0, len = filters.length; i < len; i++) {
+                filter = filters[i];
+                // if filter config found add filter for the column
+                if (filter) {
+                    this.addFilter(filter);
+                }
+            }
+        }
+    },
+
+    /**
+     * Returns a filter for the given dataIndex, if one exists.
+     * @param {String} dataIndex The dataIndex of the desired filter object.
+     * @return {Ext.ux.grid.filter.Filter}
+     */
+    getFilter : function (dataIndex) {
+        return this.filters.get(dataIndex);
+    },
+
+    /**
+     * Turns all filters off. This does not clear the configuration information
+     * (see {@link #removeAll}).
+     */
+    clearFilters : function () {
+        this.filters.each(function (filter) {
+            filter.setActive(false);
+        });
+    },
+
+    /**
+     * Returns an Array of the currently active filters.
+     * @return {Array} filters Array of the currently active filters.
+     */
+    getFilterData : function () {
+        var filters = [], i, len;
+
+        this.filters.each(function (f) {
+            if (f.active) {
+                var d = [].concat(f.serialize());
+                for (i = 0, len = d.length; i < len; i++) {
+                    filters.push({
+                        field: f.dataIndex,
+                        data: d[i]
+                    });
+                }
+            }
+        });
+        return filters;
+    },
+
+    /**
+     * Function to take the active filters data and build it into a query.
+     * The format of the query depends on the <code>{@link #encode}</code>
+     * configuration:
+     * <div class="mdetail-params"><ul>
+     *
+     * <li><b><tt>false</tt></b> : <i>Default</i>
+     * <div class="sub-desc">
+     * Flatten into query string of the form (assuming <code>{@link #paramPrefix}='filters'</code>:
+     * <pre><code>
+filters[0][field]="someDataIndex"&
+filters[0][data][comparison]="someValue1"&
+filters[0][data][type]="someValue2"&
+filters[0][data][value]="someValue3"&
+     * </code></pre>
+     * </div></li>
+     * <li><b><tt>true</tt></b> :
+     * <div class="sub-desc">
+     * JSON encode the filter data
+     * <pre><code>
+filters[0][field]="someDataIndex"&
+filters[0][data][comparison]="someValue1"&
+filters[0][data][type]="someValue2"&
+filters[0][data][value]="someValue3"&
+     * </code></pre>
+     * </div></li>
+     * </ul></div>
+     * Override this method to customize the format of the filter query for remote requests.
+     * @param {Array} filters A collection of objects representing active filters and their configuration.
+     *    Each element will take the form of {field: dataIndex, data: filterConf}. dataIndex is not assured
+     *    to be unique as any one filter may be a composite of more basic filters for the same dataIndex.
+     * @return {Object} Query keys and values
+     */
+    buildQuery : function (filters) {
+        var p = {}, i, f, root, dataPrefix, key, tmp,
+            len = filters.length;
+
+        if (!this.encode){
+            for (i = 0; i < len; i++) {
+                f = filters[i];
+                root = [this.paramPrefix, '[', i, ']'].join('');
+                p[root + '[field]'] = f.field;
+
+                dataPrefix = root + '[data]';
+                for (key in f.data) {
+                    p[[dataPrefix, '[', key, ']'].join('')] = f.data[key];
+                }
+            }
+        } else {
+            tmp = [];
+            for (i = 0; i < len; i++) {
+                f = filters[i];
+                tmp.push(Ext.apply(
+                    {},
+                    {field: f.field},
+                    f.data
+                ));
+            }
+            // only build if there is active filter
+            if (tmp.length > 0){
+                p[this.paramPrefix] = Ext.JSON.encode(tmp);
+            }
+        }
+        return p;
+    },
+
+    /**
+     * Removes filter related query parameters from the provided object.
+     * @param {Object} p Query parameters that may contain filter related fields.
+     */
+    cleanParams : function (p) {
+        // if encoding just delete the property
+        if (this.encode) {
+            delete p[this.paramPrefix];
+        // otherwise scrub the object of filter data
+        } else {
+            var regex, key;
+            regex = new RegExp('^' + this.paramPrefix + '\[[0-9]+\]');
+            for (key in p) {
+                if (regex.test(key)) {
+                    delete p[key];
+                }
+            }
+        }
+    },
+
+    /**
+     * Function for locating filter classes, overwrite this with your favorite
+     * loader to provide dynamic filter loading.
+     * @param {String} type The type of filter to load ('Filter' is automatically
+     * appended to the passed type; eg, 'string' becomes 'StringFilter').
+     * @return {Class} The Ext.ux.grid.filter.Class
+     */
+    getFilterClass : function (type) {
+        // map the supported Ext.data.Field type values into a supported filter
+        switch(type) {
+            case 'auto':
+              type = 'string';
+              break;
+            case 'int':
+            case 'float':
+              type = 'numeric';
+              break;
+            case 'bool':
+              type = 'boolean';
+              break;
+        }
+        return Ext.ClassManager.getByAlias('gridfilter.' + type);
+    }
+});