Monday, January 28, 2013

Simple pagination with Backbone and Bootstrap

"Why would anyone need to see more than 20 results?"

I once had a customer ask me that when I explained that we needed to add pagination to the prototype we were developing.  The customer didn't want to spend the time (and the money) to implement it.  Guess how well that turned out?

In case you work for someone with a better grasp of reality, here's how to add pagination to your project using Backbone and Bootstrap.  We'll be using this jQuery Bootstrap Pagination plugin which gets us most of the way there, but requires you to wire up the link between pagination and your view.

So, let's say we're working in Bike Store, and we want a basic inventory display.  We're going for something that looks like the image below (don't mind the weird data, its the appearance we want).

Pagination information above our Backbone Collection view of bicycles.


Getting Started


We'll assume the backend is a RESTful service that is returning a list of bicycles in inventory.  The url for that would look something like:

http://localhost/bikes

The result of which is a JSON array that looks like this:

[
	{ 
		"manufacturer" : "Boulder",
		"model" : "Rampage",
		"cost" : "$297.21",
		"inStock" : "6",
		"bikeId" : "42"
	},
	{ 
		"manufacturer" : "Champion",
		"model" : "Sleek",
		"cost" : "$531.66",
		"inStock" : "2"
		"bikeId" : "43"
	},
	...
]

In order to add pagination, we're going to have to add the ability to send in a starting index and the desired number of results to the root URL, and the resulting JSON should be an object, not just an array.

For instance, the URL to return the first 20 bicycles may look something like this:

http://localhost/bikes?startAt=0&count=20

And the resulting JSON object would be:

{ 
	"count" : 20,
	"total" : 179,
	"startAt" : 0,
	"records" : [
	{ 
		"manufacturer" : "Boulder",
		"model" : "Rampage",
		"cost" : "$297.21",
		"inStock" : "6"
		"bikeId" : "42"
	},
	{ 
		"manufacturer" : "Champion",
		"model" : "Sleek",
		"cost" : "$531.66",
		"inStock" : "2"
		"bikeId" : "43"
	},
	...
	]
}

The startAt and count attributes are the same as the ones we passed in.  The total attribute tells us how many bicycles are in the entire database, so that we can show the end user how many pags are available.

The Backbone Model


Our very simple model is below. I like to add defaults to my model just in case any of the data coming back from the server is null or missing, which happens with more often than I'd like to see.

var Bicycle = Backbone.Model.extend({
	urlRoot : "http://localhost:3000/bikes",
	idAttribute : "bikeId",
	defaults: {
		"manufacturer" : "",
		"model" : "",
		"cost":    "",
		"inStock" : 0
	}
});


The Backbone Collection


The Backbone collection is simple, with just a modified parse attribute that accomplishes two things:
  • Fires a custom event called "collection:updated" that we'll use to tie in to the pagination.
  • Gets the results array out of the JSON object.

// A collection of bicycles.
var BicycleList = Backbone.Collection.extend({
	url : "http://localhost:3000/bikes",
	model : Bicycle,
	parse : function( response ){
		this.trigger("collection:updated", { count : response.count, total : response.total, startAt : response.startAt } );
		return response.records;
	}
});


The Backbone View


Each bicycle will get rendered into a row in a table.

var BicycleRow = Backbone.View.extend({
	tagName : "tr",
	template: _.template("<td><%=manufacturer%></td><td><%=model%></td><td><%=cost%></td><td><%=inStock%></td>"),
	render: function(){
		var attributes = this.model.toJSON();
		this.$el.html( this.template( attributes ) );
	}
});

The Backbone Collection View


