skalb.com

How to (Easily) Handle Model Relationships in Rails and Backbone.js

While playing around with Backbone.js, I couldn’t find an easy way to build an app that used the RESTful hierarchy of my models. I think Spine’s implementation is fairly straightforward.

I did find a relevant active project, but for my specific case the added complexity of an additional component and dependency didn’t seem justified. Rails already does the hard part for me, I just need Backbone to call the correct Urls.

I wanted to learn more about Backbone, so I prototyped a very basic project management app using Rails and Backbone called Trackbone that I’ll walk through in this post.

Briefly, this is a single page three panel app with drill-downs:

  • Many projects
  • Project has many Features
  • Featurehas many Bugs

Demo on Heroku Source

Rails backend:

Rails controllers provide the REST API for our Backbone app. I haven’t inlined them here since they only have a few modifications post-scaffolding, but you can view them here

Modifications:

  • Projects#index is moved to HomeController
  • Location is not returned in response after #create
  • Model is returned after #update
routes.rb
1
2
3
4
5
6
7
8
9
root :to => "home#index"

Trackbone::Application.routes.draw do
  resources :projects do
    resources :features do
      resources :bugs
    end
  end
end

For setting up Backbone, the rails-backbone gem provides a good guide.

Originally, I scaffolded Backbone here as well. On review, I’m not sure I would do that again. I think you’ll end at a better design if you start from scratch.

Creating Backbone Models:

Features only exist in the context of a Project, so they should only be loaded for a specific Project and similarly for Bugs.

javascripts/backbone/models/project.js.coffee
1
2
3
4
5
6
7
8
9
10
11
12
class Trackbone.Models.Project extends Backbone.Model
  paramRoot: 'project'

  defaults:
    name: null

  loadFeatures: ->
    @features = new Trackbone.Collections.FeaturesCollection([], {project_url: @url()});

class Trackbone.Collections.ProjectsCollection extends Backbone.Collection
  model: Trackbone.Models.Project
  url: '/projects'

On reflection, loadFeatures is poorly named. It’s really more of an ‘initialize’, but anyways, calling that method will create a FeaturesCollection and pass in the Url for this project. You can see how this is used in the Features model

javascripts/backbone/models/feature.js.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Trackbone.Models.Feature extends Backbone.Model
  paramRoot: 'feature'

  defaults:
    name: null

  loadBugs: ->
    @bugs = new Trackbone.Collections.BugsCollection([], {feature_url: @url()});

class Trackbone.Collections.FeaturesCollection extends Backbone.Collection
  model: Trackbone.Models.Feature
  initialize: (model, args) ->
    @url = ->
      args.project_url + "/features"

Using the project_url from args will prepend all RESTful requests made on the features model with projects/:project_id.

javascripts/backbone/models/bug.js.coffee
1
2
3
4
5
6
7
8
9
10
11
class Trackbone.Models.Bug extends Backbone.Model
  paramRoot: 'bug'

  defaults:
    name: null

class Trackbone.Collections.BugsCollection extends Backbone.Collection
  model: Trackbone.Models.Bug
  initialize: (model, args) ->
    @url = ->
      args.feature_url + "/bugs"

Backbone Routers:

Next we need to create the Projects router which will be the entry point into our single page app.

javascripts/backbone/routers/projects_router.js.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Trackbone.Routers.ProjectsRouter extends Backbone.Router
  initialize: (options) ->
    @projects = new Trackbone.Collections.ProjectsCollection()
    @projects.reset options.projects

  routes:
    ".*" : "index"

  index: ->
    @view = new Trackbone.Views.Projects.IndexView(projects: @projects)
    $("#list-projects").html(@view.render().el)

    @newProjectView = new Trackbone.Views.Projects.NewView(collection: @projects)
    $("#new-projects").html(@newProjectView.render().el)

Because we’re not worrying about permalinks (yet! I’ve started looking into this, and hope to create a follow-up post), we only need one catch-all route. We then pass in the respective models to our views.

Listing projects:

views/projects/index.html.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<h1>Trackbone</h1>
<hr>
<div class="container">
  <div id="new-projects"></div>
  <div id="list-projects"></div>
</div>
<div class="container">
  <div id="new-features"></div>
  <div id="list-features"></div>
</div>
<div class="container">
  <div id="new-bugs"></div>
  <div id="list-bugs"></div>
