mercredi 12 décembre 2012

Pagination côté client avec une directive AngularJS

Dans une application AngularJS, on peut avoir parfois une liste assez importante de données entièrement chargée côté client, et qu'on veut afficher de façon paginée. C'est facile à faire au moyen d'une directive, qui peut être réutilisée pour paginer n'importe quelle liste.

Ça ne veut pas dire qu'il faut systématiquement charger toutes les données et faire la pagination côté client, bien au contraire. Pour de gros volumes notamment, il est beaucoup plus logique de filtrer directement sur le serveur, dans la requête à la base de données, la partie de la liste qui doit être affichée sur la page. Donc n'allez pas répéter que je vous ai dit de toujours faire la pagination côté client. Mais pour les cas où c'est pertinent, voici comment on peut créer une directive réalisant la pagination.

Pour afficher une liste, on utilise la directive ngRepeat standard du framework ; on l'applique en ajoutant un attribut ng-repeat à une ligne de table, ou un élément <li> d'une liste.

Le paginateur qu'on va créer aura plusieurs rôles :
  • afficher la page courante, les boutons de navigation entre les pages
  • fournir dans le scope courant une fonction renvoyant les éléments de la page à afficher, à utiliser à la place de la liste complète dans l'attribut ng-repeat
  • permettre le choix entre plusieurs tailles de page

La directive 'paginator'

En voici le code source :

var paginator = angular.module('paginator', []);
paginator.directive('paginator', function () {
  var pageSizeLabel = "Page size";
  return {
    priority: 0,
    restrict: 'A',
    scope: {items: '&'},
    template: 
       '<button ng-disabled="isFirstPage()" ng-click="decPage()">&lt;</button>'
     + '{{paginator.currentPage+1}}/{{numberOfPages()}}'
     + '<button ng-disabled="isLastPage()" ng-click="incPage()">&gt;</button>'
     + '<span>' + pageSizeLabel + '</span>'
     + '<select ng-model="paginator.pageSize" ng-options="size for size in pageSizeList"></select>',
    replace: false,
    compile: function compile(tElement, tAttrs, transclude) {
      return {
        pre: function preLink(scope, iElement, iAttrs, controller) {
          scope.pageSizeList = [10, 20, 50, 100];
          scope.paginator = {
            pageSize: 10,
            currentPage: 0
          };

          scope.isFirstPage = function () {
            return scope.paginator.currentPage == 0;
          };
          scope.isLastPage = function () {
            return scope.paginator.currentPage 
                >= scope.items().length / scope.paginator.pageSize - 1;
          };
          scope.incPage = function () {
            if (!scope.isLastPage()) {
              scope.paginator.currentPage++;
            }
          };
          scope.decPage = function () {
            if (!scope.isFirstPage()) {
              scope.paginator.currentPage--;
            }
          };
          scope.firstPage = function () {
            scope.paginator.currentPage = 0;
          };
          scope.numberOfPages = function () {
            return Math.ceil(scope.items().length / scope.paginator.pageSize);
          };
          scope.$watch('paginator.pageSize', function(newValue, oldValue) { 
            if (newValue != oldValue) {
              scope.firstPage();
            } 
          });

          // ---- Functions available in parent scope -----

          scope.$parent.firstPage = function () {
            scope.firstPage();
          };
          // Function that returns the reduced items list, to use in ng-repeat
          scope.$parent.pageItems = function () {
            var start = scope.paginator.currentPage * scope.paginator.pageSize;
            var limit = scope.paginator.pageSize;
            return scope.items().slice(start, start + limit); 
          };
        },
        post: function postLink(scope, iElement, iAttrs, controller) {}
      };
    }
  };
});

Le template qui est en dur dans la directive peut être mis dans un fichier HTML séparé pour un peu plus de lisibilité, et chargé grâce à la propriété templateUrl, au prix d'une portabilité un peu moins pratique puisqu'on se retrouve avec deux fichiers au lieu d'un seul. Là ce n'est pas vraiment critique puisqu'il se limite à 5 lignes assez simples.

Comment on l'utilise

Vous avez ici un jsFiddle complet montrant l'utilisation de cette directive. Le principe est simple, il suffit d'ajouter la directive sous la forme d'un attribut paginator à l'élément HTML dans lequel doivent être générés les contrôles de navigation, comme ceci :

