-
Notifications
You must be signed in to change notification settings - Fork 12
/
09-errors.md.erb
551 lines (403 loc) · 20 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
---
title: Fehler
slug: errors
date: 0009/01/01
number: 9
contents: Fehler und Meldungen besser darstellen| erfahren wie man `Template.rendered` benutzt um zu wissen wann dem Nutzer einen Fehler anzeigt wurde| einen Routing-Filter einsetzen um sicherzustellen, dass Fehler nur einmal angezeigt werden
paragraphs: 31
---
Die Standardfunktion des Browsers `alert()` ist eine unbefriedegende Lösung um dem Benutzer mitzuteilen, dass ein Problem vorliegt und ist aus Sicht der User-Experience sicher nicht optimal Das können wir besser.
Wir bauen anstatt dessen ein vielseitigeres Fehler-Reporting, dass dem Benutzer genau mitteilt was passiert ohne den Ablauf dert App zu behindern.
Zu diesem Zweck implementieren wir ein einfaches System, das neue Fehlermeldungen in der oberen rechten Ecke des Fensters anzeigt, wie bei der bekannten Mac OS App [Growl](http://growl.info)
### Einführung in lokale Collections
Zu Beginn erstellen wir eine neue lokale Collection um unsere Fehler darin zu speichern. Vorausgesetzt Fehler sind nur für die aktuelle Session relevant und müssen nicht nachhaltig in irgendeiner Form gespeichert werden, machen wir etwas neues, wir erstellen eine *lokale Collection*. Das heißt, dass die `Errors` Collection nur *im Browser* existiert und keine Anstallten macht sich mit dem Server zu synchronisieren.
Das zu erreichen ist ziemlich leicht: wir erzeugen die Fehlermeldungen in einer Datei, im Verzeichnis `client` (um die Collection lokal zu machen). Dabei wird der Name der MongoDB Collection auf `null` gesetzt (da die Daten dieser Collection niemals in der serverseitigen Datenbank gespeichert werden):
~~~js
// Local (client-only) collection
Errors = new Mongo.Collection(null);
~~~
<%= caption "client/helpers/errors.js" %>
Da die Collection jetzt erzeugt ist, können wir die Funktion `throwError` erstellen um Fehler hinzuzufügen. Wir müssen uns nicht über `allow`, `deny` oder ähnliches kümmern, da die Collection "lokal" ist.
~~~js
throwError = function(message) {
Errors.insert({message: message})
}
~~~
<%= caption "client/helpers/errors.js" %>
Der Vorteil einer lokalen Collection um die Fehler zu speichern, ist, dass diese - wie alle Collections - reaktive sind. Das bedeutet, dass wir Fehler auf die gleiche Art und Weise, reaktiv anzeigen können, wie wir es von anderen Daten aus Collections gewohnt sind.
### Das Anzeigen von Fehlern
Wir zeigen die Fehler im oberen Teil unseres Hauptlayouts an:
~~~html
<template name="layout">
<div class="container">
{{> header}}
{{> errors}}
<div id="main">
{{> yield}}
</div>
</div>
</template>
~~~
<%= caption "client/templates/application/layout.html" %>
Nun legen wird die Templates `errors` und `error` in der Datei `errors.html` an:
~~~html
<template name="errors">
<div class="errors">
{{#each errors}}
{{> error}}
{{/each}}
</div>
</template>
<template name="error">
<div class="alert alert-danger" role="danger">
<button type="button" class="close" data-dismiss="alert">×</button>
{{message}}
</div>
</template>
~~~
<%= caption "client/templates/includes/errors.html" %>
<% note do %>
### Doppel-Templates
Dir ist vielleicht aufgefallen, dass wir zwei Templates in eine Datei gelegt haben. Bisher haben wir uns an eine "Eine Datei - ein Template"-Konvention gehalten. Aber was Meteor betrifft, würde eine einzelne Datei genausogut funktionieren (wobei das eine sehr unübersichtliche `main.html` Datei wäre!).
In diesem Fall machen wir eine Ausnahme, da beide Templates sehr kurz sind. Dadurch wird unser Repository ein wenig übersichtlicher.
<% end %>
Jetzt implementieren wir unseren Template-Helper und schon kann es losgehen!
~~~js
Template.errors.helpers({
errors: function() {
return Errors.find();
}
});
~~~
<%= caption "client/templates/includes/errors.js" %>
Man kann die Fehlermeldungen jetzt schon manuell testen. Öffne einfach die Browser Konsole und gibt ein:
~~~js
❯ throwError("I'm an error!");
~~~
<%= screenshot "9-1", "Fehlermeldung testen" %>
<%= commit "9-1", "Basic error reporting." %>
<% note do %>
### Zwei Arten von Fehlern
An diesem Punkt muss man eine wichtige Unterscheidung machen zwischen App-Level Fehlern und Code-Level Fehlern.
**App-Level** Fehler werden normalerweise vom Benutzer ausgelöst, die dann ebenfalls die Möglichkeit haben darauf zu reagieren. Dazu gehören Validierungs-Fehler, Berechtigungs-Fehler, "not found"-Fehler und so weiter. Das sind die Art von Fehlern, die man dem Nutzer anzeigen sollte um zu helfen, Probleme zu lösen.
**Code-Level** Fehler sind unerwartete Fehler aufgrund von Bugs im Code und man sollte sie dem Benutzer nicht direkt anzeigen, sondern mit einer Error-Tracing Lösung eines Drittanbieters abfangen (wie z.B. [Kadira](http://kadira.io)).
In diesem Kapitel behandeln wir den ersten Typ von Fehlern und nicht wie man Bugs abfängt.
<% end %>
### Fehler erzeugen
Wir wissen jetzt, wie man Fehler anzeigt aber wir müssen sie zunächst auslösen, bevor wir etwas sehen. Wir haben bereits ein gutes Fehler-Szenario implementiert: unsere Doppelpost-Warnung. Wir ersetzen einfach die Aufrufe von `alert` im Eventhelper `postSubmit` durch die neue Funktion `throwError`, die wir gerade erstellt haben:
~~~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(),
message: $(e.target).find('[name=message]').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" %>
Wo wir schon dabei sind, passen wir auch den Eventhelper `postEdit` an:
~~~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" %>
<%= commit "9-2", "Actually use the error reporting." %>
Versuchen wir es mal: erstellt man ein Post mit der URL `http://meteor.com`, sieht man folgendes:
<%= screenshot "9-2", "Einen Fehler auslösen" %>
### Fehlermeldungen wieder entfernen
Du hast vielleicht schon bemerkt, dass die Fehlermeldungen nach einigen Sekunden selbstständig wieder verschwinden. Dafür ist ein bisschen CSS-Magie verantwortlich, die wir ganz am Anfang dieses Buches hinzugefügt haben:
~~~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" %>
Wir definieren eine CSS-Animation `fadeOut`, die unsere Keyframes für die Opacity-Eigenschaft festlegt (bei 0%, 10%, 90% und 100% der gesamten Animationsdauer) und wenden diese Animation auf die Klasse `alert` an.
Die Animation läuft insgesamt für 2700 Millisekunden, benutzt ein `ease-in` Timing, läuft mit einer Verzögerung von 0 Sekunden, wird einmalig ausgeführt und verharrt im letzten Keyframe, wenn sie fertig ist.
<% note do %>
### Animation vs Animation
Es mag vielleicht überraschen, dass wir CSS-Animationen benutzen (die festgelegt sind und außerhalb der Konstroller der App liegen) anstatt die Meteor-eigene Animationskontrolle zu nutzen.
Obwohl Meteor das Einfügen von Anomationen unterstützt, wollen wir uns in diesem Kapitel nur mit Fehlern beschäftigen. Also belassen wir es vorerst bei den "dummen" Css-Animationen und bewahren uns die Leckerbissen für das Kapitel Animationen auf.
<% end %>
Das funktioniert nun soweit, doch sobald man mehrere Fehler auslöst (indem man z.B. den gleichen Link dreimal abschickt), wird deutlich, dass sie sich stapeln:
<%= screenshot "9-3", "Stack-Overflow" %>
Das passiert, da die `.alert` Elemente zwar ausgebelndet werden, aber immernoch im DOM vorhanden sind. Das sollten wir beheben.
Dies ist eine der Situationen wo Meteor glänzen kann. Da die Collection `Errors` reaktiv ist, brauchen wir lediglich die alten Fehler aus der Collection zu entfernen!
Wir nutzen `Meteor.setTimeout` um eine Callback-Funktion festzulegen, die nach dem Timeout ausgefürt wird (in diesem Fall 3000 Millisekunden).
~~~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" %>
<%= commit "9-3", "Clear errors after 3 seconds." %>
Der Callback `onRendered` wird aufgerufen, sobald unser Template im Browser gerendered wurde. In diesem Callback verweist `this` auf die derzeitige Template-Instanz und `this.data` erlaubt es uns auf die Daten des Objekts zuzugreifen, das gerade gerendert wird (in unserem Fall handelt es sich um ein Fehlerobjekt).
### Validierung
Bisher haben validieren wir das Formular noch überhaupt nicht. Benutzer sollen aber zumindest eine URL und einen Titel für einen neuen Post eingeben. Also stellen wir sicher, dass sie das auch machen.
Wr machen zwei Dinge um auf fehlende Eingaben hinzuweisen: zunächst wenden wir die spezielle CSS-Klasse `has-error` auf jedes fehlerhafte Eingabefeld an. Dann zeigen wir eine hilfreiche Fehlermeldung unter dem Feld an.
Wir bereiten unser Template postSubmit vor, um die neuen Helper einsetzen zu können:
~~~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" %>
Man beachte, dass wir Parameter (wie `url` und `title`) an jeden Helper übergeben. Das ermöglicht uns den selben Helper in beiden Fällen zu benutzen indem wir die Parameter jeweils anpassen.
Nun kommt der spaßige Teil: die Helper zum Leben erwecken.
Wir benutzen die **Session** um das Objekt `postSubmitErrors` mit Fehlermeldungen zu befüllen. Interagiert der Nutzer mit dem Formular, verändert sich dieses Formular, was zu reaktiven Veränderungen in der Anzeige des Formular führt.
Zuerst initialisieren wir das Objekt, sobald das Template `postSubmit` erzeugt wird. So erreichen wir, dass dem Benutzer keine Fehlermeldung von einem vorigen Besuch der Seite angezeigt werden.
Dann definieren wir die zwei Template-Helper. Beide beziehen sich auf die Eigenschaft `field` von `Session.get('postSubmitErrors') (wo `field` entweder die `url` oder der `title` ist, je nachdem von wo wir den Helper aufrufen).
Während `errorMessage` nur die Fehlermeldung selbst zurückgibt, überprüft `errorClass` die *Existenz* einer solchen und returnt `has-error` wenn eine Meldung vorliegt.
~~~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" %>
Um zu überprüfen, dass der Helper korrekt funktioniert, öffnen wir die Konsole des Browsers und geben folgenden Code ein:
~~~js
❯ Session.set('postSubmitErrors', {title: 'Warning! Intruder detected. Now relea
sing robo-dogs.'});
~~~
<%= caption "Browser Konsole" %>
<%= screenshot "9-4", "Alarm! Alarm!" %>
Im nächsten Schritt verbinden wir das Formular mit dem Objekt `postSubmitErrors` der Session.
Bevor wir das allerdings tun, erzeugen wir eine neue Funktion `validatePost`, die ein Objekt `posts` überprüft und ein Objekt `errors` zurückgibt, wenn Fehler gefunden wurden (also wenn eines der Felder `title` oder `url` fehlt), in der Datei `posts.js`:
~~~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" %>
Diese Funktion rufen wir im Event-Helper `postSubmit` auf:
~~~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) {
// 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/post_submit.js" %>
Beachte, dass wir `return` benutzen um die Ausführung des Helpers abzubrechen, weil ein Fehler vorliegt nicht weil wir den Wert wirklich zurückgeben wollen.
<%= screenshot "9-5", "Erwischt!" %>
### Serverseitige Validierung
Wir sind aber noch nicht ganz fertig. Wir validieren die Existenz einer URL und eines Titels beim *Client* aber was ist mit dem *Server*? Schließlich könnte jemand versuchen einen leeren Post über die Browser Konsole mit `postInsert` einzuschläusen.
Auch wenn wir auf dem Server keine Fehlermeldung ausgeben müssen, können wir trotzdem die selbe Funktion `validatePost` nutzen. Nur dass wir sie dieses Mal auch in der *Methode* `postInsert` ausführen und nicht nur im Event-Helper:
~~~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" %>
Nochmal: Benutzer sollten die Nachricht "You must set a title and URL for your post" normalerweise nie zu sehen bekommen. Sie wird nur ausgegeben, wenn jemand versucht unsere mühevoll gestaltete Benutzeroberfläche zu umgehen und an deren Stelle die Browser Konsole benutzt.
Um das zu testen, öffnen wir die Browser Konsole und versuchen einen Post ohne URL zu erstellen:
~~~js
Meteor.call('postInsert', {url: '', title: 'No URL here!'});
~~~
Wenn wir unseren Job gut gemacht haben, wird jetzt eine große Menge Code zurückgegeben, mit der Nachricht "You must set a title and URL for your post".
<%= commit "9-4", "Validate post contents on submission" %>
### Validierung beim Bearbeiten
Um das Ganze abzurunden, wenden wir die Validierung auch auf das Formular für die Post-*Bearbeitung* an. Das Code sieht sehr ähnlich aus. Zuerst das 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="Y
our 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}}" placehol
der="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" %>
Dann die Template-Helper:
~~~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) {
// display the error to the user
throwError(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');
}
}
});
~~~
<%= caption "client/templates/posts/post_edit.js" %>
Genau wie beim Absende-Formular wollen wir auch hier unsere Posts zusätzlich auf dem Server validieren. Da wir aber keine Methode benutzen um Posts zu bearbeiten, sondern direkt ein `update` beim Client durchführen, müssen wir ein neues Callback `deny` hinzufügen:
~~~js
//...
Posts.deny({
update: function(userId, post, fieldNames, modifier) {
var errors = validatePost(modifier.$set);
return errors.title || errors.url;
}
});
//...
~~~
<%= caption "lib/collections/posts.js" %>
Beachten sollte man, dass das Argument `post` auf einen *existierenden* Posts verweist. In diesem Fall wollen wir ein *update* validieren, weshalb wir `validatePost` auf den Inhalt der Eigenschaft `$set` von `modifier` anwenden (wie bei `Posts.update({$set: {title: ..., url: ...}})`).
Das funktioniert, da `modifier.$set` die gleichen Eigenschaften `title` und `url` beinhaltet wie das ganze Objekt `post`. Das bedeutet natürlich auch, dass ein teilweises Update von `title` oder `url` fehlschlagen würde, doch dies sollte in der Praxis kein Problem darstellen.
Dir ist vielleicht aufgefallen, dass dies unser zweites `deny` Callback ist. Setzt man mehrer `deny` Callbacks ein, schlägt die Operation fehl sobald eine von ihnen `true` zurückgibt. In diesem Fall bedeutet das, dass das `update` nur ausgeführt wird, wenn es sich auf die Felder `title` und `url` bezieht und keiner der beiden leer ist.
<%= commit "9-5", "Validate post contents when editing" %>