Loccasions: Giving Events a Backbone

Glenn Goodrich
Ruby Editor

Last time we setup our client-side code and made the map. In this article, we’ll get Events integrated into our Backbone-based client code, displaying the collection of user Events.

Event Views

The following screenshot is from the last post, and shows how we divided our events#index view into multiple Backbone views:

What Backbone Sees

We have already dealt with the MapView. Let’s start with the Events List View, which is (as you might have guessed) simply where the Events are listed. In fact, that is our first test:

describe("EventsListView", function() {

  describe("Rendering events", function() {
    beforeEach(function() {
      loadFixtures("eventList.html")
      this.eventView = new Backbone.View();
      this.eventViewStub = sinon.stub(App, "EventView")
        .returns(this.eventView);
      this.eventViewSpy = sinon.spy(this.eventView, "render");
      this.event1 = new Backbone.Model({id:1});
      this.event2 = new Backbone.Model({id:2});
      this.event3 = new Backbone.Model({id:3});
      this.view = new App.EventListView({collection:
        new Backbone.Collection([
          this.event1,
          this.event2,
          this.event3
        ])
      });
    });

    it("should add a list item for each event", function() {
      //Arrange
      // happening in beforeEach
      //Act
      this.view.render();
      //Assert
      expect(this.eventViewSpy).toHaveBeenCalledThrice();
    });
  });
});

