Syntax Highlighter

Wednesday, May 22, 2013

Superheroic dynamic pagination with AngularJs

Data binding, custom directives, reusable components, dependency injection, you name it! AngularJs has all you need in order to boost front-end productivity. I've been using it for quite a while now and recently I came by an interesting problem: dynamic pagination. I'm not sure if you understand what I mean by "dynamic". I wanted to implement an admin page containing a paginated table holding all the site's signed-up users There would be a searchbar as well, and the number of pages and filtering should occur dynamically as the user types in the query.
Using data binders and clever event handling this can be easily accomplished by angular and the result is incredibly satisfying. Let me show you the code. First add these stylesheets:
<link href="http://netdna.bootstrapcdn.com/twitter-bootstrap/2.1.1/css/bootstrap.no-icons.min.css" rel="stylesheet">
<link href="http://netdna.bootstrapcdn.com/font-awesome/2.0/css/font-awesome.css" rel="stylesheet">
Now the table view. It binds any change to the text input to the function $scope.search(), adds all the pagedItems[currentPage] array to the table's body and sets up the pagination logic in the table's foot:
        <div ng-controller="ctrlRead">
            <div class="input-append">
                <input type="text" ng-model="query" ng-change="search()" class="input-large search-query" placeholder="Search">
             <span class="add-on"><i class="icon-search"></i></span>
            </div>
            <table class="table table-striped table-condensed table-hover">
                <thead>
                    <tr>
                        <th class="id">Id&nbsp;</th>
                        <th class="name">Name&nbsp;</th>
                        <th class="description">Description&nbsp;</th>
                        <th class="field3">Field 3&nbsp;</th>
                        <th class="field4">Field 4&nbsp;</th>
                        <th class="field5">Field 5&nbsp;</th>
                    </tr>
                </thead>
                <tfoot>
                    <td colspan="6">
                        <div class="pagination pull-right">
                            <ul>
                                <li ng-class="{disabled: currentPage == 0}">
                                    <a href ng-click="prevPage()">« Prev</a>
                                </li>
                                <li ng-repeat="n in range(pagedItems.length)"
                                    ng-class="{active: n == currentPage}"
                                ng-click="setPage()">
                                    <a href ng-bind="n + 1">1</a>
                                </li>
                                <li ng-class="{disabled: currentPage == pagedItems.length - 1}">
                                    <a href ng-click="nextPage()">Next »</a>
                                </li>
                            </ul>
                        </div>
                    </td>
                </tfoot>
                <tbody>
                    <tr ng-repeat="item in pagedItems[currentPage] | orderBy:sortingOrder:reverse">
                        <td>{{item.id}}</td>
                        <td>{{item.name}}</td>
                        <td>{{item.description}}</td>
                        <td>{{item.field3}}</td>
                        <td>{{item.field4}}</td>
                        <td>{{item.field5}}</td>
                    </tr>
                </tbody>
            </table>
        </div>
We need only build the filteredItems array after each call to $scope.search() accordingly. This can be done by passing the classic needle/haystack function as $filter's matching function and then split the result based on the number of elements per page. But talk is cheap, I know:
//classical way of finding a substring (needle) in a given string (haystack)
    var searchMatch = function (haystack, needle) {
        if (!needle) {
            return true;
        }
        return haystack.toLowerCase().indexOf(needle.toLowerCase()) !== -1;
    };

    // filter the items following the search string
    $scope.search = function () {
        $scope.filteredItems = $filter('filter')($scope.items, function (item) {
            for(var attr in item) {
                if(item.hasOwnProperty(attr))
                    if (searchMatch(item[attr], $scope.query))
                        return true;
            }
            return false;
        });
        // take care of the sorting order
        if ($scope.sortingOrder !== '') {
            $scope.filteredItems = $filter('orderBy')($scope.filteredItems, $scope.sortingOrder, $scope.reverse);
        }
        $scope.currentPage = 0;
        // now group by pages
        $scope.groupToPages();
    };
    
    // divide elements by page
    $scope.groupToPages = function () {
        $scope.pagedItems = [];
        
        for (var i = 0; i < $scope.filteredItems.length; i++) {
            if (i % $scope.itemsPerPage === 0) {
                $scope.pagedItems[Math.floor(i / $scope.itemsPerPage)] = [ $scope.filteredItems[i] ];
            } else {
                $scope.pagedItems[Math.floor(i / $scope.itemsPerPage)].push($scope.filteredItems[i]);
            }
        }
    };
Finally, just add the paging logic for the table's foot and call $scope.search() so we show everything after the page loads.
    $scope.range = function (end) {
        var ret = [];
        for(var i = 0; i < end; i++) {
            ret.push(i);
        }
        return ret;
    };
    
    $scope.prevPage = function () {
        if ($scope.currentPage > 0) {
            $scope.currentPage--;
        }
    };
    
    $scope.nextPage = function () {
        if ($scope.currentPage < $scope.pagedItems.length - 1) {
            $scope.currentPage++;
        }
    };
    
    $scope.setPage = function () {
        $scope.currentPage = this.n;
    };

    // create filtered items for the first time
    $scope.search();
Just add this code to our view's controller and we're done! Check out this fiddle with our working code and some mock data! Now, of course this is only useful when all the necessary data can be fetched from the server quickly, so that the search is consistent. If you cannot provide Angular with all the necessary data, perhaps you should consider infinite scrolling instead. If that's the case, check this out. Have fun!
ftw.