Errors

9

翻訳の進捗

本章で学ぶこと:

  • エラーとメッセージを表示するためのより良いメカニズムを作成します。
  • より厳密なフォームバリデーションを実装します。
  • ユーザーがエラーを見ているかを知る`Template.rendered`を使用方法を学んでください。
  • 投稿に問題がある時に、 ユーザーに警告をするために単純にブラウザの標準的なalert()ダイアログを使うことは少々不愉快で、 素晴らしいUXに役立つわけではありません。 この点をよりよくできます。

    多目的で使えるエラーを報告する仕組みを作りましょう。 これはユーザーのワークフローを中断することなくユーザーに何が起きているか知らせる上でより良い役割を果たします。

    人気のMacOSアプリに似たウィンドウの右上隅、 Growlのような新たなエラーを表示する単純なシステムを実装しようと思います。

    ローカルコレクションの紹介

    始めるにあたり、私たちのエラーを格納するコレクションを作成する必要があります。 エラーは、現在のセッションにのみ関連し、永続的である必要はないことを考えると、 新しい何かをする、ローカルコレクションを作成しようと思います。

    上記を達成するために、我々はclientディレクトリ内のエラーを作成します。 (このコレクションのデータはサーバー側のデータベースに保存されることはありません) そのMongoDBのコレクション名がnullに設定します(クライアント専用のコレクションを作ります。)

    // Local (client-only) collection
    Errors = new Mongo.Collection(null);
    
    client/helpers/errors.js

    これでコレクションが作られたので、 私たちはこのコレクションにエラーの追加を呼び出すthrowError関数を入れることができます。 私たちはallowdenyなどのセキュリテイを気にする必要がありません。 というのも、このコレクションは「ローカル」で、MongoDBに保存されていないからです。

    throwError = function(message) {
      Errors.insert({message: message});
    };
    
    client/helpers/errors.js

    エラーを格納するためにローカルコレクションを使うことの利点は、 すべてのコレクションと同様に、リアクティブだからです。 つまり、私たちは他のコレクションデータを表示するのと同じように リアクティブにエラーを表示することができます。

    エラー表示

    メインレイアウトの上部でエラーを表示していきます:

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

    errors.html 内に、errors and error テンプレートを作りましょう:

    <template name="errors">
      <div class="errors">
        {{#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

    2つのテンプレート

    私たちが2つのテンプレートを1つのファイルに置いていることにお気づきでしょう。 今まで私たちは 「1つのファイルに1つのテンプレート」という慣習を支持してきましたが、 Meteorに関する限りでは、1つのファイルにすべてのテンプレートを置くこともできます。 (混乱を招くことになりますがずべてをmain.htmlに収めることすらできます!)

    この場合、両方のerrorテンプレートはとても短いので、 私たちはリポジトリを少しきれいにしておくために、例外として同じファイルにerrorテンプレートを置きました。 

    私たちはテンプレートヘルパーをまとめる必要があり、用意ができています。

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

    実はすでに新しいエラーメッセージを手動で試すことができます。 以下のようにブラウザコンソールでタイプするだけです。:

    throwError("I'm an error!");
    
    Testing error messages.
    Testing error messages.

    コミット 9-1

    Basic error reporting.

    2種類のエラー

    この時点で、「アプリレベル」エラーと「コードレベル」のエラーとを区別することは重要です。

    アプリレベルエラーは、一般的にユーザがトリガーとなり、 ユーザーが順番にそれらに基づいて行動していきます。 これらは、検証エラー、権限エラー、「Not Found.」エラーなどというものです。 これらはユーザーが遭遇した瞬間に問題が解決するのを助けるために、 ユーザーに表示するエラーの一種です。

    コードレベルエラーは、上記以外の種類で、予想外にあなたのコード内の実際のバグがトリガーで、 おそらく直接ユーザーに、見せたくない部分です。 その代わりに、サードパーティのエラー追跡サービスがいくつかありますのでそれらで追跡することになります。 (例えばKadira)

    この章で取り扱うバグは、アプリレベルエラーのタイプに焦点を当てます。

    エラーの作成

    今、エラーを表示する方法を知りましたが、私たちは何かを表示する前に、 表示するためのトリガーを必要としています。 でも、実はすでに良好なエラーのシナリオを実装しています。:重複した投稿の警告です。 単純にpostSubmitイベントヘルパー内のalert呼び出しを、 新しいthrowError関数に置き換えるだけです。:

    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

    さらに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

    コミット 9-2

    Actually use the error reporting.

    試してましょう:URLにhttp://meteor.comを入力して投稿を作ってみましょう。 このURLがすでに固定ファイルの投稿情報に入っているため、このように表示されるはずです。

    Triggering an error
    Triggering an error

    エラーの削除

    数秒後にエラーメッセージは消えていきます。 これは、私たちはこの本の冒頭に戻ればわかりますが、追加のスタイルシートに含まれたCSSマジックの小辺に 実際にあります。

    @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

    opacityプロパティのための4つのキーフレームを指定するfadeOut CSSアニメーション (0%、10%、90%、100%を設定)を定義し、 .alertクラスにこのアニメーションを適用しています。

    アニメーションは、2700ミリ秒の合計に対して実行されease-inタイミングの式を使用します。 一度だけ実行0秒の遅延で実行し、それが実行されてたら、最後のキーフレームに滞在します。

    アニメーション vs アニメーション

    Meteor自身が制御アニメーションを行わず、 (あらかじめ決められ、我々のアプリのコントロールの外にある)、 CSSベースのアニメーションを使用していることに不思議に思われるかもしれません。

    Meteorは挿入アニメーションのサポートを提供しませんが、 我々はエラーに集中するためにこの章を使っています。だから我々は今のところ「イマイチ」なCSSアニメーションを使用しますし、 我々はアニメーションの章のための派手なものを残しておきます。

    これは動作しますが、複数のエラーをトリガします。 (例えば、同じリンクを3回提出する等を試してみてください。) それらが互いの上に積み重ね取得していることに気付きますか。

    Stack overflow.
    Stack overflow.

    これは.alert見た目としては消えていますが、DOMには存在しているからなのです。 この問題を解決する必要があります。

    これはまさにMeteorが輝く状況の一つです。 Errorsコレクションがリアクティブであるため、 私たちはこれらの古いエラーを取り除くためにやらなければならないのは、 コレクションからそれらを削除することです!

    Meteor.setTimeoutを使用し、タイムアウト後(この場合は、3000ミリ秒)に 実行されるコールバック関数を指定します。

    Template.errors.helpers({
      errors: function() {
        return Errors.find();
      }
    });
    
    Template.error.rendered = function() {
      var error = this.data;
      Meteor.setTimeout(function () {
        Errors.remove(error._id);
      }, 3000);
    };
    
    client/templates/includes/errors.js

    コミット 9-3

    Clear errors after 3 seconds.

    renderedコールバックは ブラウザでテンプレートがレンダリングされた後に一回だけ実行されます。 コールバック内でthisが参照するのは現在のテンプレートインスタンスで this.dataはレンダリングされたオブジェクトデータにアクセスします。 (この場合はエラー)

    バリーデーション

    これまでのところ、どの種類のフォーム上の検証も強制していません。 最低でも、私たちは、ユーザーがURLとその新しいポストのタイトルの両方を提供したいと思います。 それでは、彼らがそれを行うことを確認してみましょう。

    私たちは、欠落しているフィールドに二つのことをします。 最初は、私たちはあらゆる問題のあるフォームフィールドの親要素divhas-error CSSクラスを付与します。 次に、フィールドの下に有用なエラーメッセージを表示させます。

    開始します。まずは新しいヘルパーを受け入れるために postSubmitテンプレートを下準備しましょう:

    <template name="postSubmit">
      <form class="main form">
        <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

    私たちは(titleurlそれぞれ)各ヘルパーにパラメータを渡していることに注意してください。 これは、パラメータに基づいてその動作を変更する、同じヘルパーの両方のリソースを再利用できます。

    ここからが楽しいところです:これらのヘルパーを実際に作っていきます。

    あらゆる潜在的なエラーメッセージを含むpostSubmitErrorsオブジェクトを格納するためにセッションを使用します。 ユーザーがフォームと対話するように、このオブジェクトは順番にリアクティブに変化していき、 フォームのマークアップと内容を更新します。

    まず、postSubmitテンプレートが作成されるたびにオブジェクトを初期化します。 これにより、ユーザはこのページへの前回の訪問から残された古いエラーメッセージが表示されないことが保証されます。

    次に、2つのテンプレートヘルパーを定義します。 彼らは両方のSession.get('postSubmitErrors')fieldプロパティを見ます。 (fieldurlまたはtitleの事です。ヘルパーを呼んでいる場所に応じて変わります)

    errorMessageは、単にメッセージ自体を返します。 errorClassはメッセージの有無をチェックしメッセージが存在する場合、 has-errorを返します。

    Template.postSubmit.created = 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

    これらのヘルパーは、ブラウザのコンソールを開き、 次のコードを入力することで正常に動作していることをテストできます。:

    Session.set('postSubmitErrors', {title: 'Warning! Intruder detected. Now releasing robo-dogs.'});
    
    Browser console
    Red alert! Red alert!
    Red alert! Red alert!

    次のステップは、postSubmitErrorsセッションオブジェクトをフォームにフックします。

    その前に、postオブジェクトを見てposts.jsで新しい validatePost関数を作成し、 関連するすべてのエラーを含むerrorsオブジェクトを返します。 (すなわち、titleurlフィールドが欠落しているかどうかを判定します):

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

    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) {
          // 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

    何らかのエラーが存在する場合、どこかにこの値を返す為ではなく、 ヘルパーの実行を中止するために returnを使用していることに注意してください。

    Caught red-handed.
    Caught red-handed.

    サーバサイド・バリデーション

    まだ終わってはいません。クライアントサイドでのURLとタイトルのバリデーション表示をできました。 しかしサーバサイドはどうでしょう? 結局のところ、手動でブラウザコンソールからpostInsertメソッドを呼び出すことによって、 空のポストを入力を試みることができます。

    サーバー上ですべてのエラーメッセージを表示する必要がないにもかかわらず、 同じ validatePost機能を利用することができます。 イベントヘルパーではなく、postInsertメソッド内からそれを呼ぶことにします。

    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

    繰り返しますが、ユーザーは通常このメッセージ「You must set a title and URL for your post(投稿のタイトルとURLを設定する必要があります)」を参照する必要はないようにしてください。 誰かが苦労してまとめたユーザーインターフェースをバイパスすることを望んでいる、 と代わりに直接コンソールを使用している場合、それは表示されます。

    これをテストするには、ブラウザコンソールを開き、URLを空欄で投稿を入力してみてください:

    Meteor.call('postInsert', {url: '', title: 'No URL here!'});
    

    適切に仕事をしていれば、「You must set a title and URL for your post」というメッセージとともに、 恐ろしげなコードがたくさん返ってきます。

    コミット 9-4

    Validate post contents on submission.

    編集のバリデーション

    さっさと済ませるために、投稿編集フォームに同じ検証を適用します。 コードはかなりの量になります。まず、テンプレート:

    <template name="postEdit">
      <form class="main form">
        <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

    そしてテンプレートヘルパー:

    Template.postEdit.created = 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

    我々は、フォームをサブミット後に行った作業のように、 また、サーバ上で私たちの投稿を検証したいと思う。 あなたは投稿を編集するメソッドを用意せず、 クライアントから直接呼び出すupdateを使ったことを覚えているだろうか。

    これは、代わりに新しいdenyコールバックを追加する必要があることを意味します:

    //...
    
    Posts.deny({
      update: function(userId, post, fieldNames, modifier) {
        var errors = validatePost(modifier.$set);
        return errors.title || errors.url;
      }
    });
    
    //...
    
    lib/collections/posts.js

    post引数は既存のポストを参照していることに注意してください。 この場合、更新が(Posts.update({$set: {title: ..., url: ...}})のように) modifier$setプロパティの内容にvalidatePostを呼んでいる理由を、検証したい。 修飾子$setpostオブジェクト全体にurlurlプロパティが含まれているため、 これが動作します。もちろん、それは部分的な更新がtitleだけだったりに影響を与えたり、 urlだけの場合は失敗しますが、実際には問題にはらないでしょう。

    あなたは、第二denyコールバックに気づくかもしれません。 複数のdenyコールバックを追加する場合、 それらのいずれかがtrueを返した場合、操作は失敗します。 この場合には、titleurlが更新対象だった場合、 もうひとつがからでなければupdateは成功します。

    コミット 9-5

    Validate post contents when editing.