Tuesday, September 6, 2011

Coping with Backbone Events When Unable to Delegate

‹prev | My Chain | next›

I have no illusions that my Backbone.js code so far in my funky, funky calendar is quality Backbone code. I have not worked enough with Backbone to have a sense of what good code looks like. So far I have been doing my best to keep the code as small as possible. I do this in the hopes that small code sticks close to the "rails". If nothing else, small code means small messes.

Anyhow, I have cleaned up most of my messes in the funky calendar, but there is a least one nagging issue. Delete events are currently bound to the entire text of a calendar appointment in addition to the "X" icon:
One might think that clicking the "Delete me" text would open an edit dialog. One would be rudely disappointed when the appointment suddenly disappeared (I really should add a confirm-delete dialog).

The appointment template is simple enough HTML:
<script type="text/template" id="calendar-appointment-template">
  <span class="appointment" title="<%= description %>">
      <%= title %>
      <span class="delete">X</span>
  </span>
</script>
This appointment template contains both the appointment title as well as the delete icon. At first glance, the easiest fix would be to change the event in the AppointmentView from "click":
    window.AppointmentView = Backbone.View.extend({
      // ...
      events: {
        'click': 'deleteClick'
      },
      // ...
    });
... to "click .delete":
    window.AppointmentView = Backbone.View.extend({
      // ...
      events: {
        'click .delete': 'deleteClick'
      },
      // ...
    });
The problem with that approach, as I found out the other night, is that events tied to an element are delegated events. Delegated events fire after real events and, as such, have no way to stop propagation in the real events. This is problematic because, for better or worse, I have a real click event tied to the date table cell. If I have no way of preventing events from bubbling, then the table cell click event will fire—even when a user clicks the "X" icon.

So I need real, non-delegated events. After noodling it through a bit, I decide that the best way to accomplish this is to continue to use just the "click" event, but to add a guard clause inside the handler:

    window.AppointmentView = Backbone.View.extend({
      // ...
      events: {
        'click': 'deleteClick'
      },
      deleteClick: function(e) {
        console.log("deleteClick");

        if (!$(e.target).hasClass('delete')) return;

        e.stopPropagation();
        this.model.destroy();
      },
      // ...
    });
If the event target has the "delete" CSS class (i.e. it is the "X" inside the <span class="delete"> tag), then execution continues past the guard clause. Event propagation is halted and the model is destroyed. But, if the event target was something else, then the guard clause kicks in and the handler returns without doing anything.

Not doing anything when the appointment title is clicked causes all kinds of trouble. In addition to not doing what the user would naturally expect (open an edit dialog), the event continues to bubble up until it reaches the calendar date TD. That TD has an add-appointment click handler defined. The end result is that, when the user clicks on the title of an existing appointment, a new appointment dialog opens.

No worries, I can refactor the handleDelete() callback into a more generalized handleClick() function. This handleClick() function then sends the event to a handleDelete(), if the event target was the "X" inside the <span class="delete"> tag. Otherwise the new handleEdit() function is invoked:
    window.AppointmentView = Backbone.View.extend({
      // ...
      events: {
        'click': 'handleClick'
      },
      handleClick: function(e) {
        if ($(e.target).hasClass('delete'))
          return this.handleDelete(e);

        return this.handleEdit(e);
      },
      handleDelete: function(e) {
        console.log("deleteClick");
        e.stopPropagation();

        this.model.destroy();
      },
      handleEdit: function(e) {
        console.log("editClick");
        e.stopPropagation();
      },
      // ...
    });
A few clicks and I can verify that I can remove appointments by clicking the "X" icon and am hitting the handleEdit() function when I click on the title of the appointment:
Just because it's easy, I can even re-purpose the appointment dialog to serve as the edit appointment dialog:
    window.AppointmentView = Backbone.View.extend({
      // ...
      handleEdit: function(e) {
        console.log("editClick");
        e.stopPropagation();

        $('#dialog').dialog('open');
        $('.startDate', '#dialog').html(this.model.get("startDate"));
        $('.title', '#dialog').val(this.model.get("title"));
        $('.description', '#dialog').val(this.model.get("description"));
      },
      // ...
    });
Of course, that edit dialog is not tied to a backend service yet, so it's more than useless.

It ought to be a simple enough matter to add such a service, but first, I think, I have finally reached the point where testing is order. So tomorrow, I plan to take a step back and investigate testing my Backbone application.


Day #135

No comments:

Post a Comment