Erreurs

9

Traduction complétée à

Au programme de ce chapitre :

  • Créer un meilleur mécanisme pour afficher les erreurs et les messages.
  • Implémenter une validation de formulaire plus stricte
  • Ajouter un rapport d'erreurs en ligne à nos formulaires
  • Utiliser simplement la boite de dialogue de navigateur standard alert() pour avertir l'utilisateur quand il y a un problème avec l'envoi de leur formulaire n'est pas très satisfaisant, et ce n'est clairement pas fait pour une bonne expérience utilisateur. Nous pouvons faire mieux.

    À la place, construisons un mécanisme de rapport d'erreurs plus versatile qui préviendra mieux l'utilisateur de ce qu'il se passe sans l'interrompre.

    Nous allons implémenter un système simple qui affiche les nouvelles erreurs dans le coin supérieur droit de la fenêtre, semblable à l'application populaire Mac OS Growl.

    Présentation des Collections Locales

    Pour commencer, nous devons créer une collection dans laquelle nous stockerons nos erreurs. Sachant que les erreurs sont seulement pertinentes pour la session en cours et n'ont besoin d'être persistantes en aucun cas, nous allons faire quelque chose de nouveau, et créer une collection locale. Cela signifie que la collection Errors existera uniquement dans le navigateur, et ne fera aucune tentative de synchronisation avec le serveur.

    Pour accomplir cela, nous créons l'erreur dans le dossier client (pour faire de la collection une collection cliente uniquement), avec son nom de collection MongoDB configuré à null (puisque les données de cette collection ne seront jamais sauvegardées dans la base de donnée côté serveur) :

    // Collection Locale (client-seulement)
    Errors = new Meteor.Collection(null);
    
    client/helpers/errors.js

    Maintenant que la collection a été créée, nous pouvons ajouter une fonction throwError que nous appellerons pour y ajouter des erreurs. Nous n'avons pas besoin de nous préoccuper de allow ou deny ou d'autre problème de sécurité puisque cette collection est “locale” à l'utilisateur en cours.

    throwError = function(message) {
      Errors.insert({message: message});
    };
    
    client/helpers/errors.js

    L'avantage d'utiliser une collection locale pour stocker les erreurs est que, comme toutes les collections, elle est réactive – cela veut dire que nous pouvons afficher les erreurs d'une manière réactive de la même façon que nous affichons les données de n'importe quelle autre collection.

    Afficher les erreurs

    Nous allons afficher les erreurs en haut de notre layout principal :

    <template name="layout">
      <div class="container">
        {{> header}}
        {{> errors}}
        <div id="main">
          {{> yield}}
        </div>
      </div>
    </template>
    
    client/templates/application/layout.html

    Maintenant, créons les templates errors et error dans errors.html :

    <template name="errors">
      <div class="errors row-fluid">
        {{#each errors}}
          {{> error}}
        {{/each}}
      </div>
    </template>
    
    <template name="error">
      <div class="alert alert-danger" role="alert">
        <button type="button" class="close" data-dismiss="alert">&times;</button>
        {{message}}
      </div>
    </template>
    
    client/templates/includes/errors.html

    Templates jumeaux

    Vous noterez que nous mettons deux templates dans un seul fichier. Jusqu'à maintenant, nous avons essayé d'adhérer à la convention “un fichier, un template”, mais, pour Meteor, mettre tous vos templates dans un seul fichier fonctionne aussi bien (bien que cela rendrait main.html très confus !).

    Dans ce cas, vu que les deux templates d'erreur sont plutôt courts, nous allons faire une exception et les mettre dans le même fichier pour rendre notre répertoire un peu plus clair.

    Nous avons juste besoin d'intégrer notre helper de template, et nous seront fin prêts !

    Template.errors.helpers({
      errors: function() {
        return Errors.find();
      }
    });
    
    client/templates/includes/errors.js

    Vous pouvez dès à présent tester nos nouveaux messages d'erreur manuellement. Ouvrez simplement la console de votre navigateur et tapez :

    throwError("I'm an error!");
    
    Tester les messages d'erreur.
    Tester les messages d'erreur.

    Commit 9-1

    Rapport d'erreur basique.

    Deux Types d'Erreur

    A ce stade, il est important de faire la distinction entre les erreurs au niveau de l'application (“app-level”) de celles au niveau du code (“code-level”).

    Les erreurs au niveau de l'application (app-level) sont généralement déclenchées par l'utilisateur, et l'on peut agir sur celles-ci. Ces erreurs comprennent notamment les erreurs de validation, les erreurs de permission, les erreurs de type “introuvables” et ainsi de suite. Ce sont le genre d'erreurs que l'on veut montrer à l'utilisateur pour l'aider à régler tout problème rencontré.

    Les erreurs au niveau du code (code-level), de leur côté, sont déclenchées de manière inattendue par de réels bugs dans votre code, et vous ne voulez probablement pas les afficher à vos utilisateurs directement, mais plutôt d'en garder une trace avec un service tiers de suivi des erreurs (tel que Kadira).

    Dans ce chapitre, nous nous concentrerons sur le premier type d'erreur, pas sur le suivi des bugs.

    Créer des erreurs

    Nous savons désormais comment afficher des erreurs, mais encore faut-il en déclencher une avant de voir quoi que ce soit. En fait, nous avons déjà implémenté un bon scénario pour une erreur: notre avertissement lors d'un article doublon. Nous remplacerons simplement les alert dans postSubmit par la nouvelle fonction throwError que nous venons de mettre en place :

    Template.postSubmit.events({
      'submit form': function(e) {
        e.preventDefault();
    
        var post = {
          url: $(e.target).find('[name=url]').val(),
          title: $(e.target).find('[name=title]').val()
        };
    
        Meteor.call('postInsert', post, function(error, result) {
          // display the error to the user and abort
          if (error)
            return throwError(error.reason);
    
          // show this result but route anyway
          if (result.postExists)
            throwError('This link has already been posted');
    
          Router.go('postPage', {_id: result._id});  
        });
      }
    });
    
    client/templates/posts/post_submit.js

    Tant que nous y sommes, nous allons faire la même chose pour postEdit :

    Template.postEdit.events({
      'submit form': function(e) {
        e.preventDefault();
    
        var currentPostId = this._id;
    
        var postProperties = {
          url: $(e.target).find('[name=url]').val(),
          title: $(e.target).find('[name=title]').val()
        }
    
        Posts.update(currentPostId, {$set: postProperties}, function(error) {
          if (error) {
            // display the error to the user
            throwError(error.reason);
          } else {
            Router.go('postPage', {_id: currentPostId});
          }
        });
      },
      //...
    });
    
    client/templates/posts/post_edit.js

    Commit 9-2

    Utilisation du rapport d'erreurs.

    Essayez par vous même : tentez de créer un article et entrez l'adresse http://meteor.com. Comme cette adresse est déjà attachée à un article dans l'installation, vous devriez voir :

    Déclencher une erreur
    Déclencher une erreur

    Effacer les Erreurs

    Vous noterez que les messages d'erreur disparaissent par eux-même après quelques secondes. Cela est en fait dû à un peu de magie CSS incluse dans la feuille de style que nous avons ajoutée au tout début de ce livre :

    @keyframes fadeOut {
      0% {opacity: 0;}
      10% {opacity: 1;}
      90% {opacity: 1;}
      100% {opacity: 0;}
    }
    
    //...
    
    .alert {
      animation: fadeOut 2700ms ease-in 0s 1 forwards;
      //...
    }
    
    client/stylesheets/style.css

    Nous définissons une animation CSS fadeOut qui précise quatre images clé pour la propriété opacité (à 0%, 10%, 90%, et 100% de la durée totale de l'animation) et appliquons cette animation à la classe .alert.

    L'animation s'exécutera pendant 2700 millisecondes au total, utilisera l'équation de timing ease-in, s'exécutera avec un délai de 0 secondes, une seule fois, et finalement restera sur la dernière image clé (keyframe) une fois terminée.

    Animations contre Animations

    Vous vous demandez peut-être pourquoi nous utilisons des animations CSS (qui sont prédéterminées et en dehors du contrôle de notre application), au lieu d'animations contrôlées par Meteor lui-même.

    Bien que Meteor offre une aide à l'insertion d'animations, nous voulions que ce chapitre soit focalisé sur les erreurs. Nous utiliserons donc pour l'instant des animations CSS “bêtes” et garderons les choses plus sophistiquées pour le chapitre Animations.

    Cela fonctionne bien, mais si vous déclenchez plusieurs erreurs (en soumettant le même lien trois fois par exemple) vous remarquerez qu'elles s'empileront les unes au dessus des autres :

    Débordement de pile.
    Débordement de pile.

    Et cela parce que alors que les éléments .alert disparaissent visuellement, ils sont en fait toujours présents dans le DOM. Nous devons régler cela.

    C'est exactement dans ce genre de situation que Meteor brille. Puisque la collection `Errors’ est réactive, tout ce que nous devons faire pour nous débarrasser de ces vieilles erreurs est de les supprimer de la collection !

    Nous utiliserons Meteor.setTimeout pour spécifier une fonction callback à être exécutée à la fin du timeout (dans ce cas, 3000 millisecondes).

    Template.errors.helpers({
      errors: function() {
        return Errors.find();
      }
    });
    
    Template.error.onRendered(function() {
      var error = this.data;
      Meteor.setTimeout(function () {
        Errors.remove(error._id);
      }, 3000);
    });
    
    client/templates/includes/errors.js

    Commit 9-3

    Effacer les erreurs après 3 secondes.

    Le callback onRendered est déclenché une fois notre template interprété dans le navigateur. À l'intérieur du callback, this se réfère à l'instance courante du template, et this.data nous permet d'accéder aux données de l'objet en cours d'interprétation (une erreur dans notre cas).

    Mettre en place une validation

    Jusqu'ici, nous n'avons pas imposé une quelconque validation de notre formulaire. Au minimum, nous voulons que les utilisateurs fournissent une URL et un titre pour leur nouveau post. Assurons-nous donc qu'ils le font.

    Nous allons faire deux choses pour signaler les champs non renseignés : premièrement, nous allons donner une classe spéciale CSS has-error au div parent de n'importe quel champ problématique du formulaire. Puis, nous allons afficher un message d'erreur utile juste en dessous du champ.

    Pour commencer, préparons notre template postSubmit pour qu'il accepte ces nouveaux helpers :

    <template name="postSubmit">
      <form class="main form page">
        <div class="form-group {{errorClass 'url'}}">
          <label class="control-label" for="url">URL</label>
          <div class="controls">
            <input name="url" id="url" type="text" value="" placeholder="Your URL" class="form-control"/>
            <span class="help-block">{{errorMessage 'url'}}</span>
          </div>
        </div>
        <div class="form-group {{errorClass 'title'}}">
          <label class="control-label" for="title">Title</label>
          <div class="controls">
            <input name="title" id="title" type="text" value="" placeholder="Name your post" class="form-control"/>
            <span class="help-block">{{errorMessage 'title'}}</span>
          </div>
        </div>
        <input type="submit" value="Submit" class="btn btn-primary"/>
      </form>
    </template>
    
    client/templates/posts/post_submit.html

    Notez que nous transmettons des paramètres (respectivement url et title) à chaque helper. Cela nous permet de réutiliser le même helper chaque fois, en modifiant son comportement selon le paramètre.

    Abordons maintenant la partie amusante : rendre ces helpers réellement fonctionnels.

    Nous utiliserons Session pour stocker un objet postSubmitErrors contenant tous les messages d'erreurs potentiels. Pendant que l'utilisateur interagit avec le formulaire, cet objet changera, ce qui, à son tour, mettra à jour activement la mise en page et le contenu du formulaire.

    En premier lieu, nous initialiserons l'objet à chaque fois que le template postSubmit est créé. Cela assure que l'utilisateur ne verra pas d'anciens messages d'erreur laissés par une précédente visite de cette page.

    Nous définirons ensuite nos deux helpers de template. Ils regardent tous les deux la propriété field de Session.get('postSubmitErrors') (où field est soit url ou title selon le lieu d'où on appelle le helper).

    Alors que errorMessage renvoie simplement lui-même le message, errorClass vérifie la présence d'un message et renvoie has-error s'il en existe un.

    Template.postSubmit.onCreated(function() {
      Session.set('postSubmitErrors', {});
    });
    
    Template.postSubmit.helpers({
      errorMessage: function(field) {
        return Session.get('postSubmitErrors')[field];
      },
      errorClass: function (field) {
        return !!Session.get('postSubmitErrors')[field] ? 'has-error' : '';
      }
    });
    
    //...
    
    client/templates/posts/post_submit.js

    Vous pouvez tester que nos helpers fonctionnent normalement en ouvrant le navigateur et en tapant la ligne de code suivante :

    Session.set('postSubmitErrors', {title: 'Attention ! Intrusion détectée. Les robots-chiens sont lâchés.'});
    
    Browser console
    Code rouge ! Code rouge !
    Code rouge ! Code rouge !

    La prochaine étape est de hooker cet objet de session postSubmitErrors au formulaire.

    Avant de faire ça, nous allons créer une nouvelle fonction validatePost dans posts.js qui regarde l'objet post, et renvoie un objet errors contenant toutes les erreurs pertinentes (à savoir, si les champs title ou url sont manquant) :

    //...
    
    validatePost = function (post) {
      var errors = {};
    
      if (!post.title)
        errors.title = "Please fill in a headline";
    
      if (!post.url)
        errors.url = "Please fill in a URL";
    
      return errors;
    }
    
    //...
    
    lib/collections/posts.js

    Nous appellerons cette fonction depuis le helper d'événement postSubmit :

    Template.postSubmit.events({
      'submit form': function(e) {
        e.preventDefault();
    
        var post = {
          url: $(e.target).find('[name=url]').val(),
          title: $(e.target).find('[name=title]').val()
        };
    
        var errors = validatePost(post);
        if (errors.title || errors.url)
          return Session.set('postSubmitErrors', errors);
    
        Meteor.call('postInsert', post, function(error, result) {
          // affiche l'erreur à l'utilisateur et s'interrompt
          if (error)
            return throwError(error.reason);
    
          // affiche ce résultat mais route quand même
          if (result.postExists)
            throwError('This link has already been posted');
    
          Router.go('postPage', {_id: result._id});
        });
      }
    });
    
    client/templates/posts/post_submit.js

    Notez que nous utilisons return pour interrompre l'exécution du helper si une erreur est présente, pas parce que nous voulons réellement renvoyer cette valeur quelque part.

    Pris la main dans le sac.
    Pris la main dans le sac.

     Validation côté serveur

    Nous n'avons pas tout à fait fini. Nous validons la présence d'une URL et d'un titre sur le client, mais qu'en est-il du serveur ? Après tout, quelqu'un pourrait toujours essayer d'entrer un post vide manuellement en appelant la méthode postInsert depuis la console du navigateur.

    Même si nous n'avons pas besoin d'afficher de messages d'erreur sur le serveur, nous pouvons toujours utiliser la même fonction validatePost. Sauf que cette fois, nous l'appellerons aussi depuis l'intérieur de la méthode, pas seulement depuis le helper d'événement :

    Meteor.methods({
      postInsert: function(postAttributes) {
        check(this.userId, String);
        check(postAttributes, {
          title: String,
          url: String
        });
    
        var errors = validatePost(postAttributes);
        if (errors.title || errors.url)
          throw new Meteor.Error('invalid-post', "You must set a title and URL for your post");
    
        var postWithSameLink = Posts.findOne({url: postAttributes.url});
        if (postWithSameLink) {
          return {
            postExists: true,
            _id: postWithSameLink._id
          }
        }
    
        var user = Meteor.user();
        var post = _.extend(postAttributes, {
          userId: user._id,
          author: user.username,
          submitted: new Date()
        });
    
        var postId = Posts.insert(post);
    
        return {
          _id: postId
        };
      }
    });
    
    lib/collections/posts.js

    Une fois de plus, les utilisateurs ne devraient normalement jamais voir ce message : « Vous devez définir un titre et une URL pour votre post ». Cela ne s'affichera que si quelqu'un veut contourner l'interface utilisateur que nous avons méticuleusement mise en place, et utiliser directement la console à la place.

    Pour tester ça, ouvrez la console du navigateur et essayez d'entrer un post sans URL :

    Meteor.call('postInsert', {url: '', title: 'No URL here!'});
    

    Si nous avons fait notre travail proprement, vous devriez voir en retour une flopée de code effrayant avec le message « Vous devez définir un titre et une URL pour votre post ».

    Commit 9-4

    Validation du contenu du post au moment de la soumission.

    Validation des éditions

    Pour arrondir les angles, nous allons aussi appliquer la même validation pour notre formulaire d’édition. Le code sera plutôt similaire. D'abord le template :

    <template name="postEdit">
      <form class="main form page">
        <div class="form-group {{errorClass 'url'}}">
          <label class="control-label" for="url">URL</label>
          <div class="controls">
            <input name="url" id="url" type="text" value="{{url}}" placeholder="Your URL" class="form-control"/>
            <span class="help-block">{{errorMessage 'url'}}</span>
          </div>
        </div>
        <div class="form-group {{errorClass 'title'}}">
          <label class="control-label" for="title">Title</label>
          <div class="controls">
            <input name="title" id="title" type="text" value="{{title}}" placeholder="Name your post" class="form-control"/>
            <span class="help-block">{{errorMessage 'title'}}</span>
          </div>
        </div>
        <input type="submit" value="Submit" class="btn btn-primary submit"/>
        <hr/>
        <a class="btn btn-danger delete" href="#">Delete post</a>
      </form>
    </template>
    
    client/templates/posts/post_edit.html

    Puis les helpers du template :

    Template.postEdit.onCreated(function() {
      Session.set('postEditErrors', {});
    });
    
    Template.postEdit.helpers({
      errorMessage: function(field) {
        return Session.get('postEditErrors')[field];
      },
      errorClass: function (field) {
        return !!Session.get('postEditErrors')[field] ? 'has-error' : '';
      }
    });
    
    Template.postEdit.events({
      'submit form': function(e) {
        e.preventDefault();
    
        var currentPostId = this._id;
    
        var postProperties = {
          url: $(e.target).find('[name=url]').val(),
          title: $(e.target).find('[name=title]').val()
        }
    
        var errors = validatePost(postProperties);
        if (errors.title || errors.url)
          return Session.set('postEditErrors', errors);
    
        Posts.update(currentPostId, {$set: postProperties}, function(error) {
          if (error) {
            // affiche l'erreur à l'utilisateur
            throwError(error.reason);
          } else {
            Router.go('postPage', {_id: currentPostId});
          }
        });
      },
    
      'click .delete': function(e) {
        e.preventDefault();
    
        if (confirm("Supprimer ce post ?")) {
          var currentPostId = this._id;
          Posts.remove(currentPostId);
          Router.go('postsList');
        }
      }
    });
    
    client/templates/posts/post_edit.js

    Tout comme nous avons fait pour le formulaire de soumission de post, nous voulons aussi valider nos posts côté serveur. À part, rappelez-vous, que nous n'utilisons pas de méthode pour éditer les posts. mais un appel direct à update depuis le client.

    Cela signifie que nous devrons ajouter un nouveau callback deny à la place :

    //...
    
    Posts.deny({
        update: function(userId, post, fieldNames, modifier) {
          var errors = validatePost(modifier.$set);
          return errors.title || errors.url;
        }
    });
    
    //...
    
    lib/collections/posts.js

    Notez que l'argument post se réfère au post existant. Dans ce cas, nous voulons valider la mise à jour, ce pourquoi nous appelons validatePost sur le contenu de la propriété $set de modifier (comme dans Posts.update({$set: {title: ..., url: ...}})).

    Cela fonctionne parce que modifier.$set contient les deux mêmes propriétés title et url que l'objet post en entier. Bien sûr, cela signifie que n'importe quelle mise à jour partielle qui concerne seulement title ou url ne fonctionnera pas, mais en pratique ça ne devrait pas être un problème.

    Vous remarquerez peut-être que c'est notre second callback deny. Lorsqu'on ajoute de multiples callbacks deny, l'opération échouera si l'un d'entre eux renvoie true. Dans ce cas, cela veut dire que update ne fonctionnera que s'il cible seulement les champs title et url, ou si aucun des deux n'est vide.

    Commit 9-5

    Valider le contenu des posts lors des éditions.