Friday, September 2, 2011

ASP.NET MVC3 - dynamic grid with ajax paging & sorting


A grid with ajax paging/sorting, something that is very common in today's web applications, is something that's not coming out of the box in ASP.NET MVC3. There is a built in helper class for making grids - System.Web.Helpers.WebGrid, but the customization is a little bit limited and even though it has paging and sorting functionalities they do postbacks...

Fortunately, it is not that hard to implement our own grid. All we need is:
  • A jQuery plugin that will handle the ajax
  • A class that will hold information about Columns
  • A MVC Helper to render the initial html
  • An action in a controller that will return data for paging
  • Make use of the whole thing in a view

1. The jQuery plugin
We start with a new jscript file (e.g. DynamicGrid.js) which we add to the Scripts folder, add an empty plugin and some option-defaults:

(function ($) {
    $.fn.DynamicGrid = function (options) {
        return this.each(function () {
            // options
            var opts = $.extend({}, $.fn.DynamicGrid.defaults, options);

        });
    }

    $.fn.DynamicGrid.defaults = {
        url: null,
        page: 1,
        pageSize: 10,
        sortProperty: null,
        allowSort: false,
        columns: null
    };
})(jQuery);

Next, we add a function that will load the data:
function loadPage(options) {
    $.ajax({
        type: "Post",
        url: options.url,
        data: { page: options.page,
                pageSize: options.pageSize,
                sortProperty : options.sortProperty },
        success: function (data, textStatus, jqXHR) {
                     // fill the grid
                     fillGrid(data, options);
                     // update the page-count (which could have changed meanwhile)
                     options.pageCount = data.pageCount;
                     // update the navigation
                     setupNavigation(options);
                 },
        error: function (jqXHR, textStatus, errorThrown) {
                     alert("error: " + textStatus + ", " + errorThrown);
               }
    });
}
It simply starts an ajax call to the given url and sends data about the current page, page size and the property which is going to be used for sorting.

This function we'll call in the plugin initialization:

return this.each(function () {
    // options
    var opts = $.extend({ grid : $(this) }, $.fn.DynamicGrid.defaults, options);

    // load the first page
    loadPage(opts);

});

Now we need to implement the functions fillGrid and setupNavigation.

function fillGrid(data, options) {
    var html = "";
    var sortableAtt = "sort_column";


    //
    // 1. Table Header
    html += "<tr>";


    // Create the header columns based on columns from the options, if given, or else
    // create a column for each property of the list items.
    if (options.columns && options.columns.length > 0) {
        for (var c in options.columns) {
            var col = options.columns[c];
            if (options.allowSort) {
                html += "<th style=\"" + col.headerStyle + "\"><a href='#' " + sortableAtt +
                        "='" + col.propertyName + "'>" + col.columnHeader + "</a></th>";
            } else {
                html += "<th style=\"" + col.headerStyle + "\">" + col.columnHeader +
                        "</th>";
            }
        }                   
    } else if (data.result.length > 0) {
        for (var prop in data[0]) {
            html += "<th>" + prop + "</th>";
        }
    }
    html += "</tr>";

    //
    // 2. Table Data
    for (var i = 0; i < data.result.length; i++) {
        var item = data.result[i];
        html += "<tr>";
        if (options.columns && options.columns.length > 0) {
            for (var c in options.columns) {
                var col = options.columns[c];
                html += "<td style=\"" + col.cellStyle +  "\">" + item[col.propertyName]
                        "</td>";
            }
        } else {
            for (var prop in item) {
                html += "<td>" + item[prop] + "</td>";
            }
        }
        html += "</tr>";
    }

    // 
    // 3. set the html of the grid (html table)
    options.grid.html(html);

    //
    // 4. sort when clicked on the column header
    options.grid.find("a[" + sortableAtt + "]").click(function() {
        var propertyName = $(this).attr(sortableAtt);
        options.sortProperty = propertyName;
        loadPage(options);
    });
}

The fillGrid function fills the grid with data received from the ajax call. It first renders the grid header and then the data. Columns are either defined in the options or generated based on properties of the list items.