<span paginator items="numbers | filter:search"></span>

Le second attribut, items, correspond à la liste complète avant pagination, telle qu'on l'aurait spécifiée dans un ng-repeat, et elle peut être filtrée ou ordonnée sans problème. C'est cette liste fournie dans l'attribut items qui est la source pour le paginateur. En fait c'est exactement l'expression qui était utilisée comme liste dans l'attribut ng-repeat qui est déplacée ici.

Et le paginateur fournit dans le scope une fonction pageItems() renvoyant uniquement les éléments de la page courante. Du coup dans l'attribut ng-repeat, on boucle sur pageItems() directement, sans faire ici ni filtrage ni tri :

<tr ng-repeat="number in pageItems()">

Il n'y a rien de plus à faire pour mettre en place avec cette directive la pagination côté client. Enfin presque rien. Quand l'utilisateur tape quelque chose dans la zone de recherche, la liste d'origine filtrée n'est plus la même, du coup la page courante n'a plus de sens. Pour revenir à la première page dès qu'on modifie la recherche, on ajoute un $watch dans le contrôleur, qui utilise la fonction firstPage() publiée par la directive dans le scope :

$scope.$watch('search', function(newValue, oldValue) { 
    if (newValue != oldValue) {
        $scope.firstPage();
    } 
});

On pourrait bien sûr préférer que ce soit géré automatiquement dans la directive, ça serait plus simple. Mais il n'est pas possible de faire un $watch sur un tableau, ni sur un objet complexe. Un $watch n'est possible que sur un type simple, chaîne de caractères, nombre ou booléen. Du coup si l'on voulait dans la directive savoir quand la liste d'origine est modifiée, pour revenir à la première page, il faudrait faire un $watch sur un hashage du tableau, lequel hashage devrait être recalculé par AngularJS à chaque modification. C'est théoriquement possible, mais potentiellement très coûteux si le tableau est un peu gros. Donc il vaut mieux en terme de performances déclencher manuellement dans le contrôleur le retour à la première page si l'utilisateur change le filtrage, ou l'ordre de tri. D'une façon générale, faire un calcul complexe dans un $watch n'est jamais une bonne idée.

Comment ça marche

Regardons de plus près le code de la directive. C'est une directive qui ne peut être appliquée que comme un attribut HTML (restrict: 'A'). Le template fourni sert à générer toute une portion de HTML à l'intérieur de l'élément sur lequel est placé l'attribut paginator, c'est ce qu'indique le replace: false. Avec replace: true, l'élément lui-même aurait été remplacé par le template.

La propriété scope est la plus complexe. Le fait de mettre comme valeur un objet entre accolades crée un scope isolé pour la directive, pour que les propriétés et fonctions publiées dans le scope par la directive n'aient pas d'impact sur le scope extérieur lié à l'emplacement de l'élément HTML dans la vue. Le scope est dit isolé car il n'a pas pour prototype JavaScript son scope parent, ce qui évite les effets de bord indésirables. Mais son scope parent est tout de même accessible, en utilisant explicitement sa propriété $parent. C'est ce qui est utilisé à la fin du code de la directive, pour publier les deux fonctions firstPage() et pageItems() dans le scope parent, puisqu'elles sont destinées à être utilisées dans la vue et le contrôleur, donc en dehors de la directive :

scope.$parent.firstPage = function () {
    scope.firstPage();
};
// Function that returns the reduced items list, to use in ng-repeat
scope.$parent.pageItems = function () {
    var start = scope.paginator.currentPage * scope.paginator.pageSize;
    var limit = scope.paginator.pageSize;
    return scope.items().slice(start, start + limit); 
};

La propriété scope de la directive est égale à {items: '&'}. Ça a pour effet de publier dans le scope isolé une propriété items, et le signe & indique que sa valeur est obtenue en exécutant l'expression contenue dans l'attribut de même nom de l'élément HTML. On pourrait expliciter le nom de l'attribut si on voulait avoir un nom de propriété publié dans le scope isolé qui diffère de celui de l'attribut, par exemple {listItems: '&items'}, mais si rien n'est indiqué ça prend l'attribut de même nom. C'est donc cette seule indication scope: {items: '&'} qui crée un scope isolé et qui y publie la liste d'objets correspondant à l'expression qu'on a mise dans l'attribut items de l'élément HTML.

