Saturday, January 19, 2013

Creating Sortable Tables with Backbone Collections

Probably the most common use cases found in any application is sorting a table of data. There are so many ways you can handle this scenario. Backbone offers one more approach you can consider when choosing a solution. Collections have built in support for establishing a sort order, maintaining that order as models are added/removed, and triggering events when a sort operation occurs. Given that, it seemed like an interesting feature to try out when building my little prototype report.

Building off the example I started in my previous post, I already have my basic model, collection, and two views which render the table of data. Now I just need to extend both the movie collection and movie table view to enable sorting any of the columns in either ascending or descending order.

Collections may have built-in sorting, but they do not have the ability to externally change which column to sort on or the direction of the sort. The comparator function does provide a lot of flexibility for returning what or how to sort. One variation allows passing two models as arguments and ordering those models based on the return value:


comparator: function(a, b) {
var a = a.get('title'),
b = b.get('title');

if (a == b) return 0;

return a > b ? 1 : -1;
}


A return value of -1 means the first model comes before the second model. A return value of 1 means the opposite. Returning 0 means they are equal. If you've used PHP, this should look familiar since its the same scheme used for the callback to usort.

Of course, if you just wanted to sort "title" in ascending order, you could just write:


comparator: function(m) {
return m.get('title');
}


However, I want to be able to sort on any column in either ascending or descending order. To do this, I need to extend Collection a little bit to add the attribute to sort on and the direction:


var Movies = Backbone.Collection.extend({

model: Movie,

sortAttribute: "rank",
sortDirection: 1,

sortMovies: function (attr) {
this.sortAttribute = attr;
this.sort();
},

comparator: function(a, b) {
var a = a.get(this.sortAttribute),
b = b.get(this.sortAttribute);

if (a == b) return 0;

if (this.sortDirection == 1) {
return a > b ? 1 : -1;
} else {
return a < b ? 1 : -1;
}
}

});


Now I can call sortMovies to reorder the collection of data based on those attributes:


myMovies.sortDirection = -1;
myMovies.sortMovies('gross');


You might ask why I make the direction 1 and -1 versus something more obvious like 'ASC'/'DSC'. Well, call me lazy, but 1/-1 are pretty easy to toggle between using:


myMovies.sortDirection *= -1;


That will come in handy in the view when I want to toggle the order order on a column between ascending and descending order.

With the collection functionality in place, I can now focus on adding functionality to my view that will enable clicking a header to sort the column and adding visual cues about which column is currently sorted and in which direction:


var MovieTable = Backbone.View.extend({

_movieRowViews: [],

tagName: 'table',
template: null,

// Make it easier to change later
sortUpIcon: 'ui-icon-triangle-1-n',
sortDnIcon: 'ui-icon-triangle-1-s',

// Need to respond to clicks on the table headers
events: {
"click th": "headerClick"
},

// Make to listen to sort events - the clicks on the header
// will change the sort order but the sort event will trigger
// rendering the data after the sort is complete.
initialize: function() {

this.template = _.template( $('#movie-table').html() );
this.listenTo(this.collection, "sort", this.updateTable);
},

// One time setup of the column headers to add a span
// to hold the icon indicating the current sort state
render: function() {

this.$el.html(this.template());

// Setup the sort indicators
this.$('th div')
.append($('<span>'))
.closest('thead')
.find('span')
.addClass('ui-icon icon-none')
.end()
.find('[column="'+this.collection.sortAttribute+'"] span')
.removeClass('icon-none').addClass(this.sortUpIcon);

this.updateTable();

return this;
},

// Now the part that actually changes the sort order
headerClick: function( e ) {
var $el = $(e.currentTarget),
ns = $el.attr('column'),
cs = this.collection.sortAttribute;

// Toggle sort if the current column is sorted
if (ns == cs) {
this.collection.sortDirection *= -1;
} else {
this.collection.sortDirection = 1;
}

// Adjust the indicators. Reset everything to hide the indicator
$el.closest('thead').find('span').attr('class', 'ui-icon icon-none');

// Now show the correct icon on the correct column
if (this.collection.sortDirection == 1) {
$el.find('span').removeClass('icon-none').addClass(this.sortUpIcon);
} else {
$el.find('span').removeClass('icon-none').addClass(this.sortDnIcon);
}

// Now sort the collection
this.collection.sortMovies(ns);
},

// This code has not changed from the example setup in the previous post.
updateTable: function () {

var ref = this.collection,
$table;

_.invoke(this._movieRowViews, 'remove');

$table = this.$('tbody');

this._movieRowViews = this.collection.map(
function ( obj ) {
var v = new MovieRow({ model: ref.get(obj) });

$table.append(v.render().$el);

return v;
});
}

});



Since I already have jQuery UI loaded on the page, I used those icons for the sort indicators. However, there is an annoying little quirk with those icons that needed a workaround. The ui-icon class attaches the background image, sets the size of the container (to 16x16), and makes it a block element. The only desired effect I wanted was the 16x16 placeholder it creates. All I really want is a blank space that will eventually have a visible icon if the column is sorted. The follow CSS is required to ensure the indicator doesn't wrap and the default icon is not showing when a column is not currently sorted:


<style>
#movies th { white-space: nowrap; cursor: pointer; }
#movies th span { display: inline-block; margin-left: 5px; }

.icon-none { visibility: hidden; }
</style>


The demo and full source are on my sandbox. This functionality could easily be abstracted into a set of base collections/views that handle the details of the sorting. Most likely, several people have already built this. However, I wanted to take the base Backbone functionality for a spin and see what it could do. So far, its pretty powerful. Next step - create aggregate summaries on the data and perform some basic slicing and dicing.