Events

Just like setting helpers with Template.teams.helpers({}); we also can set events with Template.teams.events({});.

However instead of specifying helpers, you specify events the user can perform, and what happens when that event occurs. Here's some examples taken from the Meteor docs:

{
  // Fires when any element is clicked
  'click': function (event) { ... },

  // Fires when any element with the 'accept' class is clicked
  'click .accept': function (event) { ... },

  // Fires when 'accept' is clicked or focused, or a key is pressed
  'click .accept, focus .accept, keypress': function (event) { ... }
}

Let's look at our code again and work out what events we need to use.

client/views/teams.html

...
  {{#if isCreatingTeam}}
    <form class="create-team">
      <input name="name" type="text">
      <button type="submit">Submit</button>
      <a class="cancel" href="#">Cancel</a>
    </form>
  {{else}}
    <a class="create" href="#">Create</a>
  {{/if}}
 ...

Here we have 3 events we want to hook up:

  1. submitting the form
  2. clicking the cancel button
  3. clicking the create button

Let's try hooking them all up.

client/views/teams.js

Template.teams.helpers({
  isCreatingTeam: function(){
    return Session.get('isCreatingTeam');
  },
  teams: function(){
    return Teams.find();
  }
});

Template.teams.events({
  'click a.create': function(e, tpl){
    Session.set('isCreatingTeam', true);
  },

  'click a.cancel': function(e, tpl){
    Session.set('isCreatingTeam', false);
  },

  'submit form.create-team': function(e, tpl){
    var teamName = $('input[name=name]').val();
    Teams.insert({name: teamName});
  }
});

If you try the above code you will find it works, but there are a couple of bugs.

Prevent Default

Often with events you don't want the default behaviour to occur. Normally when you click a link, it takes you to whatever the href attribute specifies. In our case the href value is #, and so when we click it you will notice the address changes:

Clicking our links change the URL\label

Also we have a submit form event. Normally when you submit a form the browser will POST the data to the action attribute of the form element. We don't want this to happen to us either.

We use the 'submit' event instead of 'click' because it takes into account the user hitting the 'Enter' key. If we had just 'click' then users might hit 'Enter' and nothing will happen.

Luckily we can solve both problems with event.preventDefault(), which does as you expect it to - prevents the default browser behaviour from happening:

client/views/teams.js

Template.teams.events({
  'click a.create': function(e, tpl){
    e.preventDefault();
    Session.set('isCreatingTeam', true);
  },

  'click a.cancel': function(e, tpl){
    e.preventDefault();
    Session.set('isCreatingTeam', false);
  },

  'submit form.create-team': function(e, tpl){
    e.preventDefault();
    var teamName = $('input[name=name]').val();
    Teams.insert({name: teamName});
  }
});

As an alternative you can make each function return false, however I think it's more clear this way.

Template jQuery scope

Another thing we should do is avoid global jQuery selectors such as $('input[name=name]').val(). The issue with this is that our template should only be able to manipulate elements inside of our template. If there happens to be another input[name=name] element on the page we might accidentally use that value instead of the one we were after.

To fix this we can change the scope of the jQuery call by using the second parameter of the event function - the parameter I named tpl, which stands for template and is an instance of the template object. It has a function called $, which reduces the scope of the jQuery selector down to just the template.

Hence we can update our code to:

client/views/teams.js

...
  'submit form.create-team': function(e, tpl){
    e.preventDefault();
    var teamName = tpl.$('input[name=name]').val();
    Teams.insert({name: teamName});
  }
...

Timing

One problem with our current UI is that when you add a new team, we don't change isCreatingTeam back to false. I purposely omitted that because it's good to ask: "where should we add this code?" Should we do it like this?

client/views/teams.js

...
  'submit form.create-team': function(e, tpl){
    e.preventDefault();
    var teamName = tpl.$('input[name=name]').val();
    Teams.insert({name: teamName});
    Session.set('isCreatingTeam', false);
  }
...

This works, but there's a problem in that Teams.insert() may fail on the server for whatever reason. But at the same time we want our app to remain snappy and don't have to wait for a server response.

The insert call takes a relatively long time

What we can do is pass a callback to Teams.insert(), which can kind of rewind our app if there is an error:

client/views/teams.js

...
  'submit form.create-team': function(e, tpl){
    e.preventDefault();
    var teamName = tpl.$('input[name=name]').val();
    Teams.insert({name: teamName}, function(error, _id){
      if(error){
        alert(error);
        Session.set('isCreatingTeam', true);
        tpl.$('input[name=name]').val(teamName);
      }
    });
    Session.set('isCreatingTeam', false);
  }
...

To test this we of course need the server insert() to fail. To do this we can temporarily remove the 'insecure' package, which is another kind of training wheels package like 'autopublish' (there are only two of these). I won't go into too much detail, as it will be explained further in the course - but trust me in that it will make the insert() fail.

meteor remove insecure

Now we get the error.

After removing insecure, we can no longer insert teams

But a problem arises when after we click away the alert. The textfield hasn't been filled with our attempted team name. This line isn't working:

client/views/teams.js

...
  'submit form.create-team': function(e, tpl){
    e.preventDefault();
    var teamName = tpl.$('input[name=name]').val();
    Teams.insert({name: teamName}, function(error, _id){
      if(error){
        alert(error);
        Session.set('isCreatingTeam', true);
        tpl.$('input[name=name]').val(teamName);
      }
    });
    Session.set('isCreatingTeam', false);
  }
...

But why? teamName and tpl both exist (as they are derived from the closure). We have set isCreatingTeam to true, so the textfield should be on the page ... or should it?

Remember to flush

This may feel like we are going too deep for such a small issue, but these kinds of bugs can plague developers (who may eventually quit, saying there's too much magic).

If you remember, reactivity is when functions are made to re-run when a reactive data source inside them changes. So when Session.set() is executed here, the template helper function for isCreatingTeam is re-run and the template re-rendered accordingly.

But this re-running of functions doesn't actually happen right away.

Instead, Meteor adds them to an internal queue and only re-runs them all once the current code has been finished executing.

This is why tpl.$('input[name=name]').val(teamName); isn't working. At that point in time the input doesn't actually exist in the template because isCreatingTeam has not been re-run yet. Only when the if(error){ ... } code block has finished running will Meteor peer into this internal queue and start executing all the functions it needs to re-run due to a reactive data source changing. At this point it will see that the isCreatingTeam session variable is set to true and render the input in the template.

The input is not rendered until later\label

Luckily there's a way of getting around this. As I said Meteor will create a kind of queue of functions to be re-run - well, when it finally gets around to emptying the queue this is called flushing (think of it as flushing out the queue). If you like you can read more about it in the docs.

We can ask Meteor to do something after flushing like this:

client/views/teams.js

...
    Teams.insert({name: teamName}, function(error, _id){
      if(error){
        alert(error);
        Session.set('isCreatingTeam', true);
        Tracker.afterFlush(function(){
          tpl.$('input[name=name]').val(teamName);
        });
      }
    });
...

The input is now filled correctly after a server error.

So now Meteor knows not to execute the code "tpl.$('input[name=name]').val(teamName);" until the queue has been flushed, by which time our input would have been rendered in the DOM.

Make sure you add the 'insecure' package again so our app can actually work:

meteor add insecure

What data is available in an event handler?

Our next step is to make teams removable. Let's add a remove button on each team:

client/views/teams.html

<template name="teams">
  <h3>Teams</h3>
  {{#if isCreatingTeam}}
    <form class="create-team">
      <input name="name" type="text">
      <button type="submit">Submit</button>
      <a class="cancel" href="#">Cancel</a>
    </form>
  {{else}}
    <a class="create" href="#">Create</a>
  {{/if}}

  <ul>
    {{#each teams}}
      <li>{{name}} <a class="remove" href="#">(x)</a></li>
    {{/each}}
  </ul>
</template>

And wire up an event:

client/views/teams.js

...
  'click a.remove': function(e, tpl){
    e.preventDefault();
    // var _id = ?;
    // Teams.remove(_id);
  }
...

Now the question is how do we get the _id of the team object? Let's read some of the docs and find out:

"The handler function receives two arguments: event, an object with information about the event, and template, a template instance for the template where the handler is defined. The handler also receives some additional context data in this, depending on the context of the current element handling the event. In a template, an element's context is the data context where that element occurs, which is set by block helpers such as #with and #each."

It seems we can access the context data through the use of this. We can have a look at what this returns in the event handler:

client/views/teams.js

...
  'click a.remove': function(e, tpl){
    e.preventDefault();
    console.log(this);
  }
...

The console log shows us our context data\label

So it appears this is where we can get our _id from! Let's get it working.

client/views/teams.js

...
  'click a.remove': function(e, tpl){
    e.preventDefault();
    Teams.remove(this._id);
  }
...

Great! Now we can add and remove teams. Only one option left: editing.