ASP.NET MVC: Enhancing The WebGrid - Excel-like FIltering

January 14th, 2016

Everyone loves Excel, but can we implement Excel filtering in a WebGrid? Today, we answer that question by enhancing our webgrid with Excel-like filtering.

Since I published my WebGrid series, I received a number of requests for adding enhancements to a standard WebGrid.

With a recent request, I've been asked if there's a way to add an Excel-like filter feature to a standard WebGrid.

Is it possible? Absolutely!

We actually need a little creativity with a some jQuery and Bootstrap magic.

Requirements

If we go by what Excel does with their filtering, we can identify two key requirements:

  1. No paging is involved with filtering. Everything is on one page.
  2. The filtering is strictly column-based.

The filtering on our WebGrid is located to the right of the column heading.

Our Standard WebGrid

Let's get our standard WebGrid

@MvcHtmlString.Create(
    grid.GetHtml(
        htmlAttributes: new
        {
            id = "grid",
            @class = "table table-bordered table-striped table-condensed"
        },
        emptyRowCellValue: "No Records Found",
        headerStyle: "grid-header",
        columns: grid.Columns(
            grid.Column(header: "{CheckBoxHeading}",
                format: item => Html.CheckBox("select"new { @class = "box", @value = item.Value.Id }),
                style: "text-center checkbox-width"),
            grid.Column("UserName""User Name"@<text>@item.Value.UserName</text>, canSort: false),
            grid.Column("FirstName""First Name"@<text>@item.Value.FirstName</text>, canSort: false),
            grid.Column("LastName""Last Name"@<text>@item.Value.LastName</text>, canSort: false),
            grid.Column("LastLogin""Last Login"@<text>@item.Value.LastLogin.ToString()</text>, canSort: false)
            )
        )
        .ToString()
        .Replace("{CheckBoxHeading}""<div class='text-center'><input type='checkbox' id='allBox'/></div>")
    )

We want something that's easy to implement for filtering and we need something that returns back a modified header that displays an indicator for filtering.

The {CheckBoxHeading} technique seemed to work quite nicely for our 'Select All' checkboxes when we wanted to modify a number of records.

With that said, here's our updated WebGrid:

@MvcHtmlString.Create(
    grid.GetHtml(
        htmlAttributes: new
        {
            id = "grid",
            @class = "table table-bordered table-striped table-condensed"
        },
        emptyRowCellValue: "No Records Found",
        headerStyle: "grid-header",
        columns: grid.Columns(
            grid.Column(header: "{CheckBoxHeading}",
                format: item => Html.CheckBox("select"new { @class = "box", @value = item.Value.Id }),
                style: "text-center checkbox-width"),
            grid.Column("UserName""{UserName-filter}"@<text>@item.Value.UserName</text>, canSort: false),
            grid.Column("FirstName""First Name"@<text>@item.Value.FirstName</text>, canSort: false),
            grid.Column("LastName""Last Name"@<text>@item.Value.LastName</text>, canSort: false),
            grid.Column("LastLogin""Last Login"@<text>@item.Value.LastLogin.ToString()</text>, canSort: false)
            )
        )
        .ToString()
        .Replace("{CheckBoxHeading}""<div class='text-center'><input type='checkbox' id='allBox'/></div>")
        .Replace("{UserName-filter}""User Name <i class='glyphicon glyphicon-filter'></i>")
    )

Unfortunately, we have two issues:

  1. This can be reused on other WebGrid's, but I don't like the copy-pastey way of programming.
  2. We need our list of possible values in the column.

It looks like we need to refactor this column header into something a little more extensible.

Time To Refactor!

What I'm thinking for this to work is to use an HTMLHelper in place of the string. This solves both of our issues.

A couple things we need to keep in mind.

I created a simple model called WebGridFilterModel.

public class WebGridFilterModel
{
    public IEnumerable<User> Users { getset; }
    public string HeadingText { getset; }
    public Func<Userstring> Property { getset; }
}

The list of users is required for our filter list, the heading text, and the property we'll use to populate our filter list.

Now that we have our model, we can create our new HtmlHelper called WebGridFilter<T>.

\Helpers\Html\WebGridHelpers.cs

public static class WebGridHelpers
{
    public static HtmlString WebGridFilter<T>(this HtmlHelper helper, 
        IEnumerable<User> users, Func<Userstring> property, 
        string headingText) where Tclass
    {
        var model = new WebGridFilterModel
        {
            Users = users.GroupBy(property).Select(g=> g.First()),
            Property = property,
            HeadingText = headingText
        };
        return helper.Partial("_webGridFilter", model);
    }
}

All we are doing is sending parameters, or a "ViewModel", to a partial View. We don't want to code HTML in C#.

Notice the GroupBy Linq statement? This is another way to create a Distinct on a list. Since we are passing the property through as a Func<User>, we can use that to get the Distinct values of each Username.

Building the Dropdown

The _WebGridFilter.cshtml is relatively simple because we are outputting the Html in the header using the parameters we passed in through our ViewModel (WebGridFilterModel).

Since Bootstrap has this built into it, we'll use the dropdown HTML to activate it.

\Views\Shared\_webGridFilter.cshtml

@model WebGridExample.Models.WebGridFilterModel
<div class="dropdown">
    <span class="button-group">
        @Model.HeadingText <span class="dropdown-toggle glyphicon glyphicon-filter" data-toggle="dropdown"></span>
        <ul class="dropdown-menu">
            <li>
                <a href="#" class="small" data-value="all" tabIndex="-1">
                    <input type="checkbox" checked />&nbsp;Select All
                </a>
            </li>
            <li class="divider"></li>
            @foreach (var user in Model.Users.OrderBy(Model.Property))
            {
                <li>
                    <a href="#" class="small" data-value="@user.Id" tabIndex="-1">
                        <input type="checkbox" class="filterbox" checked />&nbsp;@Model.Property(user)
                    </a>
                </li>
            }
        </ul>
    </span>
