Sunday, September 25, 2011

Duplicate Backbone Events with jQuery UI Dialogs

‹prev | My Chain | next›

Up tonight, I hope to continue making progress switching entirely to faye for my Backbone.js application's persistence layer. I can populate appointments in the calendar via Faye, so up tonight I hope to be able to add and update calendar appointments.

To figure out where to start, I open the add-dialog and press OK:
Since the persistence layer has already been swapped out by replacing Backbone.sync:
    var faye = new Faye.Client('/faye');
    Backbone.sync = function(method, model, options) {
      faye.publish("/calendars/" + method, model);
    }
And, since I am logging the various CRUD operations:
    _(['create', 'update', 'delete', 'read', 'changes']).each(function(method) {
      faye.subscribe('/calendars/' + method, function(message) {
        console.log('[/calendars/' + method + ']');
        console.log(message);
      });
    });
Then the create operation shows up in the Javascript console:
Thus, I know that the create operation is being published to Faye, so I need to handle it on the backend. For that, I adopt my server side faye listener from last night. This time, I subscribe to the /calendars/create channel. When a message is received there, I POST into the CouchDB backend store, accumulate the CouchDB response and, when ready, send back the actual Javascript object:
client.subscribe('/calendars/create', function(message) {
  // HTTP request options
  var options = {
    method: 'POST',
    host: 'localhost',
    port: 5984,
    path: '/calendar',
    headers: {'content-type': 'application/json'}
  };

  // The request object
  var req = http.request(options, function(response) {
    console.log("Got response: %s %s:%d%s", response.statusCode, options.host, options.port, options.path);

    // Accumulate the response and publish when done
    var data = '';
    response.on('data', function(chunk) { data += chunk; });
    response.on('end', function() {
      client.publish('/calendars/add', JSON.parse(data));
    });
  });

  // Rudimentary connection error handling
  req.on('error', function(e) {
    console.log("Got error: " + e.message);
  });

  // Write the POST body and send the request
  req.write(JSON.stringify(message));
  req.end();
});
And that seems to work. I get my HTTP 201 response back from CouchDB (man, I love me a HTTP DB) and the newly created object is broadcast back on the /calendars/add faye channel. Even the browser sees it. But something weird happens when I create a second appointment—it gets created twice. And something weird happens when I create a third appointment—it is created three times. I see this in the console.log() output in Chrome's Javascript console and I see it in the server's logs:
{"title":"#1","description":"asdf","startDate":"2011-09-03"}
Got response: 201 localhost:5984/calendar
{"title":"#2","description":"asdf","startDate":"2011-09-03"}
Got response: 201 localhost:5984/calendar
{"title":"#2","description":"asdf","startDate":"2011-09-03"}
Got response: 201 localhost:5984/calendar
{"title":"#3","description":"asdf","startDate":"2011-09-03"}
{"title":"#3","description":"asdf","startDate":"2011-09-03"}
{"title":"#3","description":"asdf","startDate":"2011-09-03"}
Got response: 201 localhost:5984/calendar
Got response: 201 localhost:5984/calendar
Got response: 201 localhost:5984/calendar
So I add a debugger statement to the add-appointment View's click handler:
        var AppointmentAdd = Backbone.View.extend({
          // ...
          events: {
            'click .ok':  'create'
          },
          create: function() {
            debugger;
            appointment_collection.create({
              title: this.el.find('input.title').val(),
              description: this.el.find('input.description').val(),
              startDate: this.el.find('.startDate').html()
            });
          }
        });
The first time I hit that debugger statement, I allow code execution to continue. And then something strange happens... I hit that same event handler again:
Dammit. Every time I open that jQuery UI dialog for adding appointments, I am adding another copy of the same event handler to it. This is not being caused by my switch to faye—this is something that I had not noticed until inadvertent testing while mucking with faye.

Backbone actually has a strategy for preventing this kind of thing from happening. Unfortunately for me, that strategy expects that the element in question is created anew each time the view is initialized. In this case, I re-show() a dialog that was previously hidden.

Backbone's strategy involves assigning a unique ID to the view instance. Before assigning new events to the View's element, Backbone first removes any existing event handlers bound with that unique ID. I wonder if I can use this to my advantage. Perhaps when I initialize the view, I can always assign the same unique ID:
        var AppointmentAdd = Backbone.View.extend({
          initialize: function(options) {
            this.startDate = options.startDate;
            this.cid = 'add-dialog';
          },
          // ...
        });
Sadly, that does not work. I am still getting multiple appointments added on second and third add dialogs. Rooting through the Backbone code, I find that this is because the delegateEvents() method is called before initialize():
  Backbone.View = function(options) {
    this.cid = _.uniqueId('view');
    this._configure(options || {});
    this._ensureElement();
    this.delegateEvents();
    this.initialize.apply(this, arguments);
  };
Thus, by the time I assign my constant cid, it is already too late—events have been bound to the non-worky cid. I am hesitant to hook into any of those other methods—the underscore at the front of the method names indicate that these ought to be treated as private methods. Sure, I could do it, but I would feel ashamed at reaching under the covers like that.

This leaves the delegateEvents() method, which is not only a public method, but also one documented in the API. Surely that is fair game. So I override it, setting the constant cid and then call() the original version of delegateEvents() from the Backbone.View's prototype:
        var AppointmentAdd = Backbone.View.extend({
          // ...
          delegateEvents: function(events) {
            this.cid = 'add-dialog';
            Backbone.View.prototype.delegateEvents.call(this, events);
          },
          // ...
        });
And that works! Now, when I add a second or third appointment to my calendar, it only adds a single event:
{"title":"#1","description":"asdf","startDate":"2011-09-05"}
Got response: 201 localhost:5984/calendar
{"title":"#2","description":"asdf","startDate":"2011-09-05"}
Got response: 201 localhost:5984/calendar
I call it a night there. I still need to finish the add circle to ensure that new appointments are added to the existing collection (and to the UI). Then I really need to get delete working. I have a lot of appointments cluttering up my calendar now...


Day #144

1 comment:

  1. If you are using the same view over and over, you should only initialize it once, and use the "el" parameter of backbone to bind it to a single dom id element.

    Then only call initialize once, and render once. Then make a method called "reset" that clears the form. Then when you need to add a new event, the click handler to open the dialog calls reset then shows the dialog.

    It's like a Singleton for a View (see my drafted chapter entitled Singleton Views :-D). We should probably put an example like this in that chapter.

    ReplyDelete