- Event Views
- Drilling Down to EventView
- Kinda Like Madlibs, But for Javascript Generated HTML
- Collection of Models (Note: Sounds sexier than it is)
- Actual Collection Construction
- All About the Route(r)
- Have You Ever Been Kicked in the Face, With an Iron Bootstrap?
- That’s Great, But I Still Can’t Add an Event
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:
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:
- Mustache, seems to be popular.
- jQuery Templates for you jQuery fanatics.
- Underscore has a template framework, as well.
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.
Glenn works for Skookum Digital Works by day and manages the SitePoint Ruby channel at night. He likes to pretend he has a secret identity, but can't come up with a good superhero name. He's settling for "Roob", for now.