Charger une route sous condition

Dans la configuration du routage d'Angular, on trouve une particularité un peu déroutante (lol) au début mais qui s'avère extrêmement pratique : la propriété resolve. Le principe est de faire charger les modèles avant la vue afin que celle-ci possède un modèle bien remplie dès son affichage. Prenons un exemple : un blog, deux routes : /posts qui affiche la liste des posts et /posts/:id qui affiche un post en mode édition :

NB : Je montre des exemples avec ui-router mais la partie resolve est absolument identique avec ngRoute.

Sans resolve

La première chose qu'on a envie de faire quand on n'a pas lu la doc jusqu'au bout, c'est de charger le modèle depuis le controlleur de la vue et d'indiquer à la vue que le chargement est en cours à l'aide d'une propriété du scope :

// Vue.html
<p ng-if="loading" class="loading">Wait for it…</p>  
<div ng-if="!loading">  
  <ul>
    <li ng-repeat="post in posts">
      <a ui-sref="main.post({id: post.id})">
        {{post.title}}
      </a>
    </li>
  </ul>
</div>  
// Controller.js
scope.loading = true;  
scope.posts = Posts.query();  
scope.posts  
.then(function() {
  scope.loading = false;
})
.catch(function(err) {
  scope.loading = false;
})

Le gros inconvénient de cette façon de procéder, c'est qu'une vue peut avoir deux états différents et qu'on rend le controlleur dépendant de la logique de chargement des modèles. On peut aussi difficilement afficher l'information de chargement de façon générale à l'application : tout se passe dans la vue courante.

Resolve

Le routing permet donc de définir des choses à charger ou à faire avant que la vue ne soit instanciée. Si ces traitements se passent mal, la vue n'est pas chargée et la route courante ne change pas. Ainsi, si je suis sur ma liste de posts (/posts) et que je clique sur le lien vers le post#1, pendant un instant relativement court (le temps de la requête ajax) rien ne va se passer. Puis, une fois le post#1 chargé, mon url va changer pour /post/1 et ma vue va changer pour afficher le post#1 en mode édition.

'use strict';

angular.module('monApp')  
  .config(['$stateProvider', function ($stateProvider) {
    $stateProvider
      .state('main', {
        abstract: true,
        templateUrl: 'views/main.html',
        controller: 'MainCtrl'
      })
      .state('main.home', {
        url: '',
      })
      .state('main.posts', {
        url: '/posts',
        templateUrl: 'views/posts.html',
        controller: 'PostsCtrl',
        resolve: {
          posts: function(Posts) {
            return Posts.query();
          }
        }
      })
      .state('main.post', {
        url: '/post/:id',
        templateUrl: 'views/post-edit.html',
        controller: 'PostEditCtrl',
        resolve: {
          post: function($stateParams, Posts) {
            var post = new Posts({
              id: $stateParams.id
            });
            return post.load();
          }
        }
      });
  }])
  .controller('PostsCtrl', function($scope, posts) {
    scope.posts = posts;
  })
  .controller('PostEditCtrl', function($scope, post) {
    scope.post = post;
  });

Resolve attend des promesses. Une fois que ces promesses sont résolues, alors la vue est chargée et affichée. Les objets correpondant seront alors disponibles dans le controlleurs sous la forme de services dont le nom correspond à ce qui a été défini dans l'objet resolve.

Les avantages de cette architecture ne sont pas visibles que dans la partie graphique de l'application : c'est aussi un gain en terme de clarté de code. En effet, le controlleur ne s'encombre plus de toute la logique de récupération des paramètres provenant de la route, et de savoir comment un modèle doit charger ses données. Cette partie est déportée au plus près de la route et le controlleur devient plus dépendant puisqu'il ne recevra toujours que des modèles prêts à l'emploi.

Un autre avantage est que des évenements globaux sont diffusés lors des changements de route. On peut donc, dans une vue principale, écouter ces évenements et afficher un message en conséquence :

