Building Pagination Directive in Angular and Twitter Bootstrap

One thing that every business app eventually needs is an ability to search for something and show results using paging.  The reason for that is that too many rows in search result set can bring any application to its knees.  Hence, you should always page search results.  If you are building a web application, there is always a temptation to use a data grid control to show those results.  If you are using Angular, you can use ngGrid.  I, however, do not feel that data grid is a good idea for web applications designed to run on any device, including smart phones.  A grid with even four columns, such as First Name, Last Name, Edit button and Delete button will now look very good on the phone. On top of that, it is not going to be very usable because the text is going to be small and button are not going to present large enough targets for touch.  Hence, I am not going to use a data grid, but instead I will present search results in Google search style. So, I am going to demonstrate in this post how to solve this problem to come up with the following solution.

image

I took this screenshot use pretty small browser window, about the size of a phone.  It looks just as well on a large screen.

image

So, I presenting the results in a “well”, showing last and first name in line with two buttons, to edit and delete below.  I have real mad design skills, don’t I?

OK, now let’s talk about solution.  First, let’s take a look at the view behind this screen.  It is quite simple, using just a few Twitter Bootstrap styles.

<div>
    <h1>Contacts</h1>
</div>
<button class="btn btn-success addButton" data-ng-click="add()">Add</button>
<div class="verticalMargin">
    <form class="form-inline" role="search">
        <div class="input-group">
            
            <span class="input-group-btn">
                <button type="submit" class="btn btn-default" data-ng-click="performSearch()"><span class="glyphicon glyphicon-search"></span> Search</button>
            </span>
            <input type="text" placeholder="Enter name" data-autocomplete="false" data-ng-model="name" class="form-control">
        </div>
    </form>
</div>
<div class="verticalMargin">
    <span><label>Total found: </label>{{totalFound}} ({{pageCount}} pages)</span>
</div>
<ul data-pagination></ul>
<div>
    <div class="well" data-ng-cloak data-ng-repeat="contact in contacts">
        <div class="h2">{{contact.LastName}}, {{contact.FirstName}}</div>
        <div>
            <button class="btn btn-primary" data-ng-click="$parent.editType(contact)">Edit</button>
            <button class="btn btn-danger" data-ng-click="$parent.deleteType(contact)">Delete</button>
        </div>
    </div>
</div>
@*<ul data-pagination data-goto-page-function="gotoPage" data-page-count="pageCount" data-current-page="currentPage"></ul>*@
<ul data-pagination></ul>

What you see above is search input box.  The user can type in part of last or first name.  The button is using glyph icons that ship with bootstrap.  The search results will be shown using ngRepeat directive.  This is where I have my “well” with placeholders for my data – last and first name.  All this is pretty easy, right? 

As far as Web Api controller goes, we have to make sure it does just a few things – we need to accept criteria, such as page number, page size and partial name and pass it into the database layer.  I am not going to spend a lot of time on that, I will just show method signature that we will call from JavaScript and return values.

        public ApiResult<PagedResult<ContactInfo>> Get([FromUri] PagedCriteria criteria)
        {
            return Execute(() => Mapper.Map<PagedResult<ContactInfo>>(
                _contactRepository.GetContacts(criteria.PageNumber, criteria.PageSize, criteria.Name)));
        }

The call to repository is pretty simple, mapping call just converts data model to business model.  My return value is more interesting.  I actually use this approach all the time.  I create a return value with predefined shape, so I can easily handle /examine it in JavaScript.  In this case, I use combination of two classes – generic result and paging result.  Paging result has result set and total number of rows and pages.  I use totals to give the user some more information about they search request.  Here is the shape of the two classes:

 

    public class ApiResult<T>
    {
        public ApiResult(T result, bool success = true, string errorMessage = "")
        {
            Result = result;
            Success = success;
            ErrorMessage = errorMessage;
        }

        public bool Success { get; set; }
        public string ErrorMessage { get; set; }
        public T Result { get; set; }
    }
    public class PagedResult<T>
    {
        public PagedResult(IEnumerable<T> result, int totalRows, int totalPages)
        {
            Result = result;
            TotalRows = totalRows;
            TotalPages = totalPages;
        }
        public int TotalRows { get; set; }
        public IEnumerable<T> Result { get; set; }
        public int TotalPages { get; set; }
    }

Then in JavaScript I can write code like – if(result.Success) { do something }.  You get the idea.  A little convention makes your life a lot easier on JavaScript side.  My Web Api methods never throw exceptions, they trap all of them and result ApiResult with success property set to false and error property set to something meaningful I can show to the user.

