vendredi 8 février 2013

Solution simple pour des formulaires avec données différées

Je ne suis pas sûr d'avoir trouvé un titre très vendeur pour cet article, mais vous y trouverez un exemple qui peut être très utile. C'est une solution hybride entre les $resource et les promises du service $q d'AngularJS. Elle est très simple à mettre en oeuvre, pour utiliser avec des formulaires travaillant sur des données chargées en asynchrone, un besoin somme toute très fréquent.

AngularJS fournit en standard le service $resource, qui permet de gérer facilement des entités chargées en asynchrone, mais il est prévu pour fonctionner avec une API serveur de type REST. Il n'est pas nécessaire que ce soit une API strictement RESTful, mais il ne faut pas qu'elle s'en éloigne trop pour que l'utilisation du service $resource soit pertinente.



L'autre inconvénient, avec $resource, c'est que les objets ressources contiennent uniquement les données, une fois que celles-ci sont arrivées. On n'a pas accès à la promise du service $http utilisé en sous-main. Donc pas moyen par exemple d'afficher un message “Chargement en cours...” à la place d'un formulaire tant que l'objet ressource correspondant n'est pas chargé. On ne peut pas non plus enchaîner des chargements de ressources, par exemple pour charger via plusieurs requêtes des entités liées entre elles, puisqu'on ne peut pas déclencher les requêtes suivantes lorsque la première entité est alimentée.

Si l'on n'utilise pas de $resource, il ne reste dans AngularJS pas d'autre option que le service $http de base, qui renvoie une promise. Avec des promises, les enchaînements de requêtes sont possibles. Mais à la fin de l'article sur l'API Promise d'AngularJS, j'expliquais que si les promises s'utilisent très bien dans les vues tant que c'est pour faire de l'affichage, ça pose problème dans les formulaires. En effet si les champs d'un formulaire sont associés aux données d'une promise, on retrouve les propriétés modifiées à un endroit et les propriétés non modifiées à un autre (voir la fin de l'article indiqué pour l'explication détaillée). Ce n'est pas vraiment pratique lors de la sauvegarde. Ce problème n'existe pas avec une $resource, qu'on peut utiliser sans aucun problème dans un formulaire.

Alors comment faire pour avoir le côté pratique d'une $resource qu'on peut associer directement aux champs d'un formulaire, mais sans les à-côtés inutiles quand on travaille avec un serveur qui n'a manifestement jamais entendu parler de l'architecture REST, et tout en gardant la possibilité d'accéder à la promise de la requête $http ?

Voici la solution à laquelle je suis arrivé, un service “DeferredData” simple à utiliser, avec un exemple complet ici sur Plunker.

Le service est défini dans le fichier deferreddata.js :

angular.module('deferreddata', [])
       .factory('DeferredData', ['$q', function ($q) {

  function deferredDataBuilder(isArray) {
    var deferredData = isArray ? [] : {};
    var defer = $q.defer();
    var waiting = true;
    deferredData.$isWaiting = function() {
      return waiting;
    };
    deferredData.$getPromise = function() {
      return defer.promise;
    };
    deferredData.$resolve = function(data) {
      if (waiting) {
        waiting = false;
        // ... ici le code qui copie les données dans l'objet deferredData...
      }
      return defer.resolve(deferredData);
    };
    return deferredData;
  }
    
  return {
    typeArray: function () {
      return deferredDataBuilder(true);
    },
    typeObject: function () {
      return deferredDataBuilder(false);
    }
  };
}]);

Pour créer un objet avec des données différées, il suffit alors d'appeler DeferredData.typeArray() si les données sont dans un tableau JavaScript, ou DeferredData.typeObject() si elles sont dans les propriétés d'un objet JS. L'objet (ou le tableau) ainsi créé possède trois méthodes spéciales, en plus des données différées qu'il va recevoir :

  • $resolve(data) : pour charger l'objet ou le tableau avec les données, quand elles sont arrivées
  • $getPromise() : qui renvoie la promise associée
  • $isWaiting() : qui indique si l'objet ou le tableau est toujours en attente de ses données
Dans l'absolu la méthode $isWaiting() n'est pas indispensable, mais c'est un raccourci plus pratique que d'appeler à chaque fois then() sur la promise pour faire basculer un témoin, surtout si on veut l'utiliser dans une expression de la vue.


