Server-side pagination
Sorting is a great way to wade
through a large amount of data to find information. We can also help
the user focus on a portion of a large data set by paginating the data.
Much like sorting,
pagination is often performed on the server. If the data to be displayed
is stored in a database, it is easy to pull out one chunk of
information at a time using MySQL's LIMIT clause, ROWNUM in Oracle, or equivalent methods in other database engines.
As with our initial sorting example, pagination can be triggered by sending information to the server in a query string, such as index.php?page=52.
And again, as before, we can perform this task either with a full page
load or by using AJAX to pull in just one chunk of the table. This
strategy is browser-independent, and can handle large data sets very
well.
Sorting and paging go together
Data that is long enough
to benefit from sorting is likely long enough to be a candidate for
paging. It is not unusual to wish to combine these two techniques for
data presentation. Since they both affect the set of data that is
present on a page, though, it is important to consider their
interactions while implementing them.
Both sorting and
pagination can be accomplished either on the server, or in the web
browser. However, we must keep the strategies for the two tasks in sync;
otherwise, we can end up with confusing behavior. Suppose, for example,
we have a table with eight rows and two columns in it, sorted initially
by the first column. If the data is re-sorted by the second column,
many rows may change places:
Now let's consider what
happens when pagination is added to the mix. Suppose only the first four
rows are provided by the server and the browser attempts to sort the
data. If paging is done by the server and sorting by the browser, the
entire data set is not available for the sorting routine, making the
results incorrect:
Only the data already
present on the page can be manipulated by JavaScript. To prevent this
from being a problem, we must either perform both tasks on the server
(polling the server for the correct data set on every page or sort
operation), or both in the browser (with all possible data available to
JavaScript at all times), so that the first displayed results are indeed
the first rows in the data set:
JavaScript pagination
So, let's examine how we
would add JavaScript pagination to the table we have already made
sortable in the browser. First, we'll focus on displaying a particular
page of data, disregarding user interaction for now.
$(document).ready(function() {
paginating, dataJavaScript pagination$('table.paginated').each(function() {
var currentPage = 0;
var numPerPage = 10;
var $table = $(this);
$table.find('tbody tr').hide()
.slice(currentPage * numPerPage,
(currentPage + 1) * numPerPage)
.show();
});
});
This code displays the first page—ten rows of data.
Once again we rely on the presence of a<tbody>
element to separate data from headers; we don't want to have the
headers or footers disappear when moving on to the second page. For
selecting the rows containing data, we hide all the rows first, then
select the rows on the current page, showing the selected rows. The .slice()
method shown here works like the array method of the same name; it
reduces the selection to the elements in between the two positions
given.
The most error-prone task in writing this code is formulating the expressions to use in the .slice()
filter. We need to find the indices of the rows at the beginning and
end of the current page. For the beginning row, we just multiply the
current page number by the number of rows on each page. Multiplying the
number of rows by one more than the current page number gives us the
beginning row of the next page; the .slice() method fetches the rows up to and not including this second parameter.
Displaying the pager
To add user interaction to the mix, we need to place a pager
next to the table: a set of links for navigating to different pages of
data. We could do this by simply inserting links for the pages in the
HTML markup, but this would violate the progressive enhancement
principle we've been espousing. Instead, we should add the links using
JavaScript, so that users without scripting available are not misled by
links that cannot work.
To display the links, we need to calculate the number of pages and create a corresponding number of DOM elements:
var numRows = $table.find('tbody tr').length;
var numPages = Math.ceil(numRows / numPerPage);
var $pager = $('<div class="pager"></div>');
for (var page = 0; page < numPages; page++) {
$('<span class="page-number">' + (page + 1) + '</span>')
.appendTo($pager).addClass('clickable');
}
$pager.insertBefore($table);
The number of pages can be found
by dividing the number of data rows by the number of items we wish to
display on each page. Since the division may not yield an integer, we
must round the result up using Math.ceil()
to ensure that the final partial page will be accessible. Then, with
this number in hand, we create buttons for each page and position the
new pager above the table:
Enabling the pager buttons
To make these new buttons actually work, we need to update the currentPage variable and then run our pagination routine. At first blush, it seems we should be able to do this by setting currentPage to page, which is the current value of the iterator that creates the buttons:
$(document).ready(function() {
$('table.paginated').each(function() {
var currentPage = 0;
var numPerPage = 10;
var $table = $(this);
var repaginate = function() {
$table.find('tbody tr').hide()
.slice(currentPage * numPerPage,
(currentPage + 1) * numPerPage)
.show();
};
var numRows = $table.find('tbody tr').length;
var numPages = Math.ceil(numRows / numPerPage);
var $pager = $('<div class="pager"></div>');
for (var page = 0; page < numPages; page++) {
$('<span class="page-number"></span>').text(page + 1)
.click(function() {
currentPage = page;
repaginate();
}).appendTo($pager).addClass('clickable');
}
$pager.insertBefore($table);
});
});
This works, in that the new repaginate()
function is called when the page loads and when any of the page links
are clicked. All of the links present us with a table that has no data
rows, though:
The problem is that in defining our click handler, we have created a closure. The click handler refers to the page variable, which is defined outside the function. When the variable changes the next time through the loop, this also affects the click
handlers that we have already set up for the earlier buttons. The net
effect is that, for a pager with 7 pages, each button directs us to page
8 (the final value of page when the loop is complete).
More information on how closures work can be found in Appendix C.
To correct this problem,
we'll take advantage of one of the more advanced features of jQuery's
event binding methods. We can add a set of custom event data
to the handler when we bind it that will still be available when the
handler is eventually called. With this capability in our bag of tricks,
we can write:
$('<span class="page-number"></span>').text(page + 1)
JavaScript paginationcustom event data, adding.bind('click', {newPage: page}, function(event) {
currentPage = event.data['newPage'];
repaginate();
}).appendTo($pager).addClass('clickable');
The new page number is passed into the handler by way of the event's data
property. In this way the page number escapes the hazards of the
closure, and is frozen in time at the value it contained when the
handler was bound. Now our pager links can correctly take us to
different pages:
Marking the current page
Our pager can be made more
user-friendly by highlighting the current page number. We just need to
update the classes on the buttons every time one is clicked:
$(document).ready(function() {
JavaScript paginationcurrent page number, highlighting$('table.paginated').each(function() {
var currentPage = 0;
var numPerPage = 10;
var $table = $(this);
var repaginate = function() {
$table.find('tbody tr').hide()
.slice(currentPage * numPerPage,
(currentPage + 1) * numPerPage)
.show();
};
var numRows = $table.find('tbody tr').length;
var numPages = Math.ceil(numRows / numPerPage);
var $pager = $('<div class="pager"></div>');
for (var page = 0; page < numPages; page++) {
$('<span class="page-number"></span>').text(page + 1)
.bind('click', {newPage: page}, function(event) {
currentPage = event.data['newPage'];
repaginate();
$(this).addClass('active')
.siblings().removeClass('active');
}).appendTo($pager).addClass('clickable');
}
$pager.insertBefore($table)
.find('span.page-number:first').addClass('active');
});
});
Now we have an indicator of the current status of the pager:
Paging with sorting
We began this discussion by
noting that sorting and paging controls needed to be aware of one
another to avoid confusing results. Now that we have a working pager, we
need to make sort operations respect the current page selection.
Doing this is as simple as calling our repaginate() function whenever a sort is performed. The scope of the function, though, makes this problematic. We can't reach repaginate() from our sorting routine because it is contained inside a different $(document).ready() handler. We could just consolidate the two pieces of code, but instead let's be a bit sneakier. We can decouple the behaviors, so that a sort calls the repaginate behavior if it exists, but ignores it otherwise. To accomplish this, we'll use a handler for a custom event.
In our earlier event handling discussion, we limited ourselves to event names that were triggered by the web browser, such as click and mouseup. The .bind() and
methods are not limited to these events, though; we can use any string
as an event name. Using this capability, we can define a new event
called repaginate as a stand-in for the function we've been calling: .trigger()
$table.bind('repaginate', function() {
$table.find('tbody tr').hide()
.slice(currentPage * numPerPage,
(currentPage + 1) * numPerPage)
.show();
});
Now in places where we were calling repaginate(), we can call:
$table.trigger('repaginate');
We can issue this call in our
sort code as well. It will do nothing if the table does not have a
pager, so we can mix and match the two capabilities as desired.
The finished code
The completed sorting and paging code in its entirety follows:
jQuery.fn.alternateRowColors = function() {
$('tbody tr:odd', this)
.removeClass('even').addClass('odd');
$('tbody tr:even', this)
.removeClass('odd').addClass('even');
return this;
};
$(document).ready(function() {
$('table.sortable').each(function() {
var $table = $(this);
$table.alternateRowColors();
$('th', $table).each(function(column) {
var $header = $(this);
var findSortKey;
if ($header.is('.sort-alpha')) {
findSortKey = function($cell) {
return $cell.find('.sort-key').text().toUpperCase()
+ ' ' + $cell.text().toUpperCase();
};
}
else if ($header.is('.sort-numeric')) {
findSortKey = function($cell) {
var key = $cell.text().replace(/^[^\d.]*/, '');
key = parseFloat(key);
return isNaN(key) ? 0 : key;
};
}
else if ($header.is('.sort-date')) {
findSortKey = function($cell) {
return Date.parse('1 ' + $cell.text());
};
}
if (findSortKey) {
$header.addClass('clickable').hover(function() {
$header.addClass('hover');
}, function() {
$header.removeClass('hover');
}).click(function() {
var sortDirection = 1;
if ($header.is('.sorted-asc')) {
sortDirection = -1;
}
var rows = $table.find('tbody > tr').get();
$.each(rows, function(index, row) {
var $cell = $(row).children('td').eq(column);
row.sortKey = findSortKey($cell);
});
rows.sort(function(a, b) {
if (a.sortKey < b.sortKey) return -sortDirection;
if (a.sortKey > b.sortKey) return sortDirection;
return 0;
sortingsorting and paging code});
pagingsorting and paging code$.each(rows, function(index, row) {
$table.children('tbody').append(row);
row.sortKey = null;
});
$table.find('th').removeClass('sorted-asc')
.removeClass('sorted-desc');
if (sortDirection == 1) {
$header.addClass('sorted-asc');
}
else {
$header.addClass('sorted-desc');
}
$table.find('td').removeClass('sorted')
.filter(':nth-child(' + (column + 1) + ')')
.addClass('sorted');
$table.alternateRowColors();
$table.trigger('repaginate');
});
}
});
});
});
$(document).ready(function() {
$('table.paginated').each(function() {
var currentPage = 0;
var numPerPage = 10;
var $table = $(this);
$table.bind('repaginate', function() {
$table.find('tbody tr').hide()
.slice(currentPage * numPerPage,
(currentPage + 1) * numPerPage)
.show();
});
var numRows = $table.find('tbody tr').length;
var numPages = Math.ceil(numRows / numPerPage);
var $pager = $('<div class="pager"></div>');
for (var page = 0; page < numPages; page++) {
$('<span class="page-number"></span>').text(page + 1)
.bind('click', {newPage: page}, function(event) {
currentPage = event.data['newPage'];
$table.trigger('repaginate');
$(this).addClass('active')
.siblings().removeClass('active');
}).appendTo($pager).addClass('clickable');
}
$pager.insertBefore($table)
.find('span.page-number:first').addClass('active');
});
});