</div>

<script type="text/javascript">
  $(function() {
    window.router = new Trackbone.Routers.ProjectsRouter(
      { projects: <%= @projects.to_json.html_safe -%> }
    );
    Backbone.history.start();
  });
</script>

There is a container, new, and list div for each model. Backbone will load data from our Rest API, build HTML using the Views and Templates and inject that HTML into those DIV elements. Note that we are passing in projects from Rails to the router. This allows us to grab data from the server when the page is first requested and save an additional call on page load. Since this is a single page app, this is the only Rails view we need.

To display our Project data we need three templates: index to list projects, project for a specific item, and new to create a project.

1
2
<td><a href="#" class="select"><%= name %></td>
<td>[Destroy](#" class="destroy)</td>
1
2
3
4
5
6
7
8
9
10
<h1>Listing projects</h1>

<table id="projects-table">
  <tr>
    <th>Name</th>
    <th></th>
  </tr>
</table>

<br/>
1
2
3
4
5
6
7
8
9
10
11
12
13
<h1>New project</h1>

<form id="new-project" name="project">
  <div class="field">
    <label for="name"> name:</label>
    <input type="text" name="name" id="name" value="<%= name %>" >
  </div>

  <div class="actions">
    <input type="submit" value="Create Project" />
  </div>

</form>

These should be fairly self-explanatory. Each template will also need a Backbone View as well.

javascripts/backbone/views/projects/index_view.js.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Trackbone.Views.Projects ||= {}

class Trackbone.Views.Projects.IndexView extends Backbone.View
  template: JST["backbone/templates/projects/index"]

  initialize: () ->
    @options.projects.bind('reset', @addAll)
    @options.projects.bind('sync', @render)

  addAll: () =>
    @options.projects.each(@addOne)

  addOne: (project) =>
    view = new Trackbone.Views.Projects.ProjectView({model : project})
    @$("tbody").append(view.render().el)

  render: =>
    $(@el).html(@template(projects: @options.projects.toJSON()))
    @addAll()

    return this

This view receives the entire list of projects, renders the index view and appends a project view for each project.

javascripts/backbone/views/projects/new_view.js.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Trackbone.Views.Projects ||= {}

class Trackbone.Views.Projects.NewView extends Backbone.View
  template: JST["backbone/templates/projects/new"]

  events:
    "submit #new-project": "save"

  save: (e) ->
    e.preventDefault()
    e.stopPropagation()

    name = $("#new-project #name").val()
    if name
      $("#new-project #name").val('')
      @collection.create(name: name)

  render: ->
    $(@el).html(@template())

    return this

The new view simply providers a handler for creating a new project within the collection. The create method actually does three things: creates the model, POSTs the model to the server, and adds the model to the collection.

javascripts/backbone/views/projects/project_view.js.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
Trackbone.Views.Projects ||= {}

class Trackbone.Views.Projects.ProjectView extends Backbone.View
  template: JST["backbone/templates/shared/item"]

  events:
    "click .select" : "select"
    "click .destroy" : "destroy"

  tagName: "tr"
  className: "item"

  select: () ->
    window.toggleSelected(@el)
    @model.loadFeatures()
    do (@model) ->
      @model.features.fetch success: ->
        featuresView = new Trackbone.Views.Features.IndexView(features: @model.features)
        $("#list-features").html(featuresView.render().el)

        # We should probably only render this once instead of each load
        newFeaturesView = new Trackbone.Views.Features.NewView(collection: @model.features)
        $("#new-features").html(newFeaturesView.render().el)

        $("#list-bugs").html('')
        $("#new-bugs").html('')
    @model.features.fetch()

  destroy: () ->
    @model.destroy()
    this.remove()

    return false

  render: ->
    $(@el).html(@template(@model.toJSON() ))
    return this

This view is really the heart of the app. Every time a Project is selected, we’ll display the Features for that Project by calling fetch() and binding to success().

I’m not going to inline the templates and views for Features and Bugs since they are more or less identical, but feel free to browse through all the client side code

Again, here’s a working demo.

Please provide any feedback you have in the comments. Was this useful? Too long? Too much/too little code inline? I’m currently working on a couple more entires that will build on this one as I’m learning more about Backbone.js, so feedback is definitely useful.

Comments