Threaded View

  1. #1
    Ext JS Premium Member
    Join Date
    Oct 2009
    Posts
    45
    Vote Rating
    2
    hhangus is on a distinguished road

      2  

    Default Tree filtering

    Tree filtering


    I have implemented an extended Ext.tree.View that provides proper filtering (hiding) of nodes and is extremely fast even on large trees. On my 3yo computer a tree of 1500+ nodes, nested up to 4 deep (maxExpandDepth:2), can be filtered in about 2 seconds.

    The code is below. Use it in a treepanel with the param "viewType:'treefilteringview'"

    You can create multiple filter components that will all be applied additively. So, for example, you could have a combo filter that filters on some category, and a text filter for general search within that selected category.

    NB: This does not filter the TreeStore. Only the view is filtered.

    NB2: Tree filtering is on the Extjs roadmap for an upcoming release so this extension may not be necessary in the long run.


    Code:
    /**
     * @class Ext.ux.tree.FilteringView
     * @extends Ext.tree.View
     * Enhances the basic tree.View with filtering capabilities. Any component that implements the functions
     * 'filterFn' and 'reset' can be used with this view. However, it is recommended to use the 
     * enhanced form fields also defined in this extension.
     */
    Ext.define('Ext.ux.tree.FilteringView',{
            extend:'Ext.tree.View',
            alias:'widget.treefilteringview',
    
            /** configs **/
            useDataIds:false,//Use node.data.id when hashing to use your own unique ids.
            maxExpandDepth:2,//Max depth to perform expansion of visible nodes.
            hideEmptyFolders:false,//hide empty folders, durrrrr
    
            /** local vars **/
            filterRegister: new Ext.util.HashMap(),
            filterNodeHash: [],
            filtered:false,
            doNotFilter:false,
    
            expand:function(node){
                    this.callParent(arguments);
                    if(this.isFiltered()){ this.applyFilters(node,0); }
            },
            refresh:function(){
                    this.callParent(arguments);
                    if(this.isFiltered()){ this.applyFilters(null,0); }
            },
    
            registerFilter: function(filterCmp){
                    if(!this.filterRegister.containsKey(filterCmp.id)){
                            this.filterRegister.add(filterCmp.id, filterCmp);
                    }
            },
    
            /**
             * Adds nodes to the filterNodeHash indicating whether they should be shown
             * or hidden. Nodes are added/removed based on the return value of the supplied
             * filterCmp.filterFn function (true = show, false = hide).
             */
            applyFilterFn: function(filterCmp) {
                    var me = this;
                    var root = this.getTreeStore().getRootNode();
                    me.registerFilter(filterCmp);
                    me.filtered = true;
    
                    if(typeof filterCmp.beforeFilter === 'function'){
                            filterCmp.beforeFilter();
                    }
                    root.cascadeBy(function(node){
                            if(node.isRoot() && !me.rootVisible){ return; }//skip invisible root
    
                            var nid = (me.useDataIds===true)? node.data.id:node.id;
    
                            if(typeof me.filterNodeHash[nid]==='undefined'){
                                    me.filterNodeHash[nid] = [];
                            }
                            if(filterCmp.filterFn.call(filterCmp,node)){
                                    me.filterNodeHash[nid][filterCmp.id] = true;
                            }else{
                                    me.filterNodeHash[nid][filterCmp.id] = false;
                            }
                    },me);
                    me.applyFilters(root,0);
                    if(typeof filterCmp.afterFilter === 'function'){
                            filterCmp.afterFilter();
                    }
            },
    
            /**
            * Runs over nodes starting from 'node' recursively expanding and hidding nodes
            * that are marked hidden by at least one filter in the filterNodeHash.
            * Nodes that have no visible children are collapsed.
            *
            * @params
            *       node    The node at which to begin filtering.
            *       myDepth The depth of the current recursive call. Used to stop expansion
            *               of nodes deeper than the value of maxExpandDepth.
            **/
            applyFilters: function(node){
                    if(this.doNotFilter){ return; }
    
                    var me = this;
                    var hasVisibleChild=false;
                    var node = (node===null || typeof node === 'undefined')? this.getTreeStore().getRootNode():node;
                    var myDepth = node.getDepth();
    
                    /** 
                    * Don't filter when we expand the node internally or we 
                    * will have several instances of filtering going on at the same time!
                    **/
                    me.doNotFilter=true;
                    node.expand();//necessary to be sure Ext.fly will have access to a rendered element
                    me.doNotFilter=false;
                    node.eachChild(function(childNode){
                            var el = Ext.fly(me.getNodeByRecord(childNode));
                            el.setVisibilityMode(Ext.Element.DISPLAY);
                            if(me.isNodeFiltered(childNode)){
                                    childNode.collapse(true);
                                    el.setVisible(false);
                            }else{
                                    hasVisibleChild=true;
                                    el.setVisible(true);
                                    if((myDepth+1) < me.maxExpandDepth){
                                            me.applyFilters(childNode);
                                    }
                            }
                    });
    
                    if(!hasVisibleChild && me.isFiltered()){
                            node.collapse();
                            if(me.hideEmptyFolders && !node.isRoot()){
                                    Ext.fly(me.getNodeByRecord(node)).setVisible(false);
                            }
                    }
            },
    
            /**
            * Clears the specified filter.
            * @params:
            *       filterCmp       The component registered as a filter.
            *       apply           Set false if you don't want the changes applied immediately.
            */
            clearFilter: function(filterCmp,apply){
                    if(this.isFiltered()){
                            if(typeof filterCmp.beforeClearFilter === 'function'){
                                    filterCmp.beforeClearFilter();
                            }
                            for(n in this.filterNodeHash){
                                    if(this.filterNodeHash[n][filterCmp.id]!=='undefined'){
                                            delete this.filterNodeHash[n][filterCmp.id];
                                    }
                                    if(this.arraySize(this.filterNodeHash[n]) <= 0){
                                            delete this.filterNodeHash[n];
                                    }
                            }
                            if(this.arraySize(this.filterNodeHash)<=0){
                                    this.filtered = false;
                            }
                            if(apply!==false){
                                    this.applyFilters(null);
                            }
                            if(typeof filterCmp.afterClearFilter === 'function'){
                                    filterCmp.afterClearFilter();
                            }
                    }
            },
            arraySize:function(obj){
                    var size = 0, key;
                    for (key in obj) {
                            if (obj.hasOwnProperty(key)) size++;
                    }
                    return size;
            },
    
            /**
            * Clears all filters.
            * @params:
            *       apply           Set false if you don't want the changes applied immediately.
            */
            clearAllFilters : function(apply) {
                    if (this.isFiltered()) {
                            this.filterNodeHash = [];
                            this.filtered = false;
                            if(apply!==false){ this.applyFilters(null,0); }
                    }
            },
    
            /**
            * Returns true if the tree is filtered
            */
            isFiltered : function() {
                    return this.filtered;
            },
    
            /**
            * Returns true if the specified node is filtered by any of the managed filters
            */
            isNodeFiltered:function(node){
                    var me = this;
                    var nid = (me.useDataIds===true)? node.data.id:node.id;
                    for(var f in me.filterNodeHash[nid]){
                            if(me.filterNodeHash[nid][f]===false){
                                    return true;
                            }
                    }
                    return false;
            }
    });
    
    
    /**
     * @class Ext.ux.tree.TreeTextFilter
     * @extends Ext.form.Trigger
     * Provides a basic text entry field with a trigger for clearing the field/filter and another
     * to apply the field value and filter.
    **/
                   Ext.define('Ext.ux.tree.TreeTextFilter',{
            extend:'Ext.form.Trigger',
            alias:'widget.treetextfilter',
            value:'',
            tree:null,
    
            trigger1Cls: 'x-form-clear-trigger',
            trigger2Cls: 'x-form-select-trigger',
            initComponent:function(){
                    this.callParent(arguments);
                    try{
                            if(typeof this.tree === 'string'){ this.tree = Ext.getCmp(this.tree); }
                    }catch(e){ console.log('Invalid tree provided to this treetextfilter'); }
    
                    //Apply filter when user types the 'Enter' key
                    this.on('specialkey', function(f, e){
                            if(e.getKey() == e.ENTER){
                                    this.onTrigger2Click();
                            }
                    }, this);
            },
            onTrigger1Click:function(){
                    this.setValue('');
                    this.tree.getView().clearFilter(this);
            },
            onTrigger2Click:function(){
                    var me = this;
                    this.value = this.getRawValue().trim();
                    me.tree.getView().applyFilterFn(me);
            },
    
            /* Override this function to implement custom filtering */
            filterFn:function(node){return true;}
    });
    
    /**
     * @class Ext.ux.tree.TreeComboFilter
     * @extends Ext.form.ComboBox
     * Provides a drop-down combobox selector that will apply the filter when
     * any item in the drop-down is selected.
    **/
    Ext.define('Ext.ux.tree.TreeComboFilter',{
            extend: 'Ext.form.ComboBox',
            alias:'widget.treecombofilter',
            tree:null,
    
            editable:false,
            triggerAction:'all',
            forceSelection:true,
            selectOnFocus:true,
            queryMode:'local',
            remove:true,
            initComponent:function(){
                    this.callParent(arguments);
                    try{
                            if(typeof this.tree === 'string'){ this.tree = Ext.getCmp(this.tree); }
                    }catch(e){ console.log('Invalid tree provided to this treecombofilter:'+this.id); }
            },
            listeners:{select:{fn:function(combo,records,eOpts){
                             var me = this;
                    this.tree.getView().applyFilterFn(this);
            }}},
    
            /* Override this function to implement custom filtering */
            filterFn:function(node){return true;}
    });
    An example implementation of a toolbar with multiple filters:

    Code:
            var tree = Ext.create('Ext.tree.Panel',{ 
                   .... ,
                   viewType:'treefilteringview'
            };
    
            var filterBar = Ext.create('Ext.toolbar.Toolbar');
            filterBar.add({
                    xtype:'treetextfilter',
                    itemId:'textfilter',
                    emptyText:'Enter search terms',
                    tree:tree,
                    _reselectNode:false,
                    filterFn:function(node){
                            var re = new RegExp(Ext.escapeRe(this.value), 'i');
    
                            return re.test(node.data.someField2Test);
                    },
                    //Reselect the node that was selected before filtering.
                    beforeClearFilter:function(){
                            this._reselectNode = this.tree.getSelectionModel().getSelection()[0] || false;
                    },
                    afterClearFilter:function(){
                            this.tree.collapseAll();
                            if(this._reselectNode){ this.tree.expandPath(this._reselectNode.getPath()); this.tree.getSelectionModel().select(this._reselectNode); }
                            else{ this.tree.expandPath('/root/node-id-overdue'); }
                    }
            },{
                    xtype:'treecombofilter',
                    itemId:'disciplinefilter',
                    emptyText:'<Filter by discipline>',
                    tree:tree,
                    displayField:'name',
                    valueField:'code',
                    store: Ext.create('Ext.data.ArrayStore',{
                            model: Ext.define('filterModel1',{
                                    extend:'Ext.data.Model',
                                    fields:['name','code']
                            }),
                            data:[....]
                    }),
                    filterFn:function(node){
                            var value = this.getValue();
                            var re = new RegExp('^([A-Z0-9]{2,3}-)?'+Ext.escapeRe(value)+'-','i');
    
                            return re.test(node.data.testField);
                    }
            },{
                    xtype:'treecombofilter',
                    itemId:'contractsfilter',
                    emptyText:'<Filter by contract>',
                    store: contractsStore,
                    tree:tree,
                    displayField:'name',
                    valueField:'id',
                    filterFn:function(node){
                            var value = this.getRawValue();
                            var re = new RegExp('^'+Ext.escapeRe(value)+'$','i');
    
                            return re.test(node.data.testField);
                    }
            },{
                    text:'Clear filters',
                    handler:function(){
                            filterBar.getComponent('textfilter').setValue('');
                            filterBar.getComponent('disciplinefilter').reset();
                            filterBar.getComponent('contractsfilter').reset();
                            var node = tree.getSelectionModel().getSelection()[0];
                            tree.getView().clearAllFilters();
                            tree.collapseAll(function(){
                                    if(node){ tree.expandPath(node.getPath()); tree.getSelectionModel().select(node); }
                                    else{ tree.expandPath(defaultPath); }
                            });
                    }
            });
            tree.addDocked(filterBar);
    Last edited by hhangus; 20 Dec 2012 at 1:31 PM. Reason: bug fix