Search code examples
javascriptjqueryslickgrid

Slickgrid Treeview Search


I am currently implementing a treeview using slickgrid.

My code is essentially based on this example.

What I am trying to do is get a search filter, similar to the one in the example, but which works on the branches as well as the parents. For instance if a tree looks like this:

-Parent 1
  -branch 1
   -sub_branch 1
  -branch 2
-Parent 2
  -branch 1
  -branch 2

and I search for the number '1' it should show this:

-Parent 1
  -branch 1
   -sub_branch 1
  -branch 2
-Parent 2
  -branch 1

rather than this:

-Parent 1

Sorry I don't have any of my code to show, I've not got anywhere. Any ideas? Thanks


Solution

  • UPDATE:

    I had to improve the code i wrote over a year ago this week, and with alot of testing, this is what i ended up with. This method is alot faster than the old one, and i mean alot! Tested with a node depth of 5 nodes and 5100 rows this data preparation takes around 1.3s, but if you don't need case insensitive search, removing toLowerCase will half that time to around 600ms. When the searchstrings are prepared, the search is instant.

    This is from our setData function where we prepare the data

    var self = this,
        searchProperty = "name";
    
    //if it's a tree grid, we need to manipulate the data for us to search it
    if (self.options.treeGrid) {
    
        //createing index prop for faster get
        createParentIndex(items);
    
        for (var i = 0; i < items.length; i++) {
            items[i]._searchArr = [items[i][searchProperty]];
            var item = items[i];
    
            if (item.parent != null) {
                var parent = items[item.parentIdx];
    
                while (parent) {
                    parent._searchArr.push.apply(
                        parent._searchArr, uniq_fast(item._searchArr)
                        );
    
                    item = parent;
                    parent = items[item.parentIdx];
                }
            }
        }
    
        //constructing strings to search
        //for case insensitive (.toLowerCase()) this loop is twice as slow (1152ms instead of 560ms for 5100rows) .toLowerCase();
        for (var i = 0; i < items.length; i++) {
            items[i]._search = items[i]._searchArr.join("/").toLowerCase(); 
            items[i]._searchArr = null;
        }
    
        //now all we need to do in our filter is to check indexOf _search property
    }
    

    In the code above, I use some functions. The first one creates two properties, one for its own position in the array, and the second parentIdx for parents index. I'm not so sure if this actually speeds up the performance, but it removes the need for a nested loop in the setData function.

    The one that actually makes all the difference here is the uniq_fast, which takes an array and removes all the duplicates in it. The method is one of the many functions from this answer remove-duplicates-from-javascript-array

    function createParentIndex(items) {
        for (var i = 0; i < items.length; i++) {
            items[i].idx = i; //own index
            if (items[i].parent != null) {
                for (var j = 0; j < items.length; j++) {
                    if (items[i].parent === items[j].id) {
                        items[i].parentIdx = j; //parents index
                        break;
                    }
                }
            }
        }
    }
    
    function uniq_fast(a) {
        var seen = {};
        var out = [];
        var len = a.length;
        var j = 0;
        for (var i = 0; i < len; i++) {
            var item = a[i];
            if (seen[item] !== 1) {
                seen[item] = 1;
                out[j++] = item;
            }
        }
        return out;
    }
    

    Now with all this preparation of the data, our filter function actually becomes pretty small and easy to handle. The filter function is called for each item, and as we now have the _search property on each item, we just check that. If no filter applied we need to make sure that we don't show closed nodes

    function treeFilter(item, args) {
        var columnFilters = args.columnFilters;
    
        var propCount = 0;
        for (var columnId in columnFilters) {
            if (columnId !== undefined && columnFilters[columnId] !== "") {
                propCount++;
    
                if (item._search === undefined || item._search.indexOf(columnFilters[columnId]) === -1) {
                    return false;
                } else {
                    item._collapsed = false;
                }
            }
        }
    
        if (propCount === 0) {
            if (item.parent != null) {
                var dataView = args.grid.getData();
                var parent = dataView.getItemById(item.parent);
                while (parent) {
                    if (parent._collapsed) {
                        return false;
                    }
    
                    parent = dataView.getItemById(parent.parent);
                }
            }
        }     
    
        return true;
    }
    

    So, the question was asked long ago, but if someone is looking for an answer for this, use the code above. It's fast, but any improvements of the code would be much appritiated!

    END OF EDIT

    old answer (this is very slow):

    As a start, you have to create a filter function that you use with your dataView. The dataView will call your function as soon as you type something. The function will be called for each row in the dataView, passing the row as the item parameter. Returning false indicates that the row should be hidden, and true for visible.

    Looking at the Tree example, the filter function looks like this

    function myFilter(item, args) {
      if (item["percentComplete"] < percentCompleteThreshold) {
        return false;
      }
    
      if (searchString != "" && item["title"].indexOf(searchString) == -1) {
        return false;
      }
    
      if (item.parent != null) {
        var parent = data[item.parent];
    
        while (parent) {
          if (parent._collapsed || (parent["percentComplete"] < percentCompleteThreshold) || (searchString != "" && parent["title"].indexOf(searchString) == -1)) {
            return false;
          }
    
          parent = data[parent.parent];
        }
      }
    
      return true;
    }
    

    In my first attempt to do this, I tried to manipulate the parent so that it should not be hidden. The problem is that i have no clue how to unhide it, and the problem is also that you don't know in which order the rows will be filtered (if the parent row is the last to be filtered, parent property is null)

    I abandoned that thought and tried to work with the item passed into the method, as this is how it's intended. The way to do it when working with basic parent/child tree structures is to use recursion.

    My solution

    To start, create a function that holds all the filtering and returns true or false. I've used fixed header row for fast filters as a base and then added my own rules to it. This is a really stripped down version of my realFilter function, so you might need to tweak it a little bit.

    function realFilter(item, args) {
        var columnFilters = args.columnFilters;
        var grid = args.grid;
        var returnValue = false;
    
        for (var columnId in columnFilters) {
            if (columnId !== undefined && columnFilters[columnId] !== "") {
                returnValue = true;
                var c = grid.getColumns()[grid.getColumnIndex(columnId)];
    
                if (item[c.field].toString().toLowerCase().indexOf(
                    columnFilters[columnId].toString().toLowerCase()) == -1) { //if true, don't show this post
                    returnValue = false;
                }
            }
        }
        return returnValue;
    }
    

    Secondly, it's time for the recursive function. This is the tricky part if you'r not familiar with how they work.

    //returns true if a child was found that passed the realFilter
    function checkParentForChildren(parent, allItems, args) { 
        var foundChild = false;
        for (var i = 0; i < allItems.length; i++) {
            if (allItems[i].parent == parent.id) {
                if (realFilter(allItems[i], args) == false && foundChild == false) //if the child do not pass realFilter && no child have been found yet for this row 
                    foundChild = checkParentForChildren(allItems[i], allItems, args);
                else
                    return true;
            }
        }
        return foundChild;
    }
    

    At last, we implement the original filter function. This is the function that is called by slickgrid and should be registered to the dataView

    //registration of the filter
    dataView.setFilter(filter);
    
    //the base filter function
    function filter(item, args) {
        var allRows = args.grid.getData().getItems();
        var columnFilters = args.columnFilters;
        var grid = args.grid;
        var checkForChildren = false;
    
        for (var i = 0; i < allRows.length; i++) {
            if (allRows[i].parent == item.id) {
                checkForChildren = true;
                break;
            }
        }
    
        for (var columnId in columnFilters) {
            if (columnId !== undefined && columnFilters[columnId] !== "") {
                var c = grid.getColumns()[grid.getColumnIndex(columnId)];
                var searchString = columnFilters[columnId].toLowerCase().trim();
    
                if (c != undefined) {
                    if (item[c.field] == null || item[c.field] == undefined) {
                        return false;
                    }
                    else { 
                        var returnValue = true;
    
                        if (checkForChildren) {
                            returnValue = checkParentForChildren(item, allRows, args);
                            if(!returnValue)
                                returnValue = realFilter(item, args);
                        }
                        else
                            returnValue = realFilter(item, args);
    
                        if (item.parent != null && returnValue == true) {
                            var dataViewData = args.grid.getData().getItems();
                            var parent = dataViewData[item.parent];
    
                            while (parent) {
                                if (parent._collapsed) {
                                    parent._collapsed = false;
                                }
                                parent = dataViewData[parent.parent];
                            }
                        }
    
                        return returnValue;
                    }
                }
            }
        }
    
        if (item.parent != null) {
            var dataViewData = args.grid.getData().getItems();
            var parent = dataViewData[item.parent];
    
            while (parent) {
                if (parent._collapsed) {
                    return false;
                }
    
                parent = dataViewData[parent.parent];
            }
        }
        return true;
    }
    

    I'm currently working on this so I have not really bothered to improve the code yet. It is working as far as i know, but you may have to tweak some things in filter and realFilter to get it to work as you expect. I wrote this today so it's not tested more than under the development phase.

    Note: If you want to use another input for your search you can just use $.keyup() on that field and then pass the data to the header filter. This way you get all the functionality to use column-level filters, even if you don't want to use them in this particular case.