Loccasions: Getting to Occasions

Glenn Goodrich
Ruby Editor
Tweet
This entry is part 12 of 15 in the series Loccasions

Loccasions

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.

Loccasions

<< Loccasions: Going Client-Side with Leaflet, Backbone, and JasmineLoccasions: Bubbly Map Events >>

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

    Hey Glenn, I tried to get through this today but got a little stuck at the end of the ‘add events’ feature. There are a few small issues that I will post later, but the major issue I’m stuck on is the Event List is not updated when adding an event. The event is being added ok, doing a page fresh works, but not via Backbone.js. I noticed that the article doesn’t mention adding the binding for the EventListView. I’ve added those and still no luck:

    https://github.com/ruprict/loccasions/blob/master/app/assets/javascripts/views/eventListView.js.coffee

    Is it possible that there’s something else missing? Definitely got me a little stumped.

    Here’s my commit so far:

    https://github.com/nicholasjhenry/loccasions/commit/c51b722fd8fe72f7997f205542973bca7eb336fa

    Any help would be much appreciated. Thanks.

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

    OK, I found the problem. The missing bindings in the EventListView was an issue, but also changes in the Rails EventsController is required:

    respond_to :html, :json

    def create

    respond_with(event) do |format|
    format.html { redirect_to events_path }
    end
    end

    Without those of course the HTTP code returned is a 302 rather than a 200 so Backbone.js will not trigger the add event on the collection.

    It would be great if these could be added to the article. Thank you, Glenn.

    • http://www.ruprict.net/ Glenn Goodrich

      Done…thanks again for finding these mistakes, Nicholas!

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

    Glenn, I managed to finish this off today and here are some issues I experienced listed below. Sometimes I found that I wasn’t one hundred percent certain where I was adding something, so I think adding the full file-path to all the code snippets would really help (some had them, some didn’t).

    Issue for spec/javascripts/views/create_event_view.js:

    * Line 03: Need to add fixture eventForm.html

    Issue for spec/javascripts/router_spec.js

    * Line 05: This should really be this.view instead of this.mockView based

    Issue for app/assets/javascripts/models/event.js.coffee

    * Line 03: #edit_event didn’t exist I had to add this element to events/index.html.haml and make the form a child

    Tip:

    Instead of writing:

    %body{:”data-controller” => controller_name, :”data-action” => action_name }

    You can do this:

    %body{:data => {:controller => controller_name, :action => action_name}

    Issue for app/assets/javascripts/app.js.coffee:

    * Probably should remove events/show as this shouldn’t exist yet

    That’s it. Thanks!

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

    Also it wasn’t noted explicitly, but I assume “Deleting Events” was “an exercise for the user”. Correct?

    • http://www.ruprict.net/ Glenn Goodrich

      No, delete events works. I may not have explained it (although, I thought I had) all the code is in there to delete events. I’ll try to find time to double check this weekend.

      Thanks!

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

        Hmmm, that’s not the experience I had :-) I originally thought that was the intention, that deletes should just work but they didn’t. This is what happened:

        * Deleting an event would return me to the login screen because the delete form didn’t contain the CSRF protection. The form was generated using a JavaScript template.
        * I reviewed your code and saw you had added a deleteEvent method to the event_view.js.coffee so I followed suit.
        * This also required an addition to EventsController#destroy method so it would respond to JSON.

        You can see my changes here:

        https://github.com/nicholasjhenry/loccasions/commit/e020586d1b8b67d9ecd273afc4d199bea8c5463f

        Excuse the fixes to:
        app/assets/templates/events/line_item.jst.hamlc
        app/views/events/index.html.haml

        I was incorrectly using “id” instead of “_id”. Once I changed those everything worked.

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

    Glenn, I’m interested in why you decided to clone the form for CreateOccasionView which you didn’t in CreateEventView:

    https://github.com/ruprict/loccasions/blob/master/app/assets/javascripts/views/createOccasionView.js.coffee#L4

    Can you please clue me in? Thanks!

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

    Shouldn’t the CreateOccasionView be instantiated in the EventRouter?

    https://github.com/ruprict/loccasions/blob/master/app/assets/javascripts/routers/eventRouter.js.coffee

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

    While implementing the Occasions functionality I realize that in the Haml markup %script was being used where :javascript is more appropriate.

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

    OK, Glenn – managed to get Occasions working, one more post to go!

    • http://www.ruprict.net/ Glenn Goodrich

      Hey Nicholas….I do plan on reviewing your comments and working them into the posts. Just really busy right now (launching in a couple of weeks….)

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

        No problem, thanks for the heads-up.