// Main.html
<div ng-show="isViewLoading" class="loading-modal">  
  Wait for it…
</div>  
<div ng-show="loadingError" class="alert-error">  
  Une erreur est survenue.
</div>  
<ui-view></ui-view>  
// Main.js
$scope.isViewLoading = true;
$scope.$on('$stateChangeStart', function() {
  $scope.isViewLoading = true;
});
$scope.$on('$stateChangeSuccess', function() {
  $scope.isViewLoading = false;
});
$scope.$on('$stateChangeError', function() {
  $scope.isViewLoading = false;
  $scope.loadingError = true;
  $timeout(
    function() {
      $scope.loadingError = false;
    },
    2000
  );
});

On remplacera $stateChangeStart et ses copains par $routeChangeStart si on utilise NgRoute au lieu de ui-router.

Ainsi, on peut afficher par exemple un petit bandeau fixé en haut de page comme le fait Google. On pourrait aussi écouter ces évenements dans un controlleur particulier afin d'afficher l'information de chargement de façon plus précise. On pourrait par exemple le faire dans le controller PostsCtrl et afficher un spin en face du titre du post en cours de chargement.

Il reste cependant deux inconvénients à régler : l'accumulation de code métier dans la config des routes et le chargement initial de l'application. Ce dernier est le plus grave alors voyons comment le régler au plus vite.

Imaginons que nous ayons chargé le post #42 en nous rendant sur /post/42 après avoir cliqué sur son lien depuis /posts. Nous avons alors bookmarké cette url et nous y revenons quelques jours plus tard. Peste que soit le manque de mémoire, j'ai oublié que j'avais supprimé le post la veille. Je recharge donc ma page /post/42, le resolve tente de charger le post, mais une erreur est renvoyée, ma vue ne se charge alors pas. Ce qu'il se passe quand je viens en cliquant sur un lien, c'est que je ne change pas d'url et mon application reste à son état courant. Sauf que là, l'état courant étant pour le moment une page vide, il ne se passe en fait rien du tout : aucune vue ne se charge. Il faudrait que je sois renvoyé vers une page correcte : soit l'accueil, soit la liste des posts, soit une page 404… Ici, je voudrais qu'on soit redirigé vers /posts et qu'une erreur de chargement soit affichée. Il va donc falloir gérer ça manuellement dans le resolve :

…
.state('main.post', {
  url: '/post/:id',
  templateUrl: 'views/post-edit.html',
  controller: 'PostEditCtrl',
  resolve: {
    post: ['$stateParams', '$state', '$timeout', 'Posts', function($stateParams, $state, $timeout, Posts) {
      var post = new Posts({
          id: $stateParams.id
        }),
        promise = post.load();

      promise.catch(function(err) {
        $timeout(function() {
          $state.go('main.posts');
        }, 1);
      });

      return promise;
    }]
  }
})
…

Donc ici, si la promesse est résolue, tout se passe comme prévu : la vue est chargée. Dans le cas où la promesse est rejetée, alors on intercepte et on redirige le client vers une autre url. J'utilise $timeout pour permettre au resolve d'émettre l'évent $stateChangeError. Sans le $timeout, le changement de route se fait avant que la promesse soit rejetée et soit donc déclaré en erreur. Un nouveau cycle de changement de route prend alors sa place. Grâce au timeout, l'évenement est émis, puis la nouvelle vue est affichée laissant ainsi apparaitre le message d'erreur.

Et c'est là qu'on arrive au deuxième inconvénient car le code des routes risque de fait de s'alourdir de façon conséquente. Nous allons alors transformer nos factories en providers. Mais le billet commence à être bien conséquent et j'expliquerais ça dans le prochain.

Hadrien

Hi, I'm a french Javascript Lead Developer, Web Architect from Toulouse, France. I've worked for 12 years for many projects with YUI, AngularJS, Aurelia.io and now React and React native.

Toulouse, France https://hadrien.eu