Voyons comment ce service est utilisé dans l'exemple sur Plunker. Dans le fichier app.js, on a un service User, avec une méthode load(id) pour charger un utilisateur, et save(user) pour enregistrer l'utilisateur modifié. L'exemple est bidon car il n'y a pas de serveur, en fait l'utilisateur est un objet en dur renvoyé au bout de 3 secondes, pour simuler une requête suffisamment lente pour qu'on puisse voir ce qui se passe, et l'enregistrement affiche juste une alerte avec les données. Mais ça serait la même chose avec de vraies requêtes $http.

    load: function (id) {
      var deferredData = DeferredData.typeObject();
      $timeout(function () {
        deferredData.$resolve({firstname: 'Thierry', lastname: 'Chatel'});
      }, 3000);
      return deferredData;
    },

Dans la fonction load(id) ci-dessus, on crée un objet de données différées en appelant DeferredData.typeObject(), et il est renvoyé immédiatement, à ce moment-là il ne contient aucune donnée. Son chargement est fait dans un $timeout au bout de 3000 millisecondes, en passant les données à charger à sa méthode resolve().

Et dans le contrôleur, on publie cet objet dans le scope :

app.controller('Ctrl', function($scope, User) {
  $scope.user = User.load(42);

  $scope.user.$getPromise().then(function (data) {
    $scope.message = "User loaded";
  });
  
  $scope.save = function () {
    User.save($scope.user);
  };
});

L'appel de la méthode then() de sa promise (renvoyée par $getPromise()) est un exemple de message publié dans le scope lorsque les données sont reçues.

Dans la vue, les champs du formulaire sont associés directement aux propriétés de cet objet aux données différées :

    <h1>User: <span ng-show="user.$isWaiting()">[loading...]</span></h1>
    <p class="message">{{message}}</p>
    <form ng-hide="user.$isWaiting()">
      <p>
        Firstname:<br/>
        <input ng-model="user.firstname">
      </p>
      
      <p>
        Lastname:<br/>
        <input ng-model="user.lastname">
      </p>
      
      <p>
        <button ng-click="save()">Save</button>
      </p>
    </form>

J'ai mis dans l'élément <h1> un <span> qui n'est visible que tant que les données ne sont pas arrivées. Et le formulaire au contraire est caché tant qu'on n'a pas reçu les données.

C'est très pratique à l'usage, et bien sûr tout ce qui est saisi dans les champs du formulaire impacte directement les propriétés de l'objet, donc il n'y a pas de précaution particulière à prendre lors de la sauvegarde, on peut envoyer l'objet tel quel au serveur.

La seule chose qu'il ne faut bien sûr pas faire, car ça reste quand même un objet dont les données sont différées, c'est d'utiliser la valeur d'une de ses propriétés directement dans le code du contrôleur, pour la copier ailleurs, ou pour conditionner du code. Parce que lorsque le contrôleur est initialisé, les données ne sont pas encore là. Donc il ne faut jamais écrire quelque chose comme ça dans le contrôleur, parce qu'on copierait une donnée encore vide :

$scope.userFirstname = $scope.user.firstname

Mais par contre on peut le faire sans risque dans un callback passé à la méthode then() de la promise :

$scope.user.$getPromise().then(function (data) {
  $scope.userFirstname = $scope.user.firstname;
});

Voilà pour les explications sur ce petit exemple qui peut vous rendre de grand services en vous simplifiant la gestion de l'asynchronisme. Bien sûr ce n'est certainement pas la seule solution possible, aussi n'hésitez pas à suggérer d'autres approches dans les commentaires ou sur le forum.

3 commentaires:

  1. Salut Thierry,

    Merci encore pour cet article très enrichissant.

    Comme tu le dis dans ton article avec $resource : "On n'a pas accès à la promise du service $http utilisé en sous-main.".

    Peut être que cela devrait changer prochainement si l'on regarde les modifications qui ont été effectuées il y a quelques jours sur le dépôt d'AngularJS : https://github.com/angular/angular.js/commit/f3bff27460afb3be208a05959d5b84233d34b7eb

    RépondreSupprimer
  2. J'avais lu des discussions il y a quelques mois sur l'accès à la promise depuis $resource, c'est peut-être pour bientôt alors.

    RépondreSupprimer
  3. Ce commentaire a été supprimé par l'auteur.

    RépondreSupprimer