The Collection view renders the entire table of bicycles.
// Creates a view of a bicycles in a table.
var BicycleListView = Backbone.View.extend({
	tagName : "tbody",
	initialize : function(){
		this.collection.on("add", this.addOne, this);
		this.collection.on("reset", this.addAll, this);
	},
	addOne : function( tethering ){
		var bicycleRow = new BicycleRow({ model : bicycle });
		bicycleRow.render();
		this.$el.append( bicycleRow.el );
	},
	addAll : function(){
		this.collection.forEach(this.addOne, this);
	},
	render: function(){
		this.collection.forEach(this.addOne, this)
	}
});


The Bicycle Page


We're going to take advantage of Bootstrap's css to style our table of bicycles and our pagination elements.  In the HTML below, we'll setup a Boostrap row to hold pagination information above the table of bicycle results.


	<div class="paginator row">
		<div class="span4 pagination-info"> </div>
		<div class="span8 pagination pagination-right pagination-boxes"></div>
	</div>


	<table class="table table-bordered table-striped table-selectable">
    	<thead>
    		<tr>
    			<th>Manufacturer</th>
    			<th>Model</th>
    			<th>Cost</th>
    			<th>In Stock</th>
    		</tr>
    	</thead>
    </table>


Hooking Everything Together



At this point we have a Backbone Collection that fires a custom event whenever gets updated, and an HTML page ready to display the results. The javascript below hooks everything up.

	/************************************************
	 * Configuration
	 ************************************************/

	var collection = new BicycleList();

	// The pagination object holds information about the current 
	// state of the page for us.
	var pagination = {
		items_per_page : 20, // how many items per page
		current_page : 1, // page to start at, first page is 1, not 0
		max_pages: 4 // max number of pages to show in the pagination element
	}

	/************************************************
	 * Document Ready
	 ************************************************/

	$(document).ready( function(){

		// Get the list of bicyclesfrom the API
		updateCollection( showBicycles );

		// When the custom event fires, update the pagination
		collection.on("collection:updated", function( details ){
			updatePagination( details, showBicycles );
		});
	});	

	/************************************************
	 * Functions
	 ************************************************/

	function showBicycles( collection, response ){

		var bicycleListView = new BicycleListView({
			collection: collection
		});

		bicycleListView.render();

		// Empty the table and append the new results.
		$(".table")
			.find("tbody").remove().end()
			.append( tetheringListView.el );

	}

	function updateCollection( successCallback ){

		// Pagination attributes will be passed in using
		// backbone's collection data method.
		var data = {
			startAt : ( pagination.current_page - 1) *  pagination.items_per_page,
			count : pagination.items_per_page 
		};

		collection.fetch({
	  		data : data,
			success : successCallback,
			error : showError
		});
	}	

	function updatePagination( details, successCallback ){

		// Calculate pagination attributes
		var start = details.startAt + 1,
			end = details.startAt + details.count,
			total_pages = Math.ceil( details.total / pagination.items_per_page ),
			current_page = details.startAt / details.count + 1;

		// Display a "Page 1 of 5" type message on the page
		$(".pagination-info").text("Showing records " + start + " through " + end + " of " + details.total );

		// Remove the pagination binding so they don't get called
		// multiple times after they're redrawn.
		$(".pagination-boxes").unbind();

		// Redraw the jquery pagination boxes
		$(".pagination-boxes").pagination({
			  total_pages: total_pages,
			  display_max: pagination.max_pages,
			  current_page: current_page,
			  callback: function(event, page) {
			  	if ( page ){
				  	pagination.current_page = page;
				  	updateCollection( successCallback );
				}
			  }
		});	
	}


Here's what's happening: When the page first opens, updateCollection is called with the default pagination attributes of startAt of 0 and count of 20.  updateCollection does a backbone fetch on the collection with the pagination attributes passed via the data parameter.

When the backend responds with the result object, the Bicycle Collection's parse function is called, which fires the custom "collection:updated" event and also parses out the records array from the response.  The records array is displayed using the BicycleListView definition.  The custom event causes the updatePagination function to be called, which updates the pagination attributes.  All of the pagination information is maintained in the pagination singleton defined in the configuration section, above.