Loccasions: Getting to Occasions
Last time we completed the client-side items needed to display the Events on the User Events page. Our focus now turns to adding and removing events asynchronously using Backbone.
In our screenshot of the Events page, the only part of the view we have not implemented is the EventFormView. Obviously, this view will be responsible for displaying a form to the user that allows the creation of a new Event. The form is simple:

The Event Form
The first thing I want to change is the name. Putting “Form” in the view name seems brittle to me, so let’s change it to what we are doing, which is creating an event: CreateEventView.
The CreateEventView should:
- Contain a form.
- Create an Event.
I think it’s worth pointing out that we are not testing the event hook-up (meaning, how the form submission event is handled), because that would be testing the Backbone framework.
Our tests look like:
describe("CreateEventView", function() {
beforeEach(function() {
loadFixtures("eventForm.html");
this.view = new App.CreateEventView();
this.form = $(this.view.el).find("form")[0];
});
it("should provide a form", function() {
expect($(this.view.el).find("form").length).toEqual(1);
});
describe("creating an event", function() {
beforeEach(function() {
window.eventCollection = new Backbone.Collection();
this.createStub = sinon.stub(window.eventCollection, "create");
$(this.form).find("#event_name").val("Test Event");
$(this.form).find("#event_description").val("Test Event Description");
this.view.createEvent();
});
it("should call create on the EventCollection", function() {
expect(this.createStub).toHaveBeenCalled();
});
});
});
Running these tests, the jasmine specs go red, as they should. Looking at the “creating an event” spec, we are putting some data into the form, then making sure the window.eventCollection.create()
is called. Backbone offers the create
method on collections as a convenience to both create the object and add it to the collection. Nice.
These tests flushed out an issue in dealing with the form. In order to create a App.TestEvent
, we have to parse the attribute values out of the form. There are many utilities out there that will serialize a form to json, but I think we’ll handle this ourselves for now.
Unfortunately, we can’t just use something as simple as jQuery’s form.serializeArray()
method, because there are values in the form that are not attributes on an Event. An example is the “authenticity_token” used by Rails to help find off CSRF attacks. We don’t want that on our Event. I am going to write a utility method to do what I think we need. Test ahoy:
describe("parsing form attributes", function() {
it("should have the correct attribute values", function() {
$(this.form).find("#event_name").val("Test Event");
$(this.form).find("#event_description").val("Test Event Description");
var attributes = this.view.parseFormAttributes().event;
expect(attributes.name).toEqual("Test Event");
expect(attributes.description).toEqual("Test Event Description");
});
});
Implementation:
parseFormAttributes: ->
_.inject(
@form.serializeArray(),
(memo, pair) ->
key = pair.name
return memo unless /^event/.test(key)
val = pair.value
if key.indexOf('[') > 0
parentKey = key.substr(0, key.indexOf('['))
childKey = key.split('[')[1].split(']')[0]
if typeof memo[parentKey] == "undefined"
memo[parentKey] = {}
memo[parentKey][childKey] = val
else
memo[key] = val
return memo
,{})
With parseFormAttributes()
in place, we can finish the createEvent()
. Here is the entire CreateEventView:
App or= {}
App.CreateEventView = Backbone.View.extend(
el: "#edit_event"
initialize: ->
@form = $(this.el).find("form")
events:
"submit form" : "handleFormSubmission"
handleFormSubmission: (e) ->
e.stopPropagation()
@createEvent()
false
createEvent: ()->
evento = new App.Event(@parseFormAttributes().event)
has_id = @form.attr("action").match(//events/(w*)/)
if has_id
evento.id = has_id[1]
evento.save()
else
eventCollection.create(evento)
parseFormAttributes: ->
_.inject(
@form.serializeArray(),
(memo, pair) ->
key = pair.name
return memo unless /^event/.test(key)
val = pair.value
if key.indexOf('[') > 0
parentKey = key.substr(0, key.indexOf('['))
childKey = key.split('[')[1].split(']')[0]
if typeof memo[parentKey] == "undefined"
memo[parentKey] = {}
memo[parentKey][childKey] = val
else
memo[key] = val
return memo
,{})
)
Lastly, we have to tell our router to create this view along with the other views.
// spec/javscripts/router_spec.js
describe("index", function() {
beforeEach(function() {
...
this.createViewSpy = sinon.stub(App, "CreateEventView").returns(this.mockView);
this.router.index();
});
afterEach(function() {
...
App.CreateEventView.restore();
});
...
it("should create the CreateEventView", function() {
expect(this.createViewSpy).toHaveBeenCalled();
});
});
});
(If it is not clear the ...
above means I have elided the existing code from the last post…just add the lines here)
Remembering from the previous post, we need to add a method in the index
method of the router, like so:
# app/assets/javascripts/router.js.coffee
index: ->
@eventListView = new App.EventListView({collection: window.eventCollection or= new App.EventsCollection()})
@eventListView.render()
@createEventView = new App.CreateEventView()
if $('#map').length > 0
@mapView = new App.MapView(App.MapProviders.Leaflet)
@mapView.render()
UPDATE: An alert reader (see comments) found some omissions in the article that made it harder to complete….sorry! I really appreciate people finding stuff like this.
You need to make sure your EventsController#create method looks like:
def create
event = current_user.events.build(params[:event])
event.save!
respond_with(event) do |format|
format.html { redirect_to events_path }
end
end
Also, make sure this is at the top of the EventsController class definition:
respond_to :html, :json
If you don’t, the wrong HTTP status is returned and Backbone won’t update the view. (Thanks Nicholas!)
If you go to http://localhost:3000 and click through the “My Events” page, you should be able to add Events, and watch them show up in our list.
Deleting Events
The Yin to our adding Events Yang (that sounds kinda dirty…) is deleting events. I am torn on whether or not to include delete functionality on the events#index page as a part of the list. While I can see use cases of wanting to delete, I can also see making them click-thru to the event page to delete the event as a more explicit you-better-know-what-the-hell-you-are-doing UI flow. Let’s assume our users are not too click-happy and are grown up enough to handle deleting the events from the list.
One Event at a Time
Awhile back, I decided that the events#show page was going to be a separate page from the events#index page, rather than trying to do a Single Page Application approach. That decision led to the question on how to execute the correct javascript on each page. In the event#index case, we have an EventsCollection and views around listing and creating Events. For the events#show page, we’ll be focused on a list of Occasions and views around manipulating the Occasions for the current Event.
A bit of searching led me to this post by Jason Garber that expands upon an approach (by the incomparable Paul Irish) to this problem. You should read the post for full details, but the crux of the approach is to create a utility class that calls a load method based on some data-* attributes written on the body element. Following that post’s lead, we change our body element in the app/views/layout/application.haml.html as follows:
%body{:"data-controller" => controller_name, :"data-action" => action_name }
With that in place, I changed the app/assets/javascripts/app.js.coffee to include our new util class:
window.App =
common:
init: ->
events:
init: ->
index: ->
window.eventCollection = new App.EventsCollection(bootstrapEvents)
new App.EventsShowRouter()
Backbone.history.start
root: "/events"
show:
new App.EventRouter()
ev_id = location.href.match(//events/(.*)/)[1]
Backbone.history.start
root: "/events/"+ev_id
UTIL =
exec: ( controller, action )->
ns = App
action or= "init"
if ( controller != "" && ns[controller] && typeof ns[controller][action] == "function" )
ns[controller][action]()
init: ->
body = document.body
controller = body.getAttribute( "data-controller" )
action = body.getAttribute( "data-action" )
UTIL.exec( "common" )
UTIL.exec( controller )
UTIL.exec( controller, action )
$(UTIL.init)
As a part of this change, I renamed App.Router
to App.EventsRouter
, created a app/assets/javascripts/routers folder and copied the newly renamed eventsRouter.js.coffee into that directory. I also had to rename the spec to eventsRouter_spec.js and modify both files, changing App.Router
to App.EventsRouter
. After each change, I reran my Jasmine suite and fixed things until the suite passed. I love having tests!
The last accommodation for this change was to change app/assets/applications/js, removing
//= require router
//= require_tree ./routers
and removing the $(App.start)
call from the bottom of that file.
All the specs should pass, and the existing functionality should work again. Now we can focus on a single Event and its Occasions.
Finally, an Occasion for Occasions
We can officially call this the “downhill slope.” Once we can add occasions and see them on a map, we are very close to done.
The approach to this page will be very similar to the Events page. As such, I think it’s a fine opportunity for you, the Loccasions reader, to attempt to create the event#show page on your own. At a minimum, the page should be able to:
- Add Occasions
- Delete Occasions
- List out all the Occasions for the Event
As a starting point, here is what mine looks like:
Shoot for this, but make it "yours"
Again, we have three Backbone view areas: the map, the list of Occasions, and the form to creaate a new Occasion. I put the list off to the right of the map in this view, just to be different. For extra credit, you can layout your page differently too.
For a couple of more clues, I created an App.EventRouter for the event show page (which you see mentioned in our UTIL code above). After that, it was almost a matter of copying the Event specs, changing them to handle Occasions, and then making those specs pass. If you get stuck, go to the git repository and see where I ended up.
I think we have about 2 more posts in this series before I am ready to take a break from Loccasions. The next post will cover interacting with the map, where we’ll take the Occasion form and integrate it with some map functionality. The last post will be a retrospective of what could I have done better (wow…that could be a LOOOOOONG one) and what could still be done with Loccasions.