Sunday, October 9, 2011

Navigation Views in Backbone.js

‹prev | My Chain | next›

I spent the last two days adding collection filtering to my calendar Backbone.js application. I even went back and did it well. Today, I hope to get the application to be able to switch months rather than doing it in Chrome's Javascript console.

First up, I need next/previous navigation elements. Since not all Backbone views need a collection or model, I create a view to handle just the navigation:
        var CalendarNavigation = Backbone.View.extend({
          render: function() {
            this.el.append('<div id="calendar-navigation">'+
              '<div class="previous"><a href="#">previous</a></div>' +
              '<div class="next"><a href="#">next</a></div>' +
            '</div>');
          }
        });
To initialize this, add a new method to the application view:
        var Application = Backbone.View.extend({
          initialize: function(options) {
            // ...
            this.initialize_navigation();
          },
          initialize_navigation : function() {
            var nav = new CalendarNavigation({el: $('#calendar')});
            nav.render();
          },
          // ...
        });
The render() method in CalendarNavigation appends the navigation HTML after the calendar in the DOM:
Now I need to attach events to this view. The user should be able to click the "next" and "previous" links to navigate months. The question is, how do I attach the events? The el attribute passed to CalendarNavigation is the calendar itself, not the navigation <div> appended to it. Any event listeners would be bound to the calendar, not the navigation links.

Can I swap the el in the initialize method? Something like:
        var CalendarNavigation = Backbone.View.extend({
          initialize: function(options) {
            this.calendar_el = options.el;
            this.el = $('#calendar-navigation');
          },
          render: function() {
            this.calendar_el.append(
              '<div id="calendar-navigation">'+
                '<div class="previous"><a href="#">previous</a></div>' +
              '<div class="next"><a href="#">next</a></div>' +
            '</div>');
          }
        });
That will not work for two reasons. The first is that the '#calendar-navigation' element does not exist at the time that the view instance is created. Even if did exist, once Backbone view objects invoke initialize(), all events have already been bound to the original el—the calendar itself.

Best not to get too cute with a Backbone view's el. So I add the navigation <div> in the application initialization:
// ...
          initialize_navigation : function() {
            $('#calendar').append('<div id="calendar-navigation">');

            var nav = new CalendarNavigation({el: $('#calendar-navigation')});
            nav.render();
          },
//...
The entire navigation view can then be written as:
        var CalendarNavigation = Backbone.View.extend({
          events: {
            'a:click': 'preventDefault',
            'click .previous': 'handlePrevious',
            'click .next': 'handleNext'
          },
          preventDefault: function(e) { e.preventDefault(); },
          handlePrevious: function() {
            console.log("[handlePrevious]");
            var date = Helpers.previousMonth(appointments.getDate());
            draw_calendar(date);
            appointments.setDate(date);
          },
          handleNext: function () {
            console.log("[handleNext]");
            var date = Helpers.nextMonth(appointments.getDate());
            draw_calendar(date);
            appointments.setDate(date);
          },
          render: function() {
            this.el.html(
              '<div class="previous"><a href="#">previous</a></div>' +
              '<div class="next"><a href="#">next</a></div>'
            );
            this.el.find("a").bind('click', function(e) {e.preventDefault();});
            return this;
          }
        });
The Helpers.previousMonth and Helpers.nextMonth helper functions are typically fun Javascript date manipulation code. I should probable move them to the Date and String prototypes at some point, but they work for now.

With that, I can navigate between months by clicking the links. However, if I try to add an appointment to my calendar, I am seeing 404s on my POSTs:
Ah, that is because I switched the url method last night. Backbone collections can obtain the backing url from either a function or simple attribute. Last night, I switched to a function that returned a URL based on the collection's most recently set date. This worked well for fetching data from the backend, but now I find myself POSTing to /appointments/2011-11. I have a resource to handle POSTs on /appointments and a resource to handle PUTs on /appointments/:id. POSTing to /appointments/2011-11 fails because there is no backend handler for that resource and HTTP method. I could try to radically alter my backend, but perhaps a step back is in order.

It seems that fetch() may have been wiser after all:
      var Collections = (function() {
        var Appointments = Backbone.Collection.extend({
          model: Models.Appointment,
          url: '/appointments',
          initialize: function(options) {
            options || (options = {});
            this.date = options.date;
          },
          fetch: function(options) {
            options || (options = {});
            var data = (options.data || {});
            options.data = {date: this.date};
            return Backbone.Collection.prototype.fetch.call(this, options);
          },
          // ...
        });
This fetch() alternative switches back to the standard /appointments URL. Now, only on fetch() does something out of the ordinary happen—a date query string parameter is included. All POSTs, PUTs, and DELETEs work on the normal /appointments resource.

And, after reverting the backend to support the query string, I can both navigate between months and POST new appointments on the calendar:
So it turns out that my conclusion from last night on when to override fetch() vs. url() in Backbone.Collection was missing another caveat—when the collection needs to be able to modify the persistent storage.


Day #169

1 comment:

  1. Actually, could you pass the collection to the CalendarNavigation view, and change its date directly thought it with setDate? Or even better to have next/previous date on the collection itself. Since this sound more like a model thing to me.

    Then maybe even triggering a "change:date" event from the collection to make some graphical changes.

    ReplyDelete