Le Vote

13

Traduction complétée à

Au programme de ce chapitre :

  • Construire un système de vote d'utilisateurs sur les articles.
  • Classer nos articles par vote sur une page des "meilleurs" articles.
  • Apprendre comment écrire un helper Spacebars général.
  • En apprendre un peu plus sur la sécurité des données dans Meteor.
  • Couvrir quelques remarques de performance intéressantes dans MongoDB.
  • Maintenant que notre site est en train de devenir populaire, trouver les meilleurs liens va rapidement devenir délicat. Ce que nous avons besoin est un système de classement pour ordonner nos articles.

    Nous pourrions construire un système de classement complexe avec karma, basé sur une perte de points dans le temps, et plein d'autres choses (la plupart sont implémentés dans Telescope, le grand frère de Microscope). Mais pour notre application, nous garderons les choses simples et nous noterons juste les articles par le nombre de votes qu'ils ont reçus.

    Commençons par donner aux utilisateurs un moyen de voter sur les articles.

    Modèle de données

    Nous stockerons une liste de votants sur chaque article afin de savoir si on doit montrer le bouton de vote aux utilisateurs et pour empêcher les personnes de voter deux fois.

    Confidentialité des données et Publications

    Nous publierons ces listes de votants à tous les utilisateurs, ce qui rendra automatiquement ces données accessibles publiquement via la console du navigateur.

    C'est le type de problème sur la confidentialité des données qui peut subvenir de la façon dont les collections fonctionnent. Par exemple, voulons-nous que les personnes soient capables de trouver qui a voté pour leurs articles ? Dans notre cas, rendre cette information disponible publiquement n'aura pas réellement de conséquences, mais il est important de reconnaître au minimum le problème.

    Nous allons également dénormaliser le nombre total de votants sur un article pour rendre plus facile la récupération de ce chiffre. Donc nous ajouterons deux attributs à nos articles, upvoters et votes. Commençons par les ajouter à notre fichier de pré-installation :

    // Données de préinstallation
    if (Posts.find().count() === 0) {
      var now = new Date().getTime();
    
      // Créer deux utilisateurs
      var tomId = Meteor.users.insert({
        profile: { name: 'Tom Coleman' }
      });
      var tom = Meteor.users.findOne(tomId);
      var sachaId = Meteor.users.insert({
        profile: { name: 'Sacha Greif' }
      });
      var sacha = Meteor.users.findOne(sachaId);
    
      var telescopeId = Posts.insert({
        title: 'Introducing Telescope',
        userId: sacha._id,
        author: sacha.profile.name,
        url: 'http://sachagreif.com/introducing-telescope/',
        submitted: new Date(now - 7 * 3600 * 1000),
        commentsCount: 2,
        upvoters: [],
        votes: 0
      });
    
      Comments.insert({
        postId: telescopeId,
        userId: tom._id,
        author: tom.profile.name,
        submitted: new Date(now - 5 * 3600 * 1000),
        body: "C'est un projet intéressant Sacha, est-ce-que je peux y participer ?"
      });
    
      Comments.insert({
        postId: telescopeId,
        userId: sacha._id,
        author: sacha.profile.name,
        submitted: new Date(now - 3 * 3600 * 1000),
        body: 'Bien sûr Tom !'
      });
    
      Posts.insert({
        title: 'Meteor',
        userId: tom._id,
        author: tom.profile.name,
        url: 'http://meteor.com',
        submitted: new Date(now - 10 * 3600 * 1000),
        commentsCount: 0,
        upvoters: [],
        votes: 0
      });
    
      Posts.insert({
        title: 'The Meteor Book',
        userId: tom._id,
        author: tom.profile.name,
        url: 'http://themeteorbook.com',
        submitted: new Date(now - 12 * 3600 * 1000),
        commentsCount: 0,
        upvoters: [],
        votes: 0
      });
    
      for (var i = 0; i < 10; i++) {
        Posts.insert({
          title: 'Test post #' + i,
          author: sacha.profile.name,
          userId: sacha._id,
          url: 'http://google.com/?q=test-' + i,
          submitted: new Date(now - i * 3600 * 1000 + 1),
          commentsCount: 0,
          upvoters: [],
          votes: 0
        });
      }
    }
    
    server/fixtures.js

    Comme d'habitude, arrêtez votre application, exécutez meteor reset, redémarrez votre application, et créez un nouvel utilisateur. Assurons-nous également ensuite que ces deux propriétés sont initialisées quand les articles sont créés :

    //...
    
    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(),
      commentsCount: 0,
      upvoters: [],
      votes: 0
    });
    
    var postId = Posts.insert(post);
    
    return {
    _id: postId
    };
    
    //...
    
    collections/posts.js

    Templates de vote

    Premièrement, nous allons ajouter un bouton de vote à notre article partiel et afficher le nombre de votes dans les métadonnées de l'article :

    <template name="postItem">
      <div class="post">
        <a href="#" class="upvote btn btn-default"></a>
        <div class="post-content">
          <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
          <p>
            {{votes}} Votes,
            submitted by {{author}},
            <a href="{{pathFor 'postPage'}}">{{commentsCount}} comments</a>
            {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
          </p>
        </div>
        <a href="{{pathFor 'postPage'}}" class="discuss btn btn-default">Discuss</a>
      </div>
    </template>
    
    client/templates/posts/post_item.html
    Le bouton de vote
    Le bouton de vote

    Ensuite, nous allons appeler une méthode serveur upvote quand l'utilisateur clique sur le bouton :

    //...
    
    Template.postItem.events({
      'click .upvote': function(e) {
        e.preventDefault();
        Meteor.call('upvote', this._id);
      }
    });
    
    client/templates/posts/post_item.js

    Finalement, nous allons revenir à notre fichier lib/collections/posts.js et ajouter une méthode Meteor côté serveur qui votera pour les articles :

    //...
    
    Meteor.methods({
      post: function(postAttributes) {
        //...
      },
    
      upvote: function(postId) {
        check(this.userId, String);
        check(postId, String);
    
        var post = Posts.findOne(postId);
        if (!post)
          throw new Meteor.Error('invalid', 'Post not found');
    
        if (_.include(post.upvoters, this.userId))
          throw new Meteor.Error('invalid', 'Already upvoted this post');
    
        Posts.update(post._id, {
          $addToSet: {upvoters: this.userId},
          $inc: {votes: 1}
        });
      }
    });
    
    //...
    
    lib/collections/posts.js

    Commit 13-1

    Algorithme basique de vote.

    Cette méthode est assez directe. Nous faisons quelques vérifications de sécurité pour nous assurer que l'utilisateur est authentifié et que l'article existe réellement. Puis nous vérifions doublement que l'utilisateur n'a pas déjà voté pour cet article, et si c'est le cas nous incrémentons le score total de vote et ajoutons l'utilisateur à l'ensemble des votants.

    L'étape finale est intéressante, comme nous avons utilisé deux opérateurs Mongo spéciaux. Il y en a beaucoup plus à apprendre, mais ces deux sont extrêmement pratique : $addToSet ajoute un item à une propriété de tableau tant qu'elle n'existe pas déjà, et $inc incrémente simplement un entier.

    Optimisations de l'interface utilisateur

    Si l'utilisateur n'est pas authentifié, ou a déjà voté un article, il ne sera pas autorisé à voter. Pour refléter ça dans notre UI, nous utiliserons un helper pour ajouter de façon conditionnelle une classe CSS disabled au bouton de vote.

    <template name="postItem">
      <div class="post">
        <a href="#" class="upvote btn {{upvotedClass}}"></a>
        <div class="post-content">
          //...
      </div>
    </template>
    
    client/templates/posts/post_item.html
    Template.postItem.helpers({
      ownPost: function() {
        //...
      },
      domain: function() {
        //...
      },
      upvotedClass: function() {
        var userId = Meteor.userId();
        if (userId && !_.include(this.upvoters, userId)) {
          return 'btn-primary upvotable';
        } else {
          return 'disabled';
        }
      }
    });
    
    Template.postItem.events({
      'click .upvotable': function(e) {
        e.preventDefault();
        Meteor.call('upvote', this._id);
      }
    });
    
    client/templates/posts/post_item.js

    Nous changeons notre classe .upvote en .upvotable, donc n'oubliez pas de changer l'événement click également.

    Griser les boutons vote.
    Griser les boutons vote.

    Commit 13-2

    Griser lien vote quand non authentifié / déjà voté.

    Ensuite, vous pouvez noter que les articles avec un seul vote sont étiquetés “1 votes”, donc prenons le temps de pluraliser ces labels proprement. Pluralisation peut être un processus compliqué, mais pour l'instant nous ferons ça d'une façon assez simpliste. Nous allons créer un helper Spacebars général que nous pourrons utiliser n'importe où :

    Template.registerHelper('pluralize', function(n, thing) {
      // pluraliser assez simpliste
      if (n === 1) {
        return '1 ' + thing;
      } else {
        return n + ' ' + thing + 's';
      }
    });
    
    client/helpers/spacebars.js

    Les helpers que nous avons créés avant ont été relié au manager et template auxquels ils s'appliquent. Mais en utilisant Template.registerHelper, nous avons créé un helper global qui peut être utilisé à l'intérieur d'un template :

    <template name="postItem">
    
    //...
    
    <p>
      {{pluralize votes "Vote"}},
      submitted by {{author}},
      <a href="{{pathFor 'postPage'}}">{{pluralize commentsCount "comment"}}</a>
      {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
    </p>
    
    //...
    
    </template>
    
    client/templates/posts/post_item.html
    Perfectionner Pluralisation Propre (maintenant dites ça 10 fois)
    Perfectionner Pluralisation Propre (maintenant dites ça 10 fois)

    Commit 13-3

    Helper pluraliser ajouté pour un meilleur format texte.

    Maintenant vous devriez voir “1 vote”.

    Algorithme de vote plus intelligent

    Notre code de vote est bon, mais nous pouvons encore faire mieux. Dans la méthode upvote, nous créons deux appels vers Mongo : un pour trouver l'article, l'autre pour le mettre à jour.

    Il y a deux problèmes avec ça. Premièrement, c'est un peu inefficace d'aller vers la base de données deux fois. Mais plus important, il introduit une concurrence. Nous suivons l'algorithme suivant :

    1. Récupérer l'article de la base de données.
    2. Vérifier si l'utilisateur a voté.
    3. Sinon, faire un vote par l'utilisateur.

    Que se passe-t-il si le même utilisateur vote plusieurs fois pour l'article entre les étapes 1 et 3 ? Notre code actuel ouvre la porte au utilisateur capables de voter pour le même article deux fois. Heureusement, Mongo nous permet d'être plus intelligent et combine les étapes 1-3 dans une seule commande Mongo :

    //...
    
    Meteor.methods({
      post: function(postAttributes) {
        //...
      },
    
      upvote: function(postId) {
        check(this.userId, String);
        check(postId, String);
    
      var affected = Posts.update({
        _id: postId,
        upvoters: {$ne: this.userId}
      }, {
        $addToSet: {upvoters: this.userId},
        $inc: {votes: 1}
      });
    
      if (! affected)
        throw new Meteor.Error('invalid', "Vous n'avez pas pu voter pour ce post.");
      }
    });
    
    //...
    
    collections/posts.js

    Commit 13-4

    Meilleur algorithme de vote.

    Ce que nous sommes en train de dire c'est “trouve tous les articles avec cet id pour lesquels cet utilisateur n'a pas déjà voté, et mets les à jour dans ce sens”. Si l'utilisateur n'a pas déjà voté, il trouvera bien entendu l'article avec cet id. D'un autre côté si l'utilisateur a voté, alors la requête correspondra à aucun documents, et par conséquent rien ne se passera.

    Compensation de la latence

    Disons que vous avez essayé de tricher et envoyé un de vos articles en haut de la lise en bidouillant son nombre de votes :

    > Posts.update(postId, {$set: {votes: 10000}});
    
    Console du navigateur

    (Où postId est l'id d'un de vos articles)

    Cette tentative impudente de jouer avec le système serait gérée par notre callback deny() (dans collections/posts.js, vous vous souvenez ?) et immédiatement rejetée.

    Mais si vous regardez attentivement, vous pourriez voir la compensation de latence en action. Ça peut être rapide, mais l'article bondira brièvement en tête de la liste avant de revenir à sa position.

    Que s'est-il passé ? Dans votre collection Posts en local, le update a été appliquée sans incident. Ça arrive instantanément, donc l'article a atteint le haut de la liste. Pendant ce temps, sur le serveur, le update a été rejeté. Puis un peu plus tard (mesuré en millisecondes si vous exécutez Meteor sur votre propre machine), le serveur a retourné une erreur, en disant à la collection en local de revenir en arrière.

    Le résultat final : en attendant que le serveur réponde, l'interface utilisateur ne peut pas aider mais fait confiance à la collection en local. Aussitôt que le serveur revient et refuse la modification, les interfaces utilisateur s'adaptent pour refléter ça.

    Classer les articles de la première page

    Maintenant que nous avons un score pour chaque article basé sur le nombre de votes, affichons la liste des meilleurs articles. Pour faire ça, nous allons voir comment gérer deux souscriptions séparées sur la collection article, et rendre notre template postList un peu plus général.

    Pour commencer, nous voulons avoir deux souscriptions, une pour chaque ordre de tri. L'astuce ici est que les deux souscriptions souscriront à la même publication posts, seulement avec des arguments différents.

    Nous allons également créer deux nouvelles routes appelées newPosts et bestPosts, accessibles respectivement aux URLs /new et /best (avec /new/5 et /best/5 pour notre pagination bien sur).

    Pour faire cela, nous étendrons notre PostsListController dans deux contrôleurs NewPostListController et BestPostsListController distincts. Ça nous laissera réutiliser exactement les mêmes options de routes pour les deux routes home et newPosts, en nous donnant un seul NewPostsListController d'où hériter. De plus, c'est juste une belle illustration de la flexibilité de Iron Router.

    Remplaçons donc la propriété de tri {submitted: -1} dans PostsListController par this.sort, qui sera fournie par NewPostsListController et BestPostsListController:

    //...
    
    PostsListController = RouteController.extend({
      template: 'postsList',
      increment: 5,
      postsLimit: function() {
        return parseInt(this.params.postsLimit) || this.increment;
      },
      findOptions: function() {
        return {sort: this.sort, limit: this.postsLimit()};
      },
      subscriptions: function() {
        this.postsSub = Meteor.subscribe('posts', this.findOptions());
      },
      posts: function() {
        return Posts.find({}, this.findOptions());
      },
      data: function() {
        var hasMore = this.posts().count() === this.postsLimit();
        return {
          posts: this.posts(),
          ready: this.postsSub.ready,
          nextPath: hasMore ? this.nextPath() : null
        };
      }
    });
    
    NewPostsController = PostsListController.extend({
      sort: {submitted: -1, _id: -1},
      nextPath: function() {
        return Router.routes.newPosts.path({postsLimit: this.postsLimit() + this.increment})
      }
    });
    
    BestPostsController = PostsListController.extend({
      sort: {votes: -1, submitted: -1, _id: -1},
      nextPath: function() {
        return Router.routes.bestPosts.path({postsLimit: this.postsLimit() + this.increment})
      }
    });
    
    Router.route('/', {
      name: 'home',
      controller: NewPostsController
    });
    
    Router.route('/new/:postsLimit?', {name: 'newPosts'});
    
    Router.route('/best/:postsLimit?', {name: 'bestPosts'});
    
    lib/router.js

    Notez que maintenant que nous avons plus d'une route, nous prenons la logique nextPath hors de PostListController et la plaçons dans NewPostsController et BestPostsController, à partir du moment où le chemin sera différent dans les deux cas.

    De plus, quand nous trions par votes, nous avons des tris ultérieurs par horodatage d'envoi puis par _id pour nous assurer que l'ordre est entièrement spécifié.

    Avec nos nouveaux contrôleurs en place, nous pouvons maintenant sans problème nous débarrasser de la route postList précédente. Supprimez simplement le code suivant :

    Router.route('/:postsLimit?', {
    name: 'postsList'
    })
    
    lib/router.js

    Nous ajouterons également des liens dans l'en-tête :

    <template name="header">
      <nav class="navbar navbar-default" role="navigation">
        <div class="navbar-header">
          <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
            <span class="sr-only">Toggle navigation</span>
            <span class="icon-bar"></span>
            <span class="icon-bar"></span>
            <span class="icon-bar"></span>
          </button>
          <a class="navbar-brand" href="{{pathFor 'home'}}">Microscope</a>
        </div>
        <div class="collapse navbar-collapse" id="navigation">
          <ul class="nav navbar-nav">
            <li>
              <a href="{{pathFor 'newPosts'}}">New</a>
            </li>
            <li>
              <a href="{{pathFor 'bestPosts'}}">Best</a>
            </li>
            {{#if currentUser}}
              <li>
                <a href="{{pathFor 'postSubmit'}}">Submit Post</a>
              </li>
              <li class="dropdown">
                {{> notifications}}
              </li>
            {{/if}}
          </ul>
          <ul class="nav navbar-nav navbar-right">
            {{> loginButtons}}
          </ul>
        </div>
      </nav>
    </template>
    
    client/templates/includes/header.html

    Et finalement, nous avons également besoin de mettre à jour notre gestionnaire de suppression d'article :

      'click .delete': function(e) {
        e.preventDefault();
    
        if (confirm("Delete this post?")) {
          var currentPostId = this._id;
          Posts.remove(currentPostId);
          Router.go('home');
        }
      }
    
    client/templates/posts/posts_edit.js

    Avec tout cela de fait, nous avons maintenant une liste des meilleurs posts.

    Classer par points
    Classer par points

    Commit 13-5

    Routes ajoutées pour les listes d'articles, et pages pour…

    Une meilleur en-tête

    Maintenant que nous avons deux pages de liste d'articles, il peut être difficile de savoir laquelle vous êtes en train de regarder. Donc revisitons notre en-tête pour rendre ça plus évident. Nous allons créer un gestionnaire header.js et créer un helper qui utilise le chemin courant et une ou plusieurs routes nommées pour ajouter une class active dans nos items de navigation :

    La raison pour laquelle nous voulons supporter de multiples routes nommées est que les deux routes home et newPosts (qui correspondent respectivement aux URLs / et /new) invoque le même template. Ça signifie que notre activeRouteClass devrait être assez intelligente pour rendre actif le tag <li> dans les deux cas.

    <template name="header">
      <nav class="navbar navbar-default" role="navigation">
        <div class="navbar-header">
          <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
            <span class="sr-only">Toggle navigation</span>
            <span class="icon-bar"></span>
            <span class="icon-bar"></span>
            <span class="icon-bar"></span>
          </button>
          <a class="navbar-brand" href="{{pathFor 'home'}}">Microscope</a>
        </div>
        <div class="collapse navbar-collapse" id="navigation">
          <ul class="nav navbar-nav">
            <li class="{{activeRouteClass 'home' 'newPosts'}}">
              <a href="{{pathFor 'newPosts'}}">New</a>
            </li>
            <li class="{{activeRouteClass 'bestPosts'}}">
              <a href="{{pathFor 'bestPosts'}}">Best</a>
            </li>
            {{#if currentUser}}
              <li class="{{activeRouteClass 'postSubmit'}}">
                <a href="{{pathFor 'postSubmit'}}">Submit Post</a>
              </li>
              <li class="dropdown">
                {{> notifications}}
              </li>
            {{/if}}
          </ul>
          <ul class="nav navbar-nav navbar-right">
            {{> loginButtons}}
          </ul>
        </div>
      </nav>
    </template>
    
    client/templates/includes/header.html
    Template.header.helpers({
      activeRouteClass: function(/* route names */) {
        var args = Array.prototype.slice.call(arguments, 0);
        args.pop();
    
        var active = _.any(args, function(name) {
          return Router.current() && Router.current().route.getName() === name
        });
    
        return active && 'active';
      }
    });
    
    client/templates/includes/header.js
    Montrer la page active
    Montrer la page active

    Arguments des helpers

    Nous n'avons pas utilisé ce patron spécifique jusqu'à maintenant, mais tout comme d'autres tags Spacebars, les tags de template helper peuvent prendre des arguments.

    Et pendant que vous pouvez bien entendu passer des arguments nommés spécifiques dans votre fonction, vous pouvez également passer un nombre non spécifié de paramètres anonymes et les récupérer en appelant l'objet arguments dans la fonction.

    Dans ce dernier cas, vous voudrez probablement convertir l'objet arguments en tableau Javascript classique et ensuite appeler pop() dessus pour vous débarrasser du hash ajouté à la fin par Spacebars.

    Pour chaque item de navigation, le helper activeRouteClass prendre une liste de noms de routes, et ensuite utilise le helper Any() de Underscore pour voir si certaines des routes passent le test (i.e. leur url correspondante est égale au chemin courant).

    Si une de ces routes correspond avec le chemin courant, any() retournera true. Finalement, nous prenons avantage du patron Javascript boolean && stringfalse && myString retourne false, mais true && myString retourne myString.

    Commit 13-6

    Classes active ajoutées à l'en-tête.

    Maintenant que les utilisateurs peuvent voter sur les articles en temps réel, vous verrez les items monter et descendre la page d'accueil en fonction de leur classement. Mais ne serait-il pas joli s'il y avait un moyen d'affiner tout ça avec quelques animations bien temporisées ?