Fehler

9

Zu ...% übersetzt

In diesem Kapitel wirst du:

  • 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
  • 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

    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):

    // Local (client-only) collection
    Errors = new Mongo.Collection(null);
    
    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.

    throwError = function(message) {
      Errors.insert({message: message})
    }
    
    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:

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

    Nun legen wird die Templates errors und error in der Datei errors.html an:

    <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">&times;</button>
        {{message}}
      </div>
    </template>
    
    client/templates/includes/errors.html

    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.

    Jetzt implementieren wir unseren Template-Helper und schon kann es losgehen!

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

    Man kann die Fehlermeldungen jetzt schon manuell testen. Öffne einfach die Browser Konsole und gibt ein:

     throwError("I'm an error!");
    
    Fehlermeldung testen
    Fehlermeldung testen

    Commit 9-1

    Basic error reporting.

    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).

    In diesem Kapitel behandeln wir den ersten Typ von Fehlern und nicht wie man Bugs abfängt.

    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:

    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});
        });
      }
    });
    
    client/templates/posts/post_submit.js

    Wo wir schon dabei sind, passen wir auch den Eventhelper postEdit an:

    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

    Actually use the error reporting.

    Versuchen wir es mal: erstellt man ein Post mit der URL http://meteor.com, sieht man folgendes:

    Einen Fehler auslösen
    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:

    @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

    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.

    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.

    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:

    Stack-Overflow
    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).

    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

    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:

    <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

    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') (wofieldentweder dieurloder dertitle` 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.

    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

    Um zu überprüfen, dass der Helper korrekt funktioniert, öffnen wir die Konsole des Browsers und geben folgenden Code ein:

     Session.set('postSubmitErrors', {title: 'Warning! Intruder detected. Now relea
    sing robo-dogs.'});
    
    Browser Konsole
    Alarm! Alarm!
    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:

    //...
    
    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

    Diese Funktion rufen wir im Event-Helper postSubmit auf:

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

    Erwischt!
    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:

    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

    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:

    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:

    <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>
    
    client/templates/posts/post_edit.html

    Dann die Template-Helper:

    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');
        } 
      }
    });
    
    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:

    //...
    
    Posts.deny({
      update: function(userId, post, fieldNames, modifier) {
        var errors = validatePost(modifier.$set);
        return errors.title || errors.url; 
      }
    });
    
    //...
    
    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