Threaded View

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