</div>

Now that we have everything setup, the last thing we need to do is activate it and use a little jQuery to make the filtering function properly. 

Adding the JavaScript

The JavaScript is meant to activate two areas on our WebGrid Filter:

  1. Each checkbox in the filter list to hide a row.
  2. The "Select All" checkbox.

First, let's focus on the checkbox filter list.

The way we have our Html set up, we trigger the checkbox from an anchor click. We need an anchor onClick event.

// Filter Checkbox
$('.dropdown-menu a', $grid).on('click'function (event) {
 
    var self = this;
 
    var $target = $(event.currentTarget),
        id = $target.attr('data-value'),
        value = $target.text().trim(),
        $inp = $target.find('input'),
        idx,
        $row = $("td:contains('"+value+"')", $grid)
            .closest("tr");
 
    if ((idx = options.indexOf(id)) > -1) {
        options.splice(idx, 1);
        $row.show();
        setTimeout(function() {
            $inp.prop('checked'true);
            updateSelectAll(self);
        }, 0);
    } else {
        options.push(id);
        $row.hide();
        setTimeout(function() {
            $inp.prop('checked'false);
            updateSelectAll(self);
        }, 0);
    }
 
    $(event.target).blur();
 
    return false;
});

We grab the current target (the link in the filter list), grab the data-value (which is in the link), grab the text value (to filter our rows), and we look for the rows that contain our selected item from the filter list.

Keep in mind that the $row variable can contain 1 or many table rows (TR). When we issue the $row.show()/.hide(), we will hide all of the rows that contain our text.

If you notice the options array, we use this for our selected list. These hold all of the values for our filter.

We also have a updateSelectAll() function that if we have any checkboxes unchecked, we uncheck the Select All (because all are NOT selected at this time).

// Force the 'select all' checkbox on/off
function updateSelectAll(anchor) {
    var parent = $(anchor).closest(".dropdown-menu");
    // Are any checkboxes checked?
    var isAll = $(".filterbox:not(:checked)", parent).length === 0;
    setTimeout(function () {
        $("a[data-value='all'] :checkbox", parent).prop('checked', isAll);
    }, 0);
}

Using the anchor, we trace it up to the parent (".dropdown-menu") and count how many filterboxes are checked. If all are checked, we check the Select All checkbox. If even one is unchecked, we uncheck the Select All checkbox.

What happens when we actually click on the "Select All" checkbox?

// 'Select All' [Click]
$("a[data-value='all']").on("click"function () {
    var $parent = $(this).closest(".dropdown-menu"),
        isChecked = $(":checkbox"this).is(":checked");
 
    $(":checkbox"this).prop("checked", !isChecked);
    $(".filterbox", $parent).prop("checked", !isChecked);
    $("td:nth-child(1)", $grid).closest("tr").toggle(!isChecked);
});

We grab the closest parent (which is the dropdown-menu) and determine if it's checked or not. Then we check or uncheck the "select all" checkbox, all of the other filtered checkboxes, and hide/show the rows that meet our selected filter.

Our finished JavaScript looks like this.

\Scripts\WebGridFilter.js

var options = [];
 
$(function() {
 
    var $grid = $("#grid");
 
    // Force the 'select all' checkbox on/off
    function updateSelectAll(anchor) {
        var parent = $(anchor).closest(".dropdown-menu");
        // Are any checkboxes checked?
        var isAll = $(".filterbox:not(:checked)", parent).length === 0;
        setTimeout(function () {
            $("a[data-value='all'] :checkbox", parent).prop('checked', isAll);
        }, 0);
    }
 
    // 'Select All' [Click]
    $("a[data-value='all']").on("click"function () {
        var $parent = $(this).closest(".dropdown-menu"),
            isChecked = $(":checkbox"this).is(":checked");
 
        $(":checkbox"this).prop("checked", !isChecked);
        $(".filterbox", $parent).prop("checked", !isChecked);
        $("td:nth-child(1)", $grid).closest("tr").toggle(!isChecked);
    });
 
    // Filter Checkbox
    $('.dropdown-menu a', $grid).on('click'function (event) {
 
        var self = this;
 
        var $target = $(event.currentTarget),
            id = $target.attr('data-value'),
            value = $target.text().trim(),
            $inp = $target.find('input'),
            idx,
            $row = $("td:contains('"+value+"')", $grid)
                .closest("tr");
 
        if ((idx = options.indexOf(id)) > -1) {
            options.splice(idx, 1);
            $row.show();
            setTimeout(function() {
                $inp.prop('checked'true);
                updateSelectAll(self);
            }, 0);
        } else {
            options.push(id);
            $row.hide();
            setTimeout(function() {
                $inp.prop('checked'false);
                updateSelectAll(self);
            }, 0);
        }
 
        $(event.target).blur();
 
        return false;
    });
});

Now we have our completed filtering on our WebGrid.

Conclusion

Today, we covered a simple way of taking our standard WebGrid and adding something users of Excel are familiar with when working with grids: filtering.

You can apply this technique to other columns as well. Replace the header with a WebGridFilter HtmlHelper and you should see similar results with each column you add the filtering to.

Did this make sense? Post your comments below or share this post!

Series: Enhancing the WebGrid