function setupNavigation(options) {
    if (options.page < options.pageCount) {
        $("#" + options.nextId).show().unbind("click").click(function () {
            options.page++;
            loadPage(options);
        });
    } else {
        $("#" + options.nextId).show().hide();
    }
    if (options.page > 1) {
        $("#" + options.previousId).show().unbind("click").click(function () {
            options.page--;
            loadPage(options)
        });
    } else {
        $("#" + options.previousId).hide();
    }
}

The setupNavigation function sets up the navigation links defined in the options (previousId and nextId are id's of html elements that will be used as links to previous/next page).

Thats the whole jQuery plugin:

(function ($) {
    $.fn.DynamicGrid = function (options) {
        return this.each(function () {
            // options
            var opts = $.extend({ grid: $(this) }, $.fn.DynamicGrid.defaults, options);

            // load the first page
            loadPage(opts);
        });
        function loadPage(options) {
            $.ajax({
                type: "Post",
                url: options.url,
                data: { page: options.page,
                        pageSize: options.pageSize,
                        sortProperty: options.sortProperty },
                success: function (data, textStatus, jqXHR) {
                    fillGrid(data, options);
                    options.pageCount = data.pageCount;
                    setupNavigation(options);
                },
                error: function (jqXHR, textStatus, errorThrown) {
                    alert("error: " + textStatus + ", " + errorThrown);
                }
            });
        }
        function fillGrid(data, options) {
            var html = "";
            var sortableAtt = "sort_column";

            //
            // 1. Table Header
            html += "<tr>";

            // Create the header columns based on columns from the options, if given, or else
            // create a column for each property of the list items.
            if (options.columns && options.columns.length > 0) {
                for (var c in options.columns) {
                    var col = options.columns[c];
                    if (options.allowSort) {
                        html += "<th style=\"" + col.headerStyle + "\"><a href='#' " +
                                sortableAtt + "='" + col.propertyName + "'>" +
                                col.columnHeader + "</a></th>";
                    } else {
                        html += "<th style=\"" + col.headerStyle + "\">" + col.columnHeader +
                            "</th>";
                    }
                }
            } else if (data.result.length > 0) {
                for (var prop in data[0]) {
                    html += "<th>" + prop + "</th>";
                }
            }
            html += "</tr>";

            //
            // 2. Table Data
            for (var i = 0; i < data.result.length; i++) {
                var item = data.result[i];
                html += "<tr>";
                if (options.columns && options.columns.length > 0) {
                    for (var c in options.columns) {
                        var col = options.columns[c];
                        html += "<td style=\"" + col.cellStyle + "\">" +
                                 item[col.propertyName] + "</td>";
                    }
                } else {
                    for (var prop in item) {
                        html += "<td>" + item[prop] + "</td>";
                    }
                }
                html += "</tr>";
            }

            //
            // 3. set the html of the grid (html table)
            options.grid.html(html);

            //
            // 4. sort when clicked on the column header
            options.grid.find("a[" + sortableAtt + "]").click(function () {
                var propertyName = $(this).attr(sortableAtt);
                options.sortProperty = propertyName;
                loadPage(options);
            });
        }
        function setupNavigation(options) {
            if (options.page < options.pageCount) {
                $("#" + options.nextId).show().unbind("click").click(function () {
                    options.page++;
                    loadPage(options);
                });
            } else {
                $("#" + options.nextId).show().hide();
            }
            if (options.page > 1) {
                $("#" + options.previousId).show().unbind("click").click(function () {
                    options.page--;
                    loadPage(options)
                });
            } else {
                $("#" + options.previousId).hide();
            }
        }
    }

    $.fn.DynamicGrid.defaults = {
        url: null,
        page: 1,
        pageSize: 10,
        sortProperty: null,
        allowSort: false,
        columns: null,
        nextId: null,
        previousId: null
    };
})(jQuery); 

2. The GridColumn class
We add a new class in the Models folder (not necessary) and call it GridColumn.cs. This class will hold information for columns of the grid:

public class GridColumn
{
    public string PropertyName { get; set; }
    public string ColumnHeader { get; set; }
    public string HeaderCssStyle { get; set; }
    public string CellCssStyle { get; set; }

    public object GetValue(PropertyInfo[] properties, object target)
    {
        var property = properties.FirstOrDefault(p => p.Name == this.PropertyName);
        return (property != null) ? property.GetValue(target, null) : null;
    }
}

3. The MVC Helper
The MVC Helper will render the inital html that will generate the grid. We add a new folder to the ASP.NET MVC3 project and call it App_Code (this name is required) and in it we add a new cshtml file (e.g. Helpers.cshtml) that will hold the helper. In this file we add the following code:

@using MvcApplication5.Models

@helper DynamicGrid(string id, string getItemsUrl, string cssStyle="", IEnumerable<GridColumn> columns = null, bool allowSort = false)
{
    string gridId = string.Concat(id, "_grid");
    string loadingId = string.Concat(id, "_loading");
    string nextId = string.Concat(id, "_next");
    string previousId = string.Concat(id, "_prev");
<text>
<div id="@id" style="@cssStyle">
    <span id="@loadingId" style="display: none;" >Loading...</span>
    <table id="@gridId" style="width: 100%;">
    </table>
    <div style="width: 100%; text-align: right;">
        <a href="#" id="@previousId" style="display: none; cursor: pointer;">PREV</a>
        <a href="#" id="@nextId" style="display: none;cursor: pointer;">NEXT</a>
    </div> 
    <script type="text/javascript">
        $(document).ready(function () {
            @{
                if (columns != null && columns.Count() > 0)
                {
                    System.Text.StringBuilder colsJscript =
                        new System.Text.StringBuilder("var cols = [");
                    foreach(var c in columns)
                    {
                        colsJscript.Append("{propertyName:\"")
                            .Append(c.PropertyName).Append("\", columnHeader : \"")
                            .Append(c.ColumnHeader).Append("\", headerStyle : \"")
                            .Append(c.HeaderCssStyle).Append("\", cellStyle : \"")
                            .Append(c.CellCssStyle).Append("\" },");
                    }
                    // makni zadnji zarez
                    colsJscript.Remove(colsJscript.Length -1, 1);
                    colsJscript.Append("]");
                    @:@System.Web.Mvc.MvcHtmlString.Create(colsJscript.ToString())
                }
            }
            $("#@gridId").DynamicGrid( {  pageSize: 5,
                                        columns: cols,
                                        allowSort: @allowSort.ToString().ToLower(),
                                        url: "@getItemsUrl" } );
        });
    </script>
</div>
</text>
}

This generates the initial html containing the html-table that will hold the grid, and the call to the DynamicGrid plugin. You can modify the helper to customize the grid as much as you want.

4. The Action that will return grid items
The Action that returns items should return a JsonResult with properties:
- result = the grid items of the current page
- page = the current page
- pageCount = the current page count

The Action could look like this:
[HttpPost]
public JsonResult GetStates()
{
    int page = int.Parse(Request["page"]);
    int pageSize = int.Parse(Request["pageSize"]);
    string sortProperty = Request["sortProperty"];

    var states = new []
    {
        new { Id = "1", Code = "AGAR" },
        new { Id = "2", Code = "AGR" },
        new { Id = "3", Code = "ADBAETH" },
        new { Id = "4", Code = "SJ" },
        new { Id = "5", Code = "DTKFZK" },
        new { Id = "6", Code = "SNZF" },
        new { Id = "7", Code = "DUK" },
        new { Id = "8", Code = "FH,FH" },
        new { Id = "9", Code = "ATH" },
        new { Id = "10", Code = "AHTJ" }
    };

    PropertyInfo pi = states[0].GetType().GetProperty(sortProperty);
    if (pi != null)
    {
        states = states.OrderBy(item => pi.GetValue(item, null)).ToArray();
    }

    return new JsonResult() { Data = new {
        result = states.Skip((page - 1) * pageSize).Take(pageSize),
        page = page,
        pageCount = (int)Math.Ceiling((float)s.Count() / pageSize)
        },
        ContentType = "text/json"
    };
}


5. Make use of the grid
Finally, we can make use of the grid. In a View we can use it like this:

@Helpers.DynamicGrid("myGrid",
    Url.Action("GetStates"),
    "width: 200px;",
    new GridColumn[]
    {
        new GridColumn(){ PropertyName = "Id",
                          ColumnHeader="Id",
                          HeaderCssStyle="width: 100px; color: Red;" },
        new GridColumn(){ PropertyName = "Code",
                          ColumnHeader="Code",
                          HeaderCssStyle="width: 100px; color: Blue;" }
    },
    true)

Remember to add a link to the script file (DynamicGrid.js) in the html-head section of the View.

Et voila, thats it!