-
Notifications
You must be signed in to change notification settings - Fork 32
/
09-errors.md.erb
557 lines (417 loc) · 21.8 KB
/
09-errors.md.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
---
title: Erreurs
slug: errors
date: 0009/01/01
number: 9
points: 10
photoUrl: http://www.flickr.com/photos/ikewinski/9413892879/
photoAuthor: Mike Lewinski
contents: Créer un meilleur mécanisme pour afficher les erreurs et les messages.|Implémenter une validation de formulaire plus stricte|Ajouter un rapport d'erreurs en ligne à nos formulaires
paragraphs: 31
---
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](http://growl.info/).
### 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) :
~~~js
// Collection Locale (client-seulement)
Errors = new Meteor.Collection(null);
~~~
<%= caption "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.
~~~js
throwError = function(message) {
Errors.insert({message: message});
};
~~~
<%= caption "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 :
~~~html
<template name="layout">
<div class="container">
{{> header}}
{{> errors}}
<div id="main">
{{> yield}}
</div>
</div>
</template>
~~~
<%= caption "client/templates/application/layout.html" %>
<%= highlight "4" %>
Maintenant, créons les templates `errors` et `error` dans `errors.html` :
~~~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">×</button>
{{message}}
</div>
</template>
~~~
<%= caption "client/templates/includes/errors.html" %>
<% note do %>
### 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.
<% end %>
Nous avons juste besoin d'intégrer notre helper de template, et nous seront fin prêts !
~~~js
Template.errors.helpers({
errors: function() {
return Errors.find();
}
});
~~~
<%= caption "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 :
~~~js
throwError("I'm an error!");
~~~
<%= screenshot "9-1", "Tester les messages d'erreur." %>
<%= commit "9-1", "Rapport d'erreur basique." %>
<% note do %>
### 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](http://kadira.io)).
Dans ce chapitre, nous nous concentrerons sur le premier type d'erreur, pas sur le suivi des bugs.
<% end %>
### 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 :
~~~js
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});
});
}
});
~~~
<%= caption "client/templates/posts/post_submit.js" %>
<%= highlight "13,17" %>
Tant que nous y sommes, nous allons faire la même chose pour `postEdit` :
~~~js
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});
}
});
},
//...
});
~~~
<%= caption "client/templates/posts/post_edit.js" %>
<%= highlight "15" %>
<%= 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 :
<%= screenshot "9-2", "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 :
~~~css
@keyframes fadeOut {
0% {opacity: 0;}
10% {opacity: 1;}
90% {opacity: 1;}
100% {opacity: 0;}
}
//...
.alert {
animation: fadeOut 2700ms ease-in 0s 1 forwards;
//...
}
~~~
<%= caption "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.
<% note do %>
### 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.
<% end %>
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 :
<%= screenshot "9-3", "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).
~~~js
Template.errors.helpers({
errors: function() {
return Errors.find();
}
});
Template.error.onRendered(function() {
var error = this.data;
Meteor.setTimeout(function () {
Errors.remove(error._id);
}, 3000);
});
~~~
<%= caption "client/templates/includes/errors.js" %>
<%= highlight "7~12" %>
<%= commit "9-3", "Effacer les erreurs après 3 secondes." %>
Le callback [`onRendered`](http://docs.meteor.com/#/full/template_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 :
~~~html
<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>
~~~
<%= caption "client/templates/posts/post_submit.html" %>
<%= highlight "3,7,10,14" %>
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.
~~~js
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' : '';
}
});
//...
~~~
<%= caption "client/templates/posts/post_submit.js" %>
<%= highlight "1~12" %>
Vous pouvez tester que nos helpers fonctionnent normalement en ouvrant le navigateur et en tapant la ligne de code suivante :
~~~js
Session.set('postSubmitErrors', {title: 'Attention ! Intrusion détectée. Les robots-chiens sont lâchés.'});
~~~
<%= caption "Browser console" %>
<%= screenshot "9-4", "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) :
~~~js
//...
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;
}
//...
~~~
<%= caption "lib/collections/posts.js" %>
<%= highlight "3~13" %>
Nous appellerons cette fonction depuis le helper d'événement `postSubmit` :
~~~js
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});
});
}
});
~~~
<%= caption "client/templates/posts/post_submit.js" %>
<%= highlight "10~12" %>
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.
<%= screenshot "9-5", "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 :
~~~js
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
};
}
});
~~~
<%= caption "lib/collections/posts.js" %>
<%= highlight "9~11" %>
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 :
~~~js
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 :
~~~html
<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>
~~~
<%= caption "client/templates/posts/post_edit.html" %>
<%= highlight "3,7,10,14" %>
Puis les helpers du template :
~~~js
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');
}
}
});
~~~
<%= caption "client/templates/posts/post_edit.js" %>
<%= highlight "1~12,25~27,32" %>
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 :
~~~js
//...
Posts.deny({
update: function(userId, post, fieldNames, modifier) {
var errors = validatePost(modifier.$set);
return errors.title || errors.url;
}
});
//...
~~~
<%= caption "lib/collections/posts.js" %>
<%= highlight "3~8" %>
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." %>