Sunday, March 24, 2013

Leveraging the jQuery UI Sortable Widget in a Backbone-based Data List

Creating a sortable list of items is a common user interface pattern. The jQuery UI Sortable interaction widget makes it very easy to enable this feature in your page. If your list is based on a set of data represented by a Backbone collection, you'll need to find a way to bind the state of the sorted list into the collection's models so they can be saved. Most likely, you'll have an attribute in the model that records the order and configure the collection to sort on that attribute. The goal is to bind to that column and use it to control the sorting and update it when the user interacts with the Sortable widget.

Let's say you have the following simple model/collection defined:


var ThingModel = Backbone.Model.extend({

defaults: {
order: 0,
title: ''
}

});

var ThingCollection = Backbone.Collection.extend({

model: ThingModel,
comparator: 'order'

});


Using that simple collection, I'm going to recreate the basic Sortable demo using Backbone views driven from the data in the above collection/model definitions. Since we're working with a list of things, it makes sense to use a list/item view pattern to render our data source. The item view will represent each LI element in our list. Since our model only contains one other attribute other than the order column, this view is relatively simple:



var ThingItem = Backbone.View.extend({

template: null,

tagName: 'li',
className: 'ui-state-default',

render: function () {

this.$el.html( this.model.get('title') );

return this;
}

});


Now we can begin building the list view which will contain the bulk of the logic required to manage the sorting of the list. This view will render a UL element which will then contain the LI elements rendered by the item view above:



var ThingList = Backbone.View.extend({

tagName: 'ul',

itemView: ThingItem,

_listItems: null,
_listIsSyncing: false,

orderAttr: 'order',

render: function () {

this._listItems = {};

this.listenTo( this.collection, 'sync reset', this.listSync );

this.listSync();

return this;
},



This sets up the basic entry point of the view - initialize an object to hold the item view references, start listening to events from the collection, and draw anything that is currently in the collection.

The listSync() function is designed to handle rebuilding the whole list. It cleans up any existing item views and then passes the new set of models through to the listAdd() function to actually instantiate the item view:



listSync: function () {

var list = this.collection.models

this._listIsSyncing = true;

_.invoke( this._listItems, 'remove' );
this._listItems = {};

for ( var m in list )
this.listAdd( list[m] );

this._listIsSyncing = false;

this.listSetup();

},



Before looking at the add/remove functions, the listSetup() function is the place where the Sortable is actually attached, configured, and bound to enable moving items around. The function seems pretty empty because this is generally where I put any other UI configuration code. Its called anytime the list changes its contents to allow the supporting UI elements to change in response. In this example, there are none so its pretty light:


listSetup: function () {

this.$el.sortable({ containment: 'parent', tolerance: 'pointer' });

},

events: {
'sortupdate' : 'handleSortComplete'
},

handleSortComplete: function () {

var oatr = this.orderAttr;

_.each( this._listItems, function ( v ) {
v.model.set(oatr, v.$el.index());
});

this.collection.sort({silent: true});
this.listSetup();

this.trigger('sorted');
},



The handleSortComplete() function actually responds to the completion of a drag event representing an item being moved in the list. Since the Sortable widget has already updated the DOM, it simply iterates over all the item views and determines their position in the DOM to update their model's ordering attribute to the same value. I found that doing this was not enough to cause the collection to sort the items - an explicit sort was required to achieve the final result.

The final pieces in the example handle the individual add/remove functions of the list


listAdd: function ( model ) {

var v;

if ( !this._listItems[model.cid] ) {
v = this._listItems[model.cid] = new this.itemView({ model: model });
this.$el.append(v.render().$el);
}

if ( !this._listIsSyncing )
this.listSetup();
},

listRemove: function ( model ) {

if ( this._listItems[model.cid] ) {
this._listItems[model.cid].remove();
delete this._listItems[model.cid];
}

if ( !this._listIsSyncing )
this.listSetup();

},


You may have noticed there was no provision to enable the user to add/remove elements. The list view might not directly support the functionality to add and remove but can be designed to respond to the collection changing by listening to the add/remove events:


render: function () {

this._listItems = {};

this.listenTo( this.collection, 'sync reset', this.listSync );
this.listenTo( this.collection, 'add', this.listAdd );
this.listenTo( this.collection, 'remove', this.listRemove );

this.listSync();

return this;
},


Now, if you use add/delete buttons, drag/drop, or some other means of changing the list, the view can respond to it accordingly. Additionally, there's no provision in the listAdd() function to add to anywhere other than the end. It does not check the order attribute of the model and insert in the correct location in the DOM. If you were building a UI that allowed for items to be added anywhere in the list, that would have to be accounted for in that function.

The final step is to actually use the collection and view in a page. Below, I created a simple set of items, bound the collection to a new list view, rendered it into the page, and added an event handler to echo the current contents of the collection to the page for illustrative purposes:



function prettyPrintData(data) {
$('#json').text( JSON.stringify(data.toJSON()).replace(/{/g, '\n\t{').slice(0, -1)+'\n]' );
}

$(function() {

var things = new ThingCollection([
{ order: 0, title: 'Item 1' },
{ order: 1, title: 'Item 2' },
{ order: 2, title: 'Item 3' },
{ order: 3, title: 'Item 4' },
{ order: 4, title: 'Item 5' },
{ order: 5, title: 'Item 6' },
{ order: 6, title: 'Item 7' }
]);

var list = new ThingList({ collection: things });

$('.wrapper').append( list.render().$el );

list.on('sorted', function () {
prettyPrintData(things);
});

prettyPrintData(things);
});



The full demo and source is on my sandbox. This provides a basic concept to harnessing the power of the both the interaction available in the jQuery UI Sortable widget and data management of the Backbone framework.