In order to test that our view lists events, we require a DOM element for our view and the Events to list. The fixture file (eventList.html) adds our DOM element, which is a simple unordered list (ul#eventsList). The responsibility of the EventListView is to render the list of Events, not to render the Events themselves.

Experience dictates that a EventView exist that will handle the rendering of a singular event. We don’t really care, from this test’s perspective, what the EventView does, so window.EventView is stubbed out. At that point, we can spy on the render method of our stub and make sure it’s called once for each event.

Another Backbone idiom is that a view can either have a collection or a model. In the case of the EventListView, we pass in a collection of our fake events.

Finally, we can actually test the view. Calling the render() function on our view and making sure that our eventView.render() was called once per event items completes the test. Let’s make it pass.

# app/assets/javascripts/views/eventListView.js.coffee
App.EventListView = Backbone.View.extend
  el: "#eventsList"
  initialize: () ->
  render: ->
    @clearList()
    @collection.each(@addEvent, @)
  clearList: ->
    $(@el).empty()
  addEvent: (ev) ->
      view = new App.EventView({model: ev})
      $(@el ).append(view.render().el)

The view code is simple (just so you know, what you see above is after a bit of refactoring). The render function clears the list and adds the event to the view’s DOM element.

It’s probably worth mentioning that a Backbone.Collection (which is what @collection is) is also an Underscore collection. As a result, the collection now has a slew of convenient functions, like each above. Sassy.

Drilling Down to EventView

Even though our test passes, the reference to EventView in the list view code needs to be addressed. Let’s write a test to create that view. The following test is the result of a couple of red-green-refactor sessions, which blew out a kinda-big deal item.

describe("EventView", function() {
  describe("render", function() {
    beforeEach(function() {
      //Arrange
      this.templateProviderStub = sinon.stub(App.TemplateProvider, "applyTemplate");
    });
    afterEach(function() {
      this.templateProviderStub.restore();
    });
    it("should call for the list_item template", function() {
      //Arrange
      var ev =  new Backbone.Model();
      var view = new App.EventView({model:ev})

      //Act
      var el = view.render();

      //Assert
      expect(this.templateProviderStub).toHaveBeenCalled();
    });
  });
});

Kinda Like Madlibs, But for Javascript Generated HTML

Looking at the test, you see I am testing if the view calls applyTemplate for something called App.TemplateProvider. Since including a ton of HTML within the javascript is a non-starter, most Backbone applications end up using templates. In essence, it’s like ERB or HAML, but for javascript.

There are currently several billion template frameworks out there, but I’ll name just a few here:

I went a bit of a different route. I am using HAML on the server for my Rails view templates, so I didn’t want to really introduce another templating language. I thought SOMEONE, SOMEWHERE has to have create a HAML template approach for javascript. Turns out, I was (kinda) right.

Allow me to introduce, haml-coffee. haml-coffee allows you to use HAML to create your javascript templates, which are placed on the client in a HAML.templates object.

(Note: haml-coffee seems to have been superseded by haml-coffee-assets. I did not know this until after the draft of this article was complete. While I will likely come back and use haml-coffee-assets later, I didn’t make the change before this article went live.)

In short, I created a app/assets/javascripts/templates directory for my templates. In the templates directory, I created an events folder, placing a line_item.js.haml-coffee in that directroy with the following content:

%span.del_form
  %div
    %form.button_to{:method => "post", :action => "/events/#{@id}"}
      %input{:name => "_method", :type => "hidden", :value => "delete"}
      %input{:data-confirm => "Are you sure?", :type => "submit", :value => "X" }
  %div.clear
%span.event_name
  %a{:href => "/events/#{@id}/edit"}= @name
%span.event_details
  %a{:href => "/events/#{@id}"}"Show Details"
%span.event_descript

In order to apply this template to a Backbone model on the client, you’d do the following:

window.HAML.templates.events.line_item(model.attributes);

I think that is pretty, danged neato.

The next bit of that EventView test I need to explain is the App.TemplateProvider stub. This object was born to handle the application of the templates to the models, which I didn’t really think was the model’s responsibility. Also, abstracting the template application to another object allowed me to test my view code cleanly.

Currently, the App.TemplateProvider code could not be more simple:

# app/assets/javascripts/lib/templateProvider.js.coffee
App.TemplateProvider =
  applyTemplate: (object_type, template_name, object) ->
    window.HAML.templates[object_type][template_name](object.attributes)

This may get refactored later, but it’ll do for now. Our specs pass, but if we run the server we don’t see any existing events.

Collection of Models (Note: Sounds sexier than it is)

In our view tests, we have stubbed/mocked/spied out our dependencies to business objects. However, we need to crank out the real objects behind these dependencies. Let’s start with the Event model.

In the specs, I test for our attributes (name and description), some validation, and the ways the url property can change:

# spec/javascripts/models/event_spec.js
describe("Event model", function() {
  beforeEach(function() {
      this.event = new App.Event({
        name: "New Event",
        description: "My Jasmine Event"
      });
  });
  describe("when instantiated with attributes", function() {
    it("should have a name and description", function() {
      expect(this.event.get("name")).toEqual("New Event");
      expect(this.event.get("description")).toEqual("My Jasmine Event");
    });
  });
  describe("when saving", function() {
    it("should not save when name is empty", function() {
      var eventSpy = sinon.spy();
      this.event.bind("error", eventSpy);
      this.event.save({"name":""});
      expect(eventSpy.calledOnce).toBeTruthy();
      // Make sure it passes in the event
      expect(eventSpy.args[0][0].cid).toEqual(this.event.cid);
      expect(eventSpy.args[0][1]).toEqual("must have a valid name.");
    });
  });
  describe("url", function() {
    it("should have a value if model is not part of a collection", function() {
      expect(this.event.url()).toEqual("/events");
    });
    it ("should reflect the collection url if part of a collection", function() {
      var collection = {
        url: "/loccasions"
      };
      this.event.collection = collection;
      expect(this.event.url()).toEqual("/loccasions");
    });
  });
});

With the specs in place, let’s make them pass:

# app/assets/javascripts/models/event.js.coffee

__super = Backbone.Model.prototype

App.Event = Backbone.Model.extend
  url: ->
    if (this.collection)
      return __super.url.call(@)
    else
      if this.id? != undefined then "/events" else "/events/" + @id
  validate: (attrs)->
    if !attrs.name
      "must have a valid name."

The most interesting bit here is how the url is handled, delegating to __super if the model is in a collection.

Actual Collection Construction

Jumping back to the EventsListView, it is expecting a collection. In our Event model test we just mocked up a collection, but we need a real collection of real events when we look at the real site (really). In Backbone, this means we need a EventsCollection that our EventsListView will consume. Test:

describe("EventsCollection", function() {
  beforeEach(function() {
    this.eventStub = sinon.stub(App, "Event");

    this.model = new (Backbone.Model.extend({
      idAttribute: "_id"
    }))
    ({
      _id: 5,
      name: "Test Event"
    });
    this.eventCollection = new App.EventsCollection();
    this.eventCollection.add(this.model);
  });

  afterEach(function() {
    this.eventStub.restore();
  });

  it("should add a model", function() {
    expect(this.eventCollection.length).toEqual(1);
  });

  it("should find a model by id", function(){
    expect(this.eventCollection.get(5).id).toEqual(5);
  });
});

If you have read Jim Newberry’s series on testing Backbone (which I have linked to before), this test looks exactly like his collection test, except for one small detail.

The discerning reader might have noticed that our id attribute is really _id. MongoDB uses _id for it’s identifier, so that is what we’ll see in our json. You can tell a Backbone model to use a different attribute for the identifer, using the idAttribute property on your model, which I do in the beforeEach above. I’ve also added it to the Event model like so:

App.Event = Backbone.Model.extend
  idAttribute: "_id"
  url: ->
    if (this.collection)
  ... elided ...

For completeness, I’ve added the following describe block to my spec/javascript/models/event_spec.js

describe("id", function() {
  it("should use _id for the id attribute", function() {
    var ev = new App.Event({_id:44, name: "MongoEvent"});
    expect(ev.id).toEqual(44);
  });
});

Removing the idAttribute from the Event model will cause that spec to fail, and putting it back gives us a passing spec. We have a working EventsCollection.

All About the Route(r)

The last bit we need to put into place before we can bootstrap our client-side code is the Router. For our current needs, the router is very simple. All we’ll have is a “” route, which needs to create our three views, as well as our collection of events. Following standard practice, we’ll have the “” route map to an function called index.

// spec/javascripts/routers/router_spec.js

describe("App.Router", function() {
  describe("routes", function() {
    beforeEach(function() {
      this.router = new App.Router();
      this.routeSpy = sinon.spy();
      try {
        Backbone.history.start({silent: true});
      } catch(e) {
        console.dir(e);
      }
      this.router.navigate("away");
    });
    it("should map the blank route to index", function() {
      this.router.bind("route:index", this.routeSpy);
      this.router.navigate("", true);
      expect(this.routeSpy).toHaveBeenCalledOnce();
      expect(this.routeSpy).toHaveBeenCalledWith();
    });
  });
});

Our router test (also eerily similar to Mr. Newberry’s router test) passes with the following code:

# app/assets/javascripts/router.js.coffee
App or= {}
App.Router = Backbone.Router.extend
  routes:
    ""      : "index"
  index: ->

The router needs to create our three views.

describe("index", function() {
  beforeEach(function() {
    this.router = new App.Router();
    this.eventListView = new Backbone.View({});
    this.eventListViewSpy = sinon.stub(App, "EventListView").returns(this.eventListView);
  });

  afterEach(function() {
    App.EventListView.restore();
  });

  it("should create the EventListView", function() {
    this.router.index();
    expect(this.eventListViewSpy).toHaveBeenCalled();
  });
});

Make it pass:

index: ->
    @eventListView = new App.EventListView({collection: window.eventCollection or= new App.EventsCollection()})
    @eventListView.render()

The EventListView is created, passing in window.eventCollection if it exists.

The flow of these client-side tests should start feeling familiar. In this case, we stub the view to be created, and then make sure that its constructor was called. Of course, we clean up after ourselves by restoring the stub. I added a test for the map view as well, but leave it to you to do on your own. (Note: the map view test is a bit different…do you know why?)

Have You Ever Been Kicked in the Face, With an Iron Bootstrap?

At this point, most of the view/model/collection code for displaying the Events is in place. However, we still won’t see the Events when we load our page in the browser. We need to bootstrap our Backbone code. It is fairly common Backbone idiom to create a function off the top-level application object (App in this case) called start and place the bootstrap code there.

For Backbone applications, bootstrapping means creating your router(s), making sure any needed collections are in place, and firing off the Backbone.history object. A minor difference between Loccasions and most of the Backbone apps I’ve seen is that our “root” route is not “/”, but it’s “/events”, which we need to configure.

Knowing all of this, the App.start function looks like:

window.App =
  start: ->
    window.eventCollection = new App.EventsCollection(bootstrapEvents)
    new App.Router()
    Backbone.history.start
      root: "/events"

The only bit we really haven’t discussed is bootstrapEvents. A decision point when dealing with collections is whether or not to load them with the page or to load the page and fire off a fetch to hydrate the collection. Here, I am doing the former, which means I have to change my events#index view to create and hydrate bootstrapEvents. The change is simple enough:

 / app/views/events/index.html.haml

%script
  var bootstrapEvents = [];
%h2 Your Events
#map.sixteen_columns
%ul#eventsList
  - for event in @events
    %script
      bootstrapEvents.push(new App.Event({ name: "#{event.name}", description: "#{event.description}:", id: "#{event._id}", }));
    %li{:class => @event == event ? :selected : nil}
      = render :partial => "event", :locals => {:event => event}
      %div.clear
%div#edit_event
  = form_for @event || Event.new do |f|
    = f.label :name
    = f.text_field :name
    = f.label :description
    = f.text_field :description
    = f.submit

In short, I added the two <code>%script</code> tags to create the <code>bootstrapEvents</code> object.

I am not sure I love mixing this stuff into the view in this manner, but I do like saving a trip to the server. My guess is I’ll be back in this code later.

Finally, I added the call to App.start to my application:

// app/assets/javascripts/application.js
// Last line of the file
$(App.start)

That’s Great, But I Still Can’t Add an Event

These posts about the Backbone/client-side code seem to run much longer than I anticipate. The next post will deal with adding and editing Events. I hope to cover the client-side of Occasions, as well.

Free book: Jump Start HTML5 Basics

Grab a free copy of one our latest ebooks! Packed with hints and tips on HTML5's most powerful new features.

  • http://blog.firsthand.ca Nicholas Henry

    I believe ‘class App.EventListView extends Backbone.View’ is more idiomatic CoffeeScript than ‘App.EventListView = Backbone.View.extend’

  • http://blog.firsthand.ca Nicholas Henry

    The last line of line_item.js.haml-coffee is truncated. Should read %span.event_description= @description

  • http://blog.firsthand.ca Nicholas Henry

    In line_item.js.haml-coffee, #line 10 ‘Show Details’ doesn’t need to be quoted. It threw an exception when using haml-coffee-assets.