Discover Meteor

Building Real-Time JavaScript Web Apps

Introduction

1

Commençons par un petit exercice mental. Imaginez que vous ouvriez le même dossier dans deux fenêtres différentes de votre ordinateur.

Cliquez maintenant dans une des deux fenêtres et effacez un fichier. Ce fichier a-t-il aussi disparu de l'autre fenêtre ?

Il n'est pas nécessaire de le faire réellement pour savoir que c'est bien le cas. Quand on modifie quelque chose dans notre système de fichier local, le changement s'applique partout sans avoir besoin de rafraîchir ou d'utiliser une fonction de rappel (callback). Ça marche, tout simplement.

Par contre, imaginons ce même scénario sur le web. Considérons par exemple que vous ouvriez le même panneau d'administration Wordpress dans deux fenêtres de navigateur, puis que vous créiez un nouveau post dans l'une d'elles. Contrairement à l'expérience précédente, et même si vous attendez, l'autre fenêtre ne changera pas à moins de la rafraîchir.

Nous nous sommes habitués d'années en années à l'idée qu'on ne communique avec un site que par de courtes actions, distinctes les unes des autres.

Heureusement, Meteor fait partie d'une nouvelle vague de frameworks et de technologies qui comptent bien remettre en question ce statu quo en rendant le web temps-réel (real-time) et réactif.

Qu'est-ce que Meteor ?

Meteor est une plate-forme basée sur Node.js pour créer des applications web temps réel. C'est ce qui fait le lien entre la base de données de votre application et son interface utilisateur, tout en assurant que les deux restent bien synchronisés.

Puisque Meteor est basé sur Node.js, il utilise JavaScript à la fois côté client et côté serveur. Autre chose, Meteor permet aussi de partager du code entre les deux environnements.

Tout cela nous donne une plate-forme pouvant être très puissante et très simple en fournissant une couche d'abstraction à de nombreux problèmes et pièges rencontrés lors du développement d'applications web.

Pourquoi Meteor ?

Bon alors pourquoi passer votre temps à apprendre Meteor plutôt qu'une autre plate-forme ? Sans compter les nombreuses fonctionnalités de Meteor, la raison est claire : Meteor est facile à apprendre.

Comparé aux autres plate-formes, Meteor permet l'implémentation et le déploiement d'une application web temps réel en l'espace de seulement quelques heures. Et si vous avez un peu d'expérience avec le développement front-end, vous êtes déjà familier avec le JavaScript et vous n'aurez pas à apprendre un nouveau langage.

Meteor est peut-être la plate-forme idéale qui répond à tous vos besoins, mais peut-être pas. Puisqu'il ne faut qu'une soirée ou un week-end pour démarrer, pourquoi ne pas essayer ?

Pourquoi ce livre ?

Sur les deux dernières années, nous avons travaillé sur de nombreux projets Meteor. Nous avons fait des applications webs et mobiles, open source et commerciales.

Nous avons beaucoup appris, mais trouver les réponses à nos questions n'était pas toujours très simple. Il nous a fallu assembler les pièces du puzzle à partir de sources multiples, et souvent même inventer nos propres solutions. Avec ce livre, nous souhaitions partager toutes ces leçons et créer un guide simple et détaillé qui vous aiderait à travers toutes les étapes de la construction d'une application Meteor.

L'application que nous allons construire est une version simplifiée des sites sociaux de nouvelles tels que Hacker News ou Reddit. Nous l'avons appelé Microscope (par analogie avec son grand frère, Telescope un autre projet Meteor open source). Pendant que nous le construirons, nous vous apprendrons tout les éléments nécessaires au développement d'une application Meteor comme la gestion des utilisateurs, les collections Meteor, le routing et beaucoup plus.

À qui ce livre est-il destiné ?

Un de nos objectifs en écrivant ce livre est de garder les choses simples et accessibles. Ainsi, vous devriez être capable de suivre le cours sans expérience préalable de Meteor, Node.js, des frameworks MVC, ou encore de la programmation côté serveur en général.

En revanche, on attend un connaissance élémentaire de la syntaxe et des concepts du JavaScript. Mais si vous avez déjà expérimenté un peu de code jQuery ou joué avec la console développeur du navigateur, vous ne devriez pas avoir de problème.

Si vous ne vous sentez pas au point avec JavaScript pour le moment, nous vous suggérons de jeter un œil à JavaScript primer for Meteor (en anglais) avant de revenir sur ce livre.

À propos des auteurs

Au cas où vous vous demandez qui nous sommes et pourquoi vous pouvez nous faire confiance, voici un peu d'information sur nous.

Tom Coleman fait partie de Percolate Studio, un studio de développement web ciblant la qualité et l'expérience utilisateur. Il travaille également sur Atmosphere, un service de paquet pour vos applications Meteor. Enfin, c'est aussi l'un des cerveaux derrière de nombreux projets Meteor open source (par exemple Iron Router).

Sacha Greif a travaillé avec des startups comme Hipmunk et RubyMotion en tant que concepteur produit et concepteur web. Il est le créateur de Telescope et de Sidebar (basé sur Telescope), et il est aussi le fondateur de Folyo.

Chapitres & Apartés

Nous voulions que ce livre soit utile aussi bien pour l’utilisateur novice de Meteor que pour le programmeur avancé, ainsi nous avons séparé les chapitres en deux catégories : les chapitres normaux (numérotés de 1 à 14) et les apartés (numéros .5).

Les chapitres normaux constituent la ligne conductrice pour construire l’application, et nous essayerons de la rendre opérationnelle dès que possible en expliquant les étapes les plus importantes sans vous encombrer de détails.

D’un autre côté, les apartés iront plus loin dans les subtilités de Meteor, et vous aideront à mieux comprendre ce qui se passe réellement dans les coulisses.

Ainsi, si vous êtes débutant, vous pouvez passer les apartés pour votre première lecture, et revenir à elles une fois que vous aurez joué un peu avec Meteor.

Commits & Instances en ligne

Il n’y a rien de pire que de suivre un livre de programmation et de tout à coup se rendre compte que le code n’est plus synchronisé avec les exemples et que plus rien ne fonctionne comme prévu.

Pour éviter cela, nous avons créé un dépôt sur GitHub pour Microscope, et nous proposerons également des liens directs vers les commits de Git à chaque modification de code. De plus, chaque commit est lié à une instance en ligne de l’application, ainsi vous pourrez le comparer avec votre copie locale. Ici un exemple de ce que vous pourriez voir :

Commit 11-2

Afficher les notifications dans l'entête.

Une seule chose, ce n’est pas parce que nous mettons à votre disposition ces commits que vous devez juste passer d’un checkout au suivant. Vous apprendrez mieux si vous prenez le temps nécessaire pour écrire le code de l’application !

Quelques ressources supplémentaires

Si jamais vous souhaitez en apprendre plus sur un point précis de Meteor, la documentation officielle de Meteor est le meilleur endroit pour commencer.

Nous vous recommandons également Stack Overflow pour la résolution de problèmes et les questions, ainsi que le canal IRC #meteor si vous avez besoin d’aide en direct.

Ai-je besoin de Git ?

Il n'est pas nécessaire d'être familiarisé avec le contrôle de versions Git pour suivre ce livre, mais nous le recommandons fortement.

Si vous souhaitez passer à la vitesse supérieure, nous vous recommandons Git Is Simpler Than You Think de Nick Farina.

Si vous êtes un débutant, nous vous recommandons également l’application GitHub (Mac OS) qui vous permet de gérer vos dépôts sans utiliser la ligne de commande, ou SourceTree (Mac OS & Windows), tous deux gratuits.

Nous contacter

  • Si vous souhaitez nous contacter, vous pouvez nous envoyer un email à hello@discovermeteor.com.
  • De même, si vous trouvez une erreur typographique ou toute autre erreur dans le contenu du livre, vous pouvez nous en informer en soumettant un bug dans ce dépôt GitHub.
  • Si vous avez un quelconque problème avec le code de Microscope, vous pouvez soumettre un bug dans le dépôt de Microscope.
  • Enfin, pour toute autre question vous pouvez aussi nous laisser un commentaire dans le panneau latéral de cette application.

Premiers pas

2

La première impression est importante, l'installation de Meteor devrait se faire sans difficulté. Vous serez en selle en moins de cinq minutes dans la plupart des cas.

Pour commencer, si vous êtes sur Mac OS ou Linux vous pouvez installer Meteor en ouvrant un terminal et en saisissant :

curl https://install.meteor.com | sh

Si vous êtes sur Windows, référez-vous aux instructions d'installation du site de Meteor.

Cette commande installera l'exécutable meteor sur votre système, et vous permettra de l'utiliser immédiatement.

Ne pas installer Meteor

Si vous ne pouvez pas — ou ne souhaitez pas — installer Meteor localement sur votre système, nous vous invitons à examiner Nitrous.io en guise d'alternative.

Nitrous.io est un service qui vous permet d'exécuter vos applications, et d'éditer leur code, directement dans votre navigateur. Nous avons rédigé un petit guide pour vous aider à le mettre en œuvre.

Suivez simplement ce guide jusqu'à la section “Installing Meteor” comprise, puis revenez dans ce chapitre, à la section « Créer une application basique ».

Créer une application basique

Maintenant que Meteor est installé, créons une application. Pour ce faire, on utilise l'outil en ligne de commande meteor :

meteor create microscope

Cette commande téléchargera Meteor, puis établira un projet Meteor élémentaire, prêt à l'emploi. Lorsqu'elle aura abouti, vous devriez trouver un dossier microscope/ avec le contenu suivant :

.meteor
microscope.css
microscope.html
microscope.js

L'application que Meteor a créée pour vous est une simple ébauche (boilerplate), qui met cependant déjà en œuvre quelques patterns courants.

Même si notre application ne fait pas grand chose, nous pouvons déjà la démarrer. Pour lancer l'application, retournez à la ligne de commande et saisissez :

cd microscope
meteor

Ouvrez une fenêtre de navigateur à l'adresse http://localhost:3000/ (ou à l'adresse http://0.0.0.0:3000/, ce qui est équivalent) et vous devriez voir quelque chose qui ressemble à :

Le ‹ Hello World › de Meteor.
Le ‹ Hello World › de Meteor.

Commit 2-1

Crée un projet Microscope élémentaire.

Félicitations ! Vous avez mis en service votre première application Meteor. Au passage, pour arrêter l'application, il vous suffit de ramener au premier plan la fenêtre de terminal dans laquelle tourne l'application, et de presser la combinaison de touches ctrl+c.

N'oubliez pas que si vous utilisez Git, c'est le bon moment pour initialiser votre repo avec git init.

Bye Bye Meteorite

Il fut un temps où Meteor était relié à un gestionnaire de paquet nommé Meteorite. Depuis la version 0.9.0 de Meteor, Meteorite n'est plus nécessaire puisqu'il a été intégré directement dans Meteor.

Donc si vous voyez une référence à la ligne de commande mrt de Meteorite au travers de ce livre ou sur Internet, vous pouvez simplement la remplacer par meteor.

Ajouter un paquet

Nous allons maintenant utiliser le système de paquet de Meteor pour ajouter Bootstrap à notre projet.

Il n'y a aucune différence par rapport à la méthode usuelle c'est-à-dire l'inclusion manuelle des fichiers CSS et JavaScript de Bootstrap à part le fait que nous laissons le soin au mainteneur du paquet de le garder à jour.

Pendant qu'on y est, nous allons aussi installer Underscore. Underscore est une bibliothèque utilitaire pour JavaScript vraiment très pratique pour manipuler des données.

Le paquet bootstrap est maintenu par l'utilisateur twbs, ce qui nous donne son nom complet, twbs:bootstrap.

En revanche, le paquet underscore fait partie des paquets «officiel» de Meteor qui sont fournis par le framework, c'est pourquoi il ne possède pas d'auteur :

meteor add twbs:bootstrap
meteor add underscore

Notez que nous ajoutons Bootstrap 3. Certaines captures d'écran de ce livre ont été prises avec une ancienne version de Microscope utilisant Bootstrap 2, ce qui signifie qu'elles seront légèrement différentes de votre version.

Commit 2-2

Avec les paquets Bootstrap et Underscore.

Vous devriez voir dès maintenant quelques changements dans votre application suite à l'ajout de Bootstrap :

Avec Bootstrap.
Avec Bootstrap.

Contrairement à la “traditionnelle” manière d'inclure des ressources externes, nous n'avons à lier aucun fichier CSS ou JavaScript car Meteor s'en occupe pour nous ! Mais ce n'est qu'un avantage des paquets Meteor parmi tant d'autres.

À propos des paquets

Lorsque nous parlons de paquets (packages) dans le contexte de Meteor, il est bon d'être précis. Meteor distingue cinq types de paquets :

  • Le cœur de Meteor comprend lui-même différents paquets essentiels (core packages). Ils font partie de chaque application Meteor, et vous n'aurez pratiquement jamais besoin de vous en soucier.
  • Les paquets classique de Meteor se nomment “isopacks”, ou paquets isomorphes (ce qui signifie qu'ils peuvent fonctionner sur le client et sur le serveur). First-party packages comme accounts-ui ou appcache sont maintenus par la team Meteor et sont directement [intégrés à Meteor]http://docs.meteor.com/#packages).
  • Les paquets tiers (Third-party packages) sont simplement des isopacks développés par d'autres utilisateurs qui les ont envoyés sur le serveur de paquet de Meteor. Vous pouvez les trouver sur Atmosphere ou avec la commande meteor search.
  • Les paquets locaux (local packages) sont des paquets spécifiques que vous pouvez créer et placer dans le sous-dossier /packages.
  • Les paquets NPM (NPM packages, Node.js Packaged Modules) sont des paquets Node.js. Bien qu'ils ne fonctionneront pas tels quels avec Meteor, ils peuvent être exploités par les précédents types de paquets.

La structure de fichiers d'une application Meteor

Avant de plonger dans le code, il convient de configurer notre projet correctement. Afin que l'assemblage (build) ne contienne que l'essentiel, ouvrez le dossier microscope et supprimez-en les fichiers microscope.html, microscope.js et microscope.css.

Puis créez quatre dossiers dans /microscope : /client, /server, /public, et /lib.

Enfin, créez main.html et main.js dans /client. Ne vous inquiétez pas si votre application ne fonctionne plus pour le moment, nous allons remplir ces fichiers dans les chapitres suivants.

Certains de ces sous-dossiers sont particuliers. Meteor traite les fichiers des dossiers selon les conventions suivantes :

  • Le code dans le dossier /server n'est exécuté que par le serveur.
  • Le code dans le dossier /client n'est exécuté que par le client.
  • Tout le reste est exécuté à la fois par le client et le serveur.
  • Vos ressources statiques (polices, images, etc) vont dans le dossier /public.

Il est également très utile de connaître dans quel ordre Meteor charge vos fichiers :

  • Les fichiers dans /lib sont chargés avant tous les autres.
  • Tous fichiers main.* sont chargés après les autres.
  • Tout le reste est chargé dans l'ordre alphabétique.

Au reste, Meteor ne vous contraint pas à utiliser de structure prédéfinie, si vous ne le souhaitez pas. La structure suggérée et décrite ci-dessus correspond à notre façon de procéder, elle n'est en aucun cas inamovible.

Nous vous encourageons à consulter la documentation officielle de Meteor si vous souhaitez davantage de détails à ce sujet.

Meteor est-il MVC ?

Si vous avez utilisé d'autres frameworks, tels que Ruby on Rails, vous pourriez vous demander si Meteor suit le pattern MVC (Model View Controller).

Pour faire court, non. À la différence de Rails, Meteor n'impose aucune structure de fichiers prédéfinie à votre application. Dans ce livre, nous disposerons notre code de la façon qui fait sens pour nous, sans trop nous soucier des acronymes.

Public ou pas ?

Pardonnez notre excès de zèle. En pratique, nous n'aurons pas besoin du dossier /public, car Microscope n'utilisera pas de ressources statiques ! Néanmoins, nous avons voulu vous signaler l'utilité de ce dossier, car la plupart des applications Meteor comprendront tout au moins quelques images.

À ce propos, vous avez peut-être observé la présence d'un dossier caché nommé .meteor. C'est l'emplacement où Meteor enregistre son propre code, et y apporter des modifications peut conduire à un effet potentiellement désastreux. Vous n'aurez généralement pas besoin de prêter attention à son contenu — sinon, à de rares exceptions, aux fichiers .meteor/packages et .meteor/release, qui contiennent respectivement la liste de vos paquets intelligents (smart packages) et la version de Meteor en usage dans votre projet. Lorsque vous ajoutez des paquets et changez de version de Meteor, vous pouvez vérifier ces changements en examinant ces deux fichiers.

Tirets_bas vs CasseAlternée

À l'antique débat concernant l'usage, dans les noms d'identifiants, de tirets bas (caractère underscore, comme dans ma_variable), ou de la casse alternée (CamelCase, comme dans maVariable), nous n'ajouterons rien, sinon qu'importe la convention que vous adopterez, pourvu que vous l'appliquiez systématiquement.

Dans cet ouvrage, nous utilisons la casse alternée camelCase, parce que c'est l'usage avec JavaScript — après tout, on écrit JavaScript, et non java_script !

Les noms de fichiers font exception à cette règle, qui utilisent des tirets bas (mon_fichier.js), et les classes CSS, qui utilisent des tirets (.ma-classe). La raison en est que les tirets bas sont habituels dans les systèmes de fichiers, et que le tiret est utilisé dans la syntaxe CSS elle-même (font-family, text-align, etc.)

CSS sans coquetterie

Ce livre ne traite pas de CSS. Ainsi, pour ne pas vous ralentir avec des considérations de style, nous avons préféré vous mettre à disposition la feuille de style complète dès le départ. Vous n'aurez, dès lors, plus à vous en préoccuper.

Les feuilles de style CSS sont automatiquement chargées et comprimées à la volée par Meteor, de sorte qu'elles doivent être placées dans le dossier /client, plutôt que /public comme le seraient les autres ressources statiques. Allez-y, créez-y maintenant un sous-dossier /client/stylesheets/, puis placez-y un fichier style.css avec le contenu suivant :

.grid-block, .main, .post, .comments li, .comment-form {
  background: #fff;
  border-radius: 3px;
  padding: 10px;
  margin-bottom: 10px;
  -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15);
  -moz-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15);
  box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15); }

body {
  background: #eee;
  color: #666666; }

#main {
  position: relative;
}
.page {
  position: absolute;
  top: 0px;
  width: 100%;
}

.navbar {
  margin-bottom: 10px; }
  /* line 32, ../sass/style.scss */
  .navbar .navbar-inner {
    border-radius: 0px 0px 3px 3px; }

#spinner {
  height: 300px; }

