After my last post on client-side paging using Knockout I had a few people comment that it would be useful if something reusable could handle server-side paging. It’s not quite what I needed but it sounded useful so I thought I’d see what I could come up with…
Strategising the Server
Server-side paging poses a slightly different problem to paging on the client-side as it requires (obviously) some cooperation from the server. Given that the server might be implemented in any of a hundred different ways I decided to use a strategy pattern to actually load the data:
var ViewModel = function() {
this.pagedList = ko.pagedList({
loadPage: function(pageIndex, pageSize) {
//load data here
}
});
};
The requirements of the strategy are:
- Accept a
pageIndex
andpageSize
to be loaded - Return a
jQuery.Deferred
instance (such as that returned by$.ajax
) - Resolve the deferred object with a value that contains an array
rows
property Assuming that the server response needs no modification (i.e. already has an arrayrows
property) then a paged list can be created as below:
var ViewModel = function() {
this.pagedList = ko.pagedList({
loadPage: function(pageIndex, pageSize) {
return $.getJson('/getPage?index=' + pageIndex + '&size=' + pageSize);
}
});
};
Getting the Total Number of Rows
When writing this I was keen to avoid enforcing anything on the server implementation that wasn’t absolutely necessary, and one of the things I decided wasn’t needed was the total number of available rows. As the client side component cannot always know how many total rows are available, I decided that by default I would simply set the totalRows
property to -1
and the pagedList
will function perfectly without ever knowing how many rows are actually available - with the following limitations:
pageCount
andtotalRows
will always return-1
nextPage
will never prevent the user from moving to the next page, even if that page will be empty That being said, I also wanted to support those scenarios where the total number of rows is available, sototalRows
can be set in one of two ways:
Manually Set totalRows
As with any Knockout observable
property the totalRows
value can be set externally, and the pagedList
will use whatever value is set to calculate pageCount
and prevent moving beyond the final page using nextPage
.
var vm = new ViewModel();
vm.pagedList.totalRows(100); //specify 100 rows total
Include totalRows
in Server Response
The minimum requirement of the data returned from the page loading strategy is that it requires an array rows
property, but if it also includes an integer totalRows
value then this will automatically set the observable value on the pagedList
.
this.pagedList = ko.pagedList({
loadPage: function(pageIndex, pageSize) {
var defer = $.Deferred();
//this is an example of the expected response object from the server
defer.resolve({
rows: [1, 2, 3],
totalRows: 100 //specify the total number of rows in the response
});
return defer;
}
});
In this way we can either specify the total number of rows on the client side, on the server side, or not at all - and still use the pagedList
in the same way.
Pre-Loading the First page
When the page containing the pagedList
is being generated on the server it is preferable that the first page of data already be loaded, rather than waiting on an unnecessary post back to the server to populate the list.
With that scenario in mind, I added the ability to specify the first page of data as another option on the constructor.
this.pagedList = ko.pagedList({
loadPage: function(pageIndex, pageSize) {
//...
},
firstPage: {
rows: [1, 2, 3],
totalRows: 100
}
});
If this option is specified then the pagedList
will not attempt to load the first page of data and will instead render the data specified in the option.
Note: this is also another mechanism by which the totalRows
property can be pre-populated on the client side.
Mapping Data to ViewModels
In most cases the server will be returning something more complicated than the numeric values in the examples above, and in those cases it is common to want to wrap the returned object in a view model of it’s own.
To support this functionality I included a map
constructor parameter that will be invoked on each of the rows returned from the page load strategy.
this.pagedList = ko.pagedList({
//...
map: function(item) {
return ko.mapping.fromJS(item);
}
});
Note: this example makes use of the Knockout mapping plugin to generate a view model.
Bindable Properties
Much of the behaviour is similar to the pagedObservableArray
as it was described in my original post, with the most significant difference being that the returned object can be used directly - there is no need to bind to a page
property as there was before. It also exposes the following properties:
- pageSize - An
observable
instance containing the number of items per page - pageIndex - An
observable
instance containing the zero-based current page index - totalRows - An
observable
instance containing the total number of rows that are assumed to be available (or -1 if this has not been determined) - pageCount - A computed
observable
that returns the number of pages of data available (or -1 iftotalRows
is unknown) - previousPage - A command that moves to the previous page, if possible
- nextPage - A command that moves to the next page, if possible
- loadPage - A command that loads the page data at the index specified as the first parameter. The
loadPage.isRunning
observable should be used for all loading notifications One point to note is that this implementation does not expose anobservableArray
containing the data; instead it exposes a regularobservable
with a value that is an array. This may seem a little strange but I felt that the additional functions and properties on the observable array do not really make sense when talking about a collection of server-specified data.
Source Code & Samples
The source code and unit tests are available on GitHub, and a working sample is available on jsFiddle.
If anyone else has any other suggestions or requests just let me know in the comments!