Il n'y a pas vraiment de subtilités dans le reste du code, c'est le même code qu'on écrirait dans un contrôleur correspondant à la portion de vue qui se trouve dans le template. On publie dans le scope diverses méthodes pour calculer le nombre de pages, passer à la page précédente ou suivante, savoir si on est sur la première ou la dernière page, etc. Le watch sert à revenir automatiquement à la première page lorsque l'utilisateur change la taille de la page.

Mise en cache de l'état de la pagination

La directive marche bien ainsi, mais on peut vouloir la perfectionner un peu. Car si l'on change de vue, puis on revient sur la vue contenant la liste paginée, on perd la taille de page ainsi que la page courante. Essayez dans le jsFiddle, j'ai mis une seconde vue vide juste pour montrer cet effet. Bon c'est normal, la page courante et la taille de page sont juste des propriétés du scope de la directive, qui est recréé à chaque fois qu'on revient sur la vue. Mais ce n'est pas très pratique : imaginons qu'on ouvre une vue de détails à partir d'un élément de la liste paginée, ça serait quand même mieux si en revenant sur la liste paginée on la retrouvait dans le même état.

On peut conserver l'état de la pagination dans un cache, en utilisant le service standard $cacheFactory d'AngularJS. En fait comme son nom l'indique ce service est une factory destinée à créer un service de cache en mémoire. Donc on peut ajouter un service à notre module contenant déjà la directive :

paginator.factory('PageStateCache', ['$cacheFactory', function ($cacheFactory) {
    return $cacheFactory('PageStateCache');
}]);

Ce service PageStateCache est alors utilisé dans la directive pour conserver en cache l'état de la pagination, et pour le retrouver dans le cache s'il y a été sauvegardé.

Voici ce que ça donne (avec un nouveau jsFiddle complet ici) :

compile: function compile(tElement, tAttrs, transclude) {
  var cacheId = tAttrs.cache ? tAttrs.cache + '.paginator' : '';
  return {
    pre: function preLink(scope, iElement, iAttrs, controller) {
      scope.pageSizeList = [10, 20, 50, 100];
      var defaultSettings = {
        pageSize: 10,
        currentPage: 0
      };
      scope.paginator = cacheId 
          ? PageStateCache.get(cacheId) || defaultSettings 
          : defaultSettings;
      if (cacheId) {
        PageStateCache.put(cacheId, scope.paginator)
      }

Un identifiant est construit à partir d'une chaîne de caractères spécifiée dans l'attribut cache du même élément HTML, afin qu'il n'y ait pas de collisions entre les caches de différentes listes paginées. Cet identifiant est utilisé pour stocker dans le cache l'objet scope.paginator, et pour le retrouver dans le cache quand on arrive sur la page. Du coup les valeurs par défaut en dur ne sont plus utilisées que lorsqu'on ne trouve rien dans le cache, c'est-à-dire lors du premier affichage de la liste paginée.

Et dans la vue, il y a simplement cet attribut cache supplémentaire pour former un identifiant unique pour cette liste paginée :

<span paginator items="numbers | filter:search" cache="numbers"></span>

Vous pouvez essayer maintenant dans le jsFiddle de changer de vue, cette fois quand on revient sur la vue contenant la liste paginée, on la retrouve bien dans l'état où elle était.

Voilà, vous savez maintenant comment faire facilement de la pagination côté client avec AngularJS.

4 commentaires:

  1. très intéressant, merci de partager !

    RépondreSupprimer
  2. Merci pour cette solution, vous faites des articles très intéressants!

    RépondreSupprimer
  3. Bonjour,

    je débute dans Angular et j'aimerais savoir si avec cette méthode il était possible de paginer plus d'un tableau sur une même page html ?
    Faut-il créer un $scope.paginator différent pour le 2ème tableau afin de ne pas avoir de conflits entre les deux ?

    Merci pour votre réponse.

    RépondreSupprimer
  4. sincerement merci de partager!!!
    j'ai apprecie la subtilite de transmettre une fonction du scope isole au scope parent
    merci!!

    RépondreSupprimer