Tuesday, November 15, 2011

Decoupling Page and Backbone.js

‹prev | My Chain | next›

Thanks to my spec fixin' efforts over the past few days, I now have a completely green jasmine test suite covering my Backbone.js calendar application. I think this is a good opportunity to refactor things a bit more.

The code behind the add-appointment dialog has been bugging me for a while. It works and I have tests covering it:


But the dialog itself is built and initialized outside of my Backbone app:
<div id="add-dialog" title="Add calendar appointment">
  <h2 class="startDate"></h2>
  <p>title</p>
  <p><input type="text" name="title" class="title"/></p>
  <p>description</p>
  <p><input type="text" name="description" class="description"/></p>
</div>

<script>
$(function() {
  $('#add-dialog').dialog({
    autoOpen: false,
    modal: true,
    buttons: [
      { text: "OK",
        class: "ok",
        click: function() { $(this).dialog("close"); } },
      { text: "Cancel",
        click: function() { $(this).dialog("close"); } } ]
  });
});
</script>
One of the oddities introduced by jQuery UI dialogs is that important dialog elements (e.g. form buttons) are added around the dialog() DOM element. In the above, a new dialog DOM element is added above <div id="add-dialog">, with the "add-dialog" then becoming a child of that dialog element.

I had to deal with that in my singleton view by setting the View's element to the parent() of "add-dialog":
    var AppointmentAdd = new (Backbone.View.extend({
      el: $("#add-dialog").parent(),
      // ....
    });
I would like to move the dialog HTML/DOM and the jQuery UI dialog() call all into my Backbone application. This will decouple my application from the web page hosting the application. This should also make it easier to test my Backbone app—I will no longer need to load in the entire fixture of the application's home page.

But, if I am to be successful in this endeavor, I will need to somehow account for the parent() weirdness that comes with jQuery UI dialogs.

First up, I delete the add-dialog HTML and Javascript from my web page. Then, in my View, I add an initialize method that will replace the stuff removed from the web page:
    var AppointmentAdd = new (Backbone.View.extend({
      initialize: function() {
        this.ensureDom();
        this.activateDialog();
      },
      // ....
    });
The ensureDom() method assumes the responsibility of the dialog HTML that used to be on my web page: ensuring that the HTML / DOM was present:
      ensureDom: function() {
        if ($('#calendar-add-appointment').length > 0) return;

        $(this.el).attr('id', 'calendar-add-appointment');
        $(this.el).attr('title', 'Add calendar appointment');

        $(this.el).append(this.make('h2', {'class': 'startDate'}));
        $(this.el).append(this.make('p', {}, 'Title'));
        $(this.el).append(this.make('p', {},
          this.make('input', {'type': "text", 'name': "title", 'class': "title"})
        ));

        $(this.el).append(this.make('p', {}, 'Description'));
        $(this.el).append(this.make('p', {},
          this.make('input', {'type': "text", 'name': "description", 'class': "description"})
        ));
      },
I am almost certainly going to remove all of that View#make stuff in there. Backbone Views expose a make() method for building very simple DOM elements. This dialog is too complicated for make() (and it is not truly complicate), but I needed an excuse to play with it.

At any rate, this ensureDom() method does nothing if it has already done its thing. "Its thing" begins by setting the View's ID attribute along with the "title" attribute (which is used by dialog() as the dialog title). The rest of the method simply converts the HTML to the make() equivalent (including a nested make() of the input fields).

Looking back on that, I can say that I now know how to use make(), but I probably will not make much use of it. It is far less readable than HTML strings.

Next up, I dialog-ify that DOM. Activating the dialog requires no more than copying the dialog() Javascript from my app page:
      activateDialog: function() {
        $(this.el).dialog({
          autoOpen: false,
          modal: true,
          buttons: [
            { text: "OK",
              class: "ok",
              click: function() { $(this).dialog("close"); } },
            { text: "Cancel",
              click: function() { $(this).dialog("close"); } } ]
        });
      }
Those two changes will build my dialog, but I have yet to account for the jQuery UI dialog parent() weirdness. In the initialize() method, I could try to say that the element is my newly initialized dialog parent:
    var AppointmentAdd = new (Backbone.View.extend({
      initialize: function() {
        this.ensureDom();
        this.activateDialog();
        this.el = $(this.el).parent();
      },
      // ...
    });
I could try, but my events would not work:
      events: {
        'click .ok':  'create',
        'keypress input[type=text]': 'createOnEnter'
      },
Backbone view events are initialized before initialize() is called. In this case, my 'click .ok' event would try to bind to an $('.ok') element in the DOM that I build, not the jQuery UI parent().

Happily, there is a easy remedy for this problem. I only need to manually invoke delegateEvents():
    var AppointmentAdd = new (Backbone.View.extend({
      initialize: function() {
        this.ensureDom();
        this.activateDialog();
        this.el = $(this.el).parent();
        this.delegateEvents();
      },
      // ...
    });
Backbone does this automatically—before it calls the initialize() method. The call to delegateEvents() also has the side-effect of removing any previously established handlers, so all is now as it should be.

And it works. When I click a date in my calendar, the add appointment dialog is shown:

That will suffice as a stopping point for tonight. Up tomorrow, I will work through the edit dialog and, hopefully, be able to eliminate the need for a fixture page in my jasmine tests entirely.


Day #206

No comments:

Post a Comment