Saturday, November 5, 2011

"Has Many" in Backbone.js by Overriding Collection Fetch

‹prev | My Chain | next›

Today, I continue playing with associations in my Backbone.js appointment calendar application. Yesterday, I came up with a "has many" solution that more or less works.

Given an appointment with several invitees:
{
   "_id": "6bb06bde80925d1a058448ac4d004fb9",
   "_rev": "2-7fb2e6109fa93284c19696dc89753102",
   "title": "Test #7",
   "description": "asdf",
   "startDate": "2011-11-17",
   "invitees": [
       "6bb06bde80925d1a058448ac4d0062d6",
       "6bb06bde80925d1a058448ac4d006758",
       "6bb06bde80925d1a058448ac4d006f6e"
   ]
}
I can convert those invitee IDs into an invitee collection to be displayed to the user:


I am under no illusion that I have found the ideal implementation here. For one thing, the responsibility for loading the invitees collection falls on the appointment dialog view template:
    var AppointmentEdit = new (Backbone.View.extend({
      // ....
      showInvitees: function() {
        $('.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;
      }
    }));
That does not bother me too much. It would be nice if the appointment model loaded this collection. I will worry about that another day.

Another problem is loading the invitees. As can be seen above, the invitees are attached to the appointment dialog view and then fetched. If the user expands the invitees list too quickly (or if it starts expanded), the invitees list is rendered as comma separated list of nothing:


Since my collection is reset, I had expected that binding the view's render() method to the "reset" event would re-display the full name list:
    var Invitees = Backbone.View.extend({
      initialize: function(options) {
        options.collection.bind('reset', this.render, this);
        this.expanded = true;
      },
      // ...
    });
The reason that does not work is the manner in which I have overridden my collection's fetch() method. It triggers a "reset" event when the models are added to the collection, but before the individual models have had a chance to load from the server:
    var Invitees = Backbone.Collection.extend({
      // ...
      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;
      }
    });
If I bind the Invitees collection view to the "change" event from the models rather than the "reset" event from the collection, then it works:
var Invitees = Backbone.View.extend({
      initialize: function(options) {
        options.collection.bind('change', this.render, this);
        this.expanded = true;
      },
      // ...
    });
That is not an ideal solution, however. The invitees list jerks around as each model is fetched from the backend and fires the "change" event, re-rendering the list. I really do want the "reset" event from the collection to fire just a single time. So I switch back to binding to the collection's "reset" event:
    var Invitees = Backbone.View.extend({
      initialize: function(options) {
        options.collection.bind('reset', this.render, this);
        this.expanded = true;
      },
      // ...
    });
And, back in the collection, I capture the model "change" events, only firing the collection's "reset" event after all of the models have been successfully fetched from the server:
    var Invitees = Backbone.Collection.extend({
      // ...
      fetch: function() {
        var collection = this;
        var models = _.map(this.invitees, function(id) {
          var invitee = new Models.Invitee({id:id});
          invitee.bind('change', collection.handleModelFetch, collection);
          invitee.fetch();
          return invitee;
        });
        this.reset(models, {silent: true});
        return this;
      },
      handleModelFetch: function() {
        this.fetchedCount || (this.fetchedCount = 0);
        if (++this.fetchedCount >= this.invitees.length) {
          this.trigger('reset', this);
          this.fetchedCount = 0;
        }
      }
    });
For good measure, when I reset() the collection, I am passing in the silent option. This prevents the "reset" event from firing. I can then manually trigger "reset" once all invitees have been fetched in the handleModelFetch() method.

And that does the trick. Even if the Invitees list starts expanded, a single redraw will occur once each of the three models have been loaded in turn. I still tend to doubt that is the ideal solution to a "has many" in Backbone, but it is a working solution.


Day #196

No comments:

Post a Comment