Monday, 22 October 2012

Knockout: Pagination using knockoutjs and ASP.NET Web API

Introduction

In this article, I am trying to implement HTML table as a grid with pagination using knockoutjs. This is the easiest way to get page wise data from server using ASP.NET Web API (or WCF service) and display them as a  grid.



Before we jump in to this article, let's have a look at the standard definition of Knockoutjs and ASP.NET Web API:

Knockoutjs is a JavaScript library that helps you to create rich, responsive displays and editor user interfaces with a clean underlying data model. Any time you have sections of UI that update dynamically (e.g., changing depending on user’s actions or when an external data source changes), KO can help you implement it more simply and maintainably.


ASP.NET Web API is a framework that makes it easy to build HTTP services that reach a broad range of clients, including browsers and mobile devices. ASP.NET Web API is an ideal platform for building RESTful applications on the .NET Framework.
----------------------------------------------------------------------------------------------------------

Define a PagedObservable

To get page wise data , create a javascript file pagedobservable.js and write the following codes:

(function (window, ko) {
    window.Utils = window.Utils || {};
    window.Utils.pagedObservable = function (options) {
        options = options || {};
        var _allData = ko.observableArray(), //the data collection to dispaly in grid
         
            _columns = ko.observableArray(options.columns || []), //the columns of grid
 
          _pageSize = ko.observable(options.pageSize || 10), //the size of the pages to display
          _pageSizes = ko.observable(options.pageSizes || []), //the size of the pages to display
          _pageIndex = ko.observable(1), //the index of the current page
          _pageCount = ko.observable(0), //the number of pages
         
           _totalRecords = ko.observable(0), //the number of records
         
           _sortName = ko.observable(options.sortName || ''), //the sort column name
         
           _sortOrder = ko.observable(options.sortOrder || 'asc'), //the sort order
         //move to the next page
       _nextPage = function () {
           if (_pageIndex() < _pageCount()) {
               _pageIndex(parseInt(_pageIndex()) + 1);
           }
       },
   //move to the previous page
       _previousPage = function () {
           if (_pageIndex() > 1) {
               _pageIndex(_pageIndex() - 1);
           }
       },
            //move to first page
            _firstPage = function () {
                if (_pageIndex() > 1) {
                    _pageIndex(1);
                }
            },
            //move to last page
            _lastPage = function () {
                if (_pageIndex() < _pageCount()) {
                    _pageIndex(_pageCount());
                }
            },
            //sort a column
            _sort = function (column) {
                _sortName(column.index);
                _sortOrder(_sortOrder() === 'asc' ? 'desc' : 'asc');
                _pageIndex(1);
                _loadFromServer();
            },
            //the message for record info
            _recordMessage = ko.computed(function () {
                if (_allData().length > 0) {
                    return 'Records ' + ((_pageIndex() - 1) * _pageSize() + 1) + ' - ' + (_pageIndex() < _pageCount() ? _pageIndex() * _pageSize() : _totalRecords()) + ' of ' + _totalRecords();
                }
                else {
                    return 'No records';
                }
            }),
            //the message for page info
            _pageMessage = ko.computed(function () {
                if (_allData().length > 0) {
                    return 'Page ' + _pageIndex() + ' of ' + _pageCount();
                }
                else {
                    return 'No pages';
                }
            }),
            //service url
            _serviceURL = ko.computed(function () {
                return options.serviceURL + (options.serviceURL.indexOf('?') != -1 ? "&" : "?") + "sidx=" + _sortName() + "&sord=" + _sortOrder() + "&page=" + _pageIndex() + "&rows=" + _pageSize();
            }, this),
           //load data from server
            _loadFromServer = function () {
                $.getJSON(_serviceURL(), function (data) {
                    if (data != null) {
                        _totalRecords(data.records);
                        _pageCount(data.total);
                        _allData(data.rows || []);
                    }
                    else {
                        _totalRecords(0);
                        _pageCount(0);
                        _allData([]);
                    }
                });
            };
        _pageIndex.subscribe(function () {
            _pageIndex() < 1 ? _pageIndex(1) :  _loadFromServer();
        });
        _pageSize.subscribe(function () {
            _pageIndex() != 1 ? _pageIndex(1) : _loadFromServer();
        });
        _loadFromServer();
        //public members
        this.columns = _columns;
        this.rows = _allData;
        this.totalRecords = _totalRecords;
        this.pageSize = _pageSize;
        this.pageSizes = _pageSizes;
        this.pageIndex = _pageIndex;
        this.pageCount = _pageCount;
        this.nextPage = _nextPage;
        this.previousPage = _previousPage;
        this.firstPage = _firstPage,
        this.lastPage = _lastPage,
        this.sortOrder = _sortOrder;
        this.sortName = _sortName;
        this.sort = _sort;
        this.recordMessage = _recordMessage;
        this.pageMessage = _pageMessage;
        this.load = _loadFromServer;
    };
}(window, ko));