OK, enough with C#.  Let’s take a look at the Angular code.  There are two main players there – controller and a directive.  Controller does just a few things, it calls service component to get the data and just sets up totals and result set properties.  Total of about 10 lines of code.  I wrote it in TypeScript, but you can look in my download to find compile JS version of all the files as well.

    class ContactsController extends app.core.controllers.CoreController {

        constructor(private http: IHttp, private utilities: IUtilities, private $scope: IContactScope, $location: ng.ILocationService) {
            super($scope);
            $scope.pageSize = 10;
            $scope.pageCount = 0;
            $scope.currentPage = 1;
            $scope.search = (pageNumber: number, pageSize: number, name: string, eventToRaise: string) => {
                http.get('/Contacts/Get', (result: IHttpPagedResult<app.contacts.models.IContactInfo>) => {
                    if (result.Success) {
                        $scope.contacts = result.Result.Result;
                        $scope.pageCount = result.Result.TotalPages;
                        $scope.totalFound = result.Result.TotalRows;
                        $scope.currentPage = pageNumber;
                        $scope.$broadcast(eventToRaise);
                    }

                }, true, { pageNumber: pageNumber, pageSize: pageSize, name: name });
            };

            $scope.search(1, $scope.pageSize, $scope.name, 'searchCompleted');

            $scope.performSearch = ()=> {
                $scope.search(1, $scope.pageSize, $scope.name, 'searchCompleted');
            };
            $scope.gotoPage = function (pageNumber: number) {
                $scope.search(pageNumber, $scope.pageSize, $scope.name, 'pageLoadCompleted');
            };
        }

    }

    angular.module('app.contacts.contactsController', ['app.core.services.utilities'])
        .controller('app.contacts.contactsController', ['http', 'utilities', '$scope', '$location',
            function (http: IHttp, utilities: IUtilities, $scope: IContactScope, $location: ng.ILocationService) {
                return new ContactsController(http, utilities, $scope, $location);
            }]);

See, I was not kidding – very little code here.  You can see that in my search function I just call my Angular service custom component that simplifies the Api for me a bit, so that I do not have to write .success and .error all the time.  After the call is done, I check Success property, then set my totals – pages and rows – and my data – contacts property.  Then I broadcast an event down my scope (view model for my screen).  Guess what is listening to the even call – my directive of course.  My performSearch function is what my search button on the view is bound to.  Name property is also on the scope, and it is bound to the search box.

Directives is an interesting animal in Angular.  The goal of directive is to manipulate HTML.  Of course, in this case, it also needs to have access to the controller.  More specifically, to a few properties on the controller as well as the events controller raises.  There are two events there – one for brand new search, the other to load a page based on page number user selected.  It does make a difference to my directive.  To access this data, I am going to rely on attributes.  It is an easy way to communicate from controller to a directive.  You can also use isolated scopes, but then I will have an issue with events.  So, I need attributes to access the current page, total pages, and the function that will get the page selected by the user properties of the controller.  You can guess by the names of course as well.  Here is how it will look in HTML:

<ul data-pagination data-goto-page-function="gotoPage" data-page-count="pageCount" data-current-page="currentPage"></ul>*

What would be nice, is to also rely on naming conventions.  So, if the next search controller wants to use directive and they can use the same default names for all three properties, we can shorten HTML to just:

<ul data-pagination></ul>

Doesn’t that look nice, if you see what this tiny line of code does in the screenshot above?  What is also interesting is that I only needed about 150 lines of JavaScript, well technically TypeScript to implement this fancy code.  If you are wondering why I used <ul> tag, take a look at paging documentation for Bootstrap.  I could have used a custom tag, such as <pagination/>, but this does not work in IE 8 without extra effort, so I use use <ul> to make my life easier.  So, here come 150 lines of JS.  I will explain what it does below.

export class PaginationDirective extends app.directives.BaseDirective {
        currentPage; number;
        totalPages: number;
        currentlyShownPages: number[];

