Thông báo

9

Percentage Translated

In this chapter, you will:

  • Tạo cơ cấu tốt hơn để hiển thị lỗi và thông báo.
  • Thực hiện kiểm tra form chặt chẽ hơn.
  • Thêm thông báo lỗi tức thời cho form.
  • Việc đơn thuần sử dụng dialog alert() của trình duyệt để cảnh báo người dùng khi có vấn đề với việc submit form thường không làm thoả mãn, và chắc chắn việc đó cũng không tạo ra UX tốt được. Chúng ta có thể làm tốt hơn.

    Để thay thế, chúng ta sẽ xây dựng một cơ cấu thông báo lỗi linh hoạt hơn để thông báo cho người dùng điều gì đang diễn ra mà không phá hỏng luồng ứng dụng.

    Chúng ta sẽ cài đặt một hệ thống đơn giản hiển thị lỗi ở góc trên bên phải cửa sổ, giống như ứng dụng Growl nổi tiếng của hệ điều hành Mac.

    Giới thiệu về Local Collections

    Để bắt đầu, chúng ta cần tạo một collection để lưu trữ lỗi. Để cho những thông báo lỗi này chỉ hữu hiệu với session hiện tại và không cần phải lưu dài hạn, chúng ta sẽ sử dụng một thứ mới, đó là tạo collection cục bộ (local collection).Điều này có nghĩa là collection Errors chỉ tồn tại trong trình duyệt và sẽ không đồng bộ ngược với server.

    Để đạt được điều này, chúng ta tạo thông báo lỗi bên trong thư mục client (để cho collection chỉ tồn tại phía client), và với tên MongoDB cho collection là null (vì dữ liệu của collection sẽ không bao giờ được lưu vào cơ sở dữ liệu phía server):

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

    Bây giờ, sau khi đã tạo được collection, chúng ta có thể thêm hàm throwError để gọi khi muốn thêm thông báo lỗi. Chúng ta không cần phải lo lắng về allow hoặc deny hoặc bất kỳ vấn đề bảo mật nào, vì collection “cục bộ” đối với người dùng hiện tại.

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

    Điểm thuận lợi để lưu thông báo lỗi với collection cục bộ là, cũng như mọi collection khác, nó tương tác lại – nghĩa là chúng ta có thể hiển thị lỗi một cách có tương tác giống như hiển thị bất kỳ dữ liệu collection nào khác.

    Hiển thị lỗi

    Chúng ta sẽ thêm thông báo lỗi ở phía trên cùng của layout chính:

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

    Bây giờ hãy cùng tạo template errorserror trong errors.html:

    <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

    Templates ghép đôi

    Bạn sẽ nhận ra rằng chúng ta đang đặt hai template vào trong cùng một file. Cho đến bây giờ, chúng ta đã bám sát quy ước “một file, một template”, nhưng Meteor vẫn hoạt động tốt dù tất cả template của chúng ta được đặt vào một file chung (mặc dù điều này sẽ tạo ra một file main.html rất lộn xộn!).

    Trong trường hợp hiện tại, vì cả hai template đều khá là ngắn gọn, chúng ta có thể tạo ra ngoại lệ và để chúng vào trong cùng một file. Việc này làm cho kho chứa của chúng ta sạch sẽ hơn.

    Chúng ta chỉ cần tạo helper cho template nữa là mọi thứ sẽ ổn!

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

    Bạn có thể thử thông báo lỗi vừa tạo bằng tay. Bạn chỉ cần mở console trình duyệt và gõ:

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

    Commit 9-1

    Basic error reporting.

    Hai loại Lỗi

    Tại thời điểm này, việc phân biệt giữ lỗi “cấp độ ứng dụng” và lỗi “cấp độ code” là rất quan trọng.

    Lỗi cấp độ ứng dụng thường tạo ra do hành động của người dùng, và người dùng có thể làm gì đó sau khi nó xảy ra. Những lỗi này bao gồm cả lỗi về kiểm tra form, lỗi về quyền truy cập, lỗi “không tìm thấy”, và nhiều lỗi khác nữa. Đây là những lỗi mà bạn muốn hiển thị cho người dùng để chỉ cho họ cách sửa bất kỳ vấn đề gì họ đang mắc phải.

    Lỗi cấp độ code thì khác, là những lỗi xảy ra không mong muốn do sai sót khi code, và bạn thường không muốn trực tiếp hiển thị ra cho người dùng. Thay vào đó là theo dõi bằng công cụ từ bên thứ ba, ví dụ như là Kadira.

    Trong chương này, chúng ta sẽ tập trung vào việc khắc phục loại lỗi thứ nhất, chứ không tập trung vào việc khắc phục lỗi sai sót do code.

    Tạo thông báo lỗi

    Chúng ta đã biết cách hiển thị thông báo lỗi, nhưng chúng ta vẫn cần phải kích hoạt trước khi có thể nhìn thấy. Chúng ta vừa thực hiện một bối cảnh lỗi khá tốt: cảnh báo về việc bị trùng lặp bài viết. Đơn giản hãy thay thế alert trong helper sự kiện postSubmit với hàm throwError vừa thiết lập:

    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

    Chúng ta sẽ làm điều tương tự cho sự kiện của helper 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

    Actually use the error reporting.

    Hãy thử tạo một bài viết và thêm vào URL là http://meteor.com. Do URL này đã được gắn với một bài viết trước đó, bạn sẽ thấy:

    Triggering an error
    Triggering an error

    Làm sạch thông báo lỗi

    Bạn vừa nhận ra rằng thông báo lỗi biến mất sau khi đã xuất hiện một vài giây. Điều này thực sự là do một đoạn CSS chúng ta đã thêm vào stylesheet ngay từ lúc bắt đầu cuốn sách:

    @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

    Chúng ta đang định nghĩa một đoạn CSS animaton fadeOut để đặc tả 4 keyframe cho thuộc tính độ trong suốt (0%, 10%, 90%, và 100% của khoảng thời gian diễn animation) và áp dụng vào class .alert.

    Đoạn animation sẽ chạy trong 2700 mili giây, sử dụng phương trình đo thời gian ease-in, chạy với độ trễ là 0 giây, chạy đúng một lần, và cuối cùng dừng lại ở keyframe cuối cùng khi mọi thứ đã xong xuôi.

    Animations vs Animations

    Có thể bạn đang tự hỏi tại sao mình lại dùng animation trên nền CSS (thứ được định nghĩa trước và ngoài khả năng kiểm soát của cúng ta), thay vì dùng animation được quản lý bởi chính bản thân Meteor.

    Trong khi Meteor đúng là có cung cấp sự hỗ trợ cho việc chèn animation, chúng ta muốn chương này tập trung vào thông báo lỗi. Vì vậy chúng ta sử dụng animation CSS đơn giản và chúng ta sẽ dành những công việc trang hoàng trong chương về Animation.

    Hiện tại mọi thứ đã hoạt động, nhưng nếu bạn kích hoạt nhiều lỗi (bằng việc submit cùng một đường dẫn ba lần chẳng hạn), bạn sẽ nhận ra rằng chúng bị chồng đống lên nhau:

    Stack overflow.
    Stack overflow.

    Điều này là do trong khi thành phần .alert biến mất khi nhìn bằng mắt nhưng thực ra vẫn tồn tại trong DOM. Chúng ta cần sửa điều này.

    Đây chính là một trong những tình huống mà Meteor toả sáng. Vì collection Errors tương tác lại, tất cả việc chúng ta cần làm là xoá thông báo lỗi cũ ra khỏi collection!

    Chúng ta sẽ dùng hàm Meteor.setTimeout để đặc tả một hàm callback sẽ được chạy sau mỗi khoảng thời gian tạm ngừng (trong trường hợp này là 3000 mili giây).

    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

    Commit 9-3

    Clear errors after 3 seconds.

    Hàm callback rendered kích hoạt mỗi khi template được đưa ra trình duyệt. Bên trong hàm callback, this tham chiếu tới template hiện tại, và this.data để chúng ta truy cập tới dữ liệu của object đang được đưa ra (render). Trong trường hợp của chúng ta, chính là một thông báo lỗi.

    Lục tìm Kiểm tra

    Cho đến bây giờ, chúng ta vẫn chưa thêm một kiểm tra (validation) nào cho form. Ít nhất thì chúng ta cũng mong muốn người dùng cung cấp cả URL và tựa đề cho bài viết mới. Vì vậy hãy cùng chắc chắn họ sẽ làm như vậy.

    Chúng ta sẽ làm hai việc để chỉ ra những trường bị thiếu: thứ nhất, chúng ta sẽ đưa ra một class CSS đặc biệt has-error cho vào div cha của bất kỳ trường nào của form có vấn đề. Thứ hai, chúng ta sẽ hiển thị một thông báo lỗi hữu ích như bên dưới.

    Để bắt đầu, hãy thêm vào template postSubmit những helper sau:

    <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

    Chú ý rằng chúng ta đã thêm vào tham số (theo trình tự là urltitle) cho mỗi helper. Điều này giúp chúng ta có thể dùng lại helper cho cả hai lần, thay đổi hoạt động dựa vào tham số.

    Bây giờ sẽ là phần thú vị: làm cho những helper này thực sự hoạt động.

    Chúng ta sẽ dùng Session để lưu object postSubmitErrors chứa bất kỳ lỗi tiềm tàng nào. Khi mà người dùng tương tác với form, object này sẽ thay đổi, nghĩa là sẽ tương tác để hiển thị lại nội dung và hình thức của form.

    Trước tiên, chúng ta sẽ khởi tạo object mỗi khi template postSubmit được tạo. Điều này giúp chắc chắn là người dùng sẽ không thấy lỗi cũ tồn đọng từ trang truy cập trước đó.

    Sau đó chúng ta định nghĩa hai template helper. Chúng cùng nhìn vào thuộc tính field của Session.get('postSubmitErrors') (khi mà fieldurl hoặc title tuỳ thuộc vào nơi chúng ta gọi helper).

    Trong khi errorMessage chỉ đơn giản trả về bản thân tin thông báo, errorClass kiểm tra sự có mặt của tin thông báo và trả về has-error nếu có lỗi tồn tại.

    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

    Bạn có thể kiểm tra rằng helper của chúng ta đang hoạt động đúng bằng việc mở console trình duyệt và gõ vào như bên dưới:

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

    Bước tiếp theo là thực sự lắp ráp Session object postSubmitErrors tới form.

    Trước khi làm vậy, chúng ta sẽ tạo một hàm validatePost trong posts.js để xem trong object post, và trả về object errors mà chứa đựng bất kỳ lỗi xác đáng nào (tức là, khi mà trường title hoặc url bị thiếu):

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

    Chúng ta sẽ gọi hàm này từ helper sự kiện 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

    Chú ý rằng chúng ta đang dùng return chỉ để huỷ thực thi của helper nếu như có lỗi xảy ra mà không phải bởi vì chúng ta muốn trả về giá trị này ở chỗ nào đó.

    Caught red-handed.
    Caught red-handed.

    Kiểm tra phía Server

    Chúng ta vẫn chưa thực sự kết thúc. Chúng ta đang kiểm tra sự có mặt của URL và tựa đề ở phía client, nhưng còn về phía server thì sao? Sau tất cả, rất có thể sẽ có ai đó sẽ cố nhập vào bài viết trống bằng tay với việc gọi method postInsert thông qua console trình duyệt.

    Mặc dù chúng ta không cần hiển thị thông báo lỗi nào ở phía server, chúng ta vẫn sẽ dùng cùng hàm validatePost. Ngoại trừ việc là lần này chúng ta sẽ gọi bên trong method postInsert, và không chỉ với sự kiện 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

    Xin được nhắc lại, người dùng thường không cần phải thấy thông báo “You must set a title and URL for your post”. Nó sẽ chỉ hiện ra nếu như ai đó muốn vượt qua giao diện người dùng chúng ta đã làm cẩn thận, bằng cách là sử dụng trực tiếp console.

    Để kiểm tra điều này, mở cửa sổ console trình duyệt và thử gõ bài viết không có URL:

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

    Nếu bạn đã thực hiện công việc đầy đủ, bạn sẽ thấy được một đoạn code đáng sợ đi kèm với thông báo “You must set a title and URL for your post”.

    Commit 9-4

    Validate post contents on submission.

    Kiểm tra lỗi khi Biên tập

    Để mọi thứ hợp lý, chúng ta cũng sẽ dùng đoạn kiểm tra với việc biên tập bài viết. Mã code sẽ trông khá giống. Đầu tiên, với template:

    <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

    Sau đó, với template helper:

    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

    Cũng giống như chúng ta đã làm cho form submit bài viết, chúng ta cũng muốn kiểm tra bài viết trên server. Bạn sẽ phải nhớ là chúng ta không dùng một method để biên tập bài viết, mà sẽ gọi update trực tiếp từ client.

    Điều này có nghĩa là chúng ta sẽ cần có hàm callback deny thay thế:

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

    Chú ý rằng tham số post tham chiếu đến bài viết đang tồn tại. Trong trường hợp này, chúng ta muốn kiểm tra việc update, do đó chúng ta sẽ gọi validatePost với nội dung modifier của thuộc tính $set (như trong Posts.update({$set: {title: ..., url: ...}})).

    Điều này hoạt động vì modifier.$set chứa cùng thuộc tính titleurl như toàn thể object url. Dĩ nhiên, nó không nghĩa là mọi cập nhật bộ phận chỉ ảnh hưởng tới title hoặc url sẽ thất bại, nhưng trong thực hành điều này không phải là một vấn đề.

    Bạn có thể nhận ra rằng đây là callback deny thứ hai của chúng ta. Khi thêm vào nhiều callback deny, hành động sẽ thất bại nếu như bất kỳ một trong số chúng trả về true. Trong trường hợp này, điều đó có nghĩa là update sẽ thành công chỉ khi mà nó hướng tới trường titleurl, và không có trường nào trong hai trường bị trống.

    Commit 9-5

    Validate post contents when editing.