Friday, November 4, 2011

Backbone.js Model Associations 2

‹prev | My Chain | next›

Last night I made a start on associations in Backbone.js. In my CouchDB backed calendaring application, I decided to add invitees / attendees to appointments.

For my first attempt, I am adding invitees to an appointment as an array property:
{
   "_id": "6bb06bde80925d1a058448ac4d004fb9",
   "_rev": "2-7fb2e6109fa93284c19696dc89753102",
   "title": "Test #7",
   "description": "asdf",
   "startDate": "2011-11-17",
   "invitees": [
       "6bb06bde80925d1a058448ac4d0062d6",
       "6bb06bde80925d1a058448ac4d006758",
       "6bb06bde80925d1a058448ac4d006f6e"
   ]
}
Then, in my Backbone app, I created an Invitees view class:
    var Invitees = Backbone.View.extend({
      template: template(
        '<div class="label">▼ Invitees</div>' +
        '<div class="names">{{names}}</div>'
      ),
      initialize: function(options) {
        options.collection.bind('reset', this.render, this);
      },
      render: function() {
        var names = this.collection.map(function(model) {
          return model.get("lastName");
        });
        $(this.el).html(this.template({names: names.join(", ")}));
        return this;
      }
    });
It was about here that things got away from me. I populate the collection from a list of IDs, but could only get it to work if I disabled asynchronous requests, which is not a good idea. I am reasonably sure that I could get this working if I made singular Invitee view classes, but that seems overkill. Besides, this gave me a surprising amount of trouble. The hope tonight is that, either I learn something new or I learn definitively that Backbone really dislikes collection views without sub-views. Either way I learn, so it is win-win.

First up, I think some refactoring of the Invitees HTML is in order. Currently the Invitees collection view is more of an expanded Invitee list:
    var Invitees = Backbone.View.extend({
      template: template(
        '<div class="label">▼ Invitees</div>' +
        '<div class="names">{{names}}</div>'
      ),
      // ...
    });
The collapsed version is in the page HTML itself(!) and toggling is handled by the AppointmentEdit view. That is just silly—the Invitee view should be responsible for it all. So I create two templates—an expanded and collapsed version of the collection view:
    var Invitees = Backbone.View.extend({
      templateCollapsed: template(
        '<div class="label">► Invitees</div>'
      ),
      templateExpanded: template(
        '<div class="label">▼ Invitees</div>' +
        '<div class="names"> {{names}}</div>'
      ),
      // ...
    });
To switch between the two templates, I create a new template() method that chooses one or the other based on an expanded attribute:
    var Invitees = Backbone.View.extend({
      template: function() {
        var which = this.expanded ? 'Expanded' : 'Collapsed';
        return this['template' +  which](arguments[0]);
      },
      templateCollapsed: template( /* ► ... */ ),
      templateExpanded: template( /* ▼ ... */ ),
      // ...
    });
I have no need for an Array.prototype.slice dance on the arguments because I will always have a single argument (template variable object).

To actually toggle between the two, I initialize the view as not-expanded and define a toggle click handler:
    var Invitees = Backbone.View.extend({
      // ...
      initialize: function(options) {
        this.expanded = false;
      },
      events: {
        'click .label': 'toggle'
      },
      toggle: function() {
        this.expanded = !this.expanded;
        this.render();
      },
      // ...
    });
Lastly, the render() method looks like an ordinary Backbone view render() method. It grabs the last names of the invitees from the collection, and passes them as a comma separated string to the template:
    var Invitees = Backbone.View.extend({
      // ...
      render: function() {
        var names = this.collection.map(function(model) {
          return model.get("lastName");
        });
        $(this.el).html(this.template({names: names.join(", ")}));
        return this;
      }
    });
The render() does not care if the current state is expanded or not. It simply calls template() and lets it decide how to proceed.

As for the collection, it remains the same as last night, with the notable exception that I can now remove the async: false option from fetch():
    var Invitees = Backbone.Collection.extend({
      model: Models.Invitee,
      initialize: function(options) {
        options || (options = {});
        this.invitees = options.invitees;
      },
      fetch: function() {
        var models = _.map(this.invitees, function(id) {
          var invitee = new Models.Invitee({id:id});
          invitee.fetch();
          return invitee;
        });
        this.reset(models);
        return this;
      }
    });
I am overriding Backbone.Collection's built-in fetch here to pull in the list of Invitees one by one, then resetting the collection to be comprised by those models.

The last piece of all of this is the calling context for my Invitees collection view. This is currently only attached to the edit dialog, to which I add a showInvitees() method:
    var AppointmentEdit = new (Backbone.View.extend({
      // ....
      showInvitees: function() {
        // ensure the dialog has an empty invitees section
        $('.invitees', this.el).remove();
        $('#edit-dialog').append('<div class="invitees"></div>');

        var invitees = this.model.get("invitees");

        if (!invitees) return this;
        if (invitees.length == 0) return this;

        var collection = new Collections.Invitees({invitees: invitees});
        var view = new Invitees({collection: collection});

        $('.invitees').append(view.render().el);

        collection.fetch();

        return this;
      }
    }));
After ensuring that the dialog has an empty invitees section, I extract the list of invitee IDs from the appointment. Two guard clauses make this method a no-op if there are no invitees. Then I simply create the invitees collection and attach them to the invitees' view. Lastly, I fetch() the collection.

And it works. When I open the edit dialog and toggle the invitees, I see their last names:


I am unsure if this is truly a quality solution to this problem. I call it a night here so that I can sleep on it. I will pick back up tomorrow continuing my exploration of Backbone associations.


Day #195

2 comments:

  1. This seems wrong:

    var invitee = new Models.Invitee({id:id});
    invitee.fetch();
    return invitee;

    Despite enabling async again, you're still treating the fetch as synchronous in your design, aren't you? I think this.reset may be called before all of your fetches are complete.

    I run into this oversight a lot with my team. Asking them to add a delay to their server side code can be a great way to identify the problem.

    ReplyDelete
  2. Hah! Good catch. I was definitely still doing that as a synchronous request. I think I fixed it (or at least did a little better) the next night: http://japhr.blogspot.com/2011/11/has-many-in-backbonejs-by-overriding.html. Still a ways to go to get it good though...

    ReplyDelete