1. #1
    Ext JS Premium Member
    Join Date
    Oct 2009
    Posts
    67
    Vote Rating
    3
    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.


    2014-07-23 - Replaced begin/endBulkUpdate with the global and much more efficient Ext.suspend/resumeLayouts. The speedup is crazy awesome, but v4.1+ only.

    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,//pretty obvious
     /** 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;
      //collapse all nodes.  adapted from the collapseAll code in the tree panel.
      Ext.suspendLayouts();
      if (root) {
       if (me.rootVisible) {
        root.collapse(true);
       } else {
        root.collapseChildren(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(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);
      Ext.resumeLayouts(true);
      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 || 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!
      **/
      if(!node.rendered){
       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){
       node.collapse();
       if(me.hideEmptyFolders){
        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]===false){ this.filterNodeHash[n][filterCmp.id]=true; }
       }
       if(apply!==false){
        this.applyFilters(null,0);
       }
       if(typeof filterCmp.afterClearFilter === 'function'){
        filterCmp.afterClearFilter();
       }
      }
     },
     /**
     * 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 just 'refreshes' the tree view back to an unfiltered state.
        //this is orders of magnitude faster than re-applying empty filters.
        if(this.rendered){
         this.refreshSize();
         this.updateIndexes();
        }
       }
      }
     },
     /**
     * 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,
     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);
                    },
                    //Example use of before/after functions.  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(this.tree.getRootNode().getPath()); }
                    }
            },{
                    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; 23 Jul 2014 at 2:53 PM. Reason: replaced begin/endBulkUpdate with global Ext.suspend/resumeLayouts

  2. #2
    Sencha - Support Team scottmartin's Avatar
    Join Date
    Jul 2010
    Location
    Houston, Tx
    Posts
    9,075
    Vote Rating
    467
    scottmartin has a brilliant future scottmartin has a brilliant future scottmartin has a brilliant future scottmartin has a brilliant future scottmartin has a brilliant future scottmartin has a brilliant future scottmartin has a brilliant future scottmartin has a brilliant future scottmartin has a brilliant future scottmartin has a brilliant future scottmartin has a brilliant future

      0  

    Default


    Thank you for the contribution.

    Scott.

  3. #3
    Sencha User
    Join Date
    Apr 2012
    Posts
    1
    Vote Rating
    0
    sinvalju is on a distinguished road

      0  

    Default Fail in extjs 4.1.1a

    Fail in extjs 4.1.1a


    Hello,
    Thanks for the work, but see this error


    Uncaught TypeError: Cannot call method 'getView' of null

  4. #4
    Ext JS Premium Member
    Join Date
    Oct 2009
    Posts
    67
    Vote Rating
    3
    hhangus is on a distinguished road

      0  

    Default


    It's been working in production on 4.1.1a for me. Likely you're not folowing the example closely enough. For example, are you making sure to pass the tree to your filter compnents?

    In any case, you're going to have to provide actual code if you want help because there are a mllion reasons getView() might not be defined.

  5. #5
    Sencha User
    Join Date
    Dec 2012
    Posts
    6
    Vote Rating
    0
    Bcg24 is on a distinguished road

      0  

    Default


    How would one utilize this 'view'. Should I just have a config in my tree panel. View:'nameofThis' ?

  6. #6
    Ext JS Premium Member
    Join Date
    Oct 2009
    Posts
    67
    Vote Rating
    3
    hhangus is on a distinguished road

      0  

    Default


    The first line of the example implementation I provided answers your question.

  7. #7
    Sencha User
    Join Date
    Dec 2012
    Posts
    6
    Vote Rating
    0
    Bcg24 is on a distinguished road

      0  

    Default


    So, you said we can use it with anything that implements filterFn and reset? is filterCmp meant to be an Ext.util.filter? Because, that only implements filterFn

  8. #8
    Sencha User
    Join Date
    Dec 2012
    Posts
    6
    Vote Rating
    0
    Bcg24 is on a distinguished road

      0  

    Default


    Also, it appears filterCmp should have an ID. creating an Ext.util.Filter with Ext.create makes a filter with no id.

  9. #9
    Ext JS Premium Member
    Join Date
    Oct 2009
    Posts
    67
    Vote Rating
    3
    hhangus is on a distinguished road

      0  

    Default


    Sorry I think you're confused. When I say it must implement filterFn I mean you must provide that function. I wasn't specifically talking about Ext.util.Filters though these should work with a little effort.

    Also, "reset" isn't necessary despite what I said. It was in one version but I have since removed it.

    Here's a more simplified example:

    Code:
    //A tree using the treefilteringview
    var tree = Ext.create('Ext.tree.Panel',{
        ....,
        viewType:'treefilteringview'
    });
    
    //Any component that defines the method "filterFn"
    var myFilteringComponentThing = Ext.create('Some.kind.of.Component',{
      ....
      filterFn:function(item){
            if(item.should.be.hidden){ return true; }
            else{ return false; }
       }
    });
    
    //Filter the view
    tree.getView().applyFilterFn(myFilteringComponentThing);
    
    //To remove this filter
    tree.getView().clearFilter(myFilteringComponentThing,true);
    
    //To clear all filters
    tree.getView().clearAllFilters(true);

  10. #10
    Sencha User
    Join Date
    Dec 2012
    Posts
    6
    Vote Rating
    0
    Bcg24 is on a distinguished road

      0  

    Default


    Any suggestions on how to use this if we are filtering only leafs. For example, my leaf nodes have a name field, whereas the folders do not. I want the folders to dissapear if there are no unfiltered leafs in that folder. However, I would have to recurse into that folder first to know this... It seems like the code takes care of driving through the tree in applyFilter. However, in applyFilterFn, would I have to implement my filterFn such that it is recursive? I don't know if this will work performance wise, any ideas?