#325 Backbone on Rails Part 2 pro
- Download:
- source codeProject Files in Zip (100 KB)
- mp4Full Size H.264 Video (44.8 MB)
- m4vSmaller H.264 Video (26 MB)
- webmFull Size VP8 Video (33.4 MB)
- ogvFull Size Theora Video (57.3 MB)
This is the second part of a two-part series on using Backbone with Rails. Our objective over these two episodes is to make the application shown below which helps us raffle off prizes. With this app we can add participants and select multiple winners. The app has some validation, too. If we try to add a name without entering anything in the text box an alert
will show to tell us what we’ve done wrong.
We started building this application in part one so before we do any more work we’ll review what we’ve done so far. First we created a new Rails application with a default page that simply contains a div with some placeholder text.
<div id="container">Loading...</div>
From here our Backbone app takes over. This starts in a raffler.js.coffee
file.
window.Raffler = Models: {} Collections: {} Views: {} Routers: {} init: -> new Raffler.Routers.Entries() Backbone.history.start() $(document).ready -> Raffler.init()
The init
function in this code is triggered when the page loads and it generates a new Entries
router then visits it. Let’s look at that router.
class Raffler.Routers.Entries extends Backbone.Router routes: '': 'index' 'entries/:id': 'show' initialize: -> @collection = new Raffler.Collections.Entries() @collection.fetch() index: -> view = new Raffler.Views.EntriesIndex(collection: @collection) $('#container').html(view.render().el) show: (id) -> alert "Entry #{id}"
This code creates a new Entries
collection then fetches and fills it with data from the database via our Rails application. The index
route then renders the EntriesIndex
view with that collection and places it inside the container
div. Here’s what that EntriesIndex
class looks like:
class Raffler.Views.EntriesIndex extends Backbone.View template: JST['entries/index'] initialize: -> @collection.on('reset', @render, this) render: -> $(@el).html(@template(entries: @collection)) this
This view passes the collection of data to this template.
<h1>Raffler</h1> <ul> <% for entry in @entries.models: %> <li><%= entry.get('name') %></li> <% end %> </ul>
The template is fairly straightforward. It loops through the entries in the collection and displays a name
for each one. When we browse our application in its current state we’ll see the list of entry records displayed.
If any of this is confusing or unfamiliar then you should read through part one to get a more thorough explanation of how we’ve got this far.
Adding New Entries
We’ll carry on now and add more functionality to our app. First we’ll add a form at the top of the page for adding new entries. We’ll add it in the template immediately above the code that lists the entries.
<h1>Raffler</h1> <form id="new_entry"> <input type="text" name="name" id="new_entry_name"> <input type="submit" value="Add"> </form> <ul> <% for entry in @entries.models: %> <li><%= entry.get('name') %></li> <% end %> </ul>
This adds the form to the page but clicking “Add” won’t add an entry yet as clicking the button just submits the form and reloads the page. We need to capture the submit
event and add the entry through the application. Events in Backbone are often handled in the views and we’ll do this inside our Entries
index view. We want to listen to the form’s submit
event and Backbone provides a special way to define and handle events by setting an events property.
class Raffler.Views.EntriesIndex extends Backbone.View template: JST['entries/index'] events: 'submit #new_entry': 'createEntry' initialize: -> @collection.on('reset', @render, this) render: -> $(@el).html(@template(entries: @collection)) this createEntry: (event) -> event.preventDefault() @collection.create name: $('#new_entry_name').val()
In events
we set the name of the event we want to listen to followed by a space and an identifier for the element we want to listen to the event on, in this case the form. The value should be the name of a function which will be triggered when the event fires. In this case we call a createEntry
function which we’ve also defined here. This function takes an event
parameter and in it we first call preventDefault
to ensure that the event’s default action, in this case submitting the form, doesn’t happen. Next we create a new record through our collection, passing in the name attribute with a value based on the text in the text field.
If we reload the page now we’ll see the form. When we enter a name and click “Add” the browser will submit a POST request in the background to our Rails application to create the record.
Although the record is added the front end doesn’t change until we reload the page so we need a way to add a new record to the list when it’s added to the database. We could update the view directly in the createEntry
function but a general practice in Backbone is only to focus on changing the model data here and to use callbacks to update the view. We have a reset
callback that we created in the previous episode which re-renders the view when we fetch the collection data from our Rails application. We’ll make another event callback here for the add event that will re-render the view when we add a new record.
class Raffler.Views.EntriesIndex extends Backbone.View template: JST['entries/index'] events: 'submit #new_entry': 'createEntry' initialize: -> @collection.on('reset', @render, this) @collection.on('add', @render, this) render: -> $(@el).html(@template(entries: @collection)) this createEntry: (event) -> event.preventDefault() @collection.create name: $('#new_entry_name').val()
If we add a new entry now (say “Bob”) the list will update as soon as we click “Add”.
Adding The New Entry More Efficiently
This solution works but every time we add a new entry the entire template including the form and all the entries are re-rendered. It’s generally best to only update the portion of the page that changes, in this case we just want to add a new item to the list. To do this we’ll need a way to render a single entry and we can do this by creating a separate entry view which handles the rendering of a single entry. This view will be quite similar to the other view but for now we’ll just have it render out its template without passing an entry object to it.
class Raffler.Views.Entry extends Backbone.View template: JST['entries/entry'] render: -> $(@el).html(@template()) this
We need to create a new template to go with this view and we’ll do that now, though we’ll just put some placeholder text in it.
Entry goes here.
In our index template we can use this new template to render the list of entries. We’ll remove the code that loops through the entries collection and replace it with a static ul
element with an id
.
<h1>Raffler</h1> <form id="new_entry"> <input type="text" name="name" id="new_entry_name"> <input type="submit" value="Add"> </form> <ul id="entries"></ul>
In the index view now we’ll change the way we render the template. We no longer need to pass the entries collection in to the template, instead we’ll fill it with the entries separately.
render: -> $(@el).html(@template()) @collection.each(@appendEntry) this appendEntry: (entry) -> view = new Raffler.Views.Entry() $('#entries').append(view.render().el)
Now in render
we loop through each entry in the collection and call a new appendEntry
function. In this we create a new entry view and then render it out by appending it to the list. If we reload the page now we’ll see the placeholder text for each item but they don’t appear to be rendered in a list.
If we look at the source we’ll see why. The the wrapper ul
element is there but each entry is wrapped in a div
which is the default wrapper for Backbone views.
<ul id="entries"><div>Entry goes here. </div><div>Entry goes here. </div><div>Entry goes here. </div><div>Entry goes here. </div><div>Entry goes here. </div><div>Entry goes here. </div><div>Entry goes here. </div><div>Entry goes here. </div></ul>
We can change the wrapper element by setting the tagName
property in the view.
class Raffler.Views.Entry extends Backbone.View template: JST['entries/entry'] tagName: 'li' render: -> $(@el).html(@template()) this
When we reload the page now it looks correct but we still need to replace the placeholder text with the actual entry name. To do this we’ll need to pass the entry model to the view when we create it.
appendEntry: (entry) -> view = new Raffler.Views.Entry(model: entry) $('#entries').append(view.render().el)
Now in the view we can pass the model into the template.
render: -> $(@el).html(@template(entry: @model)) this
Finally in the template we can replace the placeholder text with the entry’s name
.
<%= @entry.get('name') %>
When we reload the page now we’ll see the list of names again.
With these changes in place we no longer have to re-render the entire template when we add a new entry. Instead we can call appendEntry
and simply add the new entry to the list.
initialize: -> @collection.on('reset', @render, this) @collection.on('add', @appendEntry, this)
This works, but when we add an item now the text field is no longer cleared as the entire template isn’t being re-rendered. We’ll need to manage this manually and we can do so by modifying the createEntry
function.
createEntry: (event) -> event.preventDefault() @collection.create name: $('#new_entry_name').val() $('#new_entry')[0].reset()
Adding Validations
Now that we know how to add entries efficiently we’ll take a look at validations. If a user clicks the “Add” button without entering a name we want to show an error message. Backbone offers support for client-side validations with the validate
function and there are details of this in the documentation but we won’t be using them here. Instead we’ll focus on server-side validations and integration with Rails.
Our app’s Entry model doesn’t have any validations yet so to start we’ll add one.
class Entry < ActiveRecord::Base validates_presence_of :name end
If we try to add a new record without a name with this validation in place nothing seems to happen. If we look at the browser’s network activity, however, we’ll see that a POST request was made and that the response status is 422 Bad Request
. The response’s content is some JSON containing the error messages.
{"errors":{"name":["can't be blank"]}}
We can use this JSON, which is generated by respond_with
call in the create
action, to display the error to the user. If you’re not using respond_with
you’ll need to manually check if there are any validation errors on the model and handle the response in a similar fashion.
def create respond_with Entry.create(params[:entry]) end
Now that we know how this error is generated we’ll handle the 422
response in our Backbone app and display the error message to the user. When the entry is created in the index view we can pass in some callback functions to handle either a successful or failing response.
createEntry: (event) -> event.preventDefault() attributes = name: $('#new_entry_name').val() @collection.create attributes, success: -> $('#new_entry')[0].reset() error: @handleError handleError: (entry, response) -> if response.status == 422 errors = $.parseJSON(response.responseText).errors for attribute, messages of errors alert "#{attribute} #{message}" for message in messages
We’ve cleaned up the code that creates the new entry by moving the code that creates the attributes out into a separate variable and we’ve also added two callbacks here: success
, which fires when the new entry is created successfully and error
which fires when it isn’t. We’ve also moved the line of code that clears the text box so that it only runs when the item is created. When the creation fails we call a new handleError
function.
This function has the entry model and the response passed to it. We can check if the response’s status is 422
and if it is we get the errors from the response by parsing its JSON content and fetching its errors
object. We then loop through this object and alert
each message in the messages array. We need to do this as there can be multiple error for each attribute. When we reload the page now and try to add a new entry without a name we’ll see an error displayed.
We do have an issue here, however. If we look at the bottom of the list we’ll see a blank entry in the collection, although if we reload the page it will go away. For some reason an entry is still being added to the collection. The issue is that calling @collection.create
doesn’t wait for the server to respond before adding the entry to the collection so if there is a validation error the entry will be added anyway. If we had client-side validation then this wouldn’t be as much of an issue but as we don’t we’ll need another approach. We can pass a wait
option to create and set it to true
and this way the new item won’t be added to the collection until the server responds.
createEntry: (event) -> event.preventDefault() attributes = name: $('#new_entry_name').val() @collection.create attributes, wait: true success: -> $('#new_entry')[0].reset() error: @handleError
Now when we try to create a new entry we’ll see the error but the blank line won’t be created.
Selecting a Winner
Now that we have validations working it’s time to add the “Draw Winner” button so that we can select a random winner from the list. First we’ll add the button to the template.
<h1>Raffler</h1> <form id="new_entry"> <input type="text" name="name" id="new_entry_name"> <input type="submit" value="Add"> </form> <ul id="entries"></ul> <button id="draw">Draw Winner</button>
Next we’ll need to write some code so that the button does something when we click on it. We can do this in the same way that we listen to the submit
event for creating an entry.
class Raffler.Views.EntriesIndex extends Backbone.View template: JST['entries/index'] events: 'submit #new_entry': 'createEntry' 'click #draw': 'drawWinner' drawWinner: (event) -> event.preventDefault() @collection.drawWinner() # Other functions omitted.
The logic for selecting a random winner is fairly complex and in such cases it’s a good idea to pass it off to the model or collection layers. We call a new drawWinner
function on the collection and we’ll need to write that next.
class Raffler.Collections.Entries extends Backbone.Collection url: '/api/entries' drawWinner: -> winner = @shuffle()[0] if winner winner.set(winner: true) winner.save()
In part one we mentioned that we can use shuffle
on a collection to return a randomized array of records. We get the first record from the shuffled collection and if that record exists mark it as the winner then call save to send a PUT request back to the server to save the record.
Clicking the button will now mark one of the entries as a winner but there’s no indication of this in the UI. We’ll do this inside the template for a single entry where we currently just display the entry’s name
.
<%= @entry.get('name') %> <% if @entry.get('winner'): %> <span class="winner">WINNER</span> <% end %>
Now when we click the button a few times then reload the page we’ll see some of the entries listed as winners.
If we click “Draw Winner” again another winner is selected but the view isn’t updated to show them. We’ll do this in the entry view where we render the individual entry. We need to create an initialize
function for this class and in it we bind the the change event for the model to the render
function so that a model is re-rendered when the model changes.
class Raffler.Views.Entry extends Backbone.View template: JST['entries/entry'] tagName: 'li' initialize: -> @model.on('change', @render, this) render: -> $(@el).html(@template(entry: @model)) this
The change
event is triggered by Backbone when a model saves and so it will be fired when we set an entry as a winner. When we reload the page now then click “Draw Winner” a new winner is selected and they’re shown as being a winner on the page. Next we’ll highlight the newest selected winner’s text in red so that they’re easier to spot. One way to do this is to trigger a custom event when a winner has been selected. We aren’t limited to the events that come with Backbone, we can trigger any event. We’ll make use of this to trigger a custom highlight
event when a winner is set.
class Raffler.Collections.Entries extends Backbone.Collection url: '/api/entries' drawWinner: -> winner = @shuffle()[0] if winner winner.set(winner: true) winner.save() winner.trigger('highlight')
In the entry view we can now listen to this event in the same way we listen to the change
event.
class Raffler.Views.Entry extends Backbone.View template: JST['entries/entry'] tagName: 'li' initialize: -> @model.on('change', @render, this) @model.on('highlight', @highlightWinner, this) highlightWinner: -> $('.winner').removeClass('highlight') @$('.winner').addClass('highlight') render: -> $(@el).html(@template(entry: @model)) this
This event calls a new highlightWinner
function when it’s triggered and this function adds a highlight
class to the .winner
element. We don’t want every one of these elements to be highlighted, only the one that’s inside this template and Backbone provides a nice way to do this by calling this.
before the element matcher, although as our code is in CoffeeScript we’ve used the @
symbol here instead. As there may be a previously highlighted winner showing on the page we remove the class from all of the winner
elements first.
If we reload the page now and click “Draw Winner” a new winner will be selected and shown in red. When we click the button again that winner will revert to being shown in grey and a new winner will be shown in red.
Refactoring
It’s currently possible for a winner to be chosen who has already been marked as a winner. This is something you may or may not want to fix but we won’t do that here. What we will do next is move the logic that sets a entry as a winner into the entry model. We call a lot of functions against the entry model here which is a sign that it’s a good idea to move this code into the model. First we’ll remove the code that sets the winner from the entries class and replace it with a call to a new win
function on the model.
class Raffler.Collections.Entries extends Backbone.Collection url: '/api/entries' drawWinner: -> winner = @shuffle()[0] winner.win() if winner
We can now define this function in the model class. Note that we now call these functions against this
instead of through the winner
variable.
class Raffler.Models.Entry extends Backbone.Model win: -> @set(winner: true) @save() @trigger('highlight')
When we try this, though, it doesn’t work. If we look at the browser’s console we’ll see an error telling us that the win
function is undefined. The issue is that the models we’ve been working with in the collection aren’t actually entry models and they don’t contain the logic we define in the entry model class. To get this behaviour we need to define the class we want to use for a model.
class Raffler.Collections.Entries extends Backbone.Collection url: '/api/entries' model: Raffler.Models.Entry drawWinner: -> winner = @shuffle()[0] winner.win() if winner
Now this collection class won’t use the base model but will use Entry
and we’ll have access to its functionality. With this fix in place the “Draw Winner” button works as it should again.
Preloading Data
Next we’ll look at preloading data. In part one we set up a call to @collection.fetch
in our router. This loads up the initial entries by making a request to our Rails application. This second request can be avoided if we preload the data in the initial request to the page. There are a variety of ways that we can do this and we covered them in episode 324. Here’s we’ll use the content_tag
technique and pass in the initial set of entries in a data-
attribute.
<%= content_tag "div", "Loading...", id: "container", data: { entries: Entry.all } %>
If we reload the page now and view the source we’ll see the JSON representation of the entries in the data-entries
attribute.
<div data-entries="[{"created_at":"2012-02-20T22:13:51Z","id":1,"name":"Matz","updated_at":"2012-02-20T22:13:51Z","winner":true},{"created_at":"2012-02-20T22:13:51Z","id":2,"name":"Yehuda Katz","updated_at":"2012-02-22T20:13:27Z","winner":true},{"created_at":"2012-02-20T22:13:51Z","id":3,"name":"DHH","updated_at":"2012-02-20T22:13:51Z","winner":true},{"created_at":"2012-02-20T22:13:51Z","id":4,"name":"Jose Valim","updated_at":"2012-02-22T20:13:35Z","winner":false},{"created_at":"2012-02-20T22:13:51Z","id":5,"name":"Dr Nic","updated_at":"2012-02-22T20:13:43Z","winner":true},{"created_at":"2012-02-20T22:13:51Z","id":6,"name":"John Nunemaker","updated_at":"2012-02-22T20:13:51Z","winner":true},{"created_at":"2012-02-20T22:13:51Z","id":7,"name":"Aaron Patterson","updated_at":"2012-02-20T22:13:51Z","winner":true},{"created_at":"2012-02-22T18:54:08Z","id":12,"name":"Bob","updated_at":"2012-02-22T20:15:00Z","winner":false}]" id="container">Loading...</div>
Now, instead of calling fetch
on the collection we can call reset
and supply the data directly from that attribute.
class Raffler.Routers.Entries extends Backbone.Router routes: '': 'index' 'entries/:id': 'show' initialize: -> @collection = new Raffler.Collections.Entries() @collection.reset($('#container').data 'entries') index: -> view = new Raffler.Views.EntriesIndex(collection: @collection) $('#container').html(view.render().el) show: (id) -> alert "Entry #{id}"
When we reload the page, however, the list is empty.
This problem goes back to the way we render the entries in the index view.
render: -> $(@el).html(@template()) @collection.each(@appendEntry) this appendEntry: (entry) -> view = new Raffler.Views.Entry() $('#entries').append(view.render().el)
After we render the template we call @collection.each
and append the view for each entry to the #entries
list element. The problem is that this list element doesn’t exist yet on the page since this now happens immediately when the view is created and rendered but not yet added to the container on the page. To fix this we need to change the way we access the list of entries and make sure that we always go through this view by using @
so the the code looks for the entries
element within the view’s element and not on the page directly.
appendEntry: (entry) -> view = new Raffler.Views.Entry() @$('#entries').append(view.render().el)
There’s still a problem with this, though. The context in this function is not the view context. When we call @collection.each
and pass it a function the context isn’t maintained. One way to fix this is to pass this as a second argument to @collection.each
but in CoffeeScript we have another solution which is to use the ‘fat arrow’ when defining the function. This will always maintain this as the view context wherever this function is called and means that we don’t need to pass this
as a second argument.
appendEntry: (entry) => view = new Raffler.Views.Entry() @$('#entries').append(view.render().el)
We could do this elsewhere, too, such as where we pass this
as a third argument to on when we’re defining event handlers. Now our page works again and is pre-populated with a list of data when it loads so there’s no need for it to make an extra request to the server to get the initial list of data.
Routing
We’ll finish off this episode with a look at routing. So far we’ve kept everything inside the index
route but we did also set up a show route in part one. Let’s say that when we click on a specific entry we want to trigger this route for that entry so that we can display more detail about it. We’ll handle this click
event inside the entry view.
class Raffler.Views.Entry extends Backbone.View template: JST['entries/entry'] tagName: 'li' events: 'click': 'showEntry' initialize: -> @model.on('change', @render, this) @model.on('highlight', @highlightWinner, this) showEntry: -> Backbone.history.navigate("entries/#{@model.get('id')}", true)
We’ve added a click
event here but haven’t passed in a second option as we’re listening on the full element. When the event fires it clicks a showEntry
function. This function will visit the URL for the clicked entry and the show route. To do this we call Backbone.history.navigate
and pass in a path. Passing true
as a second option here triggers that route.
If we reload the page now then click one of the entries we’ll see an alert
and the path will change to that specific route.
We mentioned in part one that there are ways to change this routing behaviour so that it uses the actual URL path instead of an anchor. To do this we need to modify the the raffler.js.coffee
file and add a pushState: true
option to the call to Backbone.history.start
.
window.Raffler = Models: {} Collections: {} Views: {} Routers: {} init: -> new Raffler.Routers.Entries() Backbone.history.start(pushState: true) $(document).ready -> Raffler.init()
Now when we click on an entry we’ll see the alert
again but the entry’s id
is now part of the path instead of in an anchor. This causes a problem if we call a URL directly. If we try it we’ll see a routing error from our Rails app as it doesn’t respond to that route. To fix this we could add a catch-all route to the end of our Rails router that matches anything and passes it to the MainController
’s index
action which will load up our Backbone application.
Raffler::Application.routes.draw do scope "api" do resources :entries end root to: "main#index" match '*path', to: 'main#index' end
Now any path in our Rails application will load up our Backbone application and we can visit the page for a single entry directly. There’s no template for this page yet so we’ll just see the ‘“Loading...” message but adding this is left as an exercise for the reader.