        static createButton(label: string, clickEvent: (eventObject: JQueryEventObject) => void): ng.IAugmentedJQuery {
            var button = angular.element('<li><a href=#>' + label + '</a></li>');
            button.click({ page: label }, clickEvent);
            return button;
        }
        constructor() {
            super();
            var that = this;
            that.currentPage = 0;
            this.restrict = 'A';
            that.currentlyShownPages = [];

            this.link = function (
                scope: ng.IScope,
                instanceElement: any,
                instanceAttributes: ng.IAttributes) {

                instanceElement.addClass('pagination');
                var searchFunc: string;
                if (instanceAttributes.$attr['gotoPageFunction']) {
                    searchFunc = instanceElement.attr(instanceAttributes.$attr['gotoPageFunction']);
                } else {
                    searchFunc = "gotoPage";
                }


                var handleButtonClick = function (eventObject: JQueryEventObject) {
                    eventObject.preventDefault();
                    var jQueryTarget = angular.element(eventObject.delegateTarget);
                    if (!jQueryTarget.hasClass("disabled")) {
                        var page = eventObject.data['page'];
                        var pageNumber: number;
                        if (page === '>') {
                            pageNumber = that.currentPage + 1;
                            if (pageNumber > that.totalPages) {
                                pageNumber = that.totalPages;
                            }
                        }
                        else if (page === '>>') {
                            pageNumber = that.totalPages;
                        }
                        else if (page === '<<') {
                            pageNumber = 1;
                        }
                        else if (page === '<') {
                            pageNumber = that.currentPage - 1;
                            if (pageNumber < 1) {
                                pageNumber = 1;
                            }
                        } else {
                            pageNumber = parseInt(page);
                        }
                        that.currentPage = pageNumber;
                        scope.$apply(searchFunc + '(' + pageNumber.toString() + ')');
                    }
                };

                var hasPageButton = function (): boolean {
                    var returnValue: boolean = false;
                    angular.forEach(instanceElement.children(), function (item: any) {
                        var jqueryObject = angular.element(item);
                        if (jqueryObject.text() === that.currentPage.toString()) {
                            returnValue = true;
                        }
                    });
                    return returnValue;
                };

                var refresh = function (goToFirstPage: boolean) {
                    if (instanceAttributes.$attr['pageCount']) {
                        that.totalPages = scope.$eval(instanceElement.attr(instanceAttributes.$attr["pageCount"]));
                    } else {
                        that.totalPages = scope.$eval("pageCount");
                    }

                    if (instanceAttributes.$attr['currentPage']) {
                        that.currentPage = scope.$eval(instanceElement.attr(instanceAttributes.$attr["currentPage"]));
                    } else {
                        that.currentPage = scope.$eval("currentPage");
                    }

                    if (that.totalPages > 0) {
                        var resetPage: boolean = goToFirstPage || (that.currentPage > that.totalPages);
                        if (resetPage) {
                            that.currentPage = 1;
                        }
                        var needToReset: boolean = (!hasPageButton()) || goToFirstPage;
                        if (needToReset) {
                            instanceElement.empty();
                            var maxButtons: number = 5;
                            var firstPageNumber: number = that.currentPage;
                            while ((firstPageNumber + 4) > that.totalPages) {
                                firstPageNumber--;
                            }
                            if (firstPageNumber < 1) {
                                firstPageNumber = 1;
                            }
                            that.currentlyShownPages = [];
                            for (var i = firstPageNumber; i <= that.totalPages; i++) {
                                if (i < firstPageNumber + maxButtons) {
                                    that.currentlyShownPages.push(i);
                                } else {
                                    break;
                                }
                            }

                            instanceElement.append(PaginationDirective.createButton('<<', handleButtonClick));
                            instanceElement.append(PaginationDirective.createButton('<', handleButtonClick));
                            for (var j = 0; j < that.currentlyShownPages.length; j++) {
                                var button = PaginationDirective.createButton(that.currentlyShownPages[j].toString(), handleButtonClick);
                                instanceElement.append(button);
                            }
                            instanceElement.append(PaginationDirective.createButton('>', handleButtonClick));
                            instanceElement.append(PaginationDirective.createButton('>>', handleButtonClick));
                        }


                        angular.forEach(instanceElement.children(), function (item: any) {
                            var jqueryObject = angular.element(item);
                            var text: string = jqueryObject.text();
                            if (that.currentPage === that.totalPages && (text === ">" || text === ">>")) {
                                jqueryObject.addClass('disabled');
                            }
                            else if (that.currentPage === 1 && (text === "<" || text === "<<")) {
                                jqueryObject.addClass('disabled');
                            } else {
                                jqueryObject.removeClass('disabled');
                            }
                            if (text === that.currentPage.toString()) {
                                jqueryObject.addClass('active');
                            } else {
                                jqueryObject.removeClass('active');
                            }
                        });
                    } else {
                        instanceElement.empty();
                    }
                };
                scope.$on('pageLoadCompleted', function () {
                    refresh(false);
                });
                scope.$on('searchCompleted', function () {
                    refresh(true);
                });
            };
        }
    }

 

There are three main functions in it.  handleButtonClick function is what I bind to the page buttons I create.  In this function I call controller’s goToPage function, passing the page number from the button.  I use scope.$eval to do that.  There is a bit of code there to handle previous, next, last and first buttons as well.  There is also a bit of code to handle clicks on disabled buttons.  Yes, those still fire, but we can eat them.  They fire because disabled is a built-in style in Bootstrap.  hasPageButton is a helper function that checks to see if a specific page button exists inside pagination <ul> element.  Refresh function just rebuild the page buttons.  It is fired in two cases – when a user clicks on a page button or when a use performs new search.  It takes a parameter to distinguish between the two.  First, this function checks to see if the page you selected is listed in the list of current 5 page buttons.  The reason this occurs is because the user can be looking at pages 1 through 5, but hit ‘next’ button to go to page 6.  In this case I am rebuilding the page buttons, preparing for the next group.  createButton helper function is what creates the UL and wires up event handler.  And, finally, if there are not pages to show, I am hiding entire <ul> by calling empty() on it.  There is a bit extra code to deal with lack of jQuery.  Angular ships with jqLite, which is missing some functionality I need for this directive, such as full children() implementation or full find() implementation.

You can download entire project here.  This functionality is in Contacts screen.

Leave a Reply

Your email address will not be published. Required fields are marked *