Getting Started with Ember.js: The Missing To-Dos Manual 2.0

Ember.js is a lightweight JavaScript framework that packs a heavy punch when it comes to binding data between objects and making the appropriate updates to the view template. As websites continue to trend towards using CSS and JavaScript to create rich interactions (sans Flash) and more of the business logic shifts to the client side, this framework helps alleviate a lot of the tedious scripting.

I first heard of Ember.js when I was exploring SproutCore, which was on version 1.6 (released in June 2011). SproutCore 2.0 was announced in May 2011, but was later renamed Ember.js (which is currently on version 0.9.7.1), while SproutCore recently came out with version 1.8 in March 2012.

SproutCore 1.6 featured excellent documentation that showed how to create a To-Do list quickly. I couldn’t find a comparable tutorial for Ember.js, so I figured I’d write my own.

Ember Starter Kit

I started the project with the Ember Starter Kit, along with two assets from TodoMVC.com: todos.css and bg.png.

You can download exactly what I started off with from github:

git clone git://github.com/tuanderful/ember-exercises.git
cd ember-exercises
git checkout b8a18dbfe7128ec45f60660c56ab8471f6f7306e

Getting that revision will help take some of the work out of bootstrapping your own server; simply navigate into the app directory cd todos, run npm install, then gulp. This will fire up a Node server with Express (and live reloading!) at http://localhost:4000.

Creating A Template

This example uses handlebars as a templating language. We’ll begin by wrapping our entire application in a script tag to indicate that it’s a handlebars template and a data-attribute to indicate a template name of todos.

<script type="text/x-handlebars" data-template-name="todos">
  <section id="todoapp">
  <!-- the rest of the application goes here -->
  </section>
  <footer id="info">
    <p>Double-click to edit a todo</p>
  </footer>
</script>

You won’t see anything in your browser window until you replace what’s currently in js/app.js with the following:

// -- Application -----------
Todos = Ember.Application.create();

// -- Router ----------------
Todos.Router.map(function() {
  this.resource('todos', { path: '/' });
});

The router manages the state of the application through the URL. When the URL changes, Ember may update the controller, load data, or in our case here, render the todos template we created earlier.

Adding the Model

We can replace the static content in our example by wiring it up to one of the adapters that Ember provides to hook up our data store to our persistence layer. In our example, the DS namespace is defined in js/libs/ember-data-1.0.0.b5.js. We’ll be using the fixture adapter, which you’ll define near the top of js/app.js:

// -- Application -----------
Todos = Ember.Application.create();
Todos.ApplicationAdapter = DS.FixtureAdapter.extend();

We’ll need to update the router by telling Ember to associate the TodosRoute with the model provided by our data store:

// -- Router ----------------
Todos.Router.map(function() {
  this.resource('todos', { path: '/' });
});

Todos.TodosRoute = Ember.Route.extend({
  model: function() {
    return this.store.find('todo');
  }
});

Convention over Configuration

I felt it was worth calling this out at this point because this, to me, was why Ember seemed like a black box at first. Object creation in Backbone was very explicit, as I manually tied all the pieces together. As a developer becomes more experienced in Backbone, he might be be inclined to come up with his or her own conventions to facilitate code reuse and readability. Perhaps this is why some believe Ember to be an evolution of frameworks such as Backbone: as applications scale, it becomes natural to establish naming conventions as Ember does.

So I decided to see how deeply things are tied together. I noticed that the name of the resource in Todos.Router is associated with the name of the Ember.Route, which is associated with the name of the template. In other words, if we renamed the resource that / maps to to foobar, the code in our router might look like this:

// -- Router ----------------
Todos.Router.map(function() {
  this.resource('foobar', { path: '/' });
});

Todos.FoobarRoute = Ember.Route.extend({
  model: function() {
    return this.store.find('todo');
  }
});

… but it wouldn’t work until we update our handlebar template name as well:

<script type="text/x-handlebars" data-template-name="foobar">

Adding Fixture Data

Next we'll need to define the data model and populate our fixture with mock data. Add the following section to the bottom of js/app.js:

// -- Models ----------------
Todos.Todo = DS.Model.extend({
  title: DS.attr('string'),
  isCompleted: DS.attr('boolean')
});