.post {
  /* For modern browsers */
  /* For IE 6/7 (trigger hasLayout) */
  *zoom: 1;
  position: relative;
  opacity: 1; }
  .post:before, .post:after {
    content: "";
    display: table; }
  .post:after {
    clear: both; }
  .post.invisible {
    opacity: 0; }
  .post.instant {
    -webkit-transition: none;
    -moz-transition: none;
    -o-transition: none;
    transition: none; }
  .post.animate{
    -webkit-transition: all 300ms 0ms;
    -moz-transition: all 300ms 0ms ease-in;
    -o-transition: all 300ms 0ms ease-in;
    transition: all 300ms 0ms ease-in; }
  .post .upvote {
    display: block;
    margin: 7px 12px 0 0;
    float: left; }
  .post .post-content {
    float: left; }
    .post .post-content h3 {
      margin: 0;
      line-height: 1.4;
      font-size: 18px; }
      .post .post-content h3 a {
        display: inline-block;
        margin-right: 5px; }
      .post .post-content h3 span {
        font-weight: normal;
        font-size: 14px;
        display: inline-block;
        color: #aaaaaa; }
    .post .post-content p {
      margin: 0; }
  .post .discuss {
    display: block;
    float: right;
    margin-top: 7px; }

.comments {
  list-style-type: none;
  margin: 0; }
  .comments li h4 {
    font-size: 16px;
    margin: 0; }
    .comments li h4 .date {
      font-size: 12px;
      font-weight: normal; }
    .comments li h4 a {
      font-size: 12px; }
  .comments li p:last-child {
    margin-bottom: 0; }

.dropdown-menu span {
  display: block;
  padding: 3px 20px;
  clear: both;
  line-height: 20px;
  color: #bbb;
  white-space: nowrap; }

.load-more {
  display: block;
  border-radius: 3px;
  background: rgba(0, 0, 0, 0.05);
  text-align: center;
  height: 60px;
  line-height: 60px;
  margin-bottom: 10px; }
  .load-more:hover {
    text-decoration: none;
    background: rgba(0, 0, 0, 0.1); }

.posts .spinner-container{
  position: relative;
  height: 100px;
}

.jumbotron{
  text-align: center;
}
.jumbotron h2{
  font-size: 60px;
  font-weight: 100;
}

@-webkit-keyframes fadeOut {
  0% {opacity: 0;}
  10% {opacity: 1;}
  90% {opacity: 1;}
  100% {opacity: 0;}
}

@keyframes fadeOut {
  0% {opacity: 0;}
  10% {opacity: 1;}
  90% {opacity: 1;}
  100% {opacity: 0;}
}

.errors{
  position: fixed;
  z-index: 10000;
  padding: 10px;
  top: 0px;
  left: 0px;
  right: 0px;
  bottom: 0px;
  pointer-events: none;
}
.alert {
          animation: fadeOut 2700ms ease-in 0s 1 forwards;
  -webkit-animation: fadeOut 2700ms ease-in 0s 1 forwards;
     -moz-animation: fadeOut 2700ms ease-in 0s 1 forwards;
  width: 250px;
  float: right;
  clear: both;
  margin-bottom: 5px;
  pointer-events: auto;
}
client/stylesheets/style.css

Commit 2-3

Structure de fichiers réorganisée.

Au sujet de CoffeeScript

Dans ce livre, nous écrirons les scripts en JavaScript pur. Si vous préférez néanmoins utiliser CoffeeScript, Meteor n'est pas en reste. Ajoutez simplement le paquet CoffeeScript et vous êtes paré :

meteor add coffeescript

Déploiement

Sidebar 2.5

Certaines personnes aiment travailler tranquillement sur un projet jusqu'à ce qu'il soit parfait, tandis que d'autres ne peuvent pas attendre de le montrer au monde le plus tôt possible.

Si vous êtes le premier type de personne et que vous préférez plutôt développer en local pour l'instant, vous êtes libre de sauter ce chapitre. D'un autre côté, si vous voulez prendre un peu de temps pour apprendre comment déployer votre application Meteor en ligne, nous avons ce qu'il vous faut.

Nous allons apprendre comment déployer une application Meteor de plusieurs façons. Vous êtes libres d'utiliser chacune d'elles à n'importe quelle étape de votre processus de développement si vous travaillez sur Microscope ou une autre application Meteor. Allons-y !

Présentation des Sidebars

Ceci est un chapitre sidebar (aparté), les Sidebars donnent une vision approfondie sur des sujets concernant Meteor, indépendamment du reste du livre.

Donc si vous préférez plutôt continuer à travailler sur Microscope, vous pouvez le sauter sans problème maintenant et y revenir plus tard.

Déployer sur Meteor

Déployer sur un sous-domaine Meteor (i.e. http://myapp.meteor.com) est l'option la plus simple et la première que nous testerons. Ça peut être utile pour présenter votre application dans ses premiers jours, ou de mettre en place rapidement un serveur de test.

Déployer sur Meteor est simple. Ouvrez juste votre terminal allez dans le répertoire de votre application Meteor et tapez :

meteor deploy myapp.meteor.com

Bien sûr, vous devrez faire attention à remplacer “myapp” avec un nom de votre choix, de préférence un qui n'est pas déjà utilisé.

Si c'est votre premier déploiement d'application, il vous sera demandé de créer un compte Meteor. Et si tout va bien après quelques secondes vous serez capable d'accéder à votre application à l'adresse http://myapp.meteor.com.

Vous pouvez vous référer à la documentation officielle pour plus d'informations sur des choses comme accéder directement à la base de données de votre instance hébergée ou configurer un domaine personnalisé pour votre application.

Déployer sur Modulus

Modulus est une bonne option pour déployer des applications Node.js. C'est un des fournisseurs PaaS (platform-as-a-service) qui supporte officiellement Meteor, et il y a déjà quelques personnes qui exécutent des applications Meteor en production dessus.

Vous pouvez en apprendre plus à propos de Modulus en lisant leur guide de déploiement pour application Meteor.

Meteor Up

Bien que de nouvelles solutions de cloud apparaissent chaque jour, elles viennent toujours avec leurs lots de problèmes et de limitations. Donc aujourd’hui, déployer sur votre propre serveur reste le meilleur moyen de mettre votre application Meteor en production. La seule chose est que déployer vous-même n'est pas si simple, surtout si vous recherchez un déploiement en production de qualité.

Meteor Up (ou mup de son petit nom) est une autre tentative de solution à ce problème, avec un outil en ligne de commande qui s'occupe de la configuration et du déploiement pour vous. Regardons comment déployer Microscope en utilisant Meteor Up.

Avant tout autre chose, nous aurons besoin d'un serveur. Nous recommandons Digital Ocean, qui démarre à 5$ par mois, ou AWS, qui fournit des Micro instances gratuitement (vous aurez rapidement des problèmes de scalabilité, mais si vous cherchez juste un terrain de jeu Meteor Up devrait être suffisant).

Quel que soit le service que vous choisissez, vous obtiendrez trois choses : l'adresse IP de votre serveur, un login (habituellement root ou ubuntu), et un mot de passe. Gardez-les de côté, nous en aurons bientôt besoin !

Initialiser Meteor Up

Pour démarrer, nous aurons besoin d'installer Meteor Up via npm comme suit :

npm install -g mup

Nous allons créer ensuite un répertoire séparé, spécial qui contiendra vos configurations Meteor Up pour un déploiement particulier. Nous utilisons un répertoire séparé pour deux raisons : c'est mieux d'éviter d'inclure des données privées dans votre dépôt Git, spécialement si vous travaillez sur du code public.

Deuxièmement, en utilisant plusieurs répertoires séparés, nous serons capables de gérer plusieurs configurations Meteor Up en parallèle. Ça sera très pratique pour déployer des instances de production et de tests par exemple.

Créons ce nouveau répertoire et utilisons le pour initialiser un nouveau projet Meteor Up :

mkdir ~/microscope-deploy
cd ~/microscope-deploy
mup init

Partage avec Dropbox

Un bon moyen de s'assurer que vous et votre équipe utilise les mêmes configurations de déploiement est simplement de créer un dossier de configuration Meteor Up dans votre Dropbox, ou un service similaire.

Configuration Meteor Up

Quand on initialise un nouveau projet, Meteor Up va créer deux fichiers pour vous : mup.json et settings.json.

mup.json gardera toutes vos configurations relatives au déploiement, pendant que settings.json contiendra toutes les configurations relatives à l'application (jetons OAuth, jetons analytics, etc.).

L'étape suivante est de configurer votre fichier mup.json. Voici le fichier mup.json par défaut généré par mup init, et tout ce que vous avez à faire est de remplir les blancs :

{
  //info d'authentication serveur
  "servers": [{
    "host": "hostname",
    "username": "root",
    "password": "password"
    //ou fichier pem (authentification basée sur ssh)
    //"pem": "~/.ssh/id_rsa"
  }],

  //installer MongoDB sur le serveur
  "setupMongo": true,

  //chemin de l'application (répertoire local)
  "app": "/path/to/the/app",

  //configuration environnementale
  "env": {
    "ROOT_URL": "http://supersite.com"
  }
}
mup.json

Traversons chacune de ces configurations.

Server Authentication

Vous noterez que Meteor Up supporte l'authentification par clés privées et basées sur des mots de passe (PEM), donc il peut être utilisé avec la plupart des fournisseur de cloud.

Note importante : Si vous choisissez d'utiliser l'authentification par mot de passe, assurez-vous d'avoir installé sshpass au préalable (référez-vous à ce guide).

Configuration MongoDB

L'étape suivante est de configurer une base de données MongoDB pour votre application. Nous vous recommandons d'utiliser Compose ou n'importe quel autre fournisseur de cloud MongoDB car ils offrent un support professionnel et de meilleurs outils de gestion.

Si vous avez décidé d'utiliser Compose, mettez setupMongo à false et ajoutez la variable d'environnement MONGO_URL dans le bloc env de mup.Json. Si vous décidez d'héberger MongoDB avec Meteor Up, mettez juste setupMongo à true et Meteor Up s'occupera du reste.

Chemin de l'application Meteor

Maintenant que notre configuration Meteor Up a pris vie dans un répertoire différent, nous allons avoir besoin de faire pointer Meteor Up vers notre application en utilisant la propriété app. Entrez juste votre chemin local complet, que vous pouvez trouver en utilisant la commande pwd dans le terminal quand vous êtes placé dans votre répertoire.

Variables d'environnement

Vous pouvez spécifier toutes les variables d'environnement de votre application (telles que ROOT_URL, MAIL_URL, MONGO_URL, etc.) à l'intérieur du bloc env.

Configurer et déployer

Avant qu'on puisse déployer nous allons avoir besoin de configurer le serveur pour qu'il puisse héberger des applications Meteor. La magie de Meteor Up encapsule ce processus complexe dans une seule commande !

mup setup

Ça va prendre quelques minutes en fonction de la performance du serveur et de la connectivité réseau. Après le succès de l'installation, vous pouvez finalement déployer votre application avec :

mup deploy

Ça va compacter votre application Meteor, et la déployer sur le serveur que nous venons de mettre en place.

Afficher les journaux

Les journaux sont importants et Meteor Up fournit un moyen très facile de les manipuler en émulant la commande tail -f. Tapez juste :

mup logs -f

Ça vous donne une vue d'ensemble des capacités de Meteor Up. Pour plus d'informations, nous vous suggérons de visiter le dépôt Github de Meteor Up.

Ces trois façons de déployer des applications Meteor devrait être suffisantes pour la plupart des cas. Bien sûr, nous savons que plusieurs d'entre vous préféreront avoir un contrôle complet et configurer leur serveur Meteor de bout en bout. Mais c'est un sujet pour un autre jour… ou peut-être un autre livre !

Templates

3

Pour faciliter le développement dans Meteor, nous allons adopter une approche de l'extérieur vers l'intérieur. En d'autres termes, nous allons créer une simple page html/javascript, puis nous la rattacherons à la mécanique interne de l'application plus tard.

Ce qui veut dire que dans ce chapitre nous nous occuperons de ce qu'il se passe dans le répertoire /client.

Si vous ne l'avez pas déjà fait, créez un nouveau fichier appelé main.html dans votre répertoire /client et insérez le code suivant :

<head>
  <title>Microscope</title>
</head>
<body>
  <div class="container">
    <header class="navbar navbar-default" role="navigation">
      <div class="navbar-header">
        <a class="navbar-brand" href="/">Microscope</a>
      </div>
    </header>
    <div id="main">
      {{> postsList}}
    </div>
  </div>
</body>
client/main.html

Ce sera le template principal de notre application. Comme vous pouvez le voir c'est du langage HTML excepté pour la balise d'inclusion de template {{> postsList}}, qui est un point d'insertion pour le template postsList que nous verrons bientôt. Nous allons maintenant créer deux templates supplémentaires.

Les templates Meteor

En son cœur, un site d'actualités social est composé d'articles organisés en listes, et c'est exactement de cette façon que nous allons organiser nos templates.

Créons un répertoire /templates dans /client. C'est ici que nous mettrons tous nos templates, et pour garder les choses en ordre nous allons également créer /posts dans /templates juste pour nos templates relatifs aux articles (posts).

Recherche de fichiers

Meteor est génial pour trouver les fichiers. Peu importe où vous mettez votre code dans le répertoire /client, Meteor le trouvera et le compilera proprement. Ce qui signifie que vous n'avez jamais besoin d'écrire manuellement des chemins d'inclusion (include) pour les fichiers javascript ou CSS.

Ça signifie également que vous pourriez aussi bien mettre tous vos fichiers dans le même répertoire, ou même tout votre code dans le même fichier. Mais sachant que Meteor compilera tout dans un seul fichier minifié de toute façon, nous garderons plutôt les choses bien organisées et utiliserons une structure de fichier la plus claire possible.

Nous sommes finalement prêt pour créer notre second template. Dans client/templates/posts, créez posts_list.html :

<template name="postsList">
  <div class="posts">
    {{#each posts}}
      {{> postItem}}
    {{/each}}
  </div>
</template>
client/templates/posts/posts_list.html

Et post_item.html :

<template name="postItem">
  <div class="post">
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
    </div>
  </div>
</template>
client/templates/posts/post_item.html

Notez l'attribut name="postsList" de la balise template. C'est le nom qui sera utilisé par Meteor pour garder la trace de quel template va où (le nom du fichier n'est pas important).

Il est temps de présenter le système de templating de Meteor, Spacebars. Spacebars est du html simple, avec trois choses en plus : inclusions (parfois désignée sous le nom de “partials”), expressions et block helpers.

Les Inclusions utilisent la syntaxe {{> templateName}}, et indiquent simplement à Meteor de remplacer le champ par le template du même nom (dans notre cas postItem).

Les Expressions telles que {{title}} appellent une propriété de l'objet courant, ou retourne la valeur du template helper comme défini dans le template manager courant (plus d'information sur ce sujet plus tard).

Finalement, les block helpers sont des balises spéciales qui contrôlent le flux du template, tels que {{#each}}...{{/each}} ou {{#if}}...{{/if}}.

Aller plus loin

Vous pouvez consulter la documentation Spacebars si vous voulez en apprendre plus à propos de Spacebars.

Armé de cette connaissance, vous pouvez dorénavant comprendre ce qui se passe ici.

Premièrement, dans le template postsList, nous faisons des itérations sur un objet posts avec le block helper {{#each}}…{{/each}}. Ensuite, pour chaque itération nous allons inclure le template postItem.

D'où vient cet objet posts ? Bonne question. C'est un template helper ; vous pouvez vous l'imaginer comme un substitut à un contenu dynamique.

Le template postItem lui-même est assez simple. Il utilise trois expression : {{url}} et {{title}} retournent des propriétés du document, et {{domain}} appelle un template helper.

Template Managers

Jusqu'à maintenant, nous avons travaillé avec Spacebars, qui est du HTML avec quelques balises parsemées dedans. Contrairement aux autres langages tel que PHP (ou même des pages HTML classiques, qui contiennent du javascript), Meteor garde les templates et leur logique séparés, et ces templates ne font rien par eux-même.

Afin d'exister, un template a besoin de helpers. Vous pouvez imaginer ces helpers comme un chef qui prend des ingrédients crus (vos données) et les prépare, avant de dresser une assiette (les templates) qu'il va donner à un serveur qui va ensuite vous les présenter.

En d'autres termes, pendant que le rôle du template est limité à l'affichage et aux itérations dans les variables, c'est celui des helpers qui fait en réalité le gros du travail en assignant une valeur à chaque variable.

Des contrôleurs ?

Il peut sembler tentant de penser aux fichiers contenant tous les helpers d'un template comme une sorte de “contrôleur” (controller). Mais cela peut être ambigu, puisque les “contrôleurs” (du moins du point de vue MVC) ont habituellement un rôle légèrement différent.

Nous avons donc décidé de ne pas utiliser cette terminologie et de nous référer simplement à “les helpers du template” ou à “la logique du template” lorsque nous parlons du code JavaScript associé au template.

Pour garder les choses simples, nous allons nommer les fichiers contenant les helpers comme le template, mais avec une extension .js. Créons donc directement posts_list.js dans client/templates/posts et commençons à construire notre premier helper :

var postsData = [
  {
    title: 'Introducing Telescope',
    url: 'http://sachagreif.com/introducing-telescope/'
  },
  {
    title: 'Meteor',
    url: 'http://meteor.com'
  },
  {
    title: 'The Meteor Book',
    url: 'http://themeteorbook.com'
  }
];
Template.postsList.helpers({
  posts: postsData
});
client/templates/posts/posts_list.js

Si vous l'avez fait correctement, vous devriez avoir quelque choses de similaire à ça dans votre navigateur :

Nos premiers templates avec données statiques
Nos premiers templates avec données statiques

Nous faisons deux choses ici. Premièrement, nous insérons des données prototype dans le tableau postsData. Ces données viendraient normalement de la base de données, mais comme nous n'avons pas encore vu comment faire (prochain chapitre) nous allons tricher en utilisant des données statiques.

Deuxièmement, nous utilisons la fonction Template.myTemplate.helpers() pour définir un template helper appelé posts qui retourne le tableau postsData défini auparavant.

Si vous vous souvenez, nous utilisons ce helper de posts dans notre template postsList :

<template name="postsList">
  <div class="posts page">
    {{#each posts}}
      {{> postItem}}
    {{/each}}
  </div>
</template>
client/templates/posts/posts_list.html

Définir le helper de posts implique qu'il est maintenant disponible pour notre template, donc notre template sera capable de faire des itérations sur le tableau postsData, et d'envoyer chaque objet vers le template postItem.

Commit 3-1

Avec le template basique de posts list et les données sta…

Le helper domain

De même, nous allons créer post_item.js pour y renseigner la logique du template de postItem :

Template.postItem.helpers({
  domain: function() {
    var a = document.createElement('a');
    a.href = this.url;
    return a.hostname;
  }
});
client/templates/posts/post_item.js

Cette fois la valeur du helper domain n'est pas un tableau, mais une fonction anonyme. Ce modèle est bien plus commun (et plus utile) comparé à nos précédents exemples de données simples.

Afficher les domaines pour chaque lien.
Afficher les domaines pour chaque lien.

Le helper domain prend une URL et retourne son domaine via un peu de magie JavaScript. Mais où prend-il l'url dans un premier temps ?

Pour répondre à cette question nous avons besoin de revenir à notre template posts_list.html. Le block helper {{#each}} ne fais pas seulement des itérations sur notre tableau, il insère également les valeurs dans this à l'intérieur du bloc de l'objet itéré.

Ça signifie qu'à l'intérieur des deux balises {{#each}}, chaque article (post) est assigné à this successivement, et ça s'étend dans le manager du template inclus (post_item.js).

Nous comprenons maintenant pourquoi this.url retourne l'URL de l'article courant. De plus, si nous utilisons {{title}} et {{url}} dans notre template post_item.html, Meteor sait ce que veut dire this.title et this.url et retourne les valeurs correctes.

Commit 3-2

Configurer un helper `domain` à partir du `postItem`.

Magie JavaScript

Bien que ce ne soit pas spécifique à Meteor, voici une petite explication de “Magie JavaScript”. Premièrement, nous créons une balise (‘a’) avec ancre vide et la stockons en mémoire.

Nous modifions ensuite l'attribut href pour être égal à l'URL de l'article courant (comme nous venons de le voir, l'objet en cours de traitement se trouve dans un helper this).

Finalement, nous prenons avantage de la propriété spéciale hostname de l'élément a pour récupérer le nom de domaine du lien sans le reste de l'URL.

Si vous avez suivi correctement, vous devriez voir une liste d'articles dans votre navigateur. C'est juste une liste de données statiques, donc ça ne tient pas compte des avantages des fonctionnalités temps réel de Meteor. Nous verrons comment changer ça dans le chapitre suivant !

Hot Code Reload

Vous avez pu noter que vous n'aviez même pas eu besoin de recharger manuellement la fenêtre de notre navigateur quand vous avez modifié vos fichiers.

C'est parce que Meteor traque tous les fichiers dans le répertoire de votre projet, et rafraîchit automatiquement le navigateur quand il détecte une modification sur l'un d'entre eux.

Le Hot Code Reload de Meteor est très intelligent, en préservant même l'état de votre application entre deux rafraîchissements !

Utiliser Git & GitHub

Sidebar 3.5

GitHub est un dépôt social pour les projets open-source basé sur le système de contrôle de version Git, et sa fonction première est de partager du code facilement et de collaborer sur des projets. Mais c'est aussi un merveilleux outil d'apprentissage. Dans cette sidebar, nous allons rapidement discuter de quelques moyens pour que vous puissiez utiliser GitHub afin de suivre avec Discover Meteor.

Cet aparté suppose que vous n'êtes pas familier avec Git et GitHub. Si vous êtes déjà à l'aise avec les deux, vous êtes libres de passer au chapitre suivant !

Être committed

Le bloc de travail de base d'un dépôt git est le commit. Vous pouvez imaginer le commit comme une image de l'état de votre code à un moment donné dans le temps.

Au lieu de vous donner simplement le code fini pour Microscope, nous avons pris ces images à chaque étape du développement, et vous pouvez toutes les voir en ligne sur GitHub.

Par exemple, c'est ce à quoi le dernier commit du chapitre précédent ressemble :

Un commit Git vu sur GitHub.
Un commit Git vu sur GitHub.

Ce que vous voyez ici est le “diff” (pour “difference”) du fichier post_item.js, en d'autres termes les changements introduits par ce commit. Dans ce cas, nous avons créé le fichier post_item.js à partir de rien, et donc tout son contenu est surligné en vert.

Comparons avec un exemple plus loin dans le livre :

Modifier du code.
Modifier du code.

Cette fois, seuls les fichiers modifiés sont surlignés en vert.

Et bien sur, parfois nous n'avons pas ajouté ou modifié de lignes de code, mais nous en avons supprimées :

Supprimer du code.
Supprimer du code.

Bien, nous venons de voir la première utilisation de GitHub : avoir une vue d'ensemble de ce qui a changé.

Explorer le code d'un Commit

La vue d'un commit de Git nous montre les changements inclus dans ce commit, mais parfois vous voudriez voir les fichiers qui n'ont pas changés, juste pour s'assurer de ce à quoi leur code doit ressembler à une étape du processus.

Une fois encore, GitHub a quelque chose à nous proposer. Quand vous êtes sur une page de commit, cliquez sur le bouton Browse code :

Le bouton Browse code.
Le bouton Browse code.

Vous allez maintenant avoir accès au dépôt tel qu'il est à ce moment spécifique du commit

Le dépôt au commit 3-2.
Le dépôt au commit 3-2.

GitHub ne nous donne pas d'indices visuels que vous êtes en train de regarder un commit, mais vous pouvez comparer avec la vue master “normale” et voir d'un coup d'oeil que la structure de fichiers est différente :

Le dépôt au commit 14-2.
Le dépôt au commit 14-2.

Accéder à un commit localement

Nous venons de voir comment explorer le code entier d'un commit en ligne sur GitHub. Mais comment faire si vous voulez faire la même chose localement ? Par exemple, vous pourriez vouloir exécuter une application localement à un commit spécifique pour voir comment elle se comportait à ce moment du processus.

Pour faire ça il y a plusieurs étapes (bien, dans ce livre du moins) avec l'utilitaire en ligne de commande git. Pour les débutants, assurez vous d'avoir Git installé. Ensuite clone (en d'autres termes, télécharger une copie localement) le dépôt de Microscope avec :

git clone https://github.com/DiscoverMeteor/Microscope.git github_microscope

Ce github_microscope à la fin est simplement le nom du répertoire local dans lequel vous allez cloner l'application. Dans le cas où vous avez déjà un répertoire microscope, prenez juste un nom différent (il n'y a pas besoin d'avoir le même nom que le dépôt GitHub).

Allez dans le répertoire avec la commande cd afin de pouvoir utiliser l'utilitaire en ligne de commande git :

cd github_microscope

Quand nous avons cloné le dépôt de GitHub, nous avons téléchargé tout le code de l'application, ce qui veut dire que nous avons le code du dernier commit.

Heureusement, il y a un moyen de revenir dans le temps et de “consulter” un commit spécifique sans affecter les autres. Essayons :

git checkout chapter3-1
Note: checking out 'chapter3-1'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:

  git checkout -b new_branch_name

HEAD is now at a004b56... Added basic posts list template and static data.

Git vous informe que vous êtes dans un état “detached HEAD”, ce qui signifie qu'aussi loin que Git a été utilisé, vous pouvez observer les commits passés mais vous ne pouvez les modifier. Vous pouvez imaginer ça comme un sorcier en train de consulter le passé dans sa boule de cristal.

(Notez que Git a également des commandes qui vous laisse modifier des commits passés. Ça serait plus comme un voyageur du temps qui revient dans le passé et peut potentiellement écraser un papillon, mais c'est hors du champ de cette brève introduction.)

La raison pour laquelle vous avez pu simplement taper chapter3-1 est que nous avons pre-taggé tous les commits de Microscope avec le marqueur de chapitre correcte. Si ça n'était pas le cas, vous auriez besoin de trouver le hash du commit, ou un identifiant unique.

Une fois encore, GitHub nous simplifie la vie. Vous pouvez trouver un hash de commit dans le coin en bas à droite du champ d'entête de commit bleu, comme ci-dessous :

Trouver un hash de commit.
Trouver un hash de commit.

Testons avec un hash au lieu d'un tag :

git checkout c7af59e425cd4e17c20cf99e51c8cd78f82c9932
Previous HEAD position was a004b56... Added basic posts list template and static data.
HEAD is now at c7af59e... Augmented the postsList route to take a limit

Et finalement, si nous voulons arrêter de regarder dans notre boule de cristal et que nous voulons revenir dans le présent ? Nous demandons à Git de consulter la branche master :

git checkout master

Notez que vous pouvez lancer l'application avec la commande meteor à tout moment, même lorsque vous êtes dans l'état “detached HEAD”. Vous aurez peut-être besoin de lancer d'abord meteor update si Meteor se plaint de paquets manquant, puisque le code des packages n'est pas inclus dans le repo Git de Microscope.

Perspective historique

Voici un autre scénario commun : vous regardez un fichier et remarquez des changements que vous n'aviez pas vu avant. Le fait est, que vous ne vous souvenez plus quand le fichier a été changé. Vous pourriez juste regarder les commits un par un jusqu'à que vous trouviez le bon, mais il y a un moyen plus facile grâce à la fonctionnalité d’historique de GitHub.

Premièrement, accédez à l'un des fichiers de votre dépôt sur GitHub, puis localisez le bouton “History" :

Bouton History de GitHub.
Bouton History de GitHub.

Vous avez maintenant une liste claire de tous les commits qui ont affectés ce fichier en particulier :

Afficher l'historique d'un fichier.
Afficher l'historique d'un fichier.

Le jeu du blâme

Pour développer, regardons Blame de plus près :

Bouton Blame de GitHub.
Bouton Blame de GitHub.

Cette vue claire nous montre ligne par ligne qui a modifié le fichier, et dans quel commit (en d'autres termes, qui est à blâmer quand les choses ne fonctionnent plus) :

Vue Blame de GitHub.
Vue Blame de GitHub.

Git est un outil assez complexe - et GitHub aussi -, donc nous ne pouvons pas espérer couvrir tout dans un seul chapitre. En fait, nous avons à peine effleuré la surface de ce qui est possible avec ces outils. Mais heureusement, même ce petit échantillon prouvera son utilité au fur et à mesure du livre.

Collections

4

Dans le chapitre un, nous avons parlé d'une fonctionnalité coeur de Meteor, la synchronisation automatique des données entre le client et le serveur.

Une collection est une structure de données spéciale qui prend soin de conserver vos données dans la base de donnée permanente MongoDB, côté serveur, et qui ensuite la synchronise en temps réel avec chaque navigateur connecté.

Nous voulons que nos articles soient permanents et partagés entre les utilisateurs, donc nous allons commencer par créer une collection appelée Posts pour les stocker dedans.

Les collections sont un élément central quelle que soit l'application, ainsi, pour s'assurer qu'elles sont toujours définies en premier on les placera dans le dossier lib, donc, si vous ne l'avez pas déjà fait, créez un répertoire collections/ dans lib, et ensuite un fichier posts.js dedans. Finalement ajoutez :

Posts = new Mongo.Collection('posts');
lib/collections/posts.js

Commit 4-1

Ajout d'une collection d'articles

Var ou pas Var ?

Dans Meteor, le mot-clé var limite le champ d'un objet au fichier courant. Ici, nous voulons rendre la collection Posts disponible dans l'application entière, et c'est la raison pour laquelle nous n’utilisons pas le mot-clé var ici.

Les applications web disposent de trois façons basiques de stocker des données, chacune correspondant à un rôle différent :

  • La mémoire du navigateur : les choses comme les variables JavaScript sont stockées dans la mémoire du navigateur, ce qui signifie qu'elle ne sont pas permanentes : elles sont locales à l'onglet courant du navigateur, et disparaîtront aussitôt que vous le fermez.
  • Le stockage du navigateur : les navigateurs peuvent aussi stocker des données de manière permanente en utilisant des cookies ou le stockage local. Bien que ces données perdureront session après session, elles sont locales à l'utilisateur courant (mais disponibles dans tous les onglets) et ne peuvent pas être partagées facilement entre les utilisateurs.
  • La base de données côté serveur : le meilleur endroit pour stocker des données que l'on veut aussi rendre accessibles à plus d'un utilisateur est la bonne vieille base de donnée (MongoDB étant la solution par défaut pour les applications Meteor).

Meteor utilise les trois façons, et synchronisera parfois les données d'un endroit à l'autre (comme nous le verrons bientôt). Cela dit, la base de donnée reste la source “canonique” de données : elle contient la copie originale de vos données.

Client & Serveur

Le code contenu dans des dossiers différents de client/ ou /serveur sera lancé dans les deux contextes. Ainsi, la collection Posts est disponible à la fois sur le client et sur le serveur. Cependant, ce qu'elle fait dans chaque environnement peut être bien différent.

Sur le serveur, la collection à pour objectif de communiquer avec la base de données MongoDB, et de lire et écrire chaque changement. Dans cette optique, elle peut être comparée à une librairie standard de base de donnée.

En revanche, sur le client, la collection est une copie d'un sous-ensemble de la réelle et canonique collection. La collection côté client est constamment mise à jour avec ce sous-ensemble, (la plupart du temps) de manière transparente.

Console vs Console vs Console

Dans ce chapitre, nous allons commencer à utiliser la console du navigateur, qu'il ne faut pas confondre avec le terminal, le shell Meteor ou le shell Mongo. Voici un topo rapide de chacuns d'entre eux.

Terminal

Le Terminal
Le Terminal
  • Appelé par votre système d'exploitation.
  • Côté serveur console.log() affiche la sortie ici.
  • Invité : $.
  • Egalement connu comme : Shell, Bash.

Console Navigateur

La console navigateur
La console navigateur
  • Appelé depuis le navigateur, exécute du code JavaScript.
  • Côté client console.log() affiche la sortie ici.
  • Invité : .
  • Egalement connu comme : Console JavaScript, Console Devtools.

Shell Meteor

Le Shell Meteor
Le Shell Meteor
  • Appelé depuis le terminal avec meteor shell.
  • Il vous donne un accès direct au code côté serveur de votre application.
  • Invité : >.

Shell Mongo

Le Shell Mongo
Le Shell Mongo
  • Appelé depuis le terminal avec meteor mongo.
  • Vous donne l'accès direct à la base de donnée de l'application.
  • Invité : >.
  • Egalement connu comme : Console Mongo.

Notez que dans chaque cas, vous n'êtes pas supposé écrire le caractère de l'invité ($, , or >) comme partie de la commande. Et vous pouvez deviner que les lignes qui ne commencent pas avec l'invité sont la sortie (résultat) de la commande précédente.

Collections côté Serveur

Revenons sur le serveur ; la collection agit comme une API dans votre base de donnée Mongo. Dans votre code côté serveur, ça vous permet d'écrire des commandes Mongo telles que Posts.insert() ou Posts.update(), et et elles feront les changements dans la collection posts stockées dans Mongo.

Pour regarder dans la base de donnée Mongo, ouvrez un second terminal (meteor est toujours en cours d'exécution sur votre premier), et allez dans le répertoire de votre application. Ensuite, exécutez la commande meteor mongo pour initier un shell Mongo, dans lequel vous pouvez taper des commandes Mongo standards (et comme habituellement, vous pouvez quitter avec le raccourci clavier ctrl+c). Par exemple, insérons un nouvel article :

meteor mongo

> db.posts.insert({title: "A new post"});

> db.posts.find();
{ "_id": ObjectId(".."), "title" : "A new post"};
Le Shell Mongo

Mongo sur Meteor.com

Notez qu'en hébergeant votre application sur *.Meteor.com, vous pouvez également accéder au shell Mongo de votre application hébergée avec meteor mongo myApp.

Et pendant que vous y êtes, vous pouvez également retrouver les journaux (logs) de votre application en tapant meteor logs myApp.

La syntaxe de Mongo est familière, vu qu'elle utilise une interface JavaScript. Nous ne ferons pas plus de manipulation de données dans le shell Mongo, mais vous pourrez y jeter un oeil de temps en temps pour vous assurer de ce qui s'y trouve.

Collections côté Client

Les collections sont plus intéressantes côté client. Quand vous déclarez Posts = new Mongo.Collection('posts'); sur le client, ce que vous êtes en train de créer est un cache local dans le navigateur de la collection Mongo réelle. Quand nous parlons d'une collection côté client en tant que “cache”, nous voulons dire qu'elle contient le sous-ensemble de vos données, et offre un accès rapide à ces données.

Il est important de comprendre ces points qui sont fondamentaux dans la façon dont Meteor fonctionne. En général, une collection côté client consiste en un sous-ensemble de tous vos documents stockés dans la collection Mongo (après tout, en général, nous ne voulons pas envoyer toute la base de données au client).

Deuxièmement, ces documents sont stockés dans la mémoire du navigateur, ce qui signifie qu'y accéder est tout simplement instantané. Donc il n'y a pas de longs trajets vers le serveur ou la base de données pour récupérer les données quand vous appelez Posts.find() sur le client, vu que les données sont déjà pré-chargées.

Présentation de MiniMongo

L'implémentation de Mongo côté client s'appelle MiniMongo. C'est n'est pas encore une implémentation parfaite, et vous pourrez rencontrer quelques fonctionnalités occasionel de Mongo qui ne fonctionneront pas dans MiniMongo. Cependant, toutes les fonctionnalités qui sont dans ce livre fonctionne de façon similaire dans Mongo et MiniMongo.

Communication Client-Serveur

La clé de voûte de tout ça est comment la collection côté client synchonise ses données avec la collection côté serveur du même nom ('posts' dans notre cas).

Plutôt que d'expliquer ça en détail, regardons juste ce qu'il se passe.

Commençons par ouvrir 2 fenêtres de navigateur, et accéder à la console du navigateur dans l'une des deux. Ensuite, ouvrez le shell Mongo dans le terminal.

À ce moment, vous devriez pouvoir trouver le document créé précédemment dans les trois contextes (notez que l'interface utilisateur de notre application affiche toujours nos trois précédents posts factices. Ignorez-les pour le moment).

> db.posts.find();
{title: "A new post", _id: ObjectId("..")};
Le Shell Mongo
 Posts.findOne();
{title: "A new post", _id: LocalCollection._ObjectID};
Console du premier navigateur

Créons un nouvel article. Dans l'une des consoles de navigateur, exécutez une commande d'insertion :

 Posts.find().count();
1
 Posts.insert({title: "A second post"});
'xxx'
 Posts.find().count();
2
Console du premier navigateur

Sans surprise, l'article a été créé dans la collection local. Maintenant vérifions Mongo :

❯ db.posts.find();
{title: "A new post", _id: ObjectId("..")};
{title: "A second post", _id: 'yyy'};
Le Shell Mongo

Comme vous pouvez le voir, l'article a été créé dans la base de données Mongo, sans qu'on ait besoin d'écrire une seule ligne de code d'envoi au serveur (bien, strictement parlant, nous avons écrit une seule ligne de code : new Mongo.Collection('posts')). Mais ce n'est pas tout !

Ouvrez la fenêtre du second navigateur et entrez ceci dans la console :

 Posts.find().count();
2
Console du second navigateur

L'article est ici aussi ! Quand bien même nous n'avons jamais rafraîchit ou même interagit avec le second navigateur, et nous avons certainement pas écrit de code pour envoyer les mises à jour. C'est arrivé par magie – et instantanément aussi, bien que ça devienne plus évident plus tard.

Ce qui est arrivé est que la collection côté serveur a été informé par la collection côté client d'un nouvel article, et a pris en charge la tâche d'insérer l'article dans la base de données Mongo et de renvoyer l'information à toutes les autres collections post connectées.

Récupérer les articles dans la console du navigateur n'est pas très utile. Nous apprendrons bientôt comment lier les données dans nos templates, et par conséquent modifier notre simple prototype HTML en application web temps réel fonctionnelle.

Remplir la base de données

Regarder le contenu de nos collections sur le navigateur est une chose, mais ce que nous aimerions vraiment faire c'est afficher les données, et les changements de ces données, sur l'écran. En faisant ça nous transformerons notre simple page web affichant du contenu statique, en application web temps réel avec des données changeantes, dynamiques.

La première chose que nous allons faire est de mettre des données dans notre base de données. Nous allons faire ça avec un fichier d'installation (fixture) qui charge un ensemble de données structurées dans la collection Posts quand le serveur démarre pour la première fois.

Premièrement, assurons nous qu'il n'y a rien dans la base de données. Nous utiliserons meteor reset, qui vide votre base de données et remet à zéro votre projet. Bien sûr, vous serez vraiment vigilant avec cette commande une fois que vous aurez commencé à travailler sur des projets réels.

Arrêtez le serveur Meteor (en faisant ctrl-c) et ensuite, dans le terminal, exécutez :

meteor reset

La commande reset vide la base de données. C'est une commande utile en développement, où il y a de fortes probabilités que votre base de données soit dans des états inconsistants.

Relançons notre application Meteor :

meteor

Maintenant que la base de données est vide, vous pouvez ajouter le code suivant qui va charger trois articles quand le serveur va démarrer si la collection Posts est vide :

if (Posts.find().count() === 0) {
  Posts.insert({
    title: 'Introducing Telescope',
    url: 'http://sachagreif.com/introducing-telescope/'
  });

  Posts.insert({
    title: 'Meteor',
    url: 'http://meteor.com'
  });

  Posts.insert({
    title: 'The Meteor Book',
    url: 'http://themeteorbook.com'
  });
}
server/fixtures.js

Commit 4-2

Ajout de données dans la collection posts.

Nous avons placé ce fichier dans le répertoire server/, il ne sera donc jamais chargé dans le navigateur d'un utilisateur. Le code sera exécuté immédiatement quand le serveur démarrera, et fera des appels d'insertion (insert) sur la base de données pour ajouter les trois articles dans notre collection Posts.

Maintenant relancez votre serveur avec meteor, et ces trois articles seront chargés dans la base de données.

Des données dynamiques

Si nous ouvrons une console navigateur, nous verrons les trois articles chargés dans MiniMongo :

 Posts.find().fetch();
Console navigateur

Pour afficher ces trois articles dans le rendu HTML, nous utiliserons un template helper.

Dans le chapitre 3 nous avons vu comment Meteor nous permettait de joindre un data context (contexte de données) à nos templates Spacebars pour construire des vues HTML de simple structures de données. Nous pouvons joindre nos données de collections de la même façon. Nous remplacerons simplement notre objet JavaScript statique postsData par une collection dynamique.

En parlant de ça, vous êtes libres de supprimer le code postsData à partir d'ici. Voici à quoi devrait ressembler posts_list.js maintenant :

Template.postsList.helpers({
  posts: function() {
    return Posts.find();
  }
});
client/templates/posts/posts_list.js

Commit 4-3

Affichage de collection dans le template `postsList`.

Chercher & Récupérer

Dans Meteor, find() retourne un cursor (curseur), qui est une source de données réactive. Quand nous voulons récupérer son contenu, nous pouvons utiliser fetch() sur ce curseur pour le transformer en tableau.

A l'intérieur d'une application, Meteor est assez intelligent pour savoir comment itérer sur un curseur sans avoir à les convertir d'abord explicitement en tableaux. C'est pourquoi vous ne verrez pas fetch() si souvent que ça dans le code Meteor actuel (et pourquoi nous ne l'utiliserons pas dans l'exemple ci-dessous).

Plutôt que de prendre une liste d'articles sous forme de tableau statique depuis une variable, nous retournons maintenant un curseur de notre helper posts (bien que les choses n'apparaîtront pas vraiment différemment puisque nous utilisons toujours les mêmes données) :

Utiliser des données dynamiques
Utiliser des données dynamiques

Notre helper {{#each}} a itéré sur tous nos Posts, et les a affiché à l'écran. La collection côté serveur a récupéré les articles de Mongo, les a envoyés vers la collection côté client, et nos helpers Spacebars les ont affichés dans notre template.

Maintenant, nous pouvons aller un peu plus loin; ajoutons un autre article via la console :

 Posts.insert({
  title: 'Meteor Docs',
  author: 'Tom Coleman',
  url: 'http://docs.meteor.com'
});
Console navigateur

Regardez dans le navigateur – vous devriez voir ça :

Ajout d'articles via la console
Ajout d'articles via la console

Vous venez juste de voir la réactivité en action pour la première fois. Quand nous parlions de Spacebars qui itérait sur le curseur Posts.find(), il savait comment observer les changements sur le curseur, et a corrigé le HTML de la plus simple façon pour afficher les données correctes à l'écran.

Inspecter les changements du DOM

Dans ce cas, le plus simple changement possible est d'ajouter un autre <div class="post">...</div>. Si vous voulez vous assurer du changement, ouvrez l'inspecteur du DOM et sélectionnez le <div> correspondant à un des articles existants.

Maintenant, dans la console JavaScript, insérez un autre article. Quand vous revenez sur l'inspecteur, vous allez voir un <div> supplémentaire, correspondant au nouvel article, mais vous aurez encore le même <div> existant sélectionné. C'est un moyen utile de voir quand les éléments ont été re-rendus et quand ils ont été laissés seuls.

Connecter les collections : Publications et Souscriptions

Depuis le début, nous avons le paquet autopublish activé, qui n'est pas conseillé pour les applications en production. Comme son nom l'indique, ce paquet dit simplement que chaque collection doit être partagée dans sa totalité à chaque client connecté. Ce n'est pas ce que nous voulons vraiment, donc désactivons le.

Ouvrez un nouveau terminal, et tapez :

meteor remove autopublish

L'effet est instantané. Si vous regardez dans votre navigateur maintenant, vous verrez que tous vos posts ont disparus ! C'est parce que nous nous reposions sur autopublish pour s'assurer que la collection posts côté client était un mirroir de tous les articles dans la base de donnée.

Eventuellement nous allons avoir besoin de nous assurer que nous transférons seulement les articles que l'utilisateur a besoin de voir (en prenant en compte les choses comme la pagination). Mais pour l'instant, nous allons juste configurer Posts pour qu'il soit publié dans sa totalité.

Pour faire cela, nous créons une fonction publish() qui retourne un curseur référençant tous les posts :

Meteor.publish('posts', function() {
  return Posts.find();
});
server/publications.js

Dans le client, nous avons besoin de souscrire à la publication. Nous allons ajouter la ligne suivante au fichier main.js :

Meteor.subscribe('posts');
client/main.js

Commit 4-4

Suppression de `autopublish` et mise en place d'une publi…

Si nous vérifions le navigateur, nos articles sont de retour. Pfiou !

Conclusion

Donc qu'est-ce qu'on a fait ? Bien que nous n'avons pas encore d'interface utilisateur, ce que nous avons maintenant est une application web fonctionnel. Nous pourrions déployer cette application sur Internet, et (en utilisant la console du navigateur) commencer à poster des nouvelles histoires et les voir apparaître dans les navigateurs d'utilisateurs partout à travers le monde.

Publications et Souscriptions

Sidebar 4.5

Publications et souscriptions sont un des concepts les plus importants et fondamentaux dans Meteor, mais peut être difficile à se représenter quand on débute.

Cela a conduit à beaucoup d'incompréhensions, telles que Meteor n'est pas sécurisé, ou que les applications Meteor ne peuvent pas gérer une large quantité de données.

Une grande part de la raison pour laquelle les gens trouvent ces concepts un peu perturbants est la “magie” que Meteor fait pour nous. Bien que cette magie est d'une aide considérable, elle peut obscurcir ce qui se passe vraiment en arrière-plan (comme la magie a tendance à le faire). Détaillons un peu les couches de magie pour essayer de comprendre ce qu'il se passe.

Les vieux jours

Mais dans un premier temps, regardons en arrière dans les vieux jours de 2011 quand Meteor n'était pas encore là. Disons que vous développez une simple application Rails. Quand un utilisateur atteint votre site, le client (i.e. votre navigateur) envoie une requête à votre application, qui est en cours sur votre serveur.

Le premier travail de l'application est de trouver quelles données l'utilisateur a besoin de voir. Ça pourrait être à la page 12 de vos résultats, les informations de profil de Marie, les 20 derniers tweets de Bob, etc. Vous pouvez imaginer un marchand de livres recherchant dans des allées le livre que vous avez demandé.

Une fois que les bonnes données ont été sélectionnées, le second travail de l'application est de traduire ces données dans un joli HTML lisible par un humain (ou en JSON dans le cas d'une API).

Dans la métaphore du marchand de livre, vous pouvez envelopper le livre que vous venez juste d'acheter et le mettre dans un joli sac. C'est la partie “vue” du fameux modèle MVC (Model-View-Controller).

Finalement, l'application prend du code HTML et l'envoie au navigateur. Le travail de l'application est terminé, et maintenant que toutes ces choses sont sorties de ses mains virtuelles, elle peut se détendre avec une bière et attendre la prochaine requête.

La vision Meteor

Regardons ce qui rend Meteor si spécial en comparaison. Comme nous l'avons vu, l'innovation clé de Meteor est que là ou l'application Rails vit seulement sur le serveur, une application Meteor inclut également un composant côté client qui va s'exécuter sur le client (le navigateur).

Envoyer un sous-ensemble de la base de données au client.
Envoyer un sous-ensemble de la base de données au client.

C'est comme un marchand qui non seulement vous trouve le livre, mais aussi vous suit jusque chez vous pour vous le lire au moment de dormir (ce qui peut paraître un peu bizarre à entendre).

Cette architecture laisse Meteor faire des choses sympathiques, parmi lesquelles la principale que Meteor appelle database everywhere. Simplement, Meteor va prendre un sous-ensemble de votre base de données et le copier sur le client.

Cela implique deux idées importantes : premièrement, au lieu d'envoyer du code HTML au client, une application Meteor va envoyer la donnée brute, véritablement, et laisser le client se débrouiller avec ça (data on the wire). Deuxièmement, vous allez pouvoir accéder aux données instantanément et même les modifier sans avoir à attendre un autre aller-retour vers le serveur (latency compensation).

Publier

Une base de données d'application peut contenir des dizaines de milliers de documents, lesquels peuvent être privés ou sensibles. Donc nous n'allons évidemment pas entièrement copier notre base de données sur le client, pour des raisons de sécurité et d'adaptabilité.

Par conséquent nous allons avoir besoin d'une méthode pour dire à Meteor quel sous-ensemble de données peut être envoyé au client, et nous l'effectuerons à l'aide d'une publication.

Revenons à Microscope. Nous avons ici tous les articles de l'application dans notre base de données :

Tous les articles contenus dans la base de données.
Tous les articles contenus dans la base de données.

Bien que cette fonctionnalité n'est pas encore présente dans Microscope, nous allons imaginer que certains articles ont été signalés pour langage abusif. Bien que nous voulons les garder dans notre base de données, ils ne devront pas être rendus disponibles aux utilisateurs (i.e. envoyés au client).

Notre première tâche sera de dire à Meteor quelles données nous voulons réellement envoyer au client. Nous allons dire à Meteor que nous voulons seulement publier les articles non signalés :

Exclure les articles signalés.
Exclure les articles signalés.

Voici le code correspondant, qui résidera sur le serveur :

// on the server
Meteor.publish('posts', function() {
  return Posts.find({flagged: false});
});

Cela assure qu'il n'y a aucun moyen possible qu'un client soit capable d'accéder à un article signalé. C'est exactement comme ça que vous allez sécuriser une application Meteor : assurez-vous juste que vous publiez seulement les données que vous voulez fournir au client.

DDP

Fondamentalement, vous pouvez imaginer le système de publication/souscription comme un tunnel où transitent les données de la collection côté serveur (source) vers la collection côté client (cible).

Le protocole qui est parlé dans ce tunnel est appelé DDP (Distributed Data Protocol). Pour en savoir plus sur DDP, vous pouvez visionner cette présentation de la Real-time Conference par Matt DeBergalis (un des créateurs de Meteor), ou cette capture vidéo par Chris Mather qui parcourt ce concept dans les moindres détails.

Souscrire

Quand bien même nous voulons rendre les articles non signalés disponibles pour les clients, nous ne pouvons pas envoyer des milliers d'articles en une fois. Nous avons besoin d'un moyen de spécifier aux clients quel sous-ensemble de données ils ont besoin à un moment donné, et c'est exactement là que les souscriptions interviennent.

Les données auxquelles vous souscrivez seront dupliquées sur le client grâce à Minimongo, l'implémentation Meteor de MongoDB côté client.

Par exemple, disons que nous sommes en train de naviguer sur la page de profil de Bob, et nous voulons seulement afficher ses articles.

Souscrire aux articles de Bob les copiera sur le client.
Souscrire aux articles de Bob les copiera sur le client.

Premièrement, nous modifierons notre publication pour prendre un paramètre :

// on the server
Meteor.publish('posts', function(author) {
  return Posts.find({flagged: false, author: author});
});

Et nous définirons ensuite ce paramètre au moment de la souscription à cette publication dans le code côté client de notre application :

// on the client
Meteor.subscribe('posts', 'bob-smith');

C'est de cette façon qu'on rend une application Meteor adaptable côté client : au lieu de souscrire à toutes les données disponibles, on choisit les parties dont nous avons actuellement besoin. De cette façon, vous allez éviter les surcharges mémoire du navigateur quelle que soit la taille de la base de données côté serveur.

Trouver

Maintenant qu'il est possible d'étiqueter les articles de Bob dans plusieurs catégories (par exemple “Javascript”, “Ruby” et “Python”), on pourrait avoir envie de charger tous les articles en mémoire mais de n'afficher que ceux de la catégorie “Javascript”. C'est dans ce cas de figure que “trouver” intervient.

Sélectionner un sous-ensemble sur le client.
Sélectionner un sous-ensemble sur le client.

Comme nous l'avons fait sur le serveur, nous utiliserons la fonction Posts.find() pour sélectionner un sous-ensemble de nos données.

// on the client
Template.posts.helpers({
  posts: function(){
    return Posts.find({author: 'bob-smith', category: 'JavaScript'});
  }
});

Maintenant que nous avons une bonne idée de quel rôle jouent les publications et les souscriptions, creusons un peu plus profond et observons quelques patrons d'implémentations.

Autopublish

Si vous créez un projet Meteor à partir de rien (i.e. en utilisant meteor create), il aura automatiquement le paquet autopublish activé. Commençons par discuter de son rôle.

Le but de autopublish est de rendre très facile le début de développement de votre application Meteor, et ce en dupliquant automatiquement toutes les données du serveur vers le client, ainsi en prenant soin des publications et des souscriptions pour vous.

Autopublish
Autopublish

Comment ça fonctionne ? Supposez que vous avez une collection appelée 'posts' sur le serveur. Alors autopublish enverra automatiquement chaque article qu'il trouvera dans la collection posts Mongo vers une collection appelées 'posts' sur le client (en considérant qu'il y en a un).

Donc si vous utilisez autopublish, vous n'avez pas besoin de penser aux publications. Les données sont omniprésentes, et les choses sont simples. Bien sûr, il y a des problèmes évidents d'avoir une copie complète de la base de données de l'application en cache sur chaque machine d'utilisateur.

Pour cette raison, autopublish est seulement approprié quand vous démarrez, et que vous n'avez pas encore réfléchi aux publications.

Publier des collections complètes

Une fois que vous avez supprimé autopublish, vous allez rapidement vous rendre compte que toutes vos données ont disparues du client. Une façon simple de revenir et simplement de copier ce que autopublish fait, et de publier une collection dans sa totalité. Par exemple :

Meteor.publish('allPosts', function(){
  return Posts.find();
});
Publier une collection complète
Publier une collection complète

Nous publions encore des collections complètes, mais au moins nous avons maintenant le contrôle sur quelles collections nous publions ou pas. Dans ce cas, nous publions la collection Posts mais pas Comments.

Publier des collections partielles

Le niveau de contrôle suivant est de publier seulement une partie d'une collection. Par exemple seuls les articles qui appartiennent à un certain auteur :

Meteor.publish('somePosts', function(){
  return Posts.find({'author':'Tom'});
});
Publier une collection partiellement
Publier une collection partiellement

En coulisses

Si vous avez lu la documentation de publication Meteor, vous avez peut-être été dépassé par l'utilisation de added() et ready() pour appliquer les attributs des enregistrements sur le client, et pour recoller ça avec les applications Meteor que vous avez déjà vu et qui n'utilise pas ces méthodes.

La raison est que Meteor fournit une méthode vraiment très pratique : _publishCursor(). Vous n'avez jamais vu ça utilisé ? Peut-être pas directement, mais si vous retournez un curseur(i.e. Posts.find({'author':'Tom'})) dans une fonction de publication, c'est exactement ce qu'utilise Meteor.

Quand Meteor voit que la publication somePosts a retourné un curseur, il appelle _publishCursor() pour – vous l'avez deviné – publier ce curseur automatiquement.

Voici ce que _publishCursor() fait :

  • Il vérifie le nom de la collection côté serveur.
  • Il prend tous les documents correspondants du curseur et envoie celui-ci dans une collection côté client du même nom. (Il utilise .added() pour faire ça).
  • A chaque moment qu'un document est ajouté, supprimé ou modifié, il envoie ces changements à la collection côté-client. (Il utilise .observe() sur le curseur et .added(), .changed() et removed() pour faire ça).

Dans l'exemple au-dessus, nous pouvons nous assurer que l'utilisateur a seulement les articles qui l'interessent (ceux écrit par Tom) disponibles dans leur cache côté client.

Publier des propriétés partielles

Nous avons vu comment publier seulement certains articles, mais nous pouvons continuer à affiner ! Voyons comment publier seulement certaines propriétés spécifiques.

Juste comme avant, nous allons utiliser find() pour retourner un curseur, mais cette fois nous allons exclure certains champs :

Meteor.publish('allPosts', function(){
  return Posts.find({}, {fields: {
    date: false
  }});
});
Publier des propriétés partiellement
Publier des propriétés partiellement

Bien sûr, nous pouvons également combiner deux techniques. Par exemple, si nous voulions retourner tous les articles de Tom en laissant de côté leurs dates, nous écririons :

Meteor.publish('allPosts', function(){
  return Posts.find({'author':'Tom'}, {fields: {
    date: false
  }});
});

Résumé

Donc nous avons vu comment publier chaque propriété de tous les documents de chaque collection (avec autopublish) jusqu'à publier seulement certaines propriétés de certains documents de certaines collections.

Ceci couvre les bases de ce que vous pouvez faire avec les publications Meteor, et ces simples techniques s'occuperont de la vaste majorité des cas d'utilisation.

Parfois, vous aurez besoin d'aller plus loin en combinant, reliant, assemblant des publications. Nous allons en discuter dans un prochain chapitre !

Le Routage

5

Maintenant que nous avons une liste de posts (qui peuvent avoir été éventuellement envoyés par des utilisateurs), nous avons besoin d'une page pour chaque post où les utilisateurs auront la possibilité de laisser des commentaires.

Nous aimerions rendre ces pages accessible par un permalien, une URL de la forme http://myapp.com/posts/xyz (où xyz est un identifiant MongoDB _id) qui est unique pour chaque post.

Cela signifie que nous allons avoir besoin d'un routage pour voir ce qu'il y a dans la barre URL du navigateur et afficher le contenu correspondant.

Ajout du package Iron Router

Iron Router est un package de routage qui a été créé spécialement pour les applications Meteor.

Non seulement c'est une aide pour le routage (la mise en place des chemins), mais le package s'occupe aussi des filtres (l'assignation de ces chemins à des actions) et il s'occupe même des abonnements (savoir quel chemin permet d'accéder à quelle donnée). (Note : Iron Router a été développé par un des co-auteurs de Discover Meteor, Tom Coleman.)

Commençons par installer le package depuis Atmosphere :

meteor add iron:router
Terminal

Cette commande va télécharger et installer le package Iron Router dans votre application. Notez que vous devrez probablement redémarrer votre application Meteor (avec ctrl+c pour terminer le processus, puis meteor pour le redémarrer) avant que le package ne soit utilisable.

Vocabulaire sur le routage

Nous allons aborder plusieurs fonctionnalités du routage dans ce chapitre. Si vous avez déjà utilisé un Framework comme Rails vous connaissez probablement la plupart de ces concepts. Si ce n'est pas le cas, voici un glossaire pour vous aider :

  • Routes : la route est le bloc de base du routage. C'est un jeu d'instruction qui dit à l'application où aller et quoi faire pour chaque URL.
  • Chemins : un chemin (ou Path) est une URL de l'application. Elle peut être statique (/information_legales) ou dynamique (/posts/xyz). Il peut même y avoir des paramètres (/search?keyword=meteor).
  • Segments : ce sont les différentes parties qui composent un chemin, séparées par un slash (/).
  • Hooks : Les Hooks sont les actions qui seront effectuées avant, après ou même pendant le processus de routage. Un exemple typique serait de vérifier si l'utilisateur a les droits nécessaire pour afficher une page.
  • Filtres : Les filtres sont des hooks qui sont définis globalement pour une ou plusieurs routes.
  • Template de routes : Chaque route doit pointer vers un template. Si vous n'en précisez pas un, le routeur cherchera le template avec le même nom que la route.
  • Layouts : Vous pouvez voir les layouts comme des cadres pour vos données. Ils contiennent tout le code HTML qui entoure les templates et qui ne bougera pas même si le template lui-même est modifié.
  • Contrôleurs : Quelques fois, vous vous rendrez compte que beaucoup de templates réutilisent les mêmes paramètres. Plutôt que de dupliquer votre code, vous pouvez faire hériter toutes ces routes d'un même contrôleur de routage qui contient toute la logique de routage ordinaire.

Pour plus d'information sur Iron Router, consultez la documentation complète sur GitHub.

Routage : Relier des URLS à des templates

Jusqu'à présent nous avons construit notre layout en utilisant des inclusions codées en dur (comme {{>postsList}}). Bien que le contenu de notre application puisse changer, la structure de la page est toujours la même : un titre avec une liste de posts en dessous.

Iron Router nous laisse sortir du cadre en nous laissant changer ce qui est affiché dans la balise HTML <body>. Donc nous n'allons pas définir le contenu de cette balise nous-même comme dans une page HTML classique. A la place, nous allons pointer le routeur vers un template spécial qui contient un helper de template {{> yield}}.

Ce helper {{> yield}} va définir une zone dynamique qui va automatiquement afficher le template correspondant à la route courante (par convention, nous désignerons à partir de maintenant ce template spécial le “template de routage”) :

Layouts et templates.
Layouts et templates.

Nous allons commencer par créer notre layout et ajouter le helper {{> yield}}. Premièrement, nous allons supprimer l'élément HTML <body> de main.html, et déplacer son contenu vers son propre template, layout.html (que nous placerons dans un nouveau dossier client/templates/application).

Iron Router s'occupera d'intégrer notre layout dans le template minimaliste main.html, qui ressemble maintenant à ça :

<head>
  <title>Microscope</title>
</head>
client/main.html

Le fichier layout.html nouvellement créé contiendra maintenant le layout extérieur de notre application :

<template name="layout">
  <div class="container">
    <header class="navbar navbar-default" role="navigation">
        <div class="navbar-header">
            <a class="navbar-brand" href="/">Microscope</a>
        </div>
    </header>
    <div id="main">
        {{> yield}}
        </div>
    </div>
</template>
client/templates/application/layout.html

Vous noterez que nous avons remplacé l'inclusion du template postsList avec un appel du helper yield.

Après ce changement, l'onglet de notre navigateur affiche la page d'aide par défaut d'Iron Router. C'est parce que nous n'avons pas encore dit au routeur que faire avec l'URL /, donc il renvoie un template vide.

Pour démarrer, nous pouvons retrouver notre ancien comportement en assignant l'URL racine / au template postList. Nous allons créer un nouveau fichier router.js à l'intérieur du répertoire /lib dans la racine du projet :

Router.configure({
  layoutTemplate: 'layout'
});

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

Nous avons effectué deux choses importantes. Premièrement, nous avons dit au routeur d'utiliser le layout que nous venons tout juste de créer comme layout par défaut pour toutes les routes.

Deuxièmement, nous avons défini une nouvelle route appelée postList et nous l'avons assignée à la racine /.

Le répertoire /lib

Quoi que vous mettiez dans le répertoire /lib, cela sera assurément chargé en premier avant tous les autres fichiers de votre application (avec comme exception possible les paquets intelligents). Ceci en fait une place de choix pour y mettre un helper qui a besoin d'être disponible en permanence.

Une petite mise en garde : notez que le répertoire /lib n'est ni dans /client ni dans /server, cela signifie que le contenu sera disponible dans les deux environnements.

Routes nommées

Éclaircissons un peu l'ambiguïté ici. Nous avons nommé notre route postList, mais nous avons également un template appelé postList. Donc qu'est-ce qu'il va se passer ici ?

Par défaut, Iron Router va chercher un template avec le même nom que celui de la route. En fait, il va même déduire le nom du chemin que vous spécifiez. Bien que cela ne marcherait pas dans ce cas particulier (puisque notre chemin est /), Iron Router aurait trouvé le bon template si nous avions utilisé http://localhost:3000/postsList comme chemin.

Vous pouvez vous demander pourquoi nous avons quand même besoin de nommer nos routes dans un premier temps. Nommer les routes nous laisse utiliser quelques fonctionnalités de Iron Router qui nous rend plus facile la création de liens dans notre application. La plus utile est le helper Spacebars {{pathFor}}, qui retourne l'URL du composant chemin de la route.

Nous voulons que notre lien d'accueil principal pointe vers la liste d'articles, donc au lieu de spécifier une URL statique /, nous allons pouvoir utiliser le helper Spacebars. Le résultat final sera le même, mais cela nous donne plus de flexibilité puisque le helper nous renverra toujours la bonne URL même si nous changeons après-coup le chemin de la route dans le routeur.

<header class="navbar navbar-default" role="navigation">
  <div class="navbar-header">
    <a class="navbar-brand" href="{{pathFor 'postsList'}}">Microscope</a>
  </div>
</header>

//...
client/templates/application/layout.html

Commit 5-1

Routage très basique.

Attente De Données

Si vous déployez la version courante de l'application (ou lancez l'instance web en utilisant le lien au-dessus), vous noterez que la liste apparaît vide un petit moment avant que les articles apparaissent. C'est parce que quand la page se charge la première fois, il n'y a pas d'articles à afficher jusqu'à que la souscription aux articles soit terminée, récupérant les données des articles du serveur.

Ce serait une bien meilleure expérience de fournir un indicateur visuel que quelque chose est en train de se passer, et que l'utilisateur doit attendre un moment.

Par chance, Iron Router nous donne un moyen facile de faire ça : nous pouvons lui demander d'attendre (to wait on) la souscription.

On commence par déplacer notre souscription posts depuis main.js vers le routeur :

Router.configure({
  layoutTemplate: 'layout',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

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

Ce que nous voulons faire ici, c'est que pour chaque route du site (nous n'en avons qu'une pour le moment, mais nous en aurons bientôt plus !), nous voulons souscrire à posts.

La principale différence entre ceci et ce que nous avions précédemment (lorsque la souscription était dans main.js, qui devrait être dorénavant vide et que vous pouvez supprimer), est que maintenant, Iron Router “sait” quand la route est prête – c'est-à-dire lorsqu'elle a les données dont il a besoin pour le rendu.

Visez un peu ça

Savoir quand la route postsList est prête ne nous est pas grandement utile si de toute façon nous n'allons afficher qu'un template vide. Heureusement, Iron Router inclut une procédé pour retarder l'affichage d'un template jusqu'à ce que la route qui l'appelle soit prête, et affiche un template de chargement (loading) à la place :

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

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

Notez que puisque nous définissons notre fonction waitOn globalement au niveau du routeur, cette séquence ne se produira qu'une fois, lorsqu'un utilisateur accédera à l'application pour la première fois. Après cela, les données seront chargées dans la mémoire du navigateur et le routeur n'aura plus besoin de les attendre.

La pièce finale du puzzle est le template de chargement. Nous allons utiliser le paquet spin pour créer un joli indicateur de chargement animé. Ajoutez le avec meteor add sacha:spin, et créez le template de chargement comme suit, dans le dossier client/templates/includes :

<template name="loading">
  {{>spinner}}
</template>
client/templates/includes/loading.html

Notez que {{>spinner}} est un partial contenu dans le paquet spin. Quand bien même ce partial “ne provient pas” de notre application, nous pouvons l'inclure comme n'importe quel autre template.

C'est normalement une bonne idée d'attendre les souscriptions, non seulement pour l'expérience utilisateur, mais aussi parce que cela signifie que vous pouvez, avec certitude, partir du principe que les données seront toujours disponibles depuis un template. Cela supprime le besoin de gérer les cas où les templates sont interprétés avant que leur données sous-jacentes soient disponibles, ce qui nécessite souvent des astuces laborieuses.

Commit 5-2

Attendre la souscription aux articles.

Un premier aperçu sur la réactivité

La réativité est une partie essentielle de Meteor, et bien que nous n'y avons pas encore vraiment touché, notre template de chargement nous donne un premier aperçu de ce concept.

Rediriger vers un template de chargement si les données ne sont pas encore chargées est vraiment bien, mais comment le routeur sait quand rediriger l'utilisateur vers la bonne page une fois que les données arrivent ?

Pour l'instant, disons juste que c'est exactement où la réactivité intervient, et restons-en là. Mais ne vous inquiétez pas, vous en apprendrez plus bientôt !

Router vers un article spécifique

Maintenant que nous avons vu comment router vers le template postsList, ajoutons une route pour afficher le détail d'un seul article.

Il n'y a pas qu'un seul article : nous ne pouvons continuer et définir une route par article, sinon il y en aurait des milliers. Donc nous allons avoir besoin de mettre une seule route dynamique, et permettre à la route d'afficher n'importe quel article que l'on souhaite.

Pour commencer, nous allons créer un template qui renvoie simplement le même template d'article que nous avons utilisé dans la liste d'articles.

<template name="postPage">
  <div class="post-page page">
    {{> postItem}}
  </div>
</template>
client/templates/posts/post_page.html

Nous allons ajouter plus d'éléments dans le template plus tard (tels que les commentaires), mais pour l'instant il va simplement servir de coquille pour notre inclusion postItem.

Nous allons créer une autre route nommée, cette fois en associant les chemins d'URL de la forme /posts/<ID> au template postPage :

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { return 
Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});
Router.route('/posts/:_id', {
  name: 'postPage'
});
lib/router.js

La syntaxe spéciale :_id dit au routeur deux choses : premièrement, faire correspondre n'importe quelle route de la forme /posts/xyz/, où “xyz” peut être n'importe quoi. Deuxièmement, mettre ce qu'il trouve à la place de xyz dans une propriété _id dans le tableau des params du routeur.

Notez que nous utilisons seulement _id par convention ici. Le routeur n'a pas de moyen de connaitre si ce que vous lui passez est un _id, ou juste une chaîne aléatoire de caractères.

Nous routons maintenant vers le template correct, mais il nous manque encore quelque chose : le routeur connaît l’_id de l'article que nous voulons afficher, mais le template n'a toujours pas d'indice. Donc comment peut-on combler ce fossé ?

Heureusement, le routeur a une solution intégrée intelligente : il vous laisse spécifier un contexte de données (data context) de template. Vous pouvez imaginer le contexte de données comme l'intérieur d'un délicieux gateau fait de templates et de layouts. Tout simplement, c'est ce avec quoi vous remplissez votre template :

Le contexte de données.
Le contexte de données.

Dans notre cas, nous pouvons récupérer le bon contexte de données en regardant notre article basé sur l’_id récupéré dans l'URL :

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});
Router.route('/posts/:_id', {
  name: 'postPage',
  data: function() { return Posts.findOne(this.params._id); }
}); 
lib/router.js

A chaque fois qu'un utilisateur accède à cette route, nous trouverons l'article approprié et le passerons au template. Souvenez-vous que findOne retourne un seul article qui correspond à la requête, et que fournir juste un _id comme argument est un raccourci pour {_id: id}.

A l'intérieur de la fonction data d'une route, this correspond à la route courante correspondante, et nous pouvons utiliser this.params pour accéder aux parties nommées de la route (que nous avons indiqué en les préfixant avec : dans notre chemin).

En savoir plus à propos des contextes de données

En initialisant un contexte de données de template, nous pouvons contrôler la valeur de this dans les helpers de template.

C'est habituellement fait implicitement avec l'itérateur {{#each}}, qui renvoie automatiquement le contexte de données de chaque itération à l'item en cours d'itération :

{{#each widgets}}
  {{> widgetItem}}
{{/each}}

Mais nous pouvons également le faire explicitement en utilisant {{#with}}, qui dit simplement “prends cet objet, et applique lui le template suivant”. Par exemple, nous pouvons écrire :

{{#with myWidget}}
  {{> widgetPage}}
{{/with}}

Il s'avère que vous pouvez obtenir le même résultat en passant le contexte comme argument dans l'appel de template. Et donc le code précédent peut être réécrit comme suit :

{{> widgetPage myWidget}}

Pour une exploration plus poussée des contextes de données nous vous suggérons de lire notre article de blog (en anglais) sur ce sujet.

En utilisant un Route Helper Dynamique Nommé

Enfin, nous allons créer un nouveau bouton “Discuter” qui redirigera vers notre page personnelle de posts. De même, nous pourrions faire quelque chose comme <a href="/posts/{{_id}}">, mais c'est plus fiable en utilisant un route helper.

Nous avons nommé la route article postPage, donc nous pouvons utiliser le helper {{pathFor 'postPage'}} :

<template name="postItem">
  <div class="post">
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
    </div>
    <a href="{{pathFor 'postPage'}}" class="discuss btn btn-default">Discuss</a>
  </div>
</template>
client/templates/posts/post_item.html

Commit 5-3

Route vers la page d'un article.

Attendez, comment le routeur sait comment récupérer la partie xyz dans /posts/xyz ? Après tout, nous ne lui passons aucun _id.

Il s'avère que Iron Router est assez intelligent pour le trouver par lui-même. Nous disons au routeur d'utiliser la route postPage, et le routeur sait que cette route requiert un _id de ce type (vu que c'est comment nous avons défini notre path).

Donc le routeur cherchera cet _id dans l'endroit disponible le plus logique : le data context du helper {{pathFor postPage}}, en d'autre mots this. Et il se trouve que notre this va correspondre à l'article, lequel (surprise !) possède une propriété _id.

Alternativement, vous pouvez également explicitement dire au routeur où vous aimeriez qu'il cherche la propriété _id, en passant un second argument au helper (i.e. {{pathFor 'postPage' someOtherPost}}). Un usage pratique de ce modèle serait de récupérer le lien des articles précédents et suivants dans une liste, par exemple.

Pour voir si ça fonctionne correctement, naviguez dans la liste d'articles et cliquez sur un des liens ‘Discuss’. Vous devriez voir quelque chose comme ça :

Page d'un article.
Page d'un article.

HTML5 pushState

Une chose à savoir est que ces changements d'URLs utilisent HTML5 pushState.

Le routeur récupère les clics sur les URLs internes au site, et empêche le navigateur de naviguer à l'extérieur de l'application, en plus de faire les changements nécessaires à l'état de l'application.

Si tout fonctionne correctement la page devrait changer instantanément. En fait, parfois les choses changent si vite qu'une sorte de transition pourrait être nécessaire. C'est hors du champ de ce chapitre, mais un sujet tout de même intéressant.

Article non trouvé

N'oublions pas que le routing fonctionne dans les deux sens : il permet de changer l'url lorsqu'on visite une page, mais il peut aussi afficher une nouvelle page lorsqu'on change l'url. Ainsi, nous devons nous assurer de ce qui se passe si quelqu'un entre une mauvaise url.

Heureusement, Iron Router s'occupe de cela pour nous grâce à l'option notFoundTemplate.

En premier lieu, nous allons mettre au point un nouveau template qui affiche un simple message d'erreur 404 :

<template name="notFound">
    <div class="not-found page jumbotron">
      <h2>404</h2>
      <p>Désolé, nous ne pouvons pas trouver une page à cette adresse.</p>
    </div>
</template>
client/templates/application/not_found.html

Ensuite, nous allons tout simplement lier Iron Route à ce template :

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

//...
lib/router.js

Pour tester notre nouvelle page d'erreur, vous pouvez essayer d'accéder à une url quelconque comme http://localhost:3000/rien-par-ici.

Un instant ; que se passe-t-il si quelqu'un entre une url de la forme http://localhost:3000/posts/xyz, où xyz n’estpas un _id valide d'article ? C'est toujours une route valide, mais elle ne mène à aucune donnée.

Heureusement, Iron Router est assez intelligent pour gérer cela, il suffit d'ajouter un hook spécial dataNotFound à la fin de router.js :

//...

Router.onBeforeAction('dataNotFound', {only: 'postPage'});
lib/router.js

Cela indique à Iron Router d'afficher la page “non trouvé” non seulement pour les routes invalides mais aussi à chaque fois que la fonction data renvoie un objet non désiré (i.e. null, false, undefined ou un objet vide).

Commit 5-4

Avec le template «non trouvé».

Pourquoi “Iron" ?

Au cas où vous vous demanderiez qu'elle est l'histoire derrière le nom "Iron Router" : d'après Chris Mather, créateur de Iron Router, cela s'explique par le fait que les météores sont composées principalement de fer (iron en anglais).

La Session

Sidebar 5.5

Meteor est un framework réactif. Ce qui veut dire que comme les données changent, les choses dans votre application changent sans que vous ayez explicitement besoin de faire quoi que ce soit.

Nous avons déjà vu ça en action lors des changements de template à la modification de données et de routes.

Nous irons un peu plus loin dans le fonctionnement plus tard dans les chapitres, mais pour le moment, nous aimerions présenter quelques fonctionnalités réactives de base qui sont extrêmement utiles dans les applications en général.

La Session Meteor

Actuellement dans Microscope, l'état courant de l'application utilisateur est complètement contenu dans l'URL qu'il regarde (et la base de données).

Mais dans de nombreux cas, vous allez avoir besoin de stocker des états éphémères qui sont seulement pertinants pour la version courante de l'utilisateur de l'application (par exemple, qu'un élément soit affiché ou caché). La session est un moyen idéal pour faire ça.

La session est une zone de stockage de données réactive. C'est global dans le sens d'un objet singleton global : il y a une session, et c'est accessible partout. Les variables globales sont habituellement vues comme des mauvaises choses, mais dans ce cas la session peut être utilisée comme un bus de communications central entre les différentes parties de l'application.

Changer la Session

La Session est disponible partout sur le client en tant que l'objet Session. Pour insérer une valeur de sessions, vous pouvez appeler :

 Session.set('pageTitle', 'A different title');
Console du navigateur

Vous pouvez ensuite lire les données avec Session.get('mySessionProperty');. C'est une source de données réactive, et ça signifie que si vous la mettez dans un helper, vous pouvez changer ce qu'affiche le helper de manière réactive en changeant la valeur de Session.

Pour essayer ça, ajoutez le code qui suit au template layout :

<header class="navbar navbar-default" role="navigation">
  <div class="navbar-header">
    <a class="navbar-brand" href="{{pathFor 'postsList'}}">{{pageTitle}}</a>
  </div>
</header>
client/templates/application/layout.html
Template.layout.helpers({
  pageTitle: function() { return Session.get('pageTitle'); }
});
client/templates/application/layout.js

Une note sur le code des apartés

Notez que le code présenté dans les chapitres apartés ne fait pas partie du cours principal du livre. Vous pouvez donc soit créer une nouvelle branche maintenant (si vous utilisez Git), soit vous assurez d'annuler les changements à la fin de ce chapitre.

Le rechargement automatique de Meteor (connu comme “hot code reload” ou HCR) préserve les variables de Session, donc nous devrions voir maintenant “A different title” affiché dans la navbar. Sinon, retapez juste la précédente commande Session.set() une nouvelle fois.

De plus, si nous changeons la valeur une fois de plus (via la console du navigateur), nous devrions voir le titre affiché changer une nouvelle fois :

 Session.set('pageTitle', 'A brand new title');
Console du navigateur

La Session est disponible globalement, donc de tels changements peuvent être fait n'importe où dans l'application. Ça nous donne un peu de pouvoir, mais ça peut également être un piège si c'est trop souvent utilisé.

D'ailleurs il est important d'indiquer que l'objet Session n’est pas partagé entre les utilisateurs, ou même entre les onglets. C'est pourquoi si vous ouvrez maintenant l'application dans un nouvel onglet, vous serez face à un titre de site vide.

Changements Identiques

Si vous modifiez une variable de Session avec Session.set() mais que vous l'initialisez à une valeur identique, Meteor est assez intelligent pour passer outre la chaîne réactive, et évitez les appels de fonction non nécessaires.

Présentation de Autorun

Nous avons regardé un exemple d'une source de données réactive, et nous l'avons observé en action à l'intérieur d'un helper de template. Mais là où les contextes dans Meteor (tels que les template helpers) sont réactifs de manière inhérentes, la majorité du code de l'application Meteor est encore plein de JavaScript non réactif.

Supposons que nous avons le petit bout de code suivant quelque part dans notre application :

helloWorld = function() {
  alert(Session.get('message'));
}

Quand bien même nous appelons une variable de Session, le contexte dans lequel elle est appelée est non réactif, ce qui signifie que nous n'aurons pas de nouvelle alerte à chaque fois que nous changerons la variable.

C'est là que Autorun intervient. Comme son nom l'indique, le code à l'intérieur d'un bloc autorun s'exécutera et restera en cours d'exécution chaque fois que les sources de données réactives utilisées à l'intérieur changeront.

Essayer de taper ça dans la console de votre navigateur :

 Tracker.autorun( function() { console.log('Value is : ' + Session.get('pageTitle')); } );
Value is : A brand new title
Console du navigateur

Comme vous pouviez vous y attendre, le bloc de code fourni à l'intérieur du autorun s'éxécute une fois, retournant ses données à la console. Maintenant, essayons de changer le titre :

 Session.set('pageTitle', 'Yet another value');
Value is : Yet another value
Console du navigateur

C'est magique ! Comme la valeur de session a changé, autorun a su qu'il devait executer son contenu une nouvelle fois, renvoyant la nouvelle valeur à la console.

Revenons à notre précédent exemple, si nous voulons déclencher une nouvelle alerte à chaque fois que la variable change, tout ce dont nous avons besoin est d'envelopper notre code d'un bloc autorun :

Tracker.autorun(function() {
  alert(Session.get('message'));
});

Comme nous venons de le voir, autorun peut être vraiment utile pour traquer les sources de données réactives et réagir.

Hot Code Reload

Durant notre développement de Microscope, nous avons profité d'une fonctionnalité de Meteor qui fait gagner du temps : hot code reload (HCR). Quand nous sauvegardons un de nos fichiers de code, Meteor détecte les changements et redémarre de façon transparente le serveur meteor en cours d'exécution, en informant chaque client de recharger la page.

C'est identique à un rechargement automatique de la page, mais avec une différence importante.

Pour trouver ce que c'est, commencer par réinitialiser la variable de session que nous avons utilisée :

 Session.set('pageTitle', 'A brand new title');
 Session.get('pageTitle');
'A brand new title'
Console du navigateur

Si nous devions recharger la fenêtre de notre navigateur manuellement, nos variables de session seraient naturellement perdues (vu que nous créerions une nouvelle session). D'un autre côté, si nous déclenchons un hot code reload (par exemple, en sauvegardant un de nos fichiers source) la page se rechargera, mais la variable de session sera encore initialisée. Essayez ça maintenant !

 Session.get('pageTitle');
'A brand new title'
Console du navigateur

Donc si nous utilisons des variables de session pour tracer exactement ce que l'utilisateur est en train de faire, le HCR devrait être transparent pour l'utilisateur, comme ça préserve la valeur de toutes les variables de session. Ceci nous permet de déployer en production des nouvelles versions de notre application Meteor avec l'assurance que nos utilisateurs seront dérangés de façon minimale.

Considérez ceci pour le moment. Si nous pouvons gérer de garder tous les états dans l'URL et la session, nous pouvons changer le code source en cours d'exécution de chaque client de l'application de manière transparente et avec un impact minimal.

Vérifions maintenant ce qu'il se passe quand nous rafraîchissons la page manuellement :

 Session.get('pageTitle');
null
Console du navigateur

Quand nous rechargeons la page, nous perdons la session. Dans un HCR, Meteor sauvegarde la session en local dans le navigateur et la recharge après le rechargement de la page. Cependant, le comportement alternatif d'un rechargement a du sens : si un utilisateur recharge la page, c'est comme s'il avait encore exploré la même URL, et il devrait être réinitialisé à l'état de départ que n'importe quel utilisateur devrait voir en visitant l'URL.

Les leçons importantes dans tout ça sont :

  1. Toujours stocker l'état utilisateur dans la Session ou dans l'URL afin que les utilisateurs ne soient pas dérangés lors du Hot Code Reload.
  2. Stockez chaque état que vous souhaitez mettre en commun entre les utilisateurs à l'intérieur de l'URL elle-même.

Cela conclut notre exploration de Session, une des fonctions les plus utiles de Meteor. N'oubliez pas d'annuler tous les changements de votre code avant de passer au chapitre suivant.

Ajouter des Utilisateurs

6

Depuis le début, nous avons appris à créer et afficher des données statiques brutes dans un style particulier et on a tout intégré dans un prototype simple.

Nous avons même vu comment notre UI est réactive aux changements de données, et les données insérées ou modifiées apparaissent immédiatement. Par contre, notre site est paralysé par le fait que nous ne pouvons pas insérer de données. En fait, nous n'avons même pas encore d'utilisateurs !

Voyons comment corriger ça.

Comptes: création d'utilisateurs faciles

Dans la plupart des frameworks web, ajouter des comptes utilisateurs est une fonctionnalité classique. Bien sur, vous devez faire ça dans presque tous les projets, mais ce n'est jamais aussi simple que ça le devrait. De plus, dès que vous devez interagir avec OAuth ou un systèmes d'authentification tiers, les choses ont tendance à se compliquer.

Heureusement, Meteor a tout prévu. Grâce à la façon dont les paquets Meteor peuvent contribuer au code côté serveur (JavaScript) et côté client (JavaScript, HTML et CSS), nous pouvons bénéficier d'un système de compte presque sans effort.

Nous pourrions simplement utiliser l'UI intégrée de Meteor pour les comptes (avec meteor add accounts-ui) mais comme nous avons développé toute notre application avec Bootstrap, nous utiliserons le paquet ian:accounts-ui-bootstrap-3 à la place (ne vous inquiétez pas, la seule différence est le style). En ligne de commande, nous tapons :

meteor add ian:accounts-ui-bootstrap-3
meteor add accounts-password
Terminal

Ces deux commandes nous donnent accès aux templates spéciaux des comptes, et nous pouvons les inclure dans notre site en utilisant le helper {{> loginButtons}}. Une astuce: vous pouvez contrôler de quel côté s'affiche la fenêtre d'authentification en utilisant l'attribut align (par exemple {{> loginButtons align="right"}}).

Nous ajouterons les boutons à notre en-tête. Et comme cet en-tête commence à grossir donnons lui un peu de place avec son propre template (nous le mettrons dans client/templates/includes/). Nous utilisons également des balises externes et des classes spécifiées par Bootstrap pour nous assurer que tout soit beau :

<template name="layout">
  <div class="container">
    {{>header}}
    <div id="main">
      {{> yield}}
    </div>
  </div>
</template>
client/templates/application/layout.html
<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 'postsList'}}">Microscope</a>
    </div>
    <div class="collapse navbar-collapse" id="navigation">
      <ul class="nav navbar-nav navbar-right">
        {{> loginButtons}}
      </ul>
    </div>
  </nav>
</template>
client/templates/includes/header.html

Maintenant, quand nous naviguons dans notre application, nous voyons les boutons d'authentification en haut à droite dans le coin du site.

Interface intégrée des comptes utilisateur de Meteor
Interface intégrée des comptes utilisateur de Meteor

On peut utiliser ça pour s'enregistrer, s'authentifier, changer son mot de passe, et tout ce qu'un simple site comportant des comptes utilisateur a besoin.

Pour indiquer au système de compte que nous voulons que les utilisateurs s'authentifient avec un nom d'utilisateur, nous ajoutons simplement un bloc de configuration Accounts.ui dans un nouveau fichier config.js à l'intérieur de client/helpers/ :

Accounts.ui.config({
  passwordSignupFields: 'USERNAME_ONLY'
});
client/helpers/config.js

Commit 6-1

Comptes ajoutés et template intégré à l'en-tête

Création de notre premier utilisateur

Enregistrez un compte : le bouton “Sign In” changera pour afficher votre nom d'utilisateur. Ça confirme que le compte utilisateur a bien été créé pour vous. Mais d'où viennent les données de ce compte ?

En ajoutant le paquet accounts, Meteor a créé une nouvelle collection spéciale, laquelle peut être accédée avec Meteor.users(). Pour la voir, ouvrez la console de votre navigateur et tapez :

 Meteor.users.findOne();
Console du navigateur

La console retournera un objet correspondant à votre objet utilisateur. Si vous regardez de plus près, vous pouvez voir que votre nom d'utilisateur est dedans, avec un _id unique qui vous identifie. Notez que vous pouvez également récupérer l'utilisateur actuellement connecté avec Meteor.user().

Maintenant déconnectez-vous et enregistrez-vous avec un compte utilisateur différent. Meteor.user() devrait retourner un second utilisateur. Mais attendez, exécutons :

 Meteor.users.find().count();
1
Console du navigateur

La console retourne 1. Ne devrait-il pas y en avoir 2 ? Le premier utilisateur a-t-il été supprimé ? SI vous essayez de vous authentifier avec le premier utilisateur, vous verrez que ce n'est pas le cas.

Assurons nous-en et vérifions dans la zone de stockage des données canonique, la base de données Mongo. On se connectera à Mongo (meteor mongo dans votre terminal) et on vérifiera :

> db.users.count()
2
Console Mongo

Il y a bien deux utilisateurs. Donc pourquoi ne pouvons-nous en voir qu'un dans le navigateur ?

Une publication mystère !

Si vous repensez au chapitre 4, vous pouvez vous souvenir qu'on a supprimé autopublish, nous avons arrêté l'envoi des données des collections du serveur vers chaque version local de collection des clients connectés. Nous avions besoin de créer un couple publication / souscription afin de faire transiter les données.

Mais nous n'avons pas encore établi de publication utilisateur. Donc d'où vient que nous pouvons quand même voir une donnée utilisateur ?

La réponse est que le paquet comptes auto-publie les détails de base du compte de l'utilisateur actuellement authentifié. S'il ne le faisait pas, l'utilisateur ne pourrait pas s'authentifier sur le site !

Le paquet comptes publie seulement l'utilisateur courant. Ceci explique pourquoi un utilisateur ne peut voir les détails d'un autre compte.

La publication publie donc seulement un objet utilisateur par utilisateur authentifié (et aucun si vous n'êtes pas authentifié).

De plus, les documents ne semblent pas avoir les mêmes champs sur le serveur et sur le client. Dans Mongo, un utilisateur a beaucoup de données. Pour les voir, retournez sur votre terminal Mongo et tapez :

> db.users.find()
{
    "_id": "H5kKyxtbkLhmPgtqs",
    "createdAt": ISODate("2015-02-10T08:26:48.196Z"),
    "profile": {},
    "services": {
        "password": {
            "bcrypt": "$2a$10$yGPywo3/53IHsdffdwe766roZviT03YBGltJ0UG"
        },
        "resume": {
            "loginTokens": [{
                "when": ISODate("2015-02-10T08:26:48.203Z"),
                "hashedToken": "npxGH7Rmkuxcv098wzz+qR0/jHl0EAGWr0D9ZpOw="
            }]
        }
    },
    "username": "sacha"
}
Console Mongo

Et de l'autre côté, dans le navigateur l'objet utilisateur est bien moins rempli, comme vous pouvez le voir en tapant la commande équivalente :

 Meteor.users.findOne();
Object {_id: "kYdBd9hr3fWPGPcii", username: "tmeasday"}
Console du navigateur

Cet exemple nous montre comment une collection local peut être un sous-ensemble sécurisée de la vrai base de données. L'utilisateur authentifié voit seulement les informations nécessaires à son bon fonctionnement (dans ce cas, l'authentification). C'est un cas utile pour apprendre, vous vous en apercevrez plus tard.

Ça ne veut pas dire que vous ne pourrez rendre publiques des données utilisateur si vous le souhaitez. Vous pouvez vous référer à la Documentation Meteor pour savoir comment vous pouvez publier plus de champs dans la collection Meteor.users.

La Réactivité

Sidebar 6.5

Si les collections sont la fonctionnalité principale de Meteor, la Réactivité est la coquille qui la rend utile.

Les Collections transforment radicalement la façon dont votre application traite les modifications de données. Plutôt que d'avoir à vérifier les modifications de données manuellement (par un appel AJAX, par exemple) et de les mettre à jour dans votre page HTML, Meteor applique automatiquement ces modifications à votre interface utilisateur de façon transparente.

Prenez un moment pour y penser : dans les coulisses, Meteor est capable de changer toute partie de votre interface utilisateur chaque fois qu'une collection sous-jacente est mise à jour.

La meilleure façon de parvenir à cela serait d'utiliser .observe(), une fonction de curseur qui déclenche des callbacks lorsque des documents correspondant à ce curseur changent. Nous pourrions alors faire des changements dans le DOM (le rendu HTML de notre page Web) à travers ces callbacks. Le code résultant ressemblerait à quelque chose comme ceci :

Posts.find().observe({
  added: function(post) {
    // quand le callback 'added' est déclenché, ajout de l'élément HTML
    $('ul').append('<li id="' + post._id + '">' + post.title + '</li>');
  },
  changed: function(post) {
    // quand le callback 'changed' est déclenché, modification du texte de l'élément HTML
    $('ul li#' + post._id).text(post.title);
  },
  removed: function(post) {
    // quand le callback 'removed' est déclenché, suppression de l'élément HTML
    $('ul li#' + post._id).remove();
  }
});

Vous pouvez probablement déjà voir comment le code va rapidement se complexifier. Imaginez comment traiter les modifications de chaque attribut de l'article, et devoir changer du HTML complexe à l'intérieur des <li> de l'article. Sans parler de tous les cas compliqués qui peuvent survenir quand nous commençons à gérer de multiples sources d'information qui peuvent toutes changer en temps réel.

Quand devrions-nous utiliser observe() ?

Utiliser le modèle ci-dessus est parfois nécessaire, spécialement quand on doit s'interfacer avec des gadgets tiers. Par exemple, imaginons que nous voulions ajouter ou supprimer sur une carte en temps réel des marqueurs basés sur des données d'une Collection (disons, pour afficher la localisation des utilisateurs authentifiés).

Dans certains cas, vous aurez besoin d'utiliser des callbacks observe() afin de faire discuter la carte avec la collection Meteor et savoir comment réagir avec les changements de données. Par exemple, vous pourriez utiliser les callbacks added et removed pour appeler les propres méthodes dropPin() et removePin() de l'API carte.

Une approche déclarative

Meteor nous fournit un meilleur outil : la réactivité, qui est dans sa structure une approche déclarative. Être déclaratif nous laisse définir la relation entre les objets une fois et savoir qu'ils resteront synchronisés, au lieu de devoir spécifier les comportements pour tous les changements potentiels.

Ceci est un concept puissant, parce qu'un système temps réel a beaucoup d'entrées qui peuvent changer à de façon imprévisible. En exposant déclarativement la façon dont nous affichons le HTML basé sur les sources de données réactives que nous observons, Meteor peut surveiller ces sources et accomplir de manière transparente ce travail de mise à jour permanente de l'interface utilisateur.

Tout ceci pour dire qu'au lieu de réfléchir sur des callbacks observe(), Meteor nous permet d'écrire :

<template name="postsList">
  <ul>
    {{#each posts}}
      <li>{{title}}</li>
    {{/each}}
  </ul>
</template>

Et ensuite récupérer notre liste d'articles avec :

Template.postsList.helpers({
  posts: function() {
    return Posts.find();
  }
});

En arrière-plan, Meteor déclenche des callbacks observe() pour nous, et redéssine les sections pertinentes du HTML quand les données réactives changent.

Surveillance de dépendance dans Meteor: Calculs (Computations)

Alors que Meteor est un framework temps réel, réactif, tout le code à l'intérieur d'une application Meteor n'est pas réactif. Si c'était le cas, votre application entière se rechargerait à chaque fois qu'il y a un changement. À la place, la réactivité est limitée à des zones spécifiques de votre code, et nous appellerons ces zones calculs.

En d'autres termes, un calcul est un bloc de code qui est exécuté à chaque fois qu'une des sources de données réactives dont il dépend change. Si vous avez une source de données réactive (par exemple, une variable de Session) et que vous aimeriez réagir de manière réactive, vous aurez besoin de mettre en place un calcul.

Notez qu'habituellement vous n'avez pas besoin de faire ceci parce que Meteor donne déjà à chaque template et helper qu'il affiche son propre calcul (ce qui signifie que vous pouvez être sûr que vos templates seront réactifs à afficher leurs données sources).

Chaque source de données réactive surveille tous les calculs qui l'utilisent pour qu'elle puisse les laisser savoir quand sa propre valeur change. Pour ce faire, il appelle la fonction invalidate() sur le calcul.

Les calculs sont généralement mis en place pour réévaluer simplement leurs contenus sur invalidation, et c'est ce qui arrive aux calculs de template (bien que les calculs de template font également la magie d'essayer et redessiner la page plus efficacement). Bien que nous pouvons avoir plus de contrôle sur ce que fait le calcul sur invalidation si vous en avez besoin, en pratique c'est presque toujours le comportement que vous utiliserez.

Mettre en place un Calcul

Maintenant que nous comprenons la théorie derrière les calculs, en mettre un en place semble beaucoup plus sensé. Nous pouvons utiliser la fonction Tracker.autorun pour enfermer un bloc de code dans un calcul et le rendre réactif :

Meteor.startup(function() {
  Tracker.autorun(function() {
    console.log('There are ' + Posts.find().count() + ' posts');
  });
});

Notez que nous avons besoin d'envelopper le bloc Tracker à l'intérieur d'un bloc Meteor.startup() pour nous assurer qu'il s'exécutera seulement une fois que Meteor a fini de charger la collection Posts.

En arrière-plan, autorun crée ensuite un calcul, et le déclenche pour réévaluer à chaque fois que la source de données dont il dépend change. Nous avons mis en place un calcul vraiment très simple qui journalise simplement le nombre d'articles à la console. Maintenant que Posts.find() est une source de données réactive, il prendra soin de dire au calcul de réévaluer à chaque fois que le nombre d'articles change.

> Posts.insert({title: 'New Post'});
There are 4 posts.

Le résultat net de tout ceci est que nous pouvons écrire du code qui utilise une donnée réactive de façon très naturelle, en sachant qu'en arrière-plan le système de dépendance prendra soin de le réexécuter juste au bon moment.

Créer des Posts

7

Nous avons vu combien il était facile de créer des posts en passant par la console, avec l'appel à la base de données Posts.insert. Mais nous ne pouvons pas attendre de nos utilisateurs qu'ils ouvrent la console pour créer un nouveau post.

À un moment, nous devrons créer une interface pour permettre à nos utilisateurs de poster de nouvelles histoires sur notre application.

Construire la page d'ajout de post

Nous commençons par définir une route pour notre nouvelle page :

Router.configure({
    layoutTemplate: 'layout',
    loadingTemplate: 'loading',
    notFoundTemplate: 'notFound',
    waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});

Router.route('/posts/:_id', {
    name: 'postPage',
    data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/submit', {name: 'postSubmit'});

Router.onBeforeAction('dataNotFound', {only: 'postPage'});
lib/router.js

Ajouter un lien à l'en-tête

Une fois cette route définie, nous pouvons maintenant ajouter un lien à notre page d'envoi dans notre 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 'postsList'}}">Microscope</a>
        </div>
        <div class="collapse navbar-collapse" id="navigation">
            <ul class="nav navbar-nav">
                <li><a href="{{pathFor 'postSubmit'}}">Créer un post</a></li>
            </ul>
            <ul class="nav navbar-nav navbar-right">
                {{> loginButtons}}
            </ul>
        </div>
    </nav>
</template>
client/templates/includes/header.html

Configurer notre route signifie que si un utilisateur se rend à l'URL /submit, Meteor affichera le template postSubmit. Écrivons donc ce template :

<template name="postSubmit">
    <form class="main form page">
        <div class="form-group">
          <label class="control-label" for="url">URL</label>
          <div class="controls">
              <input name="url" id="url" type="text" value="" placeholder="Votre URL" class="form-control"/>
            </div>
        </div>
      <div class="form-group">
          <label class="control-label" for="title">Title</label>
          <div class="controls">
              <input name="title" id="title" type="text" value="" placeholder="Nommez votre article" class="form-control"/>
            </div>
        </div>
      <input type="submit" value="Submit" class="btn btn-primary"/>
    </form>
</template>
client/templates/posts/post_submit.html

Note : ça fait beaucoup de code, mais ça vient simplement du fait que nous utilisons Twitter Bootstrap. Bien que seuls les éléments de formulaire soient essentiels, les balises supplémentaires aideront à rendre notre appli un peu plus jolie. Cela devrait maintenant ressembler à ça :

Le formulaire de soumission d'article
Le formulaire de soumission d'article

C'est un formulaire simple. Nous n'avons pas à nous inquiéter de lui ajouter un champ ‘action’, puisque nous allons intercepter les événements sur le formulaire et mettre à jour les données en passant par JavaScript. Il n'y aurait pas de sens à fournir une solution alternative sans JavaScript quand on considère qu'une appli Meteor ne fonctionne plus du tout si JavaScript est désactivé.

Créer des posts

Lions un gestionnaire d'événement à l'événement submit du formulaire. Il est préférable d'utiliser l'événement submit (plutôt qu'un événement click sur le bouton par exemple), car cela permettra de prendre en compte toutes les façons possibles d'envoyer le formulaire (comme appuyer sur entrée par exemple).

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()
    };

    post._id = Posts.insert(post);
    Router.go('postPage', post);
  }
});
client/templates/posts/post_submit.js

Commit 7-1

Avec une page d'ajout de post et un lien vers celle-ci da…

Cette fonction utilise jQuery pour vérifier les valeurs de nos divers champs de formulaire et construire un nouvel objet post à partir des résultats. Nous devons nous assurer d'utiliser preventDefault sur l'argument event de notre gestionnaire pour être sûr que le navigateur n'essaie pas de prendre les devants en essayant d'envoyer le formulaire.

Finalement, nous pouvons router l'utilisateur vers la page de son nouveau post. La fonction insert() (utilisée sur une collection), renvoie l’id généré pour l'objet qui vient d'être inséré dans la base de données. La fonction go() du Router va utiliser cet id pour construire l'URL correspondante, et nous amener sur la bonne page.

Ainsi, l'utilisateur envoie le formulaire, un nouveau post est créé, et l'utilisateur est instantanément dirigé vers la page de discussion de ce nouveau post.

Ajouter un peu de sécurité

Créer des posts est très bien, mais nous ne voulons pas laisser n'importe quel visiteur le faire : ils doivent être authentifiés pour pouvoir poster. Bien sûr, nous pouvons commencer en cachant la page d'ajout de post pour les utilisateurs non authentifiés. Cependant un utilisateur pourrait créer un post depuis la console du navigateur sans être authentifié, et nous ne pouvons pas nous le permettre.

Heureusement la sécurité des données est incluse dans les collections Meteor. Elle est désactivée par défaut lorsque vous créez un nouveau projet. Cela vous permet de commencer facilement à créer votre application tout en laissant les trucs ennuyeux pour plus tard.

Notre appli n'a plus besoin de ces petites roues, alors enlevons-les ! Supprimons le paquet insecure :

meteor remove insecure
Terminal

Après avoir fait ça, vous remarquerez que le formulaire de post ne fonctionne plus. C'est parce que sans le paquet insecure, les insert() côté client sur la collection des posts ne sont plus autorisés.

Nous devons soit donner quelques règles explicites à Meteor pour définir quand un client est autorisé à insérer un post, soit faire nos insertions côté serveur.

Autoriser les insertions de post

Pour commencer, nous allons voir comment autoriser les insertions de post côté client pour que notre formulaire fonctionne à nouveau. Il s'avère que nous finirons par adopter une technique différente, mais pour le moment, le simple code suivant permettra que les choses fonctionnent à nouveau :

Posts = new Meteor.Collection('posts');

Posts.allow({
  insert: function(userId, doc) {
    // autoriser les posts seulement si l'utilisateur est authentifié
    return !! userId;
  }
 });
collections/posts.js

Commit 7-2

Sans Insecure et avec des règles de modifications sur les…

Nous appelons Posts.allow, qui dit à Meteor que “ceci est un ensemble de circonstances dans lesquelles les clients sont autorisés à faire des choses à la collection Post”. Dans notre cas, nous disons “les clients peuvent insérer des posts du moment qu'ils ont un userId”.

Le userId de l'utilisateur procédant à la modification est passé lors des appels à allow et deny (ou renvoie null si l'utilisateur n'est pas authentifié), ce qui est presque toujours utile. Et comme les comptes utilisateurs sont liés au noyau de Meteor, nous pouvons compter sur userId pour être toujours fiable.

Nous avons réussi à nous assurer que l'on doit être authentifié pour créer un post. Essayez de vous déconnecter et de créer un post ; vous devriez voir ceci dans votre console :

Échec de l'insertion : Accès refusé
Échec de l'insertion : Accès refusé

Cependant, nous avons encore à nous occuper de quelques problèmes :

  • Les utilisateurs non authentifiés peuvent toujours accéder au formulaire de création de post.
  • Le post n'est pas lié à l'utilisateur de quelque façon que ce soit (et il n'y a pas de code côté serveur pour s'occuper de ça).
  • Plusieurs posts peuvent être créés pointant vers la même URL.

Résolvons ces problèmes.

Sécuriser l'accès au nouveau formulaire

Commençons par éviter l'accès des utilisateurs non authentifiés au formulaire d'ajout de post. Nous le ferons au niveau du routeur, en définissant un route hook.

Un hook (“crochet” en français) intercepte le processus de routage et change potentiellement l'action prise par le routeur. Vous pouvez l'imaginer comme un garde de sécurité qui vérifie vos identifiants avant de vous laisser entrer (ou de vous refuser l'accès).

Nous avons besoin de vérifier si l'utilisateur est authentifié, et sinon afficher le template accessDenied au lieu du postSubmit attendu (puis nous stoppons le routeur ici, il n'a rien d'autre à faire). Modifions donc router.js :

Router.configure({
    layoutTemplate: 'layout',
    loadingTemplate: 'loading',
    notFoundTemplate: 'notFound',
    waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});

Router.route('/posts/:_id', {
    name: 'postPage',
    data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/submit', {name: 'postSubmit'});

var requireLogin = function() {
    if (! Meteor.user()) {
        this.render('accessDenied');
    } else {
        this.next();
    }
}

Router.onBeforeAction('dataNotFound', {only: 'postPage'});
Router.onBeforeAction(requireLogin, {only: 'postSubmit'});
lib/router.js

Nous créons aussi le template pour la page 'accès refusé’, accessDenied :

<template name="accessDenied">
    <div class="access-denied page jumbotron">
        <h2>Accès refusé</h2>
        <p>Vous ne pouvez pas accéder à cette page ! Veuillez vous connecter.</p>
    </div>
</template>
client/templates/includes/access_denied.html

Commit 7-3

Accès à la page d'ajout de post refusé lorsque non authen…

Maintenant, si vous allez à http://localhost:3000/submit/ sans être authentifié, vous devriez voir ceci :

Le template d'accès refusé
Le template d'accès refusé

Ce qui est pratique à propos des hooks de routage, c'est qu'ils sont eux aussi réactifs. C'est-à-dire que nous n'avons pas besoin de penser à des callbacks quand l'utilisateur s'authentifie : quand l'état d'authentification de l'utilisateur change, le template utilisé par le routeur change instantanément de accessDenied à postSubmit sans que nous ayons rien à écrire d'explicite pour le gérer (et cela fonctionne d'ailleurs pour tous les onglets).

Authentifiez-vous, puis essayer de rafraîchir la page. Vous verrez peut-être parfois le template accessDenied s'afficher brièvement avant que la page d'ajout de post apparaisse. La raison en est que Meteor commence à afficher les templates dès que possible, avant même qu'il ait conversé avec le serveur est vérifié si l'utilisateur actuel (stocké dans la mémoire locale du navigateur) existe réellement.

Pour éviter ce problème (qui est courant et que vous rencontrerez d'autant plus que vous allez vous pencher sur les subtilités de latence entre client et serveur), nous allons simplement afficher un écran de chargement pendant que nous attendons de savoir si l'utilisateur est authentifié ou pas.

Après tout, à cet instant nous ne savons pas si l'utilisateur a les identifiants corrects, et nous ne pouvons afficher aucun des templates accessDenied ou postSubmit avant de le savoir.

Nous modifions donc notre hook pour utiliser notre template de chargement pendant que Meteor.loggingIn() est vrai :

//...

var requireLogin = function() {
    if (! Meteor.user()) {
        if (Meteor.loggingIn()) {
            this.render(this.loadingTemplate);
        } else {
            this.render('accessDenied');
        }
    } else {
        this.next();
    }
}

Router.onBeforeAction('dataNotFound', {only: 'postPage'});
Router.onBeforeAction(requireLogin, {only: 'postSubmit'});
lib/router.js

Commit 7-4

Afficher un écran de chargement pendant l'authentification.

Cacher le lien

La façon la plus simple d'éviter aux utilisateurs d'essayer d'atteindre cette page par erreur lorsqu'ils ne sont pas connectés est de leur cacher le lien. Nous pouvons faire cela facilement :

//...

<ul class="nav navbar-nav">
    {{#if currentUser}}<li><a href="{{pathFor 'postSubmit'}}">Créer un post</a></li>{{/if}}
</ul>

//...
client/templates/includes/header.html

Commit 7-5

Afficher le lien d'ajout de post seulement si authentifié.

Le helper currentUser est fourni par le paquet accounts et est l'équivalent Spacebars de Meteor.user(). Puisqu'il est réactif, le lien apparaîtra ou disparaîtra selon que vous vous connectez ou déconnectez de l'application.

Meteor Method : une meilleure abstraction et sécurité

Nous avons réussi à sécuriser l'accès à la page d'ajout de post pour les utilisateurs non authentifiés, et éviter qu'ils puissent créer des posts même s'ils trichent en utilisant la console. Nous avons cependant encore plusieurs choses à faire :

  • Marquer la date du post avec un timestamp
  • S'assurer que la même URL ne peut pas être postée plus d'une fois
  • Ajouter des détails à propos de l'auteur du post (ID, nom d'utilisateur, etc.)

Vous pourriez penser que nous pouvons faire tout cela dans notre gestionnaire de l'événement submit. Cependant, en réalité, nous arriverions vite à plusieurs problèmes :

  • Pour le timestamp, nous aurions à espérer que l'heure soit correcte sur l'ordinateur de l'utilisateur, ce qui ne sera pas toujours le cas
  • Le côté client ne sera pas au courant de toutes les URL postées sur le site. Il connaîtra seulement les posts que l'utilisateur peut actuellement voir (nous verrons plus tard comment ceci fonctionne exactement). Il n'y a donc pas de moyen d'assurer l'unicité des URL du côté client.
  • Enfin, même si nous pourrions ajouter les détails utilisateurs côté client, nous ne nous assurerions pas de leur exactitude, ce qui pourrait exposer notre appli à une exploitation par des gens utilisant la console.

Pour toutes ces raisons, il vaut mieux garder nos gestionnaires d'événements simples. Et si nous faisons plus que les insertions et mises à jour les plus basiques, utilisons plutôt une Méthode.

Une Méthode Meteor est une fonction côté serveur qui est appelée côté client. Elles ne nous sont pas totalement étrangères – en fait, en coulisses, les fonctions insert, update, et remove de Collection sont toutes des Méthodes. Voyons comment créer la nôtre.

Revenons à post_submit.js. Plutôt que d'insérer directement dans la collection Posts, nous allons appeler une Méthode nommée postInsert :

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) {
            // affiche l'erreur à l'utilisateur et s'interrompt
            if (error)
                return alert(error.reason);

            Router.go('postPage', {_id: result._id});
        });
    }
});
client/templates/posts/post_submit.js

La fonction Meteor.call appelle une Methode nommée par son premier argument. Vous pouvez fournir des arguments pour l'appel (ici, l'objet post construit depuis le formulaire), et finalement y attacher un callback, qui sera executé quand la Methode côté serveur sera terminé.

Les callbacks des méthodes meteor ont toujours deux arguments, error et result. Si pour une raison ou une autre, l'argument error existe, nous alerterons l'utilisateur (en utilisant return pour interrompre le callback). Si tout fonctionne normalement, nous redirigerons l'utilisateur vers la page de discussion du post fraîchement créée.

Test de sécurité

Nous allons profiter de cette opportunité pour sécuriser notre méthode un peu plus en utilisant le paquet audit-argument-checks.

Ce paquet vous permet de tester n'importe quel objet JavaScript selon un schéma prédéfini. Dans notre cas, nous l'utiliserons pour tester que l'utilisateur qui utilise la méthode est bien authentifié (en nous assurant que Meteor.userId() est une chaîne (String), et que l'objet postAttributes passé en tant qu'argument à la méthode contient les chaînes title et url, pour ne pas nous retrouver à entrer des morceaux aléatoires de données dans notre base de données.

Définissons donc la méthode postInsert dans notre fichier collections/posts.js. Nous supprimerons le bloc allow() de posts.js puisque de toute façon les méthodes Meteor les ignorent.

Nous allons ensuite étendre (extend) l'objet postAttributes avec trois nouvelles propriétés : l’_id et le nom (username) de l'utilisateur, ainsi que l'horodatage du post soumis (submitted), avant d'insérer le tout dans notre base de donnée et de retourner l’_id final au client (en d'autres mots, celui à l'origine de l'appel de cette méthode) dans un objet JavaScript.

Posts = new Mongo.Collection('posts');

Meteor.methods({
    postInsert: function(postAttributes) {
        check(Meteor.userId(), String);
        check(postAttributes, {
            title: String,
            url: String
        });

        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

Notez que la méthode _.extend() fait partie de la librairie Underscore, et qu'elle vous permet simplement d’“étendre” un objet avec les propriétés d'un autre.

Commit 7-6

En utilisant une méthode pour soumettre le post.

Bye Bye Allow/Deny

Les méthodes Meteor sont exécutées sur le serveur, et donc Meteor leur fait confiance. C'est pourquoi les méthodes Meteor ignorent n'importe quel callback allow/deny.

Si vous voulez exécuter du code avant chaque insert, update, ou remove même sur le serveur, nous vous suggérons de jeter un œil au paquet collection-hooks.

Éviter les doublons

Nous ferons un dernier test avant d'intégrer notre méthode. Si un post avec la même url a déjà été créé auparavant, nous n'ajouterons pas le lien une nouvelle fois, mais au lieu de cela nous allons rediriger l'utilisateur vers ce post.

Meteor.methods({
    postInsert: function(postAttributes) {
        check(this.userId, String);
        check(postAttributes, {
            title: String,
            url: String
        });

        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

Nous cherchons dans notre base de données un post avec la même url. Si nous en trouvons un, nous retournons (return) l’_id de ce posts avec la propriété postExists:true pour informer le client de ce cette situation particulière.

Et puisque nous déclenchons un appel à return, la méthode s'interrompt à ce moment sans exécuter la déclaration insert, évitant ainsi avec élégance de créer des doublons.

Tout ce qui nous reste à faire est d'utiliser cette nouvelle information postExists dans notre helper d'événement côté client pour afficher un message d'avertissement :

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) {
            // affiche l'erreur à l'utilisateur et s'interrompt
            if (error)
                return alert(error.reason);

            // affiche ce résultat mais route tout de même
            if (result.postExists)
                alert('Ce lien a déjà été utilisé');

            Router.go('postPage', {_id: result._id});
        });
    }
});
client/templates/posts/post_submit.js

Commit 7-7

S'assurer de l'unicité de l'url du post.

Trier les posts

Maintenant que nous avons une date de création sur tous nos posts, il semble bienvenu de s'assurer qu'ils soient classés en fonction de cet attribut. Pour cela, nous pouvons simplement utiliser l'opérateur sort de Mongo, qui attend un objet constitué des clés par lesquelles trier et un signe indiquant si le tri doit être croissant ou décroissant.

Template.postsList.helpers({
    posts: function() {
        return Posts.find({}, {sort: {submitted: -1}});
    }
});
client/templates/posts/posts_list.js

Commit 7-8

Tri des posts par date de création.

Ça a demandé un peu de travail, mais nous avons maintenant une interface utilisateur qui permet d'ajouter du contenu de façon sécurisée à notre appli !

Mais toute application qui permet à ses utilisateurs de créer du contenu doit aussi leur donner la possibilité de le modifier ou de le supprimer. Ce sera le sujet du prochain chapitre.

La Compensation de Latence

Sidebar 7.5

Dans le dernier chapitre, nous avons présenté un nouveau concept du monde Meteor : les Méthodes.

Sans compensation de latence
Sans compensation de latence

Une Méthode Meteor est un moyen d'exécuter une série de commandes sur le serveur d'une façon structurée. Dans notre exemple, nous avons utilisé une Méthode car nous voulions nous assurer que les nouveaux articles seraient tagués avec le nom et l'id de leur auteur ainsi que l'heure du serveur.

Cependant, si Meteor exécute des Methodes dans sa plus simple façon, nous aurions un problème. Considérez la séquence suivante d’événements (note : les horodatages sont des valeurs aléatoires choisies seulement pour l'exemple) :

  • +0ms: L'utilisateur clique sur un bouton et le navigateur renvoie un appel de Méthode.
  • +200ms: Le serveur fait des changements dans la base de données Mongo
  • +500ms: Le client reçoit ces changements, met à jour l'interface utilisateur.

Si Meteor opérait de cette façon, il y aurait alors un petit décalage entre la réalisation de ces actions et l'affichage des résultats (ce décalage étant plus ou moins visible selon votre proximité avec le serveur). Nous ne pouvons nous le permettre dans une application web moderne !

Compensation de latence

Avec compensation de latence
Avec compensation de latence

Pour éviter ce problème, Meteor introduit un concept appelé Compensation de latence. Quand nous avons définit notre Méthode post, nous l'avons placée à l'intérieur d'un fichier dans le répertoire collections/. Ceci signifie qu'elle est disponible pour le serveur et le client – et elle s'exécutera sur les deux en même temps !

Quand nous faisons un appel de Méthode, le client envoie l'appel au serveur, mais simule également simultanément l'action de la Méthode sur ses collections côté client. Notre workflow devient :

  • +0ms: L'utilisateur clique sur un bouton et le navigateur renvoie un appel de Méthode.
  • +0ms: Le client simule l'action de l'appel de Méthode sur les collections côté client et met à jour l'interface utilisateur pour refléter ceci.
  • +200ms: Le serveur fait les changements dans la base de données Mongo.
  • +500ms: Le client reçoit ces changements et annule ses changements simulés, en les remplaçant par les changements du serveur (qui sont généralement les mêmes). L'interface utilisateur change pour refléter ceci.

Le résultat pour l'utilisateur est de voir les changements instantanément. Quand la réponse du serveur revient un peu plus tard, il peut y avoir (ou non) des changements visibles à mesure que les documents canoniques du serveur arrivent. Une chose à retenir est donc que nous devons nous assurer que nous simulons les documents aussi proches de la réalité que possible.

Observer une compensation de latence

Nous pouvons faire un petit changement à l'appel de la méthode post pour voir cela en action. Pour ce faire, nous utiliserons la fonction bien pratique Meteor._sleepForMs() pour retarder l'appel de la méthode de 5 secondes, mais (c'est un point crucial) seulement sur le serveur.

Nous utiliserons isServer pour demander à Meteor si la Méthode est actuellement invoquée sur le client (comme une “ébauche”) ou sur le serveur. Une ébauche est la simulation d'une Méthode que Meteor exécute sur le client en parallèle, pendant que la “vraie” Méthode est en cours d'exécution sur le serveur.

Nous allons donc demander à Meteor si le code est en cours d'exécution sur le serveur. Si c'est le cas, nous allons retarder l'avancement des choses de 5 secondes et ajouter la chaîne de caractère (server) à la fin du titre de notre article. Sinon, nous ajouterons la chaîne de caractère (client) :

Posts = new Mongo.Collection('posts');

Meteor.methods({
    postInsert: function(postAttributes) {
        check(this.userId, String);
        check(postAttributes, {
            title: String,
            url: String
        });

      if (Meteor.isServer) {
            postAttributes.title += postAttributes.title + "(server)";
            // attente de 5 secondes
            Meteor._sleepForMs(5000);
        } else {
            postAttributes.title += "(client)";
        }

        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
        };
    }
});
collections/posts.js

Si nous nous arrêtions ici, la démonstration ne serait pas concluante. Dans l'état des choses, on dirait que le formulaire de soumission d'article se met en pause pendant 5 secondes avant de rediriger vers la liste des posts principaux, et pas grand chose d'autre se passe.

Pour comprendre cela, revenons au manager de l'événement de soumission de post :

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) {
        // affiche l'erreur à l'utilisateur et s'interrompt
          if (error)
              return alert(error.reason);

          // affiche ce résultat mais 'route' tout de même
            if (result.postExists)
                alert('This link has already been posted');

            Router.go('postPage', {_id: result._id});
        });
    }
});
client/templates/posts/post_submit.js

Nous avons placé notre appel de routeur Router.go() à l'intérieur du callback de l'appel de la méthode. Ce qui signifie que le formulaire attend que cette méthode réussisse avant de rediriger.

C'est normalement la bonne manière de faire les choses. Après tout, vous ne pouvez pas rediriger l'utilisateur avant de savoir si la soumission de son post est valide ou pas, parce que ce serait extrêmement déroutant d'être redirigé une première fois et ensuite d'être redirigé une nouvelle fois, en arrière, vers la page de soumission de post pour corriger vos données, tout cela en quelques secondes.

Mais pour le bien de cet exemple, nous voulons voir les résultats de nos actions immédiatement. Ainsi, nous allons changer l'appel du routeur pour rediriger vers la route postsList (nous ne pouvons pas diriger vers le post car nous ne connaissons pas son _id en dehors de la méthode), pour ensuite l'extraire du callback, et voir ce qui se passe :

Template.postSubmit.events({
  'submit form': function(event) {
    event.preventDefault();

    var post = {
        url: $(e.target).find('[name=url]').val(),
        title: $(e.target).find('[name=title]').val()
    };

    Meteor.call('postInsert', post, function(error, result) {
        // affiche l'erreur à l'utilisateur et s'interrompt
      if (error)
        return alert(error.reason);

      // affiche se résultat mais 'route' tout de même
      if (result.postExists)
        alert('This link has already been posted');
    });

    Router.go('postsList');

  }
});
client/templates/posts/post_submit.js

Commit 7-5-1

Démonstration de l'ordre d'apparition des articles en uti…

Si nous créons un article maintenant, nous verrons clairement la compensation de latence en action. Premièrement, un article est inséré avec (client) dans le titre (le premier article dans la liste, relié à Github) :

Notre article comme initialement stocké dans la collection côté client
Notre article comme initialement stocké dans la collection côté client

Ensuite, cinq secondes plus tard, il est proprement remplacé avec le vrai document qui a été inséré par le serveur :

Notre article une fois que le client reçoit la mise à jour de la collection côté serveur
Notre article une fois que le client reçoit la mise à jour de la collection côté serveur

Méthodes des collections sur le client

Vous pourriez penser que les Méthodes sont compliquées après cela, mais en fait elles peuvent être plutôt simples. En fait, nous avons déjà vu trois méthodes très simples : les Méthodes de mutation de collection, insert, update et remove.

Quand vous définissez une collection serveur appelée 'posts', vous êtes implicitement en train de définir trois Méthodes : posts/insert, posts/update et posts/delete. En d'autres mots, quand vous appelez Posts.insert() sur votre collection client, vous appelez une Méthode de compensation de latence qui fait deux choses :

  1. Des vérifications pour voir si vous faites la mutation en appelant des callbacks allow et deny (cependant il n'est pas nécessaire que cela arrive dans la simulation).
  2. La modification vers le stockage de données sous-jacent.

Des méthodes qui appellent des Méthodes

Si vous suivez bien, vous venez peut-être juste de réaliser que notre Méthode post appelle une autre Méthode (posts/insert) quand nous insérons notre article. Comment ça marche ?

Quand la simulation (version côté client de la Méthode) est en cours d'exécution, nous effectuons la simulation d'un insert (nous insérons donc dans notre collection cliente), mais nous ne faisons pas le vrai, insert côté serveur, nous attendons que la version côté serveur de post le fasse.

Par conséquent, quand la Méthode post côté serveur appelle insert il n'y a pas besoin de s'inquiéter de la simulation, et l'insertion continue sans encombre.

N'oubliez pas comme précédemment de supprimer les changements effectués au cours de ce chapitre avant de passer au suivant.

Editer des Posts

8

Maintenant que nous pouvons créer des posts, la prochaine étape est de pouvoir les modifier et les supprimer. Puisque l'interface est simple à mettre en œuvre, c'est le moment idéal de voir comment Meteor gère les permissions des utilisateurs.

Voyons d'abord le router. Nous lui ajoutons une route afin d'accéder à la page de modification des posts et lui donnons des données :

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});

Router.route('/posts/:_id', {
  name: 'postPage',
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/posts/:_id/edit', {
  name: 'postEdit',
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/submit', {name: 'postSubmit'});

var requireLogin = function() {
  if (! Meteor.user()) {
    if (Meteor.loggingIn()) {
      this.render(this.loadingTemplate);
    } else {
      this.render('accessDenied');
    }
  } else {
    this.next();
  }
}

Router.onBeforeAction('dataNotFound', {only: 'postPage'});
Router.onBeforeAction(requireLogin, {only: 'postSubmit'});
lib/router.js

La template de modification des posts

Concentrons nous maintenant sur la template. Notre template postEdit reste très classique :

<template name="postEdit">
  <form class="main form page">
    <div class="form-group">
      <label class="control-label" for="url">URL</label>
      <div class="controls">
          <input name="url" id="url" type="text" value="{{url}}" placeholder="Votre URL" class="form-control"/>
      </div>
    </div>
    <div class="form-group">
      <label class="control-label" for="title">Title</label>
      <div class="controls">
        <input name="title" id="title" type="text" value="{{title}}" placeholder="Nommez votre post" class="form-control"/>
      </div>
    </div>
    <input type="submit" value="Submit" class="btn btn-primary submit"/>
    <hr/>
    <a class="btn btn-danger delete" href="#">Supprimer le post</a>
  </form>
</template>
client/templates/posts/post_edit.html

Et voilà le fichier post_edit.js qui va avec :

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) {
        // affiche l'erreur à l'utilisateur
        alert(error.reason);
      } else {
        Router.go('postPage', {_id: currentPostId});
      }
    });
  },

  'click .delete': function(e) {
    e.preventDefault();

    if (confirm("Delete this post?")) {
      var currentPostId = this._id;
      Posts.remove(currentPostId);
      Router.go('postsList');
    }
  }
});
client/templates/posts/post_edit.js

Normalement la majeure partie de ce code devrait vous être familier à présent.

Deux événements sont présents dans notre template : l'un pour l'événement submit du formulaire et l'autre pour l'événement click du lien de suppression du post.

L'événement de suppression est très simple : on empêche l'activation des événements par défaut et on demande une confirmation à l'utilisateur. Enfin si on l'obtient, on récupère l'ID du post actuel depuis les informations de la template, on supprime le post et on redirige l'utilisateur sur l'accueil.

L'événement de mise à jour du post est un peu plus long, mais pas plus compliqué. Après avoir, une fois de plus, empêché l'activation des événements classiques (lors de la soumission du formulaire) et récupéré l'ID du post concerné, on récupère les valeurs du formulaire depuis la page et on les sauvegarde dans l'objet postProperties.

Nous passons alors cet objet à la méthode Collection.update() de Meteor avec l'opérateur $set (qui remplace un ensemble de champs) et utilisons un callback pour afficher une erreur si la mise à jour est un échec ou renvoie l'utilisateur sur le post concerné si la mise à jour est un succès.

Ajouter des liens

Nous devons bien évidemment rajouter un lien pour que les utilisateurs puissent modifier leurs posts :

<template name="postItem">
  <div class="post">
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
      <p>
        submitted by {{author}}
        {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
      </p>
    </div>
    <a href="{{pathFor 'postPage'}}" class="discuss btn btn-default">Discuter</a>
  </div>
</template>
client/templates/posts/post_item.html

De plus, nous ne devons pas afficher ce lien à n'importe qui. C'est pour cela que nous rajoutons un helper ownPost :

Template.postItem.helpers({
  ownPost: function() {
    return this.userId === Meteor.userId();
  },
  domain: function() {
    var a = document.createElement('a');
    a.href = this.url;
    return a.hostname;
  }
});
client/templates/posts/post_item.js
Formulaire d'édition
Formulaire d'édition

Commit 8-1

Ajout des formulaire d'édition.

Notre formulaire pour modifier les posts parait correct, pourtant vous ne pourrez pas les modifier tout de suite. Que se passe-t-il ?

Mettre en place les permissions

Depuis que nous avons supprimé le paquet insecure, toutes les requêtes de modifications provenant du client sont catégoriquement refusées.

Pour régler cela, nous devons fixer des permissions. Pour commencer, créez un nouveau fichier permissions.js dans le dossier lib. Nous serons ainsi sûr que nos permissions seront chargées en premier (et disponible dans les deux environnements) :

// check that the userId specified owns the documents
ownsDocument = function(userId, doc) {
  return doc && doc.userId === userId;
}
lib/permissions.js

Dans le chapitre Créer des Posts, nous n'avions pas utilisé la méthode allow() car nous insérions les nouveaux posts via des méthodes côté serveur (qui passent outre allow()).

Mais maintenant que nous éditons et supprimons des posts depuis le client, retournons dans le fichier posts.js et rajoutons la fameuse méthode allow() :

Posts = new Mongo.Collection('posts');

Posts.allow({
  update: function(userId, post) { return ownsDocument(userId, post); },
  remove: function(userId, post) { return ownsDocument(userId, post); },
});

//...
lib/collections/posts.js

Commit 8-2

Ajout de permissions basiques pour vérifier le propriétai…

Limiter les éditions

Ce n'est pas parce que vous pouvez éditer vos propres posts que vous devez être capable d'éditer toutes les propriétés. Par exemple, nous ne voulons pas que l'utilisateur crée un post et l'assigne à quelqu'un d'autre.

Donc nous allons utiliser le callback deny() pour permettre à l'utilisateur d'éditer seulement certains champs :

Posts = new Mongo.Collection('posts');

Posts.allow({
  update: function(userId, post) { return ownsDocument(userId, post); },
  remove: function(userId, post) { return ownsDocument(userId, post); },
});

Posts.deny({
  update: function(userId, post, fieldNames) {
    // may only edit the following two fields:
    return (_.without(fieldNames, 'url', 'title').length > 0);
  }
});

//...
lib/collections/posts.js

Commit 8-3

Accepter le changement de seulement certain champs.

Nous transmettons le tableau fieldNames qui contient la liste des champs modifiés et en utilisant la fonction without() d’Underscore nous obtenons un tableau qui contient les champs qui ne sont pas url ou title.

Si tout se passe bien, le tableau sera vide et sa taille devra être de 0. Si quelqu'un essaie de jouer un peu avec le code, la taille du tableau vaudra 1 ou plus, et le callback retournera true (ce qui empêchera la mise à jour).

Vous aurez peut-être remarqué que nous ne vérifions nulle part dans notre code de modification des posts la présence de liens dupliqués. Cela veut dire qu'un utilisateur pourrait soumettre un lien et l'éditer afin de changer l'URL pour passer outre la vérification. La solution à ce problème serait d'utiliser une méthode de Meteor (Meteor.methods()) pour modifier les posts, mais nous avons voulu vous montrer cela pour le principe et vous exercer.

Les appels de méthode vs la manipulation de données côté client

Pour créer des posts, nous avons utilisé une méthode Meteor postInsert, par contre pour les modifier et les supprimer nous appelons update et remove directement depuis le client en utilisant allow et deny pour sécuriser les transactions de données.

Quand utiliser l'une ou l'autre méthode ?

Lorsque les choses sont relativement simple et que vous pouvez rapidement adapter votre sécurité avec allow et deny, il est plus simple de faire les opérations directement depuis le client.

Par contre, à partir du moment où vous devez faire des choses qui ne doivent pas être contrôlé par l'utilisateur (comme dater un nouveau post ou l'assigner au bon utilisateur), vous devriez utiliser une méthode Meteor Meteor.methods.

Les méthodes Meteor sont aussi plus adaptés dans certains cas :

  • Quand vous devez connaître ou renvoyer des valeurs via un callback plutôt que d'attendre que la réactivité et la synchronisation prennent effet.
  • Pour les fonctions opérant de grosses manipulations sur la base de données qui seraient trop lourdes à transmettre entre le client et le serveur.
  • Pour des calculs sur la base de données (exemple : count, average, sum).

Jetez un œil à notre blog pour une exploration plus en détail de ce sujet.

Allow et Deny

Sidebar 8.5

Le système de sécurité de Meteor nous permet de contrôler les modifications de la base de données sans avoir à définir des Méthodes à chaque fois que l'on fait des changements.

Parce que nous avions besoin de réaliser des tâches auxiliaires comme décorer l'article avec des propriétés supplémentaires et décider d'une action spéciale quand l'URL de l'article était déjà été postée, utiliser une Méthode post spécifique quand un article est créé était tout à fait sensé.

D'un autre côté, nous n'avions pas vraiment eu besoin de créer de nouvelles Méthodes pour mettre à jour ou supprimer des articles. Nous avions juste besoin de vérifier si l'utilisateur avait la permission de réaliser ces actions, et cela fut facilité par les callbacks allow et deny.

Utiliser ces callbacks nous laisse être plus déclaratif à propos des modifications de la base de données, et dire quels types de mises à jour peuvent être utilisées. Le fait qu'ils s'intègrent avec le système de comptes est un bonus.

Multiples callbacks

Nous pouvons définir autant de callbacks allow que nécessaires. Nous avons juste besoin qu’au moins l'un d'entre eux retourne true durant le changement qui est en train de s'opérer. Donc quand Posts.insert est appelé dans un navigateur (peu importe si c'est depuis le code côté client de votre application ou depuis la console), le serveur va faire à son tour toutes les vérifications allowed-insert qu'il peut jusqu'à ce qu'il en trouve une qui retourne true. S'il n'en trouve aucune, il n'autorisera pas le insert, et retournera une erreur 403 au client.

De la même façon, nous pouvons définir un ou plusieurs callbacks deny. Si un de ces callbacks retourne true, le changement sera annulé et une erreur 403 sera retourné. La logique de tout cela signifie que pour un insert réussi, un ou plusieurs callbacks allow insert aussi bien que chaque callback deny insert seront exécutés.

Note: n/e est l'abréviation de Not Executed
Note: n/e est l'abréviation de Not Executed

En d'autres mots, Meteor descend la liste de callback en partant du premier avec deny, puis avec allow, et exécute chaque callback jusqu'à ce que l'un d'entre eux retourne true.

Un exemple pratique de ce pattern serait d'avoir deux callbacks allow(), l'un qui vérifie si un article appartient à l'utilisateur courant, et un second qui vérifie si l'utilisateur courant a les droits d'administration. Si l'utilisateur courant est un administrateur, cela assure qu'il sera capable de mettre à jour n'importe quel article, puisqu'au moins l'un de ces callback retournera true.

Compensation de latence

Souvenez-vous que ces Méthodes de mutation de base de données (telles que .update()) sont compensé en latence, comme toutes les autres méthodes. Donc par exemple, si vous essayez de supprimer un article qui ne vous appartient pas via la console du navigateur, vous verrez l'article brièvement disparaître au moment où votre collection locale perd le document, puis réapparaître lorsque le serveur l'informe que, non, en fait le document n'a pas été supprimé.

Bien sûr ce comportement n'est pas un problème quand il est déclenché depuis la console (après tout, si les utilisateurs essaient de bidouiller avec les données dans la console, ce qui se passe dans leur navigateur n'est pas vraiment votre problème). Cependant, vous devez vous assurer que cela n'arrive pas dans l'interface utilisateur. Par exemple, vous devez vous donner du mal pour vous assurer que vous ne montrez pas les boutons supprimer lorsqu'ils ne sont pas autorisés à supprimer.

Heureusement, puisque vous pouvez partager le code de permissions entre client et serveur (par exemple, vous pourriez écrire une fonction bibliothèque canDeletePost(user, post) et la mettre dans le répertoire lib partagé), cela ne requiert habituellement pas beaucoup de code supplémentaire.

Permissions côté serveur

Souvenez-vous que le système de permissions s'applique seulement sur les mutations de base de données initiées par le client. Sur le serveur, Meteor assume que toutes les opérations sont permises.

Ceci signifie que dans le cas où vous écririez une Méthode Meteor côté serveur deletePost qui pourrait être appelée par le client, n'importe qui serait capable de supprimer chaque article. Vous ne voulez donc probablement pas faire cela à moins que vous ayez également vérifié les permissions à l'intérieur de cette Méthode.

Erreurs

9

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.

Créer un Package Meteor

Sidebar 9.5

Nous avons construit un pattern réutilisable avec notre travail sur les erreurs, donc pourquoi ne pas l'empaqueter dans un paquet intelligent et le partager avec le reste de la communauté Meteor ?

Pour être prêts, nous devons nous assurer d'avoir un compte développeur Meteor. Vous pouvez revendiquer le votre sur meteor.com, mais il est fort probable que vous l'ayez déjà fait quand vous vous êtes inscrits pour le livre ! Dans tous les cas, vous devez connaître votre nom d'utilisateur car nous allons l'utiliser intensivement dans ce chapitre.

Nous allons utiliser le nom d'utilisateur tmeasday dans ce chapitre – vous pouvez substituez le votre à celui-ci.

Premièrement, nous avons besoin de créer une structure pour notre paquet. Nous pouvons utiliser la commande meteor create --package tmeasday:errors pour cela. Notez que Meteor a créé un dossier nommé packages/tmeasday:errors/, avec quelques fichiers à l'intérieur. Nous allons commencer par éditer package.js, le fichier qui informe à Meteor comment il doit utiliser le paquet, et quels objets et fonctions il a besoin d'exporter.

Package.describe({
  name: "tmeasday:errors",
  summary: "A pattern to display application errors to the user",
  version: "1.0.0"
});

Package.onUse(function (api, where) {
  api.versionsFrom('0.9.0');

  api.use(['minimongo', 'mongo-livedata', 'templating'], 'client');

  api.addFiles(['errors.js', 'errors_list.html', 'errors_list.js'], 'client');

  if (api.export)
    api.export('Errors');
});
packages/tmeasday:errors/package.js

Quand on développe un paquet pour un usage destiné au monde réel, c'est une bonne pratique de remplir la section git du bloc Package.describe avec les URL Git de votre repo (comme https://github.com/tmeasday/meteor-errors.git). De cette manière, les utilisateurs peuvent lire le code source, et (en supposant que vous utilisez GitHub) le lisez-moi (readme) de votre paquet apparaîtra sur Atmosphere.

Ajoutons trois fichiers au paquet. Nous pouvons récupérer ces trois fichiers de Microscope sans trop de changements à part pour certains espaces de noms propres et une API légèrement plus claire :

Errors = {
  // Collection local (client seulement)
  collection: new Mongo.Collection(null),

  throw: function(message) {
    Errors.collection.insert({message: message, seen: false})
  }
};
packages/tmeasday:errors/errors.js
<template name="meteorErrors">
  <div class="errors">
    {{#each errors}}
      {{> meteorError}}
    {{/each}}
  </div>
</template>

<template name="meteorError">
  <div class="alert alert-danger" role="alert">
    <button type="button" class="close" data-dismiss="alert">&times;</button>
      {{message}}
  </div>
</template>
packages/tmeasday:errors/errors_list.html
Template.meteorErrors.helpers({
  errors: function() {
    return Errors.collection.find();
  }
});

Template.meteorError.rendered = function() {
  var error = this.data;
  Meteor.setTimeout(function () {
    Errors.collection.remove(error._id);
  }, 3000);
};
packages/tmeasday:errors/errors_list.js

Tester le paquet avec Microscope

Nous allons tester maintenant les choses localement avec Microscope pour nous assurer que notre code modifié fonctionne. Pour relier le paquet dans notre projet, nous exécutons meteor add tmeasday:errors. Ensuite, nous avons besoin de supprimer les fichiers existants qui ont été rendu redondants par le nouveau paquet :

rm client/helpers/errors.js
rm client/templates/includes/errors.html
rm client/templates/includes/errors.js
Suppression des vieux fichiers via la console bash

Une autre chose que nous avons besoin de faire est d'effectuer quelques mises à jour mineures pour utiliser l'API correctement :

  {{> header}}
  {{> meteorErrors}}
client/templates/application/layout.html
Meteor.call('postInsert', post, function(error, result) {
  if (error) {
    // affiche l'erreur à l'utilisateur
    Errors.throw(error.reason);

client/templates/posts/post_submit.js
Posts.update(currentPostId, {$set: postProperties}, function(error) {
  if (error) {
    // Afficher l'erreur à l'utilisateur
    Errors.throw(error.reason);

    // affiche ce résultat mais route quand même
    if (result.postExists)
      Errors.throw('Ce lien à déjà été utilisé');   
client/templates/posts/post_edit.js

Commit 9-5-1

Créer des erreurs basiques et les relier.

Une fois que ces changements ont été faits, nous devrions récupérer notre comportement original pré-paquet.

Ecrire des Tests

La première étape quand on développe un paquet est de le tester dans une application, mais la suivante est d'écrire une suite de test qui teste proprement le comportement du paquet. Meteor propose Tinytest (un testeur de paquet intégré), qui rend facile l'exécution de ce type de tests et permet de rester serein quand on partage notre paquet avec les autres.

Créons un fichier de test qui utilise Tinytest pour exécuter des tests sur le code du paquet errors :

Tinytest.add("Errors - collection", function(test) {
  test.equal(Errors.collection.find({}).count(), 0);

  Errors.throw('A new error!');
  test.equal(Errors.collection.find({}).count(), 1);

  Errors.collection.remove({});
});

Tinytest.addAsync("Errors - template", function(test, done) {
  Errors.throw('A new error!');
  test.equal(Errors.collection.find({}).count(), 1);

  // render the template
  UI.insert(UI.render(Template.meteorErrors), document.body);

  Meteor.setTimeout(function() {
    test.equal(Errors.collection.find({}).count(), 0);
    done();
  }, 3500);
});
packages/tmeasday:errors/errors_tests.js

Dans ces tests nous vérifions que les fonctions basiques Meteor.Errors fonctionnent, ainsi qu'une deuxième vérification que le code rendered dans le template fonctionne encore.

Nous ne couvrirons pas les spécificités d'écriture des tests de paquets Meteor ici (comme l'API n'est pas encore finalisée et hautement changeante), mais heureusement son fonctionnement est assez bien expliqué.

Pour dire à Meteor comment exécuter les tests dans package.js, utilisez le code suivant :

Package.onTest(function(api) {
  api.use('tmeasday:errors', 'client');
  api.use(['tinytest', 'test-helpers'], 'client');  

  api.addFiles('errors_tests.js', 'client');
});
packages/tmeasday:errors/package.js

Commit 9-5-2

Ajout des tests du paquet.

Puis nous pouvons exécuter les tests avec :

meteor test-packages tmeasday:errors
Terminal
Passer tous les tests
Passer tous les tests

Déployer le paquet

Maintenant, nous voulons délivrer le paquet et le rendre disponible à tout le monde. Nous faisons ça en le mettant sur le serveur de paquets de Meteor, et en le déployant sur Atmopshere.

Heureusement, c'est très facile. Il suffit de se rendre dans le dossier du paquet (via cd), et de lancer meteor publish --create :

cd packages/tmeasday:errors
meteor publish --create
Terminal

Maintenant que le paquet est déployé, nous pouvons le supprimer du projet puis le rajouter directement :

rm -r packages/errors
meteor add tmeasday:errors
Terminal (lancé depuis la racine de l'application)

Commit 9-5-4

Paquet supprimé du dossier de développement

Maintenant nous devrions voir Meteor télécharger notre paquet pour la toute première fois. Bien joué !

Comme d'habitude avec les apartés, assurez-vous d'annuler les changements avant de continuer (ou sinon assurez-vous d'en tenir compte en suivant le reste du livre).

Commentaires

10

Le but d'un site de nouvelles sociales est de créer une communauté d'utilisateurs, et cela sera difficile à atteindre sans fournir aux gens un moyen de communiquer entre eux. Nous allons donc dans ce chapitre ajouter les commentaires !

Nous allons commencer par ajouter une nouvelle collection pour y enregistrer les commentaires et quelques données basiques préenregistrées.

Comments = new Mongo.Collection('comments');
lib/collections/comments.js
// Données préenregistrées
if (Posts.find().count() === 0) {
  var now = new Date().getTime();

  // crée 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)
  });

  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)
  });

   Posts.insert({
    title: 'The Meteor Book',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://themeteorbook.com',
    submitted: new Date(now - 12 * 3600 * 1000)
  });
}
server/fixtures.js

N'oublions pas de publier dans notre nouvelle collection et d'y souscrire :

Meteor.publish('posts', function() {
  return Posts.find();
});

Meteor.publish('comments', function() {
  return Comments.find();
});
server/publications.js
Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() {
    return [Meteor.subscribe('posts'), Meteor.subscribe('comments')];
  }
});
lib/router.js

Commit 10-1

Avec la collection de commentaires, la publication, la so…

Notez que pour déclencher ce code de données préenregistrées, il faudra faire un meteor reset pour remettre à zéro votre base de données. Après avoir réinitialisé, n'oubliez pas de créer un nouveau compte et de vous reconnecter.

Nous avons tout d'abord créé un couple d'utilisateurs (factices), nous les avons enregistrés dans la base de données, et nous pourrons utiliser leur id plus tard pour les référencer en dehors de la base de données. Ensuite nous avons ajouté un commentaire pour chaque utilisateur sur le premier post, en liant le commentaire avec le post (via postId) et avec l'utilisateur (via userId). Nous avons aussi ajouté une date de publication et un body à chaque commentaire, en plus de author, un champ dénormalisé.

Nous avons aussi amélioré notre router pour attendre un tableau (array) contenant à la fois les commentaires et les souscriptions aux posts.

Afficher les commentaires

C'est très bien d'ajouter des commentaires à la base de données, mais nous voulons aussi les afficher sur la page de discussion. Heureusement, ce processus devrait vous être maintenant familier, et vous devriez avoir une idée des étapes à réaliser :

<template name="postPage">
  <div class="post-page page">
    {{> postItem}}

  <ul class="comments">
    {{#each comments}}
      {{> commentItem}}
    {{/each}}
  </ul>
 </div>
</template>
client/templates/posts/post_page.html
Template.postPage.helpers({
  comments: function() {
    return Comments.find({postId: this._id});
  }
});
client/templates/posts/post_page.js

Nous insérons le bloc {{#each comments}} à l'intérieur du template de post, donc this est un post dans le helper comments. Pour trouver les commentaires pertinents, nous vérifions ceux qui sont liés à ce post via l'attribut postId.

Sachant ce que nous avons appris à propos des helpers et de Spacebars, représenter un commentaire est plutôt simple. Nous allons créer un nouveau dossier comments à l'intérieur de templates pour y stocker toute l'information de nos commentaires et un nouveau template commentItem :

<template name="commentItem">
  <li>
    <h4>
      <span class="author">{{author}}</span>
      <span class="date">on {{submittedText}}</span>
    </h4>
    <p>{{body}}</p>
  </li>
</template>
client/templates/comments/comment_item.html

Créons donc un rapide helper de template pour représenter notre date submitted dans un format plus convivial :

Template.commentItem.helpers({
  submittedText: function() {
    return this.submitted.toString();
  }
});
client/templates/comments/comment_item.js

Nous allons ensuite afficher le nombre de commentaires de chaque post :

<template name="postItem">
  <div class="post">
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
      <p>
        Rédigé par {{author}},
        <a href="{{pathFor 'postPage'}}">{{commentsCount}} commentaires</a>
        {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
      </p>
    </div>
    <a href="{{pathFor 'postPage'}}" class="discuss btn btn-default">Commenter</a>
  </div>
</template>
client/templates/posts/post_item.html

Et ajouter le helper commentsCount à post_item.js :

Template.postItem.helpers({
  ownPost: function() {
    return this.userId === Meteor.userId();
  },
  domain: function() {
    var a = document.createElement('a');
    a.href = this.url;
    return a.hostname;
  },
  commentsCount: function() {
    return Comments.find({postId: this._id}).count();
  }
});
client/templates/posts/post_item.js

Commit 10-2

Affichage des commentaires sur `postPage`.

Vous devriez maintenant être capable d'afficher nos commentaires préenregistrés et voir quelque chose comme ça :

Affichage des commentaires
Affichage des commentaires

Publier des commentaires

Ajoutons un moyen pour nos utilisateurs de créer de nouveaux commentaires. Le procédé que nous allons suivre est similaire à la façon dont nous avons permis à nos utilisateurs de créer de nouveaux posts.

Nous allons commencer par ajouter un bouton de publication en bas de chaque post :

<template name="postPage">
  <div class="post-page page">
    {{> postItem}}

    <ul class="comments">
      {{#each comments}}
        {{> commentItem}}
      {{/each}}
    </ul>

    {{#if currentUser}}
      {{> commentSubmit}}
    {{else}}
      <p>Please log in to leave a comment.</p>
    {{/if}}
  </div>
</template>
client/templates/posts/post_page.html

En ensuite créer le template du formulaire de commentaire :

<template name="commentSubmit">
  <form name="comment" class="comment-form form">
    <div class="form-group {{errorClass 'body'}}">
      <div class="controls">
        <label for="body">Réagir à ce post</label>
        <textarea name="body" id="body" class="form-control" rows="3"></textarea>
        <span class="help-block">{{errorMessage 'body'}}</span>
      </div>
    </div>
    <button type="submit" class="btn btn-primary">Add Comment</button>
  </form>
</template>
client/templates/comments/comment_submit.html

Pour publier nos commentaires, nous appelons une Méthode comment dans comment_submit.js qui opère d'une manière similaire à ce que nous avons fait pour les publications de posts :

Template.commentSubmit.onCreated(function() {
  Session.set('commentSubmitErrors', {});
});

Template.commentSubmit.helpers({
  errorMessage: function(field) {
    return Session.get('commentSubmitErrors')[field];
  },
  errorClass: function (field) {
    return !!Session.get('commentSubmitErrors')[field] ? 'has-error' : '';
  }
});

Template.commentSubmit.events({
  'submit form': function(e, template) {
    e.preventDefault();

    var $body = $(e.target).find('[name=body]');
    var comment = {
      body: $body.val(),
      postId: template.data._id
    };

    var errors = {};
    if (! comment.body) {
      errors.body = "Please write some content";
      return Session.set('commentSubmitErrors', errors);
    }

    Meteor.call('commentInsert', comment, function(error, commentId) {
      if (error){
        throwError(error.reason);
      } else {
        $body.val('');
      }
    });
  }
});
client/templates/comments/comment_submit.js

Tout comme nous avons précédemment mis en place une Méthode Meteor post côté serveur, nous allons mettre en place une Méthode Meteor comment pour créer nos commentaires, s'assurer que tout est conforme, et finalement insérer le nouveau commentaire dans la collection ‘comments’.

Comments = new Mongo.Collection('comments');

Meteor.methods({
  commentInsert: function(commentAttributes) {
    check(this.userId, String);
    check(commentAttributes, {
      postId: String,
      body: String
    });

    var user = Meteor.user();
    var post = Posts.findOne(commentAttributes.postId);

    if (!post)
      throw new Meteor.Error('invalid-comment', 'Vous devez commenter sur un post');

    comment = _.extend(commentAttributes, {
      userId: user._id,
      author: user.username,
      submitted: new Date()
    });

    return Comments.insert(comment);
  }
});
lib/collections/comments.js

Commit 10-3

Avec le formulaire de publication de commentaires

Cela ne fait rien de bien fantaisiste, on s'assure simplement que l'utilisateur est connecté, que le commentaire a un corps (body), et qu'il est lié au post.

Le formulaire de publication de commentaires
Le formulaire de publication de commentaires

Contrôle de la publication de commentaires

Dans l'état des choses, nous publions tous les commentaires à travers les posts à tous les clients connectés. Cela semble un peu déraisonnable. Après tout, nous n'utilisons réellement qu'une petite partie de ces données à tout moment. Améliorons donc nos publications et souscriptions pour contrôler exactement quels commentaires sont publiés.

Si nous y pensons, le seul moment où on a besoin de souscrire à la publication de nos commentaires est lorsqu'un utilisateur accède à la page individuelle d'un post, et nous avons besoin de charger uniquement les commentaires reliés à ce post particulier.

La première étape consistera à changer la manière dont nous souscrivons à nos commentaires. Jusqu'à maintenant, nous avons souscrit au niveau du routeur, ce qui signifie que nous chargeons toutes nos données d'un seul coup lorsque le routeur est initialisé.

Mais nous voulons maintenant que notre souscription dépende d'un paramètre chemin, et ce paramètre doit bien sûr pouvoir changer à n'importe quel moment. Nous allons donc avoir besoin de déplacer notre code de souscription du niveau du routeur à celui des routes.

Cela a une autre conséquence : au lieu de charger nos données quand nous initialisons notre appli, nous les chargerons maintenant à chaque fois que notre route sera atteinte. Cela veut dire que vous aurez maintenant des temps de chargement pendant l'utilisation de l'appli, mais c'est un inconvénient inévitable à moins que vous ne prévoyiez de charger définitivement l'ensemble des données au début.

Premièrement, nous allons arrêter de précharger tous les commentaires dans le bloc configure en supprimant Meteor.subscribe('comments') (autrement dit revenir à ce que nous avions précédemment) :

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() {
    return Meteor.subscribe('posts');
  }
 });
lib/router.js

Et nous allons ajouter une nouvelle fonction waitOn de niveau route pour la route postPage :

//...

Router.route('/posts/:_id', {
  name: 'postPage',
  waitOn: function() {
    return Meteor.subscribe('comments', this.params._id);
  },
  data: function() { return Posts.findOne(this.params._id); }
});

//...
lib/router.js

Nous passons this.params._id en tant qu'argument à la souscription. Utilisons donc cette nouvelle information pour s'assurer de restreindre notre lot de données aux commentaires appartenant au post courant :

Meteor.publish('posts', function() {
  return Posts.find();
});

Meteor.publish('comments', function(postId) {
  check(postId, String);
  return Comments.find({postId: postId});
});
server/publications.js

Commit 10-4

Avec une simple publication/souscription pour les comment…

Il n'y a qu'un seul problème, quand nous retournons à la page d'accueil, il prétend que tous nos posts n'ont aucun commentaires :

Nos commentaires ont disparu !
Nos commentaires ont disparu !

Compter les commentaires

La raison va nous apparaître bientôt claire : nous ne chargeons les commentaires que sur la route postPage, donc lorsque nous appelons Comments.find({postId: this._id}) dans le helper commentsCount, Meteor ne peut pas trouver les données nécessaires côté client pour nous fournir un résultat.

La meilleure façon de régler cela est de dénormaliser le nombre de commentaires sur le post (si vous n'êtes pas sûr de savoir ce que cela veut dire, ne vous inquiétez pas, le prochain aparté s'occupe de vous !). Bien que comme nous allons le voir, notre code s'en retrouve légèrement complexifié, le bénéfice de performance que nous retirons de ne pas avoir à publier tous les commentaires pour afficher le post en vaut le coup.

Nous allons terminer ça en ajoutant une propriété commentsCount à la structure de données du post. Nous allons commencer par mettre à jour nos données préenregistrées de posts (et faire meteor reset pour les recharger – n'oubliez pas de recréer votre compte utilisateur après) :

// Données préenregistrées
if (Posts.find().count() === 0) {
  var now = new Date().getTime();

  // create two users
  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
  });

  Comments.insert({
    postId: telescopeId,
    userId: tom._id,
    author: tom.profile.name,
    submitted: new Date(now - 5 * 3600 * 1000),
    body: 'Interesting project Sacha, can I get involved?'
  });

  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
  });

  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
  });
}
server/fixtures.js

Comme d'habitude lorsque nous mettons à jour notre fichier fixtures, vous devez lancer meteor reset sur votre base de donnée pour vous assurer qu'il sera relancé.

Puis, nous nous assurons que tous les nouveaux posts débutent avec 0 commentaire :

//...

var post = _.extend(postAttributes, {
    userId: user._id,
    author: user.username,
    submitted: new Date(),
    commentsCount: 0
});

var postId = Posts.insert(post);

//...
lib/collections/posts.js

Ensuite nous mettons à jour le commentsCount qui s'y rapporte lorsque nous créons un nouveau commentaire en utilisant l'opérateur Mongo $inc (qui incrémente un champ numérique de un) :

//...

comment = _.extend(commentAttributes, {
  userId: user._id,
    author: user.username,
    submitted: new Date()
});

// update the post with the number of comments
Posts.update(comment.postId, {$inc: {commentsCount: 1}});

return Comments.insert(comment);

//...
lib/collections/comments.js

Finalement, nous pouvons simplement supprimer le helper commentsCount de client/templates/posts/post_item.js, puisque le champ est maintenant directement disponible sur le post.

Commit 10-5

Dénormalisation du nombre de commentaires dans le post.

Maintenant que les utilisateurs peuvent discuter entre eux, ce serait dommage qu'ils ratent les nouveaux commentaires. Et vous savez quoi, le prochain chapitre vous montrera comment implémenter les notifications pour éviter ça !

La Dénormalisation

Sidebar 10.5

Dénormaliser des données veut dire ne pas stocker ces données sous une forme “normale”. En d'autres termes, dénormalisation veut dire avoir de multiples copies du même morceau de donnée dans de multiples endroits.

Dans le dernier chapitre, nous avons dénormalisé le total du nombre de commentaires dans l'objet ‘post’ pour éviter d'avoir à constamment charger tous les commentaires. D'un point de vue de la modélisation des données, ceci est redondant puisque nous pourrions plutôt compter les commentaires adéquats à tout moment pour calculer cette valeur (en omettant les considérations de performance).

Dénormaliser est souvent synonyme de travail supplémentaire pour le développeur. Dans notre exemple, chaque fois que l'on ajoute ou enlève un commentaire, il faudra également se rappeler de mettre à jour l'article concerné pour s'assurer que le champs commentsCount (total des commentaires) reste juste. C'est exactement pour cela que les bases de données telles que MySQL voient cette approche d'un mauvais œil.

Cependant, l'approche classique a également ses inconvénients : sans une propriété commentsCount, nous devrions envoyer tous les commentaires en permanence vers le serveur afin de pouvoir les compter, ce que nous faisions au départ. La dénormalisation permet de nous éviter tout cela.

Une Publication Spéciale

Il serait possible de créer une publication spéciale qui envoie uniquement le total des commentaires qui nous intéressent (par exemple le total des commentaires d'articles que l'on peut voir actuellement, en utilisant des requêtes d'agrégation sur le serveur).

Mais il conviendrait d'étudier si la complexité d'un tel code de publication l'emporterait sur les difficultés créées par la dénormalisation…

Bien sûr, de telles considérations sont propre à l'application : si vous écrivez du code où l'intégrité des données est d'importance capitale, alors éviter les inconsistances de données est bien plus important et de priorité bien plus élevée que des gains de performance.

Intégrer des Documents ou Utiliser des Collections Multiples

Si vous êtes familier avec Mongo, vous avez peut-être été surpris de voir que nous avons créé une seconde collection uniquement pour les commentaires : pourquoi ne pas juste intégrer les commentaires dans une liste dans le 'post' ?

Il s'avère que de nombreux outils que Meteor nous fournis marchent bien mieux lorsqu'ils fonctionnent au niveau d'une collection. Par exemple :

  1. La fonction {{#each}} est très efficace lorsque l'on itère sur un curseur (le résultat de collection.find()). Il n'en est pas de même lorsque l'on itère sur un tableau d'objets dans un plus gros document.
  2. allow et deny fonctionnent au niveau du document, nous assurant ainsi que toute modification de commentaire individuel soit correcte, chose qui serait plus complexe si l'on opérait au niveau du 'post’.
  3. le Protocole de Données Distribuées (DDP) opère au niveau des attributs au plus au niveau du document– cela voudrait dire que si comments était une propriété d'un article, le serveur enverrait la liste complète des commentaires mis à jour pour cet article vers chaque client connecté.
  4. Les publications et souscriptions sont beaucoup plus faciles à contrôler au niveau des documents. Par exemple, si l'on voulait paginer les commentaires d'un article, on trouverait cela difficile à réaliser à moins que les commentaires ne soient dans leur propre collection.

Mongo suggère d'intégrer les documents dans l'ordre pour réduire le nombre de requêtes coûteuses pour les récupérer. Cependant, ceci revêt moins d'importance lorsque l'on prend en compte l'architecture de Meteor : la plupart du temps l'on recherchera les commentaires sur le client, où l'accès à la base de données est essentiellement gratuite.

Les Inconvénients de la Dénormalisation

On peut facilement défendre le fait qu'il ne faille pas dénormaliser ses données. Pour examiner les arguments contre la dénormalisation, nous vous recommandons Why You Should Never Use MongoDB par Sarah Mei.

Les Notifications

11

Maintenant que les utilisateurs peuvent commenter les articles de chacun, il serait bien de les avertir qu'une conversation a commencé.

Pour ce faire, nous notifierons à l'auteur de l'article qu'il y a eu un commentaire, et nous lui fournirons un lien pour voir le commentaire.

C'est sur ce type de fonctionnalité que Meteor resplendit : parce que Meteor est en temps réel par défaut, nous afficherons ces notifications instantanément. Nous n'avons pas besoin d'attendre que l'utilisateur rafraîchisse la page ou vérifie par un quelconque moyen, nous pouvons simplement faire apparaître de nouvelles notifications sans même écrire de code spécial.

Créer des Notifications

Nous allons créer une notification quand quelqu'un commente sur vos articles. Dans le future, les notifications pourront être étendues pour couvrir beaucoup d'autres scénarios, mais pour l'instant ce sera suffisant pour garder les utilisateurs informés de ce qu'il se passe.

Créons notre collection Notifications, ainsi qu'une fonction createCommentNotification qui insérera une notification correspondante pour chaque nouveau commentaire sur un de vos articles.

Puisque nous allons mettre à jour les notifications depuis le client, nous devons nous assurer que notre appel allow est blindé. Nous allons donc vérifier que :

  • l'utilisateur qui lance l'appel update possède la notification qui est en train d'être modifiée.
  • l'utilisateur essaie seulement de mettre à jour un seul champ.
  • ce seul champ est la propriété read de nos notifications.
Notifications = new Mongo.Collection('notifications');

Notifications.allow({
  update: function(userId, doc, fieldNames) {
    return ownsDocument(userId, doc) &&
      fieldNames.length === 1 && fieldNames[0] === 'read';
  }
});

createCommentNotification = function(comment) {
  var post = Posts.findOne(comment.postId);
  if (comment.userId !== post.userId) {
    Notifications.insert({
      userId: post.userId,
      postId: post._id,
      commentId: comment._id,
      commenterName: comment.author,
      read: false
    });
  }
};
lib/collections/notifications.js

Comme les articles ou commentaires, cette collection Notifications sera partagée côté client et serveur. Comme nous avons besoin de mettre à jour les notifications une fois que l'utilisateur les a vues, nous autorisons également les mises à jours, en s'assurant comme d'habitude de bien limiter les permissions aux propres données de l'utilisateur.

Nous avons également créé une simple fonction qui surveille l'article que l'utilisateur commente, détermine qui devrait être notifié à ce moment, et insère une nouvelle notification.

Nous créons déjà des commentaires dans une méthode côté serveur, donc nous pouvons juste compléter cette méthode pour appeler notre fonction. Nous remplacerons return Comments.insert(comment); par comment._id = Comments.insert(comment) afin de sauvegarder l’_id du commentaire nouvellement créé dans une variable, puis appellerons notre fonction createCommentNotification :

Comments = new Mongo.Collection('comments');

Meteor.methods({
  commentInsert: function(commentAttributes) {

    //...

    comment = _.extend(commentAttributes, {
      userId: user._id,
      author: user.username,
      submitted: new Date()
    });

    // met à jour le post avec le nombre de commentaires
    Posts.update(comment.postId, {$inc: {commentsCount: 1}});

    // crée le commentaire et enregistre l'id
    comment._id = Comments.insert(comment);

    // crée maintenant une notification, informant l'utilisateur qu'il y a eu un commentaire
    createCommentNotification(comment);

    return comment._id;
  }
});
lib/collections/comments.js

Publions également les notifications :

Meteor.publish('posts', function() {
  return Posts.find();
});

Meteor.publish('comments', function(postId) {
  check(postId, String);
  return Comments.find({postId: postId});
});

Meteor.publish('notifications', function() {
  return Notifications.find();
});
server/publications.js

Et souscrivons sur le client :

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() {
    return [Meteor.subscribe('posts'), Meteor.subscribe('notifications')]
  }
});
lib/router.js

Commit 11-1

Ajout d'une collection basique de notifications.

Affichage des notifications

Maintenant nous pouvons continuer et ajouter une liste de notifications à 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 'postsList'}}">Microscope</a>
    </div>
    <div class="collapse navbar-collapse" id="navigation">
      <ul class="nav navbar-nav">
        {{#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 créer les modèles de notifications et notificationItem (ils partageront un même fichier notifications.html) :

<template name="notifications">
  <a href="#" class="dropdown-toggle" data-toggle="dropdown">
    Notifications
    {{#if notificationCount}}
      <span class="badge badge-inverse">{{notificationCount}}</span>
    {{/if}}
    <b class="caret"></b>
  </a>
  <ul class="notification dropdown-menu">
    {{#if notificationCount}}
      {{#each notifications}}
        {{> notificationItem}}
      {{/each}}
    {{else}}
      <li><span>No Notifications</span></li>
    {{/if}}
  </ul>
</template>

<template name="notificationItem">
  <li>
    <a href="{{notificationPostPath}}">
      <strong>{{commenterName}}</strong> a commenté votre article
    </a>
  </li>
</template>
client/templates/notifications/notifications.html

Nous pouvons voir que l'idée est de fournir dans chaque notification un lien vers l'article qui a été commenté, et le nom de l'utilisateur qui l'a commenté.

Ensuite, nous avons besoin de nous assurer que nous sélectionnons la bonne liste de notifications dans notre helper, et mettons à jour les notifications comme “lues” quand l'utilisateur clique sur le lien vers lequel elles pointent.

Template.notifications.helpers({
  notifications: function() {
    return Notifications.find({userId: Meteor.userId(), read: false});
  },
  notificationCount: function(){
    return Notifications.find({userId: Meteor.userId(), read: false}).count();
  }
});

Template.notificationItem.helpers({
  notificationPostPath: function() {
    return Router.routes.postPage.path({_id: this.postId});
  }
});

Template.notificationItem.events({
  'click a': function() {
    Notifications.update(this._id, {$set: {read: true}});
  }
});
client/templates/notifications/notifications.js

Commit 11-2

Afficher des notifications dans l'en-tête.

Vous pouvez penser que les notifications ne sont pas si différentes des erreurs, et c'est vrai que leur structure est très similaire. Il y a néanmoins une différence clé : nous avons créé une collection client / serveur synchronisée. Cela signifie que nos notifications sont persistantes et, tant que nous gardons le même compte utilisateur, survivront au rafraîchissement des navigateurs et des différents appareils.

Essayez-le : ouvrez un deuxième navigateur (disons Firefox), créez un nouveau compte utilisateur, et commentez sur un article que vous avez créé avec votre compte utilisateur principal (que vous avez laissé ouvert dans Chrome). Vous devriez voir apparaître quelque chose comme ça :

Afficher des notifications.
Afficher des notifications.

Contrôler l'accès aux notifications

Les notifications fonctionnent bien. Cependant, il y a un petit problème : nos notifications sont publiques.

Si vous avez encore votre deuxième navigateur ouvert, essayez d’exécuter le code suivant dans une console navigateur :

 Notifications.find().count();
1
Console du navigateur

Ce nouvel utilisateur (celui qui a commenté) ne devrait pas avoir de notifications. La notification qu'il voit dans la collection Notifications appartient en fait à notre utilisateur original.

En marge de ces potentiels problèmes de confidentialité, nous ne pouvons pas nous permettre d'avoir toutes les notifications des utilisateurs chargées dans tous les navigateurs des autres utilisateurs. Sur un assez gros site, cela pourrait surcharger la mémoire disponible du navigateur et faire apparaître des sérieux problèmes de performance.

Nous corrigeons ce problème avec les publications. Nous pouvons utiliser nos publications pour spécifier précisément quelle partie de notre collection nous voulons partager dans chaque navigateur.

Pour accomplir cela, nous avons besoin de retourner un curseur différent dans notre publication de Notifications.find(). Plus exactement, nous voulons retourner un curseur qui correspond aux notifications de l'utilisateur courant.

Faire cela est assez direct, puisqu’ une fonction publish a l’id de l'utilisateur courant disponible via this.userdId :

Meteor.publish('notifications', function() {
  return Notifications.find({userId: this.userId, read: false});
});
server/publications.js

Commit 11-3

Synchroniser uniquement les notifications pertinentes pou…

Maintenant, si nous vérifions dans nos deux fenêtres de navigateur, nous devrions voir deux collections de notifications différentes :

 Notifications.find().count();
1
Console du navigateur (utilisateur 1)
 Notifications.find().count();
0
Console du navigateur (utilisateur 2)

En fait, la liste de Notifications devrait même changer selon que vous vous authentifiez et vous déconnectiez de votre application. Tout cela parce que les publications sont republiées automatiquement quand le compte utilisateur change.

Notre application devient de plus en plus fonctionnelle, et à mesure que des utilisateurs s'inscrivent et ajoutent des liens nous courons le risque de finir avec une page d'accueil sans fin. Nous allons nous occuper de ça dans le prochain chapitre en implémentant une pagination.

Réactivité Avancée

Sidebar 11.5

Il est rare d'avoir besoin d'écrire du code de traçage vous-même, mais il est assurément utile de le comprendre pour comprendre la façon dont le processus de résolution de dépendance fonctionne.

Imaginez que nous voulions tracer combien d'amis Facebook de l'utilisateur courant ont “aimé” chaque article sur Microscope. Partons du principe que nous avons déjà travaillé sur les détails de l'authentification de l'utilisateur avec Facebook, fait les appels appropriés vers l'API, et fait l'analyse des données pertinentes. Nous avons maintenant une fonction asynchrone côté client qui retourne le nombre de “j'aime”, getFacebookLikeCount(user, url, callback).

La chose importante à retenir au sujet d'une telle fonction est qu'elle est vraiment non réactive et non temps réel. Elle fera une requête HTTP vers Facebook, récupérera des données, et les rendra disponible à l'application via un rappel asynchrone, mais la fonction ne s'exécutera pas une nouvelle fois par elle-même quand le compte changera chez Facebook, et notre UI ne changera pas quand les données sous-jacentes le feront.

Pour corriger cela, nous pouvons démarrer en utilisant setInterval pour appeler notre fonction toutes les quelques secondes :

currentLikeCount = 0;
Meteor.setInterval(function() {
  var postId;
  if (Meteor.user() && postId = Session.get('currentPostId')) {
    getFacebookLikeCount(Meteor.user(), Posts.find(postId).url, 
      function(err, count) {
        if (!err)
          currentLikeCount = count;
      });
  }
}, 5 * 1000);

Quel que soit le moment où nous vérifions cette variable currentLikeCount, nous pouvons nous attendre à avoir un nombre correct avec une marge d'erreur de cinq secondes. Nous pouvons utiliser cette variable dans un helper comme suit :

Template.postItem.likeCount = function() {
  return currentLikeCount;
}

Cependant, rien ne dit à notre template de se recharger quand currentLikeCount change. Bien que la variable soit maintenant pseudo temps réel du fait qu'elle change par elle-même, elle n'est pas réactive donc elle ne peut pas tout à fait communiquer proprement avec le reste de l'écosystème Meteor.

Réactivité de la surveillance : Computations (Calculs)

La réactivité de Meteor est contrôlée par des dépendances, des structures de données qui surveillent un ensemble de calculs.

Comme nous l'avons vu dans un précédent aparté sur la réactivité, un calcul est une section de code qui utilise des données réactives. Dans notre cas, un calcul a été implicitement créé pour le template postItem, chaque helper de ce gestionnaire de template a sa propre partie calculs.

Vous pouvez penser au calcul comme à la section de code qui “s'occupe” de la source de données réactives. Quand la donnée change, ce sera ce calcul qui en sera informé (via invalidate()), et c'est le calcul qui décide si quelque chose doit être fait.

Transformer une Variable en une Fonction Réactive

Pour transformer notre variable currentLikeCount en source de données réactives, nous avons besoin de surveiller tous les calculs qui l'utilisent dans une dépendance. Cela requiert de la transformer de variable à fonction (qui retourne une valeur) :

var _currentLikeCount = 0;
var _currentLikeCountListeners = new Tracker.Dependency();

currentLikeCount = function() {
  _currentLikeCountListeners.depend();
  return _currentLikeCount;
}

Meteor.setInterval(function() {
  var postId;
  if (Meteor.user() && postId = Session.get('currentPostId')) {
    getFacebookLikeCount(Meteor.user(), Posts.find(postId), 
      function(err, count) {
        if (!err && count !== _currentLikeCount) {
          _currentLikeCount = count;
          _currentLikeCountListeners.changed();
        }
      });
  }
}, 5 * 1000);

Nous avons mis en place une dépendance _currentLikeCountListeners, qui surveille tous les calculs dans lesquels currentLikeCount() a été utilisé. Quand la valeur de _currentLikeCount change, nous appelons la fonction changed() sur cette dépendance, qui invalide tous les calculs surveillés.

Ces calculs peuvent ensuite continuer et traiter le changement au cas par cas.

Si cela vous semble être une approche un peu rébarbative pour une simple source de données réactive, vous avez raison, et Meteor fournit des outils intégrés pour rendre ça un peu plus simple (tout comme vous n'avez pas besoin d'utiliser des calculs directement, vous utilisez d'habitude des autoruns). Il y a un paquet plate-forme appelé reactive-var qui fait exactement ce que notre currentLikeCount() fonction fait. Si nous l'ajoutons :

meteor add reactive-var

Nous pouvons l'utiliser pour simplifier notre code un petit peu :

var currentLikeCount = new ReactiveVar();

Meteor.setInterval(function() {
  var postId;
  if (Meteor.user() && postId = Session.get('currentPostId')) {
    getFacebookLikeCount(Meteor.user(), Posts.find(postId),
      function(err, count) {
        if (!err) {
          currentLikeCount.set(count);
        }
      });
  }
}, 5 * 1000);

Maintenant, pour l'utiliser, nous appellerons currentLikeCount.get() dans notre helper et cela fonctionnera comme avant. Il y a aussi un autre paquet plate-forme reactive-dict, qui fournit un entrepôt réactif clé-valeur (presque exactement comme Session), qui peut être aussi utile.

Comparer Tracker à Angular

Angular est une bibliothèque de rendu réactif côté client seulement, développé par les bonnes gens de Google. Il est intéressant de comparer l'approche de surveillance de dépendance de Meteor à celle d'Angular, car celles-ci sont assez différentes.

Nous avons vu que le modèle de Meteor utilise des blocs de code appelés computations (calculs). Ces calculs sont traqués par des sources de données “réactives” (fonctions) qui prennent soin de les invalider quand c'est approprié. Donc les sources de données informent explicitement toutes ses dépendances quand elles ont besoin d'appeler invalidate(). Notez que bien que cela se passe généralement quand les données ont changé, la source de données pourrait potentiellement décider de déclencher une invalidation pour d'autres raisons.

De plus, bien que les calculs se re-exécutent habituellement quand ils sont invalidés, vous pouvez les configurer pour se comporter comme vous le voulez. Tout ceci nous donne un haut niveau de contrôle sur la réactivité.

Dans Angular, la réactivité est contrôlée par l'objet scope. Un scope peut être imaginé comme un objet JavaScript simple avec plusieurs méthodes spéciales.

Quand vous voulez dépendre activement d'une valeur dans un scope, vous appelez scope.$watch, en fournissant l'expression qui vous intéresse (c'est-à-dire la partie du scope qui vous intéresse) et une fonction listener qui s'exécutera à chaque fois que la valeur de l'expression change.

De retour sur notre exemple Facebook, nous écririons :

$rootScope.$watch('currentLikeCount', function(likeCount) {
  console.log('Current like count is ' + likeCount);
});

Bien sûr, tout comme vous ne mettez que rarement en place des calculs dans Meteor, vous n'appelez pas souvent $watch explicitement dans Angular puisque les directives ng-model et les {{expressions}} configurent automatiquement des watches qui ensuite s'occupent de réactualiser la page.

Quand ce type de valeur réactive a changé, scope.$apply() doit être appelé. Ceci réévalue chaque watcher du scope, mais appelle seulement la fonction listener des veilleurs pour qui la valeur de l'expression a changé.

scope.$apply() est similaire à dependency.changed(), excepté qu'il agit au niveau du scope, plutôt que vous donner le contrôle de dire précisément quels listeners doivent être réévalués. Ceci dit, ce léger manque de contrôle donne à Angular l'habilité d'être vraiment intelligent et efficace dans la manière dont il détermine précisément quels listeners doivent être réévalués.

Avec Angular, notre code de fonction getFacebookLikeCount() devrait ressembler à ça :

Meteor.setInterval(function() {
  getFacebookLikeCount(Meteor.user(), Posts.find(postId), 
    function(err, count) {
      if (!err) {
        $rootScope.currentLikeCount = count;
        $rootScope.$apply();
      }
    });
}, 5 * 1000);

Certes, Meteor s'occupe du gros du travail pour nous et nous laisse bénéficier de la réactivité sans trop de travail de notre part. Mais heureusement, apprendre ces pratiques s’avérera utile si vous avez besoin de pousser les choses un peu plus loin.

La Pagination

12

Les choses semblent bien avec Microscope, et nous pouvons nous attendre à un succès quand il va sortir pour tout le monde.

Donc nous devrions probablement réfléchir un peu à propos de la conséquence sur les performances du nombre de nouveaux articles qui vont être entrés dans le site au moment de son envol !

Nous avons précédemment parlé de comment une collection côté client peut contenir un sous-ensemble des données du serveur, et nous avons même appris à réaliser cela pour nos collections de notifications et de commentaires.

À présent, nous publions encore tous nos articles d'un seul coup, à tous les utilisateurs connectés. Éventuellement, si des milliers de liens sont postés, ceci deviendra problématique. Pour résoudre cela, nous avons besoin de paginer nos articles.

Ajouter plus d'articles

Premièrement, dans nos données de pré-installation, chargeons assez d'articles pour que cette pagination ait un sens :

// Fixture data
if (Posts.find().count() === 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
  });

  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),
      commentsCount: 0
    });
  }
}
server/fixtures.js

Après avoir exécuté meteor reset et relancé votre appli, vous devriez obtenir quelque chose comme ça :

Afficher des données factices.
Afficher des données factices.

Commit 12-1

Ajout d'assez d'articles pour que la pagination soit néce…

Pagination infinie

Nous allons implémenter une pagination de style “infini”. Ce que nous voulons dire par là c'est que nous voulons premièrement afficher, disons, 10 articles à l'écran, avec un lien “charger plus d'articles” inscrit en bas. Cliquer sur le lien affichera 10 articles supplémentaires dans la liste, et ainsi de suite ad infinitum. Cela signifie que nous pouvons contrôler notre système de pagination entier avec un simple paramètre représentant le nombre d'articles à afficher à l'écran.

Maintenant nous allons avoir besoin d'un moyen d'indiquer au serveur ce paramètre afin qu'il sache combien d'articles envoyer au client. Il se trouve que nous nous abonnons déjà à la publication posts dans le routeur, donc nous allons profiter de ça et laisser le routeur gérer notre pagination.

La façon la plus facile de configurer cela est simplement de faire du paramètre de limitation d'articles partie intégrante du chemin, nous donnant des URLs de la forme http://localhost:3000/25. Un autre bon côté d'utiliser l'URL plutôt que d'autres méthodes est que si vous affichez 25 articles et que vous recharger la page dans le navigateur par erreur, vous verrez toujours 25 articles une fois la page chargée.

Afin de faire ça proprement, nous allons avoir besoin de changer la façon dont nous nous abonnons aux articles. Juste comme nous l'avons fait dans le chapitre Commentaires, nous allons avoir besoin de déplacer notre code de l'abonnement du niveau routeur au niveau route.

Tout ça est peut-être un peu beaucoup à comprendre en une seule fois, mais cela deviendra plus clair avec le code.

Premièrement, nous arrêterons l'abonnement à la publication posts dans le bloc Routeur.configure(). Supprimez juste Meteor.suscribe('posts'), en laissant seulement l'abonnement aux notifications :

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() {
    return [Meteor.subscribe('notifications')]
  }
});
lib/router.js

Nous ajouterons un paramètre postsLimit au chemin de la route. Ajouter un ? après le nom du paramètre signifie que c'est optionnel. Donc cette route ne correspondra pas seulement à http://localhost:3000/50, mais également au simple et ancien http://localhost:3000.

//...

Router.route('/:postsLimit?', {
  name: 'postsList',
});

//...
lib/router.js

C'est important de noter qu'un chemin de la forme /:parameter? correspondra à tous les chemins possibles. Chaque route sera analysée successivement pour voir si elle correspond avec le chemin courant, nous avons besoin de nous assurer que nous organisons nos routes dans un ordre de spécificité décroissante.

En d'autres mots, les routes qui ciblent des routes plus spécifiques comme /posts/:_id viendraient en première, et notre route postsList serait déplacée en bas du groupe de routes vu qu'elle correspond pratiquement avec tout.

Il est maintenant temps d'aborder le dur problème de s'abonner et trouver les bonnes données. Nous avons besoin de gérer le cas où le paramètre postsLimit n'est pas présent, donc nous l'assignerons à une valeur par défaut. Nous utiliserons “5” pour nous donner vraiment assez de place pour jouer avec les paginations.

//...

Router.route('/:postsLimit?', {
  name: 'postsList',
  waitOn: function() {
    var limit = parseInt(this.params.postsLimit) || 5;
    return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
  }
});

//...
lib/router.js

Vous noterez que nous passons maintenant un objet JavaScript ({sort: {submitted: -1}, limit: postsLimit}) avec le nom de notre publication posts. Cet objet servira au même titre que le paramètre options pour l'expression de Posts.find() côté serveur. Faisons quelques modifications dans notre code côté serveur pour implémenter ça :

Meteor.publish('posts', function(options) {
  check(options, {
    sort: Object,
    limit: Number
  });
  return Posts.find({}, options);
});

Meteor.publish('comments', function(postId) {
  check(postId, String);
  return Comments.find({postId: postId});
});

Meteor.publish('notifications', function() {
  return Notifications.find({userId: this.userId});
});
server/publications.js

Passer des paramètres

Notre code de publications est effectivement en train de dire au serveur qu'il peut faire confiance à tout objet JavaScript envoyé par le client (dans notre cas, {limit: postsLimit}) pour servir comme les options d'expression de find(). Ceci rend possible pour les utilisateurs de soumettre des options qu'ils aiment via la console du navigateur.

Dans notre cas, c'est relativement anodin, vu que tout ce qu'un utilisateur peut faire est réordonner les articles différemment, ou changer la limite (ce que nous voulons mettre en place en premier lieu). En revanche une appli du monde réel devrait probablement avoir besoin de limiter les limites !

Heureusement, en utilisant check() nous savons que les utilisateurs ne peuvent pas passer en douce des options supplémentaires (comme fields, qui pourrait dans certains cas exposer des données privées).

Malgré tout, un pattern plus sécurisé pourrait être de passer les paramètres individuels eux-mêmes au lieu de l'objet entier, pour nous assurer que nous gardons le contrôle de nos données :

Meteor.publish('posts', function(sort, limit) {
  return Posts.find({}, {sort: sort, limit: limit});
});

Maintenant que nous nous sommes abonnés au niveau de la route, cela aurait du sens de mettre le contexte de données au même endroit. Nous dévierons un peu de notre précédent pattern et feront en sorte que la fonction data retourne un objet JavaScript au lieu de simplement retourner un curseur. Ceci nous laisse créer un contexte de données nommé, que nous appellerons posts.

Ce que cela signifie est simplement qu'au lieu d'être implicitement disponible comme this à l'intérieur du template, notre contexte de données sera disponible à posts. A part ce petit élément, le code devrait être familier :

//...

Router.route('/:postsLimit?', {
  name: 'postsList',
  waitOn: function() {
    var limit = parseInt(this.params.postsLimit) || 5;
    return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
  },
  data: function() {
    var limit = parseInt(this.params.postsLimit) || 5;
    return {
      posts: Posts.find({}, {sort: {submitted: -1}, limit: limit})
    };
  }
});

//...
lib/router.js

Et puisque que nous avons mis le contexte de données au niveau des routes, nous pouvons maintenant nous débarrasser sans problème du template helper posts dans le fichier posts_list.js et supprimer le contenu de ce fichier.

Nous avons nommé notre contexte de données posts (le même nom que le helper), donc nous n'avons même pas besoin de toucher au template postsList !

Récapitulons, Voici à quoi notre nouveau code amélioré de router.js ressemble :

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() {
    return [Meteor.subscribe('notifications')]
  }
});

Router.route('/posts/:_id', {
  name: 'postPage',
  waitOn: function() {
    return Meteor.subscribe('comments', this.params._id);
  },
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/posts/:_id/edit', {
  name: 'postEdit',
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/submit', {name: 'postSubmit'});

Router.route('/:postsLimit?', {
  name: 'postsList',
  waitOn: function() {
    var limit = parseInt(this.params.postsLimit) || 5;
    return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
  },
  data: function() {
    var limit = parseInt(this.params.postsLimit) || 5;
    return {
      posts: Posts.find({}, {sort: {submitted: -1}, limit: limit})
    };
  }
});

var requireLogin = function() {
  if (! Meteor.user()) {
    if (Meteor.loggingIn()) {
      this.render(this.loadingTemplate);
    } else {
      this.render('accessDenied');
    }
  } else {
    this.next();
  }
}

Router.onBeforeAction('dataNotFound', {only: 'postPage'});
Router.onBeforeAction(requireLogin, {only: 'postSubmit'});
lib/router.js

Commit 12-2

Améliorer la route postsList pour avoir une limite.

Essayons notre tout nouveau système de pagination. Nous avons maintenant la possibilité d'afficher un nombre arbitraire d'articles sur la page d'accueil simplement en changeant le paramètre dans l'URL. Par exemple, essayez d'accéder à http://localhost:3000/3. Vous verrez maintenant quelque chose comme ça :

Contrôler le nombre d'articles sur la page d'accueil.
Contrôler le nombre d'articles sur la page d'accueil.

Pourquoi pas des pages ?

Pourquoi utilisons-nous une approche de “pagination infinie” au lieu de montrer des pages successives avec 10 articles sur chaque, comme ce que Google fait pour ses résultats de recherche ? C'est actuellement dû au paradigme temps-réel embrassé par Meteor.

Imaginons que nous paginons notre collection Posts en utilisant le pattern de pagination de Google, et que nous sommes à la page 2, qui affiche les articles 10 à 20. Que se passe-t-il si un utilisateur supprime un des 10 précédents articles ?

Étant donné que notre application est temps-réel, notre ensemble de données pourrait changer. L'article 10 deviendrait l'article 9, et disparaîtrait de notre vue, pendant que l'article 11 serait maintenant dans le créneau. Le résultat final serait que l'utilisateur verrait soudainement ses articles changer sans raisons apparentes.

Même si nous tolérions cette bizarrerie dans l'expérience utilisateur, la pagination traditionnelle est également difficile à implémenter pour des raisons techniques.

Revenons à notre exemple précédent. Nous publions les articles 10 à 20 de la collection Posts, mais comment trouver ces articles sur le client ? Vous ne pouvez pas prendre les articles de 10 à 20, comme il y a seulement dix articles en tout dans l'ensemble de données côté client.

Une solution serait simplement de publier ces 10 articles sur le serveur, et ensuite faire un Posts.find() côté client pour récupérer tous les articles publiés.

Ceci fonctionne si vous avez seulement un abonnement. Mais que va-t-il se passer si vous avez plus d'un abonnement aux articles, comme nous le ferons bientôt ?

Disons qu'un abonnement demande les articles 10 à 20, et un autre les articles 30 à 40. Vous avez maintenant 20 articles chargés côté client au total, avec aucun moyen de savoir lesquels appartiennent à quel abonnement.

Pour toutes ces raisons, la pagination traditionnelle n'a pas beaucoup de sens lorsqu'on travaille avec Meteor.

Créer un contrôleur de route

Vous avez noté que nous répétons la ligne var limit = parseInt(this.params.postsLimit) || 5; deux fois. De plus, coder en dur le nombre “5” n'est pas vraiment idéal. Ce n'est pas la fin du monde, mais comme c'est toujours mieux de suivre le principe DRY (Don’t Repeat Yourself : Ne vous répétez pas) si vous le pouvez, voyons comment nous pouvons remanier un petit peu les choses.

Nous allons présenter un nouvel aspect de Iron Router, les Contrôleurs de Route. Un contrôleur de route est simplement un moyen de grouper les fonctionnalités de routage ensemble dans un paquet réutilisable dont toutes les routes peuvent hériter. Maintenant nous allons l'utiliser uniquement pour une seule route, mais vous verrez dans le prochain chapitre comment cette fonctionnalité deviendra pratique.

//...

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5,
  postsLimit: function() {
    return parseInt(this.params.postsLimit) || this.increment;
  },
  findOptions: function() {
    return {sort: {submitted: -1}, limit: this.postsLimit()};
  },
  waitOn: function() {
    return Meteor.subscribe('posts', this.findOptions());
  },
  data: function() {
    return {posts: Posts.find({}, this.findOptions())};
  }
});

//...

Router.route('/:postsLimit?', {
  name: 'postsList'
});

//...
lib/router.js

Allons y pas à pas. Premièrement, nous créons notre contrôleur en étendant RouteController. Puis Nous mettons la propriété template juste comme nous l'avons fait avant, et ensuite une nouvelle propriété increment.

Puis nous définissions une nouvelle fonction postslimit qui retournera la limite courante, et une fonction findOptions qui retournera un objet options. Cela pourrait ressembler à une étape supplémentaire, mais nous en ferons usage plus tard.

Après, nous définissions des fonctions waitOn et data juste comme avant, excepté que maintenant elles vont utiliser notre nouvelle fonction findOptions.

Puisque notre contrôleur est appelé PostsListController et notre route postsList, Iron Router utilisera automatiquement le contrôleur. Nous avons donc seulement besoin de supprimer waitOn et data de la définition de notre route (puisque dorénavant le contrôleur s'en occupe). Si nous avions besoin d'utiliser un contrôleur avec un autre nom, nous aurions pu utiliser l'option controller (nous verrons un exemple de ça dans le prochain chapitre).

Commit 12-3

Route postsList remaniées dans un contrôleur de route.

Ajouter un lien « Charger plus »

Nous avons une pagination qui fonctionne, et notre code est bien fait. Il y a juste un problème : il n'y a aucun moyen d'utiliser actuellement cette pagination excepté en changeant l'URL manuellement. Ceci ne fait définitivement pas une bonne expérience utilisateur, donc retournons au travail pour corriger ça.

Ce que nous voulons faire est assez simple. Nous allons ajouter un bouton “Charger plus” en bas de notre liste d'articles, qui incrémentera le nombre d'articles affichés par 5 chaque fois qu'on clique dessus. Donc si je suis actuellement sur l'URL http://localhost:3000/5, cliquer sur “Charger plus” nous amène à http://localhost:3000/10. Si vous êtes arrivé aussi loin dans le livre, nous pensons que vous pouvez supporter un peu d'arithmétique.

Comme précédemment, nous ajouterons notre logique de pagination dans notre route. Vous vous souvenez quand nous avons explicitement nommé notre contexte de données plutôt que juste utiliser un curseur anonyme ? Bien, il n'y a pas de règle qui dit que la fonction data peut seulement passer des curseurs, donc nous utiliserons la même technique pour générer l'URL de notre bouton “charger plus”.

//...

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5,
  postsLimit: function() {
    return parseInt(this.params.postsLimit) || this.increment;
  },
  findOptions: function() {
    return {sort: {submitted: -1}, limit: this.postsLimit()};
  },
  waitOn: function() {
    return Meteor.subscribe('posts', this.findOptions());
  },
  posts: function() {
    return Posts.find({}, this.findOptions());
  },
  data: function() {
    var hasMore = this.posts().count() === this.postsLimit();
    var nextPath = this.route.path({postsLimit: this.postsLimit() + this.increment});
    return {
      posts: this.posts(),
      nextPath: hasMore ? nextPath : null
    };
  }
});

//...
lib/router.js

Regardons plus en détail la magie du routeur. Vous vous souvenez que la route postsList (qui hérite du contrôleur PostsListController sur lequel nous travaillons) prend un paramètre postsLimit.

Donc quand nous alimentons this.route.path() avec {postsLimit: this.postslimit() + this.increment}, nous disons à la route postsList de construire son propre chemin en utilisant cet objet JavaScript comme contexte de données.

En d'autres mots, c'est exactement la même chose qu'utiliser le helper Spacebars {{pathFor 'postsList'}}, excepté que nous remplaçons le this implicite par notre contexte de données personnalisé.

Nous prenons ce chemin et l'ajoutons au contexte de données pour notre template, mais seulement s'il y a plus d'articles à afficher. La manière dont on fait ça est un peu rusée.

Nous savons que this.limit() retourne le nombre courant d'articles que nous aimerions montrer, qui peut être la valeur dans l'URL courante, ou notre valeur par défaut (5) si l'URL ne contient pas de paramètre.

D'un autre côté, this.posts réfère au curseur courant, donc this.posts.count() réfère au nombre d'articles qui sont actuellement dans le curseur.

Donc ce que nous disons ici est que si nous demandons n articles et nous récupérons n, nous continuerons d'afficher le bouton “charger plus”. Mais si nous demandons n et que nous récupérons moins de n, ça voudra dire que nous avons atteint la limite et que nous voulons arrêter d'afficher ce bouton.

Ceci étant dit, notre système échoue dans un cas ; quand le nombre d'items dans notre base de données est exactement n. Si cela arrive, le client demandera n articles et récupérera n articles et continuera à afficher le bouton “charger plus”, inconscient qu'il n'y a plus d'items restants.

C'est triste, il n'y a pas de contournements simples à ce problème, donc pour l'instant nous devrons nous contenter de cette implémentation moins-que-parfaite.

Tout ce qu'il reste à faire est d'ajouter le lien “charger plus” en bas de notre liste d'articles, en nous assurant de l'afficher seulement si nous avons encore des articles à charger :

<template name="postsList">
  <div class="posts">
    {{#each posts}}
      {{> postItem}}
    {{/each}}

    {{#if nextPath}}
      <a class="load-more" href="{{nextPath}}">Load more</a>
    {{/if}}
  </div>
</template>
client/templates/posts/posts_list.html

Voici à quoi votre liste d'articles devrait ressembler :

Le bouton “Charger plus“.
Le bouton “Charger plus“.

Commit 12-4

nextPath() ajouté au contrôleur et utilisé pour franchir …

Une meilleure expérience utilisateur

Notre pagination fonctionne maintenant correctement, mais elle souffre d'une ennuyeuse bizarrerie : à chaque fois que nous cliquons sur « charger plus » et que le routeur demande plus d'articles, nous sommes envoyés vers le template loading pendant que nous attendons l'arrivée des nouvelles données. Le résultat est que nous sommes envoyés en haut de la page à chaque fois et nous avons besoin de faire défiler la page vers le bas pour continuer notre navigation.

Donc premièrement, nous devrons dire à Iron Router de ne pas waitOn l'abonnement après tout. Au lieu de ça, nous définirons nos abonnements dans un hook subscriptions.

Notez que nous ne renvoyons pas cet abonnement dans le hook. Le renvoyer (ce qui est fait normalement avec le hook subscription) déclencherais le hook de chargement global, et c'est exactement ce que nous voulons éviter. À la place, nous utilisons simplement le hook subscriptions comme un endroit pratique pour définir notre abonnement, de la même façon que pour le hook onBeforeAction.

Nous passons aussi une variable ready qui se réfère à this.postsSub.ready comme un élément de notre contexte de données. Cela nous permettra de dire au template quand l'abonnement au post a terminé de charger.

//...

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5,
  postsLimit: function() {
    return parseInt(this.params.postsLimit) || this.increment;
  },
  findOptions: function() {
    return {sort: {submitted: -1}, 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();
    var nextPath = this.route.path({postsLimit: this.postsLimit() + this.increment});
    return {
      posts: this.posts(),
      ready: this.postsSub.ready,
      nextPath: hasMore ? nextPath : null
    };
  }
});

//...
lib/router.js

Nous allons ensuite vérifier dans le template que cette variable ready affiche un spinner en bas de la liste de posts pendant que nous chargeons un nouveau lot de posts :

<template name="postsList">
  <div class="posts">
    {{#each posts}}
      {{> postItem}}
    {{/each}}

    {{#if nextPath}}
      <a class="load-more" href="{{nextPath}}">Load more</a>
    {{else}}
      {{#unless ready}}
        {{> spinner}}
      {{/unless}}
    {{/if}}
  </div>
</template>
client/templates/posts/posts_list.html

Commit 12-5

Avec un spinner pour rendre la pagination plus agréable.

Accéder à des articles

Nous sommes en train de charger les cinq articles les plus récents par défaut, mais que se passe-t-il quand quelqu'un explore une page d'article individuelle ?

Un template vide.
Un template vide.

Si vous essayez, vous allez faire face à une erreur « non trouvé ». Cela semble normal : nous avons dit au routeur de s'abonner à la publication posts quand il charge la route postsList, mais nous ne lui avons pas dit quoi faire au sujet de la route postPage.

Mais, tout ce que nous savons faire c'est nous abonner à une liste des n derniers articles. Comment demande-t-on au serveur pour un seul article spécifique ? Nous allons vous donner un petit secret ici : vous pouvez avoir plus d'une publication pour chaque collection !

Donc pour retrouver nos articles manquants, nous allons simplement créer une nouvelle publication singlePost séparée qui publie seulement un article, identifié par son _id.

Meteor.publish('posts', function(options) {
  return Posts.find({}, options);
});

Meteor.publish('singlePost', function(id) {
  check(id, String)
  return Posts.find(id);
});

//...
server/publications.js

Maintenant, abonnons-nous aux bons articles côté client. Nous nous sommes déjà abonnés à la publication comments sur la fonction waitOn de la route postPage, donc nous pouvons simplement ajouter l'abonnement à singlePost ici. Et n'oublions pas d'ajouter également notre abonnement à la route postEdit, vu qu'elle nécessite aussi les mêmes données :

//...

Router.route('/posts/:_id', {
  name: 'postPage',
  waitOn: function() {
    return [
      Meteor.subscribe('singlePost', this.params._id),
      Meteor.subscribe('comments', this.params._id)
    ];
  },
  data: function() { return Posts.findOne(this.params._id); }
});

Router.route('/posts/:_id/edit', {
  name: 'postEdit',
  waitOn: function() {
    return Meteor.subscribe('singlePost', this.params._id);
  },
  data: function() { return Posts.findOne(this.params._id); }
});

//...
lib/router.js

Commit 12-6

Utiliser un abonnement à un unique article pour nous assu…

Avec la pagination activée, notre application ne souffre plus de problèmes de montée en charge, et les utilisateurs sont sûrs de contribuer avec même plus de liens qu'avant. Donc ne serait-il pas super de pouvoir d'une manière ou d'une autre classer ces liens ? Si vous ne le saviez pas, c'est précisément l'objet du chapitre suivant.

Le Vote

13

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 ?

Publications Avancées

Sidebar 13.5

À partir de maintenant, vous devriez avoir une bonne idée de comment les publications et les abonnements interagissent. Donc passez le cap de l’entraînement et examinez des scénarios un peu plus avancés.

Publier une collection de multiples fois

Dans notre première annexe à propos des publications, nous avons vu quelques patterns classiques de publications et d'abonnements, et nous avons appris comment la fonction _publishCursor nous a rendu la tâche facile pour les implémenter dans nos propres sites.

Premièrement, rappelons ce que _publishCursor fait pour nous exactement : il prend tous les documents qui correspondent à un curseur donné, et les envoie dans la collection côté client du même nom. Notez que le nom de la publication n'est en aucun cas impliqué.

Cela signifie que nous avons plus d'une publication reliant les versions client et serveur d'une collection.

Nous avons déjà rencontré ce pattern dans notre chapitre sur la pagination, quand nous avons publié un sous-ensemble paginé de tous les articles en plus de l'article affiché.

Un autre cas similaire est de publier une vue d'ensemble d'un large échantillons de documents, aussi bien que les détails complets d'un seul item :

Publier une collection deux fois
Publier une collection deux fois
Meteor.publish('allPosts', function() {
  return Posts.find({}, {fields: {title: true, author: true}});
});

Meteor.publish('postDetail', function(postId) {
  return Posts.find(postId);
});

Maintenant que le client s'abonne à ces deux publications, sa collection 'posts' est remplie de deux sources : une liste de titres et de noms d'auteur du premier abonnement, et les détails complets d'un article provenant du deuxième abonnement.

Vous pouvez réaliser que l'article publié par postDetail est également publié par allPosts (bien qu'avec seulement un sous-ensemble de ses propriétés). Cependant, Meteor prend soin du chevauchement en rassemblant les champs et en s'assurant qu'il n'y a pas d'article dupliqué.

C'est génial, parce que maintenant quand nous affichons la liste récapitulative des articles, nous avons affaire à des objets de données qui ont juste assez de données pour afficher ce que nous avons besoin. Cependant, quand nous affichons la page pour un seul article, nous avons tout ce que nous avons besoin pour le montrer. Bien sûr, nous devons faire attention sur le client à ne pas s'attendre à ce que tous les champs soient disponibles sur tous les articles dans ce cas – c'est un piège habituel !

Il convient de noter que vous n'êtes pas limité aux différentes propriétés des documents. Vous pouvez très bien publier les mêmes propriétés dans les deux publications, mais les trier différemment.

Meteor.publish('newPosts', function(limit) {
  return Posts.find({}, {sort: {submitted: -1}, limit: limit});
});

Meteor.publish('bestPosts', function(limit) {
  return Posts.find({}, {sort: {votes: -1, submitted: -1}, limit: limit});
});
server/publications.js

S'abonner à une publication plusieurs fois

Nous venons juste de voir comment publier une collection plus d'une fois. Il s'avère que vous pouvez accomplir le même résultat avec un autre pattern : créer une seule publication, mais s'y abonner plusieurs fois.

Dans Microscope, nous nous abonnons à la publication posts plusieurs fois, mais Iron Router configure et détruit chaque abonnement pour nous. Pourtant il n'y a aucune raison qu'on ne puisse pas nous abonner plusieurs fois simultanément.

Par exemple, disons que nous voulons charger en même temps les meilleurs articles et les plus récents en mémoire :

S'abonner deux fois à une publication
S'abonner deux fois à une publication

Nous mettons en place une publication :

Meteor.publish('posts', function(options) {
  return Posts.find({}, options);
});

Et ensuite nous nous abonnons à cette publication plusieurs fois. En fait c'est plus ou moins exactement ce que nous faisons dans Microscope :

Meteor.subscribe('posts', {submitted: -1, limit: 10});
Meteor.subscribe('posts', {baseScore: -1, submitted: -1, limit: 10});

Qu'est-ce qu'il se passe exactement ? Chaque navigateur ouvre deux abonnements différents, chacun se connecte à la même publication sur le serveur.

Chaque abonnement fournit des arguments différents à la publication, mais fondamentalement, chaque fois un ensemble (différent) de documents est recueilli de la collection posts et envoyé dans le tuyau vers la collection côté client.

Vous pouvez aussi souscrire deux fois au même abonnement avec les mêmes arguments ! Difficile d'imaginer beaucoup de situations où c'est utile, mais peut être qu'un jour cette flexibilité le sera !

De multiples collections dans un seul abonnement

Contrairement aux bases de données traditionnelles comme MySQL qui utilise des joins, les bases de données NoSQL comme Mongo sont plutôt dans la dénormalisation et l’embarqué. Voyons comment ça fonctionne dans le contexte de Meteor.

Regardons un exemple concret. Nous avons ajouté des commentaires à nos articles, et depuis, nous sommes heureux de publier uniquement les commentaires de l'article que l'utilisateur est en train de lire.

Cependant, supposez que nous voulions montrer les commentaires de tous les articles de la première page (garder en tête que ces articles changeront quand nous les paginerons). Ce cas classique d'utilisation présente une bonne raison pour embarquer les commentaires dans les articles, et en fait c'est ce qui nous pousse à dénormaliser les compteurs de commentaires.

Bien sûr nous pouvons toujours juste embarquer les commentaires dans les articles, se débarrasser de la collection Comments. Mais comme nous avons vu précédemment dans le chapitre de dénormalisation, en faisant ça nous perdrions certains des bénéfices de travailler avec des collections différentes.

Mais il s'avère qu'il y a une astuce impliquant les abonnements qui rende possible d'inclure nos commentaires tout en préservant des collections séparées.

Supposons qu'avec notre première page de liste d'articles, nous voulons nous abonner à une liste des deux top commentaires de chaque article.

Il serait difficile d'accomplir ça avec une publication de commentaires indépendants, spécialement si la liste d'articles était limité d'une certaine façon (disons, les 10 plus récents). Nous devrions écrire une publication qui ressemblerait à ça :

Deux collections dans un abonnement
Deux collections dans un abonnement
Meteor.publish('topComments', function(topPostIds) {
  return Comments.find({postId: topPostIds});
});

Ça causerait un problème de performance, comme la publication aurait besoin d'être démontée et ré-établie à chaque fois que la liste de topPostsIds change.

Il y a un moyen de faire ça. Nous utilisons juste le fait que nous ne pouvons pas seulement avoir plus d'une publication par collection, mais nous pouvons aussi avoir plus d'une collection par publication :

Meteor.publish('topPosts', function(limit) {
  var sub = this, commentHandles = [], postHandle = null;

  // Envoyer les deux top commentaires d'un post unique
  function publishPostComments(postId) {
    var commentsCursor = Comments.find({postId: postId}, {limit: 2});
    commentHandles[postId] = 
      Mongo.Collection._publishCursor(commentsCursor, sub, 'comments');
  }

  postHandle = Posts.find({}, {limit: limit}).observeChanges({
    added: function(id, post) {
      publishPostComments(postId);
      sub.added('posts', id, post);
    },
    changed: function(id, fields) {
      sub.changed('posts', id, fields);
    },
    removed: function(id) {
      // Stopper l'observation des changelents sur les commentaires de l'article
      commentHandles[id] && commentHandles[id].stop();
      // Supprimer l'article
      sub.removed('posts', id);
    }
  });

  sub.ready();

  // S'assurer que nous nettoyons tout (notez que `_publishCursor`
  // fait ça pour nous avec les observateurs de commentaire)
  sub.onStop(function() { postHandle.stop(); });
});

Notez que nous ne retournons rien dans cette publication, comme nous envoyons nous-mêmes manuellement des messages à sub (via .added() et consorts). Donc nous n'avons pas besoin de demander à _publishCursor de faire ça pour nous en retournant un curseur.

Maintenant, chaque fois que nous publions un article nous publions également automatiquement les deux top commentaires qui y sont attachés. Et tout ça avec un seul appel d'abonnement !

Bien que Meteor ne rend pas encore cette approche très direct, vous pouvez voir chercher le paquet publish-with-relations sur Atmosphere, qui a pour objectif de rendre ce pattern plus facile à utiliser.

Relier des collections différentes

Qu'est-ce que notre nouvelle connaissance sur la flexibilité des abonnements pourrait nous apporter ? Bien, si nous n'utilisons pas _publishCursor, nous n'avons pas besoin de suivre la contrainte que la collection source sur le serveur a besoin d'avoir le même nom que la collection cible sur le client.

Une collection pour deux abonnements
Une collection pour deux abonnements

Une raison de pourquoi nous voudrions faire ça est Single Table Inheritance (héritage de tableau unique).

Supposons que nous voulions référencer divers types d'objets de nos articles, dont chacun de ces champs communs stockés mais aussi a différé légèrement dans le contenu. Par exemple, nous pourrions construire un système de blog comme Tumblr où chaque article possède le classique ID, timestamp et title ; mais en plus peut également gérer une image, vidéo, lien, ou juste du texte.

Nous pouvons stocker tous ces objets dans une seule collection 'resources', en utilisant un attribut type pour indiquer quelle sorte d'objet ils sont. (video, image, link, etc.).

Et bien que nous avons une seule collection Resources sur le serveur, nous pouvons transformer cette unique collection en multiples collections Videos, Images, etc. sur le client avec ce petit tour de magie :

  Meteor.publish('videos', function() {
    var sub = this;

    var videosCursor = Resources.find({type: 'video'});
    Mongo.Collection._publishCursor(videosCursor, sub, 'videos');

    // _publishCursor ne fait pas l'appel pour nous au cas où nous voudrions le faire plusieurs fois.
    sub.ready();
  });

Nous disons à _publishCursor de publier nos vidéos (juste en retournant) que le curseur ferait, mais plutôt que de publier à la collection resources sur le client, à la place nous publions de 'resources' vers 'videos'.

Une autre idée similaire est d'utiliser publish avec une collection côté client où il n'y a pas du tout de collection côté serveur ! Par exemple, vous pouvez récupérer les données d'un service tiers et les publier dans une collection côté client.

Grâce à la flexibilité de l'API publish, les possibilités sont infinies.

Animations

14

////

Meteor & the DOM

////

////

////

////

  1. ////
  2. ////
  3. ////
  4. ////
  5. ////
  6. ////

////

Swtiching two posts
Swtiching two posts

////

////

////

Proper Timing

////

////

////

////

////

////

CSS Positioning

////

////

////

////

////

.post{
  position:relative;
  transition:all 300ms 0ms ease-in;
}
client/stylesheets/style.css

////

////

Position:absolute

////

////

Total Recall

////

////

////

////

////

Ranking Posts

////

////

////

////

Template.postsList.helpers({
  postsWithRank: function() {
    this.posts.rewind();
    return this.posts.map(function(post, index, cursor) {
      post._rank = index;
      return post;
    });
  }
});
/client/views/posts/posts_list.js

////

////

<template name="postsList">
  <div class="posts">
    {{#each postsWithRank}}
      {{> postItem}}
    {{/each}}

    {{#if nextPath}}
      <a class="load-more" href="{{nextPath}}">Load more</a>
    {{/if}}
  </div>
</template>
/client/views/posts/posts_list.html

Be Kind, Rewind

////

////

////

Putting it together

////

Template.postItem.helpers({
  //...
});

Template.postItem.rendered = function(){
  // animate post from previous position to new position
  var instance = this;
  var rank = instance.data._rank;
  var $this = $(this.firstNode);
  var postHeight = 80;
  var newPosition = rank * postHeight;

  // if element has a currentPosition (i.e. it's not the first ever render)
  if (typeof(instance.currentPosition) !== 'undefined') {
    var previousPosition = instance.currentPosition;
    // calculate difference between old position and new position and send element there
    var delta = previousPosition - newPosition;
    $this.css("top", delta + "px");
  }

  // let it draw in the old position, then..
  Meteor.defer(function() {
    instance.currentPosition = newPosition;
    // bring element back to its new original position
    $this.css("top",  "0px");
  }); 
};

Template.postItem.events({
  //...
});
/client/views/posts/post_item.js

Commit 14-1

Added post reordering animation.

////

////

////

Animating New Posts

////

////

  1. ////
  2. ////

////

////

Template.postItem.helpers({
  //...
});

Template.postItem.rendered = function(){
  // animate post from previous position to new position
  var instance = this;
  var rank = instance.data._rank;
  var $this = $(this.firstNode);
  var postHeight = 80;
  var newPosition = rank * postHeight;

  // if element has a currentPosition (i.e. it's not the first ever render)
  if (typeof(instance.currentPosition) !== 'undefined') {
    var previousPosition = instance.currentPosition;
    // calculate difference between old position and new position and send element there
    var delta = previousPosition - newPosition;
    $this.css("top", delta + "px");
  } else {
    // it's the first ever render, so hide element
    $this.addClass("invisible");
  }

  // let it draw in the old position, then..
  Meteor.defer(function() {
    instance.currentPosition = newPosition;
    // bring element back to its new original position
    $this.css("top",  "0px").removeClass("invisible");
  }); 
};

Template.postItem.events({
  //...
});
/client/views/posts/post_item.js

Commit 14-2

Fade items in when they are drawn.

////

CSS & JavaScript

////

////

////

Vocabulaire

99

Client

Lorsque nous parlons de client, nous faisons référence au code qui tourne dans le navigateur web des utilisateurs, que ce soit un navigateur traditionnel comme Firefox ou Safari, ou bien quelque chose d'aussi complexe que UIWebView dans une application native pour iPhone.

Collection

Une collection Meteor est l'entrepôt de données (datastore) qui est automatiquement synchronisé entre le client et le serveur. Les collections ont un nom (ex. posts), et existent généralement sur le client et sur le serveur. Même si elles se comportent différemment, elles ont une API commune basée sur l'API de Mongo.

Computation

Une “computation” est un bloc de code qui tourne à chaque fois qu'il y a un changement dans les sources de données versatiles dont il dépend. Si vous avez une source de données versatiles (par exemple une variable de session) et que vous voulez interagir avec elle, il faudra lui définir une computation.

Curseur (Cursor)

Un curseur est le produit d'une requête sur une collection Mongo. Au niveau du client, un curseur n'est pas qu'un tableau de résultats, mais un objet réactif qui peut être observé lorsque des objets dans la collection en question sont ajoutés, enlevés et modifiés.

DDP

DDP, protocole de distribution de données de Meteor, est le protocole utilisé pour synchroniser les collections et faire les appels de méthodes. DDP est supposé être un protocole générique qui prend la place du HTTP pour les applications en temps réel lourdes en données.

Tracker

Tracker est le système réactif de Meteor. Tracker agit dans les coulisses pour garder le HTML automatiquement synchronisé avec le modèle de données choisi.

Document

Mongo utilise les “documents” pour stocker les données et donc les objets appartenant aux collections sont appelés “documents”. Ce sont des objets JavaScript de base (même s'ils ne peuvent pas contenir de fonctions) avec une propriété spéciale _id qui permet à Meteor de suivre les données sur DDP.

Helpers

Quand un template a besoin de générer quelque chose de plus complexe qu'une propriété de document, il peut faire appel à un helper, une fonction qui aide pour le rendu.

Compensation de latence (Latency compensation)

C'est une technique qui permet de simuler des appels de méthodes du coté client pour éviter les délais en attentant la réponse du serveur.

Le groupe de développement de Meteor (Meteor Development Group, MDG)

L'entreprise développant actuellement Meteor, par opposition avec le framework lui-même.

Méthode (Method)

Une méthode Meteor est un appel de procédure du client vers le serveur. Les méthodes ont une logique spéciale pour détecter les changements dans les collections ainsi que des mécanismes de compensation de délai.

MiniMongo

La collection coté client est stockée dans un datastore qui offre une API comparable à Mongo. La librairie qui supporte cette API est appelée “MiniMongo” pour indiquer que c'est une version simplifiée de Mongo qui opère entièrement dans la mémoire.

Paquet (Package)

Un paquet Meteor peut être du code JavaScript destiné à tourner sur le serveur, du code JavaScript destiné à tourner sur le client, des instructions sur la gestion des ressources (comme par exemple SASS vers CSS), ou des ressources à traiter.
Un paquet est comme une bibliothèque surpuissante. Non seulement Meteor est livré avec une liste complète de paquets de base, mais il y a aussi Atmosphere qui est une collection de paquets offerts par la communauté.

Publication

Une publication est un jeu de données qui a un nom et qui est personnalisé pour chaque utilisateur qui s'y abonne. Vous créez une publication sur le serveur.

Serveur (Server)

Le serveur Meteor est un serveur HTTP et DDP via Node.js. Il est constitué de toutes les bibliothèques Meteor ainsi que du code JavaScript que vous avez créé du coté du serveur. Quand le serveur Meteor démarre, il se connecte à la base de donnée Mongo (qui démarre automatiquement).

Session

La session dans Meteor correspond à une source de données versatile du coté client, qui peut être utilisée par votre application pour garder l'état dans lequel se trouve l'utilisateur.

Abonnement (Subscription)

Un abonnement est une connexion à une publication pour un client particulier. L'abonnement est du code qui s’exécute dans le navigateur et qui garde les données synchronisées avec la publication du serveur.

Patron (Template)

Un patron permet de générer de l'HTML en Javascript. Par défaut, Meteor utilise Spacebars, un système de template sans logique (logic-less), qui pourrait être étendu dans le futur.

Contexte des données du patron (Template Data Context)

Lorsqu'un patron génère un rendu, il se réfère à un objet JavaScript qui livre des données spécifiques à ses besoins. Généralement ce sont de bons vieux objets JavaScript (plain-old-JavaScript-objects, POJOs), souvent des documents appartenant à une collection, même s'ils peuvent être beaucoup plus élaborés et posséder leur propres fonctions.