Skip to content

Instantly share code, notes, and snippets.

@jimmont jimmont/paginate.js
Created Apr 16, 2015

Embed
What would you like to do?
paginate
.directive('paginateThis', function(paginate, $parse, $location){
return {
restrict: 'A',
link: function (scope, elem, attr){
function mapOnToModel(res){
var p = scope.paginate
, _esp = JSON.stringify( res.options.query )
, _sort = JSON.stringify( res.options.sort )
;
// if the request doesn't match the moving target (the Paginator instance)
// then the current esp query has changed since this paginate was first made
if ( p._esp !== _esp || p._sort !== _sort ){
p.model = new Array( scope.stuff.total );
// keep the current page unless the query changed
p.update( {current: p._esp !== _esp ? 1 : p.current} );
p._esp = _esp;
p._sort = _sort;
};
var i = 0
, results = res.results
, l = results.length
, from = res.options.from
, model = p.model;
// note the res.options.from might get out of sync with paginate.index because the http-request is async
// if the first item in the response is already in the corresponding model location assume we've already mapped this set onto the model
if( (results[0]||{id:Infinity}).id === (p.model[ from ]||{}).id ) return;
while(i < l){
model[ from + i ] = results[ i ];
i++;
};
scope.paginate.updateItems( 'force' );
};
// paginate.index tracks through the model, drives the update
scope.$watch('paginate.index', function(index, old, scope){
$location.esp({page: scope.paginate.current});
// if there's no first value assume we need a new set
// TODO remove the scope.initialized and encapsulate this concern in scope.refresh
if( scope.paginate.items[0] || !scope.initialized ) return;
scope.getThoseResults({from: index, size: scope.paginate.size}).then(mapOnToModel);
});
// eg: attr.paginateThose = '{size:10}'
scope.paginate = $parse( attr.paginateThose )( scope )
scope.paginate.current = +($location.esp().page || 1);
scope.paginate = paginate( scope.paginate );
function relayToModel(nu, old, scope){
if (!nu) return;
mapOnToModel(scope.stuff);
};
scope.$watch('stuff.options.query', relayToModel);
scope.$watch('stuff.options.sort', relayToModel);
// refresh when model changes
scope.$watchCollection('paginate.model', function(nu, old, scope){
scope.paginate.update();
var current = scope.paginate.current;
if(scope.paginate.last && current > scope.paginate.last){
current = scope.paginate.current = scope.paginate.last;
scope.paginate.updateItems();
}else if(current < scope.paginate.first){
current = scope.paginate.current = scope.paginate.first;
scope.paginate.updateItems();
};
});
/* TODO
elem
.on('scrolltotop', scope.paginate.prev)
.on('scrolltoend', scope.paginate.next)
*/
}
};
})
.factory('paginate', function(){
/**
* @ngdoc service
* @description
setup the object and methods for generic pagination
extend the basic functionality elsewhere as-desired
* @param {object} [setup] object definition with options
* @param {boolean} [setup.loop=true]
* @param {number} [setup.size=1] number of items per-page
* @param {number} [setup.current=1] active page
* @param {array} [setup.model=[]] array of items to paginate
* @example
<form paginate-this="{size:10}">
...
<tbody ng-if="paginate.items.length" ng-repeat="row in paginate.items">
...
<div ng-if="paginate.last > 1" class="pager"><button class="prev-button" ng-disabled="paginate.current === paginate.first" data-ng-click="paginate.prev()">Prev</button><button class="number-button" data-ng-click="paginate.goTo(page)" data-ng-repeat="page in paginate.numbers" ng-class="{active: page === paginate.current}">{{page}}</button><button class="next-button" ng-disabled="paginate.current === paginate.last" ng-click="paginate.next()">Next</button></div>
...
</form>
* @return
Paginator instance {
model: [items to paginate],
items: [items for current page from model],
_length: model.length,
size: number of items per page,
loop: boolean,
index: array index in this.model for first item on the active page,
first: number for 1st page,
last: number of total pages,
current: number active page base-1,
numbers: array of page-numbers from first to last eg [1,2,3...]
}
* @method
all methods return the instance
instance.goTo( number ) take me to page given, if it exists, otherwise a sensible (possibly looping) default
instance.prev( )
instance.next( )
instance.update( ) update properties: _length, first, last, index, items
*/
function Paginator(setup){
this.loop = 'boolean' === typeof(setup.loop) ? setup.loop : true;
// any data source with Array-like methods and properties
// note the optional length where an empty array is created for later population
this.model = setup.model || [];
// starting page
this.current = setup.current || 1;
this.first = 1;
// items to include on a page
this.size = setup.size || 1;
this.update();
};
Paginator.prototype = {
updateItems: function(force){
// find the starting point in the whole array
var l = (this.current - 1) * this.size;
force = force || l !== this.index;
if(force){
// allow passing in the current page to jump to a specific one
if(force.current){
// validate and set this.current
this.goTo( force.current );
l = (this.current - 1) * this.size;
};
// if the index changed then update items (force it for deep model changes to propagate)
this.index = l;
// [1,2,3,4,5].slice(0,2); returns [1,2]
this.items = this.model.slice(l, l + this.size);
};
return this;
},
update: function(force){
// base 1, page min/max:
// min = 1
// max = this.model.length / size (or items) per page
var l = this.model.length;
if(force || this._length !== l){
this._length = l;
this.last = Math.ceil( l / this.size );
this.numbers = [];
l = this.first;
do{
this.numbers.push( l++ );
}while(l <= this.last);
// when the model changes ensure the index and items are updated too
// passing current results in validation of the value
force = (force && force.current) ? force : {current: this.current};
};
return this.updateItems( force );
},
goTo: function page( n ){
// assume n is always a number
if( n < this.first ) n = this.loop ? this.last : this.first;
else if( n > this.last ) n = this.loop ? this.first : this.last;
this.current = n;
return this.updateItems();
},
next: function nextPage(){
return this.goTo( this.current + 1 );
},
prev: function previousPage(){
return this.goTo( this.current - 1 );
}
};
return function(setup){
return new Paginator(setup || {});
};
})
/* unit test */
describe('paginate', function(){
var paginate;
beforeEach(inject(function(_paginate_) {
paginate = _paginate_;
}));
it("should create a default pagination object", function(){
var instance = paginate();
expect( JSON.stringify( instance.model ) ).toBe( '[]' );
expect( JSON.stringify( instance.items ) ).toBe( '[]' );
expect( instance.numbers.join() ).toBe( '1' );
expect( instance.first ).toBe( 1 );
expect( instance.last ).toBe( 0 );
expect( instance.size ).toBe( 1 );
expect( instance.current ).toBe( 1 );
expect( instance.loop ).toBe( true );
expect( instance._length ).toBe( 0 );
expect( instance.index ).toBe( 0 );
});
it("should paginate something and work (update, goTo, next, prev)", function(){
var instance = paginate({
model: 'abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz01234567-_'.split('')
,size:10
});
expect( instance.model.length ).toBe( 72 );
expect( instance.items.length ).toBe( 10 );
expect( instance.items.join('') ).toBe( 'abcdefghij' );
expect( instance.numbers.length ).toBe( 8 );
// this messes up the numbers, but it's just a test
expect( instance.numbers.pop() ).toBe( 8 );
expect( instance.numbers.length ).toBe( 7 );
expect( instance._length ).toBe( 72 );
expect( instance.index ).toBe( 0 );
// fix the numbers
instance.update( 'force' );
expect( instance.numbers.length ).toBe( 8 );
// page is: current +1 +1 -1 => 1 + 1 + 1 - 1 = 2
instance.next().next().prev();
expect( instance.current ).toBe( 2 );
// now go to page 8
instance.goTo(8);
expect( instance.items.join('') ).toBe( '-_' );
// update the model, change the start to these characters:
var alt = 'ryebreads!'.split('');
while(alt.length){
instance.model[ alt.length - 1 ] = alt.pop();
};
instance.goTo(1);
instance.updateItems();
expect( instance.items.join('') ).toBe('ryebreads!');
// make sure it loops
expect( instance.current ).toBe( 1 );
expect( instance.current ).toBe( instance.first );
instance.prev();
expect( instance.current ).toBe( 8 );
expect( instance.current ).toBe( instance.last );
instance.next();
expect( instance.current ).toBe( 1 );
});
it("should not loop", function(){
var instance = paginate({
model: '1234567890'.split('')
,size: 2
,loop: false
});
expect( instance.numbers.length ).toBe( 5 );
expect( instance.first ).toBe( 1 );
expect( instance.last ).toBe( 5 );
expect( instance.current ).toBe( 1 );
expect( instance.loop ).toBe( false );
instance.prev();
expect( instance.current ).toBe( 1 );
expect( instance.items.join('') ).toBe( '12' );
instance.goTo(5);
expect( instance.current ).toBe( 5 );
instance.next();
expect( instance.current ).toBe( 5 );
expect( instance.items.join('') ).toBe( '90' );
instance.prev();
expect( instance.current ).toBe( 4 );
});
});
describe('paginateThis', function(){
var elem, scope;
beforeEach(inject(function($rootScope, $compile, $injector){
scope = $rootScope.$new();
scope.refresh = function( options ){
scope.refresh.res = scope.stuff
return scope.refresh;
};
scope.refresh.then = function(fn){
fn(scope.refresh.res);
return scope.refresh;
};
scope.stuff = {
// make these look like nodes for this directive test
results: [1,2,3,4,5,6,7,8,9,10,11].map(function(d,i,a){ return {id: d}; })
, total: 11
, options: {query: {}}
};
elem = $compile(
'<form paginate-this="{size:2}">'
+'<table><tr ng-repeat="row in paginate.items track by $index"><td>{{ row }}</td></tr></table>'
+'<div class="pager"><button class="prev" ng-click="paginate.prev()">Prev</button><button class="n" ng-click="paginate.goTo(page)" ng-repeat="page in paginate.numbers" ng-class="{active: page === paginate.current}">{{page}}</button><button class="next" ng-click="paginate.next()">Next</button></div>'
+'</form>'
)(scope);
scope.$digest();
}));
it('should work in a generic DOM-element pagination scenario', function(){
expect( scope.paginate.current ).toBe(1);
// buttons look right
expect(elem.find('div').text()).toBe('Prev123456Next');
expect(elem.find('tr').length).toBe( scope.paginate.size );
// loop left to wrap
angular.element(elem[0].querySelector('button.prev')).triggerHandler('click');
scope.$digest();
expect(elem[0].querySelector('button.active').textContent).toBe( scope.paginate.last.toString() );
expect(elem.find('tr').length).toBe( scope.paginate.items.length );
// trigger model change on query change
scope.stuff.options.query = {different: 1};
scope.stuff.results = scope.stuff.results.slice(0, 5);
scope.stuff.total = scope.stuff.results.length;
scope.$digest();
expect(elem.find('div').text()).toBe('Prev123Next');
// trigger model change on sort change
scope.stuff.options.sort = 'asc:different';
scope.stuff.results = scope.stuff.results.slice(0, 2);
scope.stuff.total = scope.stuff.results.length;
scope.$digest();
expect(elem.find('div').text()).toBe('Prev1Next');
});
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.