Thursday, September 15, 2011

jQuery UI Dialogs as Edit Views in Backbone.js

‹prev | My Chain | next›

Up today, I hope to finally get editing working in my Backbone.js calendar application.

I am using a jQuery UI dialog to add appointments in my calendar. The implementation leaves much to be desired, but it seems to work. Better yet, it is well tested with Jasmine (running in the jasmine ruby gem, with help from jasmine-jquery and sinon.js).
Editing should be similar in that clicking an already existing appointment should open a jQuery UI edit dialog. At this point, it is too hard for me to visualize whether or not it will be easy to re-use the same HTML dialog. Given that uncertainty, it is best to create two dialogs for now.

So first up, I rename the existing #dialog elements as #add-dialog (the templating language is Jade):
#add-dialog(title="Add calendar appointment")
  h2.startDate
  p title
  p
    input.title(type="text", name="title")
  p description
  p
    input.description(type="text", name="description")

script
  $(function() {
    $('#add-dialog').dialog({ /* config options here */ });
  });
With that, I can add edit dialogs:
#edit-dialog(title="Edit calendar appointment")
  h2.startDate
  p title
  p
    input.title(type="text", name="title")
  p description
  p
    input.description(type="text", name="description")

script
  $(function() {
    $('#edit-dialog').dialog({ /* config options here */ });
  });
That is copy & paste programming at its finest. Obviously I need to address that at some point. I would argue that copy & paste is perfectly valid here. I am unsure of what the end result is going to look like. There is no need to attempt to force code-reuse where it may very well be completely innappropriate.

Get it working, but don't forget to get it right later.

My new edit dialog does absolutely nothing. I confess that I am unsure of best to implement this. Do I just add vanilla Javascript to service the dialog? Do I make yet another Backbone View? Do I glom onto an existing view?

My best guess is that another view is the best course. If nothing else, this may be the ideal opportunity to learn when not to use Backbone views.

Most of the Appointment Edit View is straight forward. Rendering this view requires opening the the jQuery UI dialog (via dialog('open')) and copying current values into the form elements:
  window.AppointmentEditView = Backbone.View.extend({
    render: function () {
      this.dialog.dialog('open');

      $('.startDate', this.el).
        html(this.model.get("startDate"));
      $('.title', this.el).
        val(this.model.get("title"));
      $('.description', this.el).
        val(this.model.get("description"));
    }
  });
Next, I need to bind an event handler to the dialog's "OK" button. That event handler should extract values from the dialog's form and save them to the backend:
  window.AppointmentEditView = Backbone.View.extend({
    // ...
    events : {
      'click .ok': 'update'
    },
    update: function() {
      console.log("updateClick");
      this.model.save({
        title: $('.title').val(),
        description: $('.description').val()
      });
    }
  });
The hard part is the element itself. Backbone views need HTML elements. They either create their own on the fly or link to an existing element. In this case, I need to do the latter—link the Appointment Edit View to the edit dialog.

In this case, things are complicated by the jQuery UI dialog. The #edit-dialog <div> tag is not the entire dialog. jQuery UI dialogs treat #edit-dialog as the content of the dialog. The #edit-dialog content, along with the title bar and form buttons, become children of the jQuery UI dialog. The upshot is that my Appointment Edit View needs the parent() in order to bind button events.

Hopefully this will do trick:
  window.AppointmentEditView = Backbone.View.extend({
    initialize: function() {
      this.dialog = $('#edit-dialog').dialog();
      this.el = this.dialog.parent();
    },
    // ...
  });
Sadly, that does not work. My Jasmine test expects that a new title, "Changed!!!", in response to a PUT will result in a "Changed" appointment in the UI:
    it("can edit appointments through an edit dialog", function() {
      $('.appointment', '#2011-09-15').click();
      $('.ok:visible').click();

      server.respondWith('PUT', '/appointments/42', '{"title":"Changed!!!"}');
      server.respond();

      expect($('#2011-09-15')).toHaveText(/Changed/);
    });
But that is not what is actually happening:
After digging through the Backbone code some (as an aside, the Backbone.js code is very readable), I figure out that the element needs to be defined before the intialize() method is called:
  Backbone.View = function(options) {
    this.cid = _.uniqueId('view');
    this._configure(options || {});
    this._ensureElement();
    this.delegateEvents();
    this.initialize.apply(this, arguments);
  };
The initialize() method is called after both delegateEvents() (binds events to UI elements) and _ensureElement() (create an HTML element if it has not already been defined). Sigh.

I could make the Appointment View responsible for doing this when it instantiates the Edit View:
    handleEdit: function(e) {
      console.log("editClick");
      e.stopPropagation();

      var edit_view = new AppointmentEditView({
        el: $('#edit-dialog').parent(),
        model: this.model
      });
      edit_view.render();
    },
That just feels wrong. Why should the Appointment View need to know about the craziness inside the Edit View? Besides, I can define the el attribute directly in the class definition:
  window.AppointmentEditView = Backbone.View.extend({
      el: $('#edit-dialog').parent(),
      render: function () {
        $('.ui-dialog-content', this.el).dialog('open');

        // ....
      },
      // ...
    });
Happily that does the trick. The el gets the parent from the already configured $('#edit-dialog'). Then, the render() method simply needs to open the jQuery UI dialog's "content", also known as #edit-dialog. Bah! That's crazy complicated, but at least it is encapsulated inside a single class. Best of all, it works:
Yay!

Now I have fairly decent test coverage of the create, update, delete and read functionality on my calendar application:
That is a fine stopping point for tonight.

Day #134

No comments:

Post a Comment