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);