Todos.Todo.FIXTURES = [
 {
   id: 1,
   title: 'Learn Ember.js!',
   isCompleted: true
 },
 {
   id: 2,
   title: '...',
   isCompleted: false
 },
 {
   id: 3,
   title: 'Profit!',
   isCompleted: false
 }
];

This loads the data into our application, but we'll need to update index.html and replace the contents of our list so that it'll know to loop through {{#each}} item in our fixture, set class="completed if the isCompleted property is true, and output the {{title}}:

<ul id="todo-list">
  {{#each}}
    <li {{bind-attr class="isCompleted:completed"}}>
      <input type="checkbox" class="toggle" />
      <label>{{title}}</label><button class="destroy"></button>
    </li>
  {{/each}}
</ul>

If you reload the application, you'll see that any item in our fixture with an isCompleted property equal to true will have a class of completed and be crossed out. However, checking the checkbox doesn't do anything until you change the input tag to:

{{input type="checkbox" checked=isCompleted class="toggle"}}

Adding an Application Controller

Next We'll add a controller for our application that will count the number of completed items. The Ember convention is to name things as follows:

  • Template: todos
  • Controller: APP.TodosController
  • Router: APP.TodosRoute

Displaying a Counter of Remaining Todos

Add Todos.TodosController to the bottom of app.js:

// -- Application Controller ----------------
Todos.TodosController = Ember.ArrayController.extend({
  remaining: function() {
    return this.filterBy('isCompleted', false).get('length');
  }.property('@each.isCompleted')
});

Output the number of completed items by updating #todo-count:

<span id="todo-count">
  <strong>{{remaining}}</strong> todos left
</span>

When rendering the number of remaining todos, the controller will look at the isCompleted property for each item in the array, filter out all the completed items, and count how many are left.

If you update the isCompleted property in our data fixture and reload the page, you'll notice that the number of incomplete todos will be reflected. If two items have isCompleted = true, you'll notice that the counter will read 1 todos left. In order to correct the pluralization, update our application controller with an inflection method:

Todos.TodosController = Ember.ArrayController.extend({
  remaining: function() {
    return this.filterBy('isCompleted', false).get('length');
  }.property('@each.isCompleted'),
  inflection: function() {
    return this.get('remaining') === 1 ? 'todo' : 'todos';
  }.property('remaining')
});

Here, we leverage the remaining method we created earlier and only output the singular todo if there is exactly 1 remaining.

<strong>{{remaining}}</strong> {{inflection}} left

Creating an Object Controller

Now that we have a controller for our application in place, we'll need to create a controller for each individual todo. We'll need to update {{#each}} to associate it with our new controller:

{{#each itemController="todo"}}

Then add the TodoController to our application namespace, along with a removeTodo action:

Todos.TodoController = Ember.ObjectController.extend({
  actions: {
    removeTodo: function() {
      var todo = this.get('model');
      todo.deleteRecord();
      todo.save();
    }
  },
});

Finally, bind the removeTodo action to our button by updating button.destroy:

<button {{action "removeTodo"}} class="destroy"></button>

Removing and Adding Items at the Application Level

The removeTodo method is a member of the ObjectController, but functions such as adding an item or removing multiple items belong in our application controller, Todos.TodosController.

Removing Multiple Todos

You may have noticed button#clear-completed. It should only appear when there are completed items in the list. We'll need to add two properties: a boolean to indicate if there is at least one complete todo in our list, and a counter of how many todos are completed:

Todos.TodosController = Ember.ArrayController.extend({
  hasCompleted: function() {
    return this.get('completed') > 0;
  }.property('completed'),
  completed: function() {
    return this.filterBy('isCompleted', true).get('length');
  }.property('@each.isCompleted'),
  // remaining: ...
  // inflection: ...
});

Our completed method is similar to our remaining method from before, but this time it filters for isCompleted = true. hasCompleted looks at the completed property and returns a boolean if there exists at least one completed item.

To conditionally show the button, we'll wrap it in an {{if}} statement:

{{#if hasCompleted}}
<button id="clear-completed">
  Clear completed ({{completed}})
</button>
{{/if}}

You'll notice that the button will update as you check items off your list. However, clicking on the button won't do anything until you bind it to an action:

<button id="clear-completed" {{action "clearCompleted"}}>

… and define the action in our controller:

Todos.TodosController = Ember.ArrayController.extend({
  actions: {
    clearCompleted: function() {
      var completed = this.filterBy('isCompleted', true);
      completed.invoke('deleteRecord');
      completed.invoke('save');
    }
  },
  // hasCompleted: function() ...
  // completed: function() ...
  // remaining: function() ...
  // inflection: function() ...
});

Its worth comparing the clearCompleted action against the removeTodo action that we wrote earlier:

clearCompleted: function() {
  var completed = this.filterBy('isCompleted', true);
  completed.invoke('deleteRecord');
  completed.invoke('save');
}
removeTodo: function() {
  var todo = this.get('model');
  todo.deleteRecord();
  todo.save();
}

Remember that clearCompleted acts on an array; the Ember array has an invoke method that can be called to execute deleteRecord on each element. In removeTodo, we have access to each individual model and thus can call deleteRecord() directly.

Adding to the Model

Next we'll add another action to our application controller that enables us to add items to the list. Update the TodosController with a createTodo action:

Todos.TodosController = Ember.ArrayController.extend({
  actions: {
    //  clearCompleted: function() ...
    createTodo: function() {
      // Get value from the input, validate that it's not empty
      var title = this.get('newTitle');
      if (!title.trim()) { return; }

      // Create a new record to be added to our model
      var todo = this.store.createRecord('todo', {
        title: title,
        isCompleted: false
      });

      // Clear the field
      this.set('newTitle', '');

      // Add to model
      todo.save();
    }
  },
  // hasCompleted: function() ...
  // completed: function() ...
  // remaining: function() ...
  // inflection: function() ...
});

This defines an action that extracts the value (which we'll set as newTitle) from an input field, validate that it's non-empty, stores it, then clears out the field.

Update input#new-todo in our template to the following:

{{input type="text" id="new-todo" placeholder="What needs to be done?"
  value=newTitle action="createTodo"}}

Viewing a Filtered List via Child Routes

At the bottom of our todos app are three links to /active and /completed. We'll be updating them so that, instead of routing to subdirectories, they will instead route to /#/active and /#/completed. When the user navigates to one of these child routes, they will still enter through our application through index.html, but based on the hash value our app will figure out the appropriate route to take, assemble the appropriate data, and output a filtered list of todos.

First we'll need to define the child routes by updating our router with a third parameter:

Todos.Router.map(function() {
  this.resource('todos', { path: '/' }, function(){
    this.route('active');
    this.route('completed');
  });
});

Linking to Child Routes

Now replace the anchor tags in our template with handlebar helpers that will create links to routes in our app:

<ul id="filters">
  <li>
    {{#link-to "todos.index" activeClass="selected"}}All{{/link-to}}
  </li>
  <li>
    {{#link-to "todos.active" activeClass="selected"}}Active{{/link-to}}
  </li>
  <li>
    {{#link-to "todos.completed" activeClass="selected"}}Completed{{/link-to}}
  </li>
</ul>

Those helpers will create links to #/route-name and add a class of selected when the route is active.

Now, update the {{#each}} helper to the following:

{{#each filteredTodos itemController="todo"}}

Previously, {{#each}} would iterate over the elements in our ArrayController. Now, it will iterate over a filteredTodos property which we'll define when we set up our controllers.

Setting Up Child Route Controllers

On the line after the Todos.TodosRoute, create a another route for index:

// Todos.TodosRoute ...

Todos.TodosIndexRoute = Ember.Route.extend({
  setupController: function () {
    this.controllerFor('todos').set('filteredTodos', this.modelFor('todos'));
  }
});

This simply sets filteredTodos to the model we defined as part of our TodosRoute when we access the IndexRoute.

Similarly, as part of the controller setup for the Active and Completed routes, we need to defined filteredTodos as follows:

Todos.TodosActiveRoute = Ember.Route.extend({
  setupController: function () {
    var todos = this.store.filter('todo', function (todo) {
      return !todo.get('isCompleted');
    });

    this.controllerFor('todos').set('filteredTodos', todos);
  }
});

Todos.TodosCompletedRoute = Ember.Route.extend({
  setupController: function () {
    var todos = this.store.filter('todo', function (todo) {
      return todo.get('isCompleted');
    });

    this.controllerFor('todos').set('filteredTodos', todos);
  }
});