update: Turns out most of this discussion is wrong. Can you spot why? It took me two months, so you're smarter than me if you can. If not, the answers are in a follow-up post.
I am making good progress on my little Backbone.js calendar application. I can now add appointments and delete them (to and from a CouchDB store). There is a bit of mess in the code, but there is also a defect in the UI. When I click the delete icon, a Backbone event deletes the record and removes the appointment from the calendar.
The problem is that immediately after the appointment is removed, a jQuery UI Dialog for a new Appointment pops up. The little "X" icon handle its event, but then allows events to continue to bubble back on up to the containing TD. The containing TD has a click event (through a separate Backbone View) which opens the add appointment dialog.
The current Appointment View looks like:
window.AppointmentView = Backbone.View.extend({
// ...
events: {
'click .delete': 'deleteClick'
},
deleteClick: function(e) {
this.model.destroy();
},
// ...
});So the solution ought to be as simple as adding a jQuery stopPropagation() to the deleteClick() handler: window.AppointmentView = Backbone.View.extend({
// ...
events: {
'click .delete': 'deleteClick'
},
deleteClick: function(e) {
e.stopPropagation();
this.model.destroy();
},
// ...
});It ought to be as simple as that, except it does not work. In fact, stopImmediatePropagation() and even the old stand-by return false do not work either: // ...
deleteClick: function(e) {
e.stopPropagation();
e.stopImmediatePropagation();
this.model.destroy();
return false;
}
// ...Hrm... next step: print STDERR. Since I am in Javascript land, that means console.log():
window.AppointmentView = Backbone.View.extend({
// ...
deleteClick: function(e) {
console.log("deleteClick");
// ...
},
// ...
});
window.DayView = Backbone.View.extend({
// ...
addClick: function() {
console.log("addClick");
// ...
}
});So now I have logging click handlers on the outer TD (which adds new appointments) and the inner "X" (which removes appointments). Clicking on the inner "X" ought to trigger an event first on the inner "X", which should then bubble up to the outer TD. Right?Well no:
The add-click is registered first. Since I am not stopping propagation from the add-click handler, the event then moves on to the "X". Weird. That seems suspiciously like the capturing event model. But I am using jQuery -- events bubble in jQuery. Right?
As a quick sanity check, I have a look at the DOM structure and, indeed, the "X" is a child of the outer TD.
So does Backbone somehow muck with event capture vs. bubbling? An easy way to check that out is to drop into the Chrome Javascript debugger:
window.DayView = Backbone.View.extend({
addClick: function(e) {
console.log("addClick");
debugger;
// ...
}
});When I try to delete an appointment, the first thing triggered is that addClick() handler. Looking around again, I am in an event bubble after all:Uh... what?
After a bit of digging, I finally notice that the two events are declared differently:
window.AppointmentView = Backbone.View.extend({
// ...
events: {
'click .delete': 'deleteClick'
},
// ...
});
window.DayView = Backbone.View.extend({
events : {
'click': 'addClick'
},
// ...
});It turns out that the difference between "click .delete" and "click" in Backbone is the difference between delegating events and directly binding events in jQuery. Delegated events will not fire until the event had propagated all the way up the DOM tree. This means that any "real" events — events bound directly to an HTML element like my TD — will fire before any delegated event, regardless of where they are in the DOM tree.Phew! I thought I was going mad. At this point, I understand the problem, but I still lack a solution. Since the TD and "X" both occupy the same space, it seems that both will have to follow the same event binding model. Either both need to be bound directly to the appropriate HTML element or both need to be delegated.
I think the Appointment view is the one that needs to change. I had been setting the element explicitly when creating a new View for individual views:
var el = $('#' + appointment.get("startDate")),
view = new AppointmentView({model: appointment, el: el});
view.render();I think the el was a rookie mistake on my part. That element does not refer to the actual appointment, but rather the TD that will contain my appointment (TDs in my calendar are ID'd by the ISO 8601 date that they represent). If there is a Backbone-way it is that View should encapsulate what they represent. Here I am trying to encapsulate the TD and then do fancy delegating to overcome my mistake.The AppointmentView object still needs to know the containing element (so that it can append the appointment to it). I could pass this in via constructor argument, but I think I will just allow the AppointmentView to have the knowledge that its parent is a TD with an ID of the ISO 8601 date of the appointment. So the constructor becomes:
var view = new AppointmentView({model: appointment});
view.render();
After mucking with the rest of the class to adjust to the new container, I can now bind directly to the proper event:
events: {
'click': 'deleteClick'
},
And finally, I can delete appointments.There is still work to be done, but I feel like I made progress tonight. At least I can remove appointments without immediately being asked to add new ones. That was like some kind of middle management nightmare.
Day #132




This comment has been removed by the author.
ReplyDelete