May 10, 2021 Meteor
Using only the
alert()
conversation window to warn users that their submission is wrong is a little unsatisfactory and clearly not a good user experience.
We can do better.
Instead, let's create a more flexible error reporting mechanism to better tell the user what's going on without interrupting the process.
We're going to implement a simple system that displays a new error message in the upper right corner of the window, similar to the popular Mac OS application Growl.
At first, we need to create a collection to store our errors. S
ince the error is only relevant to the current session and does not need to last in any way, we're going to do something new
here and create a local collection.
This means that the
Errors
collection will only
exist in
the browser and will not attempt to synchronize back to the server.
To do this, we
client
in the client folder (to make sure that the collection exists only on the client side), and we name its MongoDB collection
null
the collection's data will not be saved in the server-side database):
// 本地(仅客户端)集合
Errors = new Mongo.Collection(null);
At first, we should build a collection that can store errors. B etween errors is just for the current session, we will take a timeliness set. This means that the error collection exists only in the current browser and is not synchronized with the service side.
Now that the collection has been established, we can create
throwError
function to add a new error.
We don't
allow
worry
deny
and deny or any other security considerations, because this collection is "local" to the current user.
throwError = function(message) {
Errors.insert({message: message});
};
The advantage of using local collections to store errors is that, like all collections, it is responsive ———— which means that we can display errors in the same way as any other collection data.
We'll insert an error message at the top of the main layout:
<template name="layout">
<div class="container">
{{> header}}
{{> errors}}
<div id="main" class="row-fluid">
{{> yield}}
</div>
</div>
</template>
Let's
errors.html
and error templates in
errors
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">×</button>
{{message}}
</div>
</template>
You may notice that we have two templates in one file. U
ntil now we've been following the "one file, one template" standard, but for Meteor, it's the same for Meteor to put all the templates in the same file (but that makes the main.html code very
main.html
)。
In the current case, because both error templates are small, we make an exception to put them in a file to make our repo code base cleaner.
We just need to add our template helper to make it great!
Template.errors.helpers({
errors: function() {
return Errors.find();
}
});
You can try testing our new error message manually. Open the browser console and enter:
throwError("我就是一个错误!");
At this point, it is important to distinguish between app-level errors and code-level errors.
Application-level errors are typically triggered by the user, who is able to take action against the disease. T hese include things like validation errors, permission errors, "not found" errors, and so on. This is the kind of error you want to show to users to help them solve any problems they just encounter.
Code-level errors, as another type, are triggered by actual code bugs that are not expected, and you may not want to present the error directly to the user, but instead track the error through, for example, a third-party error tracking service, such as Kadira.
In this chapter, we focus on dealing with the first type of error, rather than catching bugs.
We know how to display errors, but we also need to trigger them before we find them. I
n fact, we've created a good error situation: repeating the post's warning.
Let's simply replace the
alert
call in
postSubmit
helper with the new
throwError
function:
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});
});
}
});
Now that we're here,
postEdit
thing for postEdit event helper:
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});
}
});
},
//...
});
Try it yourself: Try to create a post and enter a URL
http://meteor.com
Because this URL already exists, you can see:
You'll notice that the error message automatically disappears after a few seconds. This is because of the magic of some of the CSS we added to the style sheet at the beginning of this book:
@keyframes fadeOut {
0% {opacity: 0;}
10% {opacity: 1;}
90% {opacity: 1;}
100% {opacity: 0;}
}
//...
.alert {
animation: fadeOut 2700ms ease-in 0s 1 forwards;
//...
}
We defined a
fadeOut
CSS animation with four frame transparency attribute changes (0%, 10%, 90%, and 100% throughout the animation process, respectively) and
.alert
style.
The animation is 2700 milliseconds long,
ease-in
with a 0-second delay, runs once, and ends at the last frame when the animation is complete.
You might be wondering why we use CSS-based animations (pre-defined, and outside our app control) instead of Meteor itself.
While Meteor does provide support for inserting animations, we want to focus on errors in this chapter. So now we're using "stupid" CSS animation, and we're going to leave the more glitzy stuff in the animation chapters.
This works, but if you want to trigger multiple errors (for example, by submitting the same connection three times), you'll see the error messages stacked together.
This is
.alert
the .alert element visually disappears, it remains in the DOM.
We need to fix the problem.
This is where Meteor glows.
Since
Errors
collection is responsive, all we have to do is remove the old error from the collection!
We use
Meteor.setTimeout
specify that a callback function is executed after a certain amount of time (current case, 3000 milliseconds).
Template.errors.helpers({
errors: function() {
return Errors.find();
}
});
Template.error.onRendered(function() {
var error = this.data;
Meteor.setTimeout(function () {
Errors.remove(error._id);
}, 3000);
});
Once the template is rendered in the browser,
onRendered
callback function is triggered.
Where this
this
to the current template instance,
this.data
the data of the currently rendered object (in this case, an error).
So far, we haven't validated the form. A t the very least, we want users to provide URLs and titles for new posts. Then we make sure they do.
Let's do two things: First, we give the parent
div
of any problematic form field a
has-error
CSS class.
Second, we display a useful error message below the field.
First, we'll
postSubmit
template to include these new helpers:
<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>
Notice that we pass parameters
url
title
to each helper.
This allows us to reuse the same helper twice and modify its behavior based on parameters.
Now it's the fun part: making these helpers really do something.
We use Session
session to
store
postSubmitErrors
When a user uses a form, the object changes, which is to update the form code and content in a responsive manner.
First, when
postSubmit
template is created, we initialize the object.
This ensures that the user does not see the old error message left over from the last time the page was visited.
Then define our two template helpers, keeping an eye on the
field
Session.get('postSubmitErrors')
field
url
or
title
on how we call helper).
errorMessage
the message itself, and
errorClass
checks to see if
the message
exists and, if true,
has-error
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' : '';
}
});
//...
You can test whether the helper is working properly, open the browser console, and enter the following code:
Session.set('postSubmitErrors', {title: 'Warning! Intruder detected. Now releasing robo-dogs.'});
Next, tie
postSubmitErrors
Session session object to the form.
Before we begin, we add a new
validatePost
function to
posts.js
to
post
object and return an error object that contains any
errors
(that is, whether the
title
or
url
field is not filled in):
//...
validatePost = function (post) {
var errors = {};
if (!post.title)
errors.title = "请填写标题";
if (!post.url)
errors.url = "请填写 URL";
return errors;
}
//...
Let's call this function through the
postSubmit
event helper:
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) {
// 向用户显示错误信息并终止
if (error)
return throwError(error.reason);
// 显示这个结果且继续跳转
if (result.postExists)
throwError('This link has already been posted');
Router.go('postPage', {_id: result._id});
});
}
});
Note that if any errors occur, we terminate
return
execution with return instead of we want to actually return this value.
We're not done yet. W
e verify
that the
URL and title exist on the client side, but
on the
server side?
After all, someone will still try to manually call the
postInsert
method by entering an empty post through the browser console.
Even if we don't need to display any error messages on the server side, we still need to take advantage
validatePost
function.
Except this time we call it within
the
postInsert
method, not just in the 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', "你必须为你的帖子填写标题和 URL");
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
};
}
});
Again, users don't normally have to see the message "You have to fill in the title and URL for your post." This will only appear if the user wants to bypass the user interface we painstakingly created and use the browser directly.
To test, open the browser console and enter a post without a URL:
Meteor.call('postInsert', {url: '', title: 'No URL here!'});
If we do it well, you'll get a bunch of scary codes and a message that says, "You have to fill in the title and URL for your post."
For added refinement, we added the same validation for the Post Edit form. T he code looks very similar. First, the 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>
Then there's the 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) {
// 向用户显示错误消息
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');
}
}
});
Just like we did for the post submission form, we also want to verify the post on the server side.
Keep in mind that we're not editing posts in one way, but are calling directly from
update
This means that we have to add
deny
callback function:
//...
Posts.deny({
update: function(userId, post, fieldNames, modifier) {
var errors = validatePost(modifier.$set);
return errors.title || errors.url;
}
});
//...
Note that the parameter
post
to a post that already
exists. W
e
wanted to
verify the
$set
we called
validatePost
in the $set property of
modifier
(just like
Posts.update({$set: {title: ..., url: ...}})
)。
This works because
modifier.$set
the
post
and url properties as the
title
url
object.
Of course, this does mean that only partial updates to
title
or
url
possible, but in practice there should be no problem.
You may notice that this is our second
deny
callback. W
hen multiple
deny
are added, if any one of the callbacks
true
the run fails.
In this case, this
update
title
if it faces both the title and
url
fields, and these fields cannot be empty.