The new instance exposes a number of properties to support paging:

  • columns– An observableArray instance which helps to display column information. 
  • rows– An observableArray instance exposing the page wise data.
  • totalRecords– An observable instance exposing the total no of records.
  • pageSize – An observable instance containing the number of items per page.
  • pageSizes – An observableArray instance exposing the page sizes user want to display on dropdownlist in grid. 
  • pageIndex – An observable instance containing the current page index.
  • pageCount – A computed observable that returns the number of pages.
  • rows – An observableArray instance that contains the current page data.
  • previousPage – A function that moves to the previous page.
  • nextPage – A function that moves to the next page.
  • firstPage – A function that moves to the first page.
  • lastPage – A function that moves to the last page.
  • recordMessage - It shows current record range information(ex: Records 1 - 10 of 14).
  • pageMessage - It shows current page index information(ex: Page 1 of 2).
  • load - It loads page wise data from server.
------------------------------------------------------------------------------------------------------------

Define a ViewModel

To make use of above PagedObservable, create an instance of Utils.pagedObservable as below:


var API_URL = "../api/contacts/";

var ViewModel = function () {

    this.pagedList = new Utils.pagedObservable({
        pageSize: 10,
        pageSizes: [5, 10, 15],
        sortName: 'Id',
        sortOrder: 'asc',
        columns: [{ name: 'ID', index: 'Id', sortable: true, width: '10%' },
                  { name: 'First Name', index: 'FirstName', sortable: true, width: '25%' },
                  { name: 'Last Name', index: 'LastName', sortable: true, width: '25%' },
                  { name: 'Phone', index: 'Phone', sortable: true, width: '25%' },
                  { name: '', index: '', sortable: false, width: '15%' }
        ],
        serviceURL: API_URL
    });

Let's explain the properties used in 
Utils.pagedObservable so that we can costomize according to our requirement:
  • pageSize: It is used to set default page size.
  • paeSizes: It is used to set page sizes which is displayed as dropdownlist in grid.
  • sortName: It is used to set default sort column name.
  • sortOrder: It is used to set sort order.
  • columns: It is used to set the columns name(which will be displayed in the header of grid), index(which is used to sort column), sortable(which is used to enable/disable sort functionality for a particular column) and width(which is used to set width of each column).
  • serviceURL: It is used to set the API controller url which is used to retrieve server data.
------------------------------------------------------------------------------------------------------------

Define a View

To bind the exposed properties to view, write some simple HTML:

<table class="table table-bordered table-condensed table-hover" data-bind="with: pagedList">
                <thead class="btn-primary">
                    <tr>
                        <!-- ko foreach: columns -->
                        <th data-bind="click: sortable ? $parent.sort : '', style: { width: width, cursor: sortable ? 'pointer' : '' }">
                            <span data-bind="text: name"></span>

                            <span class="icon-white icon-circle-arrow-down" data-bind="visible: $parent.sortOrder() === 'desc' && sortable && $parent.sortName() === index"></span>

                            <span class="icon-white icon-circle-arrow-up" data-bind="visible: $parent.sortOrder() === 'asc' && sortable && $parent.sortName() === index"></span>
                        </th>
                        <!-- /ko -->
                    </tr>
                </thead>
                <tbody data-bind="foreach: rows">
                    <tr>
                        <td>
                            <span data-bind="text: Id"></span>
                        </td>
                        <td>
                            <span data-bind="text: FirstName"></span>
                        </td>
                        <td>
                            <span data-bind="text: LastName"></span>
                        </td>
                        <td>
                            <span data-bind="text: Phone"></span>
                        </td>
                        <td>
                            <a href="#" data-bind="click: $root.editContact">Edit</a>

                            <a href="#" data-bind="click: $root.removeContact">Delete</a>

                        </td>
                    </tr>
                </tbody>
                <tfoot class="btn-primary">
                    <tr>
                        <td colspan="5">
                            <div class="row">
                                <div class="span9"></div>
                                <div class="span9" align="center">
                                    <span class="icon-white icon-fast-backward" data-bind="click: firstPage, style: { cursor: pageIndex() > 1 ? 'pointer' : '' }"></span>
                                    <span class="icon-white icon-backward" data-bind="click: previousPage, style: { cursor: pageIndex() > 1 ? 'pointer' : '' }"></span>
                                    <input type="text" style="width: 30px;" class="search-query" data-bind="value: pageIndex" />
                                    <span data-bind="text: pageMessage"></span>
                                    <select style="width: 60px;" class="search-query" data-bind="options: pageSizes, value: pageSize"></select>
                                    <span class="icon-white icon-forward" data-bind="click: nextPage, style: { cursor: pageIndex() < pageCount() ? 'pointer' : '' }"></span>
                                    <span class="icon-white icon-fast-forward" data-bind="click: lastPage, style: { cursor: pageIndex() < pageCount() ? 'pointer' : '' }"></span>
                                </div>
                                <div class="span9" align="right">
                                    <span data-bind="text: recordMessage"></span>
                                </div>
                            </div>
                        </td>
                    </tr>
                </tfoot>
            </table>

Let's explain three section of HTML Table so that it will be easy to customize:


1.Header:

<thead class="btn-primary">
                    <tr>
                        <!-- ko foreach: columns -->
                        <th data-bind="click: sortable ? $parent.sort : '', style: { width: width, cursor: sortable ? 'pointer' : '' }">
                            <span data-bind="text: name"></span>

                            <span class="icon-white icon-circle-arrow-down" data-bind="visible: $parent.sortOrder() === 'desc' && sortable && $parent.sortName() === index"></span>

                            <span class="icon-white icon-circle-arrow-up" data-bind="visible: $parent.sortOrder() === 'asc' && sortable && $parent.sortName() === index"></span>
                        </th>
                        <!-- /ko -->
                    </tr>
  </thead>

In this section I am using some knockout bindings to 

  • enable/disable column sorting
  • show header text 
according to the columns specified in paged List of ViewModel.


2.Body:
<tbody data-bind="foreach: rows">
                    <tr>
                        <td>
                            <span data-bind="text: Id"></span>
                        </td>
                        <td>
                            <span data-bind="text: FirstName"></span>
                        </td>
                        <td>
                            <span data-bind="text: LastName"></span>
                        </td>
                        <td>
                            <span data-bind="text: Phone"></span>
                        </td>
                        <td>
                            <a href="#" data-bind="click: $root.editContact">Edit</a>

                            <a href="#" data-bind="click: $root.removeContact">Delete</a>

                        </td>
                    </tr>
 </tbody>

In this section, rows are populating using knockout "foreach" binding..



3.Footer:
<tfoot class="btn-primary">
                    <tr>
                        <td colspan="5">
                            <div class="row">
                                <div class="span9"></div>
                                <div class="span9" align="center">
                                    <span class="icon-white icon-fast-backward" data-bind="click: firstPage, style: { cursor: pageIndex() > 1 ? 'pointer' : '' }"></span>
                                    <span class="icon-white icon-backward" data-bind="click: previousPage, style: { cursor: pageIndex() > 1 ? 'pointer' : '' }"></span>
                                    <input type="text" style="width: 30px;" class="search-query" data-bind="value: pageIndex" />
                                    <span data-bind="text: pageMessage"></span>
                                    <select style="width: 60px;" class="search-query" data-bind="options: pageSizes, value: pageSize"></select>
                                    <span class="icon-white icon-forward" data-bind="click: nextPage, style: { cursor: pageIndex() < pageCount() ? 'pointer' : '' }"></span>
                                    <span class="icon-white icon-fast-forward" data-bind="click: lastPage, style: { cursor: pageIndex() < pageCount() ? 'pointer' : '' }"></span>
                                </div>
                                <div class="span9" align="right">
                                    <span data-bind="text: recordMessage"></span>
                                </div>
                            </div>
                        </td>
                    </tr>
 </tfoot>

In this section I am using knockout bindings to 

  • enable/disable forward and backward option
  • show page size dropdownlist
  • show page index textbox
  • show page index message(ex: Page 1 of 2)
  • show record range message(ex: Records 1 - 10 of 14)
------------------------------------------------------------------------------------------------------------

Web API Controller



public class ContactsController : ApiController
    {
        IContactComponent contactComponent = new ContactComponent();

        // GET api/
        public dynamic GetContactList(string sidx, string sord, int page, int rows)
        {
            IQueryable contactQuery = contactComponent.GetAll();

            IList contactList;

            var totalRecords = contactQuery.Count();
            var pageIndex = page - 1;

            switch (sidx.ToLower())
            {
                case "id":
                    if (sord == "asc")
                    {
                        contactList = contactQuery.OrderBy(o => o.Id).Skip(pageIndex * rows).Take(rows).ToList();
                    }
                    else
                    {
                        contactList = contactQuery.OrderByDescending(o => o.Id).Skip(pageIndex * rows).Take(rows).ToList();
                    }
                    break;
                case "firstname":
                    if (sord == "asc")
                    {
                        contactList = contactQuery.OrderBy(o => o.FirstName).Skip(pageIndex * rows).Take(rows).ToList();
                    }
                    else
                    {
                        contactList = contactQuery.OrderByDescending(o => o.FirstName).Skip(pageIndex * rows).Take(rows).ToList();
                    }
                    break;
                case "lastname":
                    if (sord == "asc")
                    {
                        contactList = contactQuery.OrderBy(o => o.LastName).Skip(pageIndex * rows).Take(rows).ToList();
                    }
                    else
                    {
                        contactList = contactQuery.OrderByDescending(o => o.LastName).Skip(pageIndex * rows).Take(rows).ToList();
                    }
                    break;
                case "phone":
                    if (sord == "asc")
                    {
                        contactList = contactQuery.OrderBy(o => o.Phone).Skip(pageIndex * rows).Take(rows).ToList();
                    }
                    else
                    {
                        contactList = contactQuery.OrderByDescending(o => o.Phone).Skip(pageIndex * rows).Take(rows).ToList();
                    }
                    break;
                default:
                    contactList = contactQuery.OrderBy(o => o.Id).Skip(pageIndex * rows).Take(rows).ToList();
                    break;
            }


            var totalPages = (int)Math.Ceiling((float)totalRecords / (float)rows);
            return new
            {
                total = totalPages,
                records = totalRecords,
                rows = contactList
            };
        }
    }

I have created API controller named ContactsController.The method "GetContactList"  is used to retrieve page wise data.It takes minimum 4 parameters(sidx, sord, page, rows).It returns data in a particular format as below:

 return new
            {
                total = totalPages,
                records = totalRecords,
                rows = contactList
            };

------------------------------------------------------------------------------------------------------------
For sample project, download from link: KnockoutWithPagination.zip