blob: 97f7a1f3a00e31dcf0c764d35076adfd54bfc2f5 [file] [log] [blame]
/**
* dirPagination - AngularJS module for paginating (almost) anything.
* https://github.com/michaelbromley/angularUtils
*
*
* Credits
* =======
*
* Daniel Tabuenca:
* https://groups.google.com/d/msg/angular/an9QpzqIYiM/r8v-3W1X5vcJ for the idea
* on how to dynamically invoke the ng-repeat directive.
*
* I borrowed a couple of lines and a few attribute names from the AngularUI
* Bootstrap project:
* https://github.com/angular-ui/bootstrap/blob/master/src/pagination/pagination.js
*
* Copyright 2014 Michael Bromley <michael@michaelbromley.co.uk>
*/
(function() {
/**
* Config
*/
var moduleName = 'app.common.directives.dirPagination';
var DEFAULT_ID = '__default';
/**
* Module
*/
angular.module(moduleName, [])
.directive(
'dirPaginate',
['$compile', '$parse', 'paginationService', dirPaginateDirective])
.directive('dirPaginateNoCompile', noCompileDirective)
.directive(
'dirPaginationControls',
[
'paginationService', 'paginationTemplate',
dirPaginationControlsDirective
])
.filter('itemsPerPage', ['paginationService', itemsPerPageFilter])
.service('paginationService', paginationService)
.provider('paginationTemplate', paginationTemplateProvider)
.run(['$templateCache', dirPaginationControlsTemplateInstaller]);
function dirPaginateDirective($compile, $parse, paginationService) {
return {
terminal: true,
multiElement: true,
priority: 100,
compile: dirPaginationCompileFn
};
function dirPaginationCompileFn(tElement, tAttrs) {
var expression = tAttrs.dirPaginate;
// regex taken directly from
// https://github.com/angular/angular.js/blob/v1.4.x/src/ng/directive/ngRepeat.js#L339
var match = expression.match(
/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+track\s+by\s+([\s\S]+?))?\s*$/);
var filterPattern =
/\|\s*itemsPerPage\s*:\s*(.*\(\s*\w*\)|([^\)]*?(?=\s+as\s+))|[^\)]*)/;
if (match[2].match(filterPattern) === null) {
throw 'pagination directive: the \'itemsPerPage\' filter must be set.';
}
var itemsPerPageFilterRemoved = match[2].replace(filterPattern, '');
var collectionGetter = $parse(itemsPerPageFilterRemoved);
addNoCompileAttributes(tElement);
// If any value is specified for paginationId, we register the un-evaluated
// expression at this stage for the benefit of any dir-pagination-controls
// directives that may be looking for this ID.
var rawId = tAttrs.paginationId || DEFAULT_ID;
paginationService.registerInstance(rawId);
return function dirPaginationLinkFn(scope, element, attrs) {
// Now that we have access to the `scope` we can interpolate any
// expression given in the paginationId attribute and potentially register
// a new ID if it evaluates to a different value than the rawId.
var paginationId =
$parse(attrs.paginationId)(scope) || attrs.paginationId || DEFAULT_ID;
// (TODO: this seems sound, but I'm reverting as many bug reports followed
// it's introduction in 0.11.0. Needs more investigation.) In case rawId
// != paginationId we deregister using rawId for the sake of general
// cleanliness before registering using paginationId
// paginationService.deregisterInstance(rawId);
paginationService.registerInstance(paginationId);
var repeatExpression = getRepeatExpression(expression, paginationId);
addNgRepeatToElement(element, attrs, repeatExpression);
removeTemporaryAttributes(element);
var compiled = $compile(element);
var currentPageGetter =
makeCurrentPageGetterFn(scope, attrs, paginationId);
paginationService.setCurrentPageParser(
paginationId, currentPageGetter, scope);
if (typeof attrs.totalItems !== 'undefined') {
paginationService.setAsyncModeTrue(paginationId);
scope.$watch(
function() {
return $parse(attrs.totalItems)(scope);
},
function(result) {
if (0 <= result) {
paginationService.setCollectionLength(paginationId, result);
}
});
} else {
paginationService.setAsyncModeFalse(paginationId);
scope.$watchCollection(
function() {
return collectionGetter(scope);
},
function(collection) {
if (collection) {
var collectionLength = (collection instanceof Array) ?
collection.length :
Object.keys(collection).length;
paginationService.setCollectionLength(
paginationId, collectionLength);
}
});
}
// Delegate to the link function returned by the new compilation of the
// ng-repeat
compiled(scope);
// (TODO: Reverting this due to many bug reports in v 0.11.0. Needs
// investigation as the principle is sound) When the scope is destroyed,
// we make sure to remove the reference to it in paginationService so that
// it can be properly garbage collected scope.$on('$destroy', function
// destroyDirPagination() {
// paginationService.deregisterInstance(paginationId);
// });
};
}
/**
* If a pagination id has been specified, we need to check that it is present
* as the second argument passed to the itemsPerPage filter. If it is not
* there, we add it and return the modified expression.
*
* @param expression
* @param paginationId
* @returns {*}
*/
function getRepeatExpression(expression, paginationId) {
var repeatExpression,
idDefinedInFilter =
!!expression.match(/(\|\s*itemsPerPage\s*:[^|]*:[^|]*)/);
if (paginationId !== DEFAULT_ID && !idDefinedInFilter) {
repeatExpression = expression.replace(
/(\|\s*itemsPerPage\s*:\s*[^|\s]*)/, '$1 : \'' + paginationId + '\'');
} else {
repeatExpression = expression;
}
return repeatExpression;
}
/**
* Adds the ng-repeat directive to the element. In the case of multi-element
* (-start, -end) it adds the appropriate multi-element ng-repeat to the first
* and last element in the range.
* @param element
* @param attrs
* @param repeatExpression
*/
function addNgRepeatToElement(element, attrs, repeatExpression) {
if (element[0].hasAttribute('dir-paginate-start') ||
element[0].hasAttribute('data-dir-paginate-start')) {
// using multiElement mode (dir-paginate-start, dir-paginate-end)
attrs.$set('ngRepeatStart', repeatExpression);
element.eq(element.length - 1).attr('ng-repeat-end', true);
} else {
attrs.$set('ngRepeat', repeatExpression);
}
}
/**
* Adds the dir-paginate-no-compile directive to each element in the tElement
* range.
* @param tElement
*/
function addNoCompileAttributes(tElement) {
angular.forEach(tElement, function(el) {
if (el.nodeType === 1) {
angular.element(el).attr('dir-paginate-no-compile', true);
}
});
}
/**
* Removes the variations on dir-paginate (data-, -start, -end) and the
* dir-paginate-no-compile directives.
* @param element
*/
function removeTemporaryAttributes(element) {
angular.forEach(element, function(el) {
if (el.nodeType === 1) {
angular.element(el).removeAttr('dir-paginate-no-compile');
}
});
element.eq(0)
.removeAttr('dir-paginate-start')
.removeAttr('dir-paginate')
.removeAttr('data-dir-paginate-start')
.removeAttr('data-dir-paginate');
element.eq(element.length - 1)
.removeAttr('dir-paginate-end')
.removeAttr('data-dir-paginate-end');
}
/**
* Creates a getter function for the current-page attribute, using the
* expression provided or a default value if no current-page expression was
* specified.
*
* @param scope
* @param attrs
* @param paginationId
* @returns {*}
*/
function makeCurrentPageGetterFn(scope, attrs, paginationId) {
var currentPageGetter;
if (attrs.currentPage) {
currentPageGetter = $parse(attrs.currentPage);
} else {
// If the current-page attribute was not set, we'll make our own.
// Replace any non-alphanumeric characters which might confuse
// the $parse service and give unexpected results.
// See https://github.com/michaelbromley/angularUtils/issues/233
var defaultCurrentPage =
(paginationId + '__currentPage').replace(/\W/g, '_');
scope[defaultCurrentPage] = 1;
currentPageGetter = $parse(defaultCurrentPage);
}
return currentPageGetter;
}
}
/**
* This is a helper directive that allows correct compilation when in
* multi-element mode (ie dir-paginate-start, dir-paginate-end). It is
* dynamically added to all elements in the dir-paginate compile function, and
* it prevents further compilation of any inner directives. It is then removed
* in the link function, and all inner directives are then manually compiled.
*/
function noCompileDirective() {
return {priority: 5000, terminal: true};
}
function dirPaginationControlsTemplateInstaller($templateCache) {
$templateCache.put(
'app.common.directives.dirPagination.template',
'<ul class="pagination" ng-if="1 < pages.length || !autoHide"><li ng-if="boundaryLinks" ng-class="{ disabled : pagination.current == 1 }"><a href="" ng-click="setCurrent(1)">&laquo;</a></li><li ng-if="directionLinks" ng-class="{ disabled : pagination.current == 1 }"><a href="" ng-click="setCurrent(pagination.current - 1)">&lsaquo;</a></li><li ng-repeat="pageNumber in pages track by tracker(pageNumber, $index)" ng-class="{ active : pagination.current == pageNumber, disabled : pageNumber == \'...\' || ( ! autoHide && pages.length === 1 ) }"><a href="" ng-click="setCurrent(pageNumber)">{{ pageNumber }}</a></li><li ng-if="directionLinks" ng-class="{ disabled : pagination.current == pagination.last }"><a href="" ng-click="setCurrent(pagination.current + 1)">&rsaquo;</a></li><li ng-if="boundaryLinks" ng-class="{ disabled : pagination.current == pagination.last }"><a href="" ng-click="setCurrent(pagination.last)">&raquo;</a></li></ul>');
}
function dirPaginationControlsDirective(paginationService, paginationTemplate) {
var numberRegex = /^\d+$/;
var DDO = {
restrict: 'AE',
scope:
{maxSize: '=?', onPageChange: '&?', paginationId: '=?', autoHide: '=?'},
link: dirPaginationControlsLinkFn
};
// We need to check the paginationTemplate service to see whether a template
// path or string has been specified, and add the `template` or `templateUrl`
// property to the DDO as appropriate. The order of priority to decide which
// template to use is (highest priority first):
// 1. paginationTemplate.getString()
// 2. attrs.templateUrl
// 3. paginationTemplate.getPath()
var templateString = paginationTemplate.getString();
if (templateString !== undefined) {
DDO.template = templateString;
} else {
DDO.templateUrl = function(elem, attrs) {
return attrs.templateUrl || paginationTemplate.getPath();
};
}
return DDO;
function dirPaginationControlsLinkFn(scope, element, attrs) {
// rawId is the un-interpolated value of the pagination-id attribute. This
// is only important when the corresponding dir-paginate directive has not
// yet been linked (e.g. if it is inside an ng-if block), and in that case
// it prevents this controls directive from assuming that there is no
// corresponding dir-paginate directive and wrongly throwing an exception.
var rawId = attrs.paginationId || DEFAULT_ID;
var paginationId = scope.paginationId || attrs.paginationId || DEFAULT_ID;
if (!paginationService.isRegistered(paginationId) &&
!paginationService.isRegistered(rawId)) {
var idMessage =
(paginationId !== DEFAULT_ID) ? ' (id: ' + paginationId + ') ' : ' ';
if (window.console) {
console.warn(
'Pagination directive: the pagination controls' + idMessage +
'cannot be used without the corresponding pagination directive, which was not found at link time.');
}
}
if (!scope.maxSize) {
scope.maxSize = 9;
}
scope.autoHide = scope.autoHide === undefined ? true : scope.autoHide;
scope.directionLinks = angular.isDefined(attrs.directionLinks) ?
scope.$parent.$eval(attrs.directionLinks) :
true;
scope.boundaryLinks = angular.isDefined(attrs.boundaryLinks) ?
scope.$parent.$eval(attrs.boundaryLinks) :
false;
var paginationRange = Math.max(scope.maxSize, 5);
scope.pages = [];
scope.pagination = {last: 1, current: 1};
scope.range = {lower: 1, upper: 1, total: 1};
scope.$watch('maxSize', function(val) {
if (val) {
paginationRange = Math.max(scope.maxSize, 5);
generatePagination();
}
});
scope.$watch(
function() {
if (paginationService.isRegistered(paginationId)) {
return (paginationService.getCollectionLength(paginationId) + 1) *
paginationService.getItemsPerPage(paginationId);
}
},
function(length) {
if (0 < length) {
generatePagination();
}
});
scope.$watch(
function() {
if (paginationService.isRegistered(paginationId)) {
return (paginationService.getItemsPerPage(paginationId));
}
},
function(current, previous) {
if (current != previous && typeof previous !== 'undefined') {
goToPage(scope.pagination.current);
}
});
scope.$watch(
function() {
if (paginationService.isRegistered(paginationId)) {
return paginationService.getCurrentPage(paginationId);
}
},
function(currentPage, previousPage) {
if (currentPage != previousPage) {
goToPage(currentPage);
}
});
scope.setCurrent = function(num) {
if (paginationService.isRegistered(paginationId) &&
isValidPageNumber(num)) {
num = parseInt(num, 10);
paginationService.setCurrentPage(paginationId, num);
}
};
/**
* Custom "track by" function which allows for duplicate "..." entries on
* long lists, yet fixes the problem of wrongly-highlighted links which
* happens when using "track by $index" - see
* https://github.com/michaelbromley/angularUtils/issues/153
* @param id
* @param index
* @returns {string}
*/
scope.tracker = function(id, index) {
return id + '_' + index;
};
function goToPage(num) {
if (paginationService.isRegistered(paginationId) &&
isValidPageNumber(num)) {
var oldPageNumber = scope.pagination.current;
scope.pages = generatePagesArray(
num, paginationService.getCollectionLength(paginationId),
paginationService.getItemsPerPage(paginationId), paginationRange);
scope.pagination.current = num;
updateRangeValues();
// if a callback has been set, then call it with the page number as the
// first argument and the previous page number as a second argument
if (scope.onPageChange) {
scope.onPageChange(
{newPageNumber: num, oldPageNumber: oldPageNumber});
}
}
}
function generatePagination() {
if (paginationService.isRegistered(paginationId)) {
var page =
parseInt(paginationService.getCurrentPage(paginationId)) || 1;
scope.pages = generatePagesArray(
page, paginationService.getCollectionLength(paginationId),
paginationService.getItemsPerPage(paginationId), paginationRange);
scope.pagination.current = page;
scope.pagination.last = scope.pages[scope.pages.length - 1];
if (scope.pagination.last < scope.pagination.current) {
scope.setCurrent(scope.pagination.last);
} else {
updateRangeValues();
}
}
}
/**
* This function updates the values (lower, upper, total) of the
* `scope.range` object, which can be used in the pagination template to
* display the current page range, e.g. "showing 21 - 40 of 144 results";
*/
function updateRangeValues() {
if (paginationService.isRegistered(paginationId)) {
var currentPage = paginationService.getCurrentPage(paginationId),
itemsPerPage = paginationService.getItemsPerPage(paginationId),
totalItems = paginationService.getCollectionLength(paginationId);
scope.range.lower = (currentPage - 1) * itemsPerPage + 1;
scope.range.upper = Math.min(currentPage * itemsPerPage, totalItems);
scope.range.total = totalItems;
}
}
function isValidPageNumber(num) {
return (
numberRegex.test(num) && (0 < num && num <= scope.pagination.last));
}
}
/**
* Generate an array of page numbers (or the '...' string) which is used in an
* ng-repeat to generate the links used in pagination
*
* @param currentPage
* @param rowsPerPage
* @param paginationRange
* @param collectionLength
* @returns {Array}
*/
function generatePagesArray(
currentPage, collectionLength, rowsPerPage, paginationRange) {
var pages = [];
var totalPages = Math.ceil(collectionLength / rowsPerPage);
var halfWay = Math.ceil(paginationRange / 2);
var position;
if (currentPage <= halfWay) {
position = 'start';
} else if (totalPages - halfWay < currentPage) {
position = 'end';
} else {
position = 'middle';
}
var ellipsesNeeded = paginationRange < totalPages;
var i = 1;
while (i <= totalPages && i <= paginationRange) {
var pageNumber =
calculatePageNumber(i, currentPage, paginationRange, totalPages);
var openingEllipsesNeeded =
(i === 2 && (position === 'middle' || position === 'end'));
var closingEllipsesNeeded =
(i === paginationRange - 1 &&
(position === 'middle' || position === 'start'));
if (ellipsesNeeded && (openingEllipsesNeeded || closingEllipsesNeeded)) {
pages.push('...');
} else {
pages.push(pageNumber);
}
i++;
}
return pages;
}
/**
* Given the position in the sequence of pagination links [i], figure out what
* page number corresponds to that position.
*
* @param i
* @param currentPage
* @param paginationRange
* @param totalPages
* @returns {*}
*/
function calculatePageNumber(i, currentPage, paginationRange, totalPages) {
var halfWay = Math.ceil(paginationRange / 2);
if (i === paginationRange) {
return totalPages;
} else if (i === 1) {
return i;
} else if (paginationRange < totalPages) {
if (totalPages - halfWay < currentPage) {
return totalPages - paginationRange + i;
} else if (halfWay < currentPage) {
return currentPage - halfWay + i;
} else {
return i;
}
} else {
return i;
}
}
}
/**
* This filter slices the collection into pages based on the current page number
* and number of items per page.
* @param paginationService
* @returns {Function}
*/
function itemsPerPageFilter(paginationService) {
return function(collection, itemsPerPage, paginationId) {
if (typeof (paginationId) === 'undefined') {
paginationId = DEFAULT_ID;
}
if (!paginationService.isRegistered(paginationId)) {
throw 'pagination directive: the itemsPerPage id argument (id: ' +
paginationId + ') does not match a registered pagination-id.';
}
var end;
var start;
if (angular.isObject(collection)) {
itemsPerPage = parseInt(itemsPerPage) || 9999999999;
if (paginationService.isAsyncMode(paginationId)) {
start = 0;
} else {
start =
(paginationService.getCurrentPage(paginationId) - 1) * itemsPerPage;
}
end = start + itemsPerPage;
paginationService.setItemsPerPage(paginationId, itemsPerPage);
if (collection instanceof Array) {
// the array just needs to be sliced
return collection.slice(start, end);
} else {
// in the case of an object, we need to get an array of keys, slice
// that, then map back to the original object.
var slicedObject = {};
angular.forEach(keys(collection).slice(start, end), function(key) {
slicedObject[key] = collection[key];
});
return slicedObject;
}
} else {
return collection;
}
};
}
/**
* Shim for the Object.keys() method which does not exist in IE < 9
* @param obj
* @returns {Array}
*/
function keys(obj) {
if (!Object.keys) {
var objKeys = [];
for (var i in obj) {
if (obj.hasOwnProperty(i)) {
objKeys.push(i);
}
}
return objKeys;
} else {
return Object.keys(obj);
}
}
/**
* This service allows the various parts of the module to communicate and stay
* in sync.
*/
function paginationService() {
var instances = {};
var lastRegisteredInstance;
this.registerInstance = function(instanceId) {
if (typeof instances[instanceId] === 'undefined') {
instances[instanceId] = {asyncMode: false};
lastRegisteredInstance = instanceId;
}
};
this.deregisterInstance = function(instanceId) {
delete instances[instanceId];
};
this.isRegistered = function(instanceId) {
return (typeof instances[instanceId] !== 'undefined');
};
this.getLastInstanceId = function() {
return lastRegisteredInstance;
};
this.setCurrentPageParser = function(instanceId, val, scope) {
instances[instanceId].currentPageParser = val;
instances[instanceId].context = scope;
};
this.setCurrentPage = function(instanceId, val) {
instances[instanceId].currentPageParser.assign(
instances[instanceId].context, val);
};
this.getCurrentPage = function(instanceId) {
var parser = instances[instanceId].currentPageParser;
return parser ? parser(instances[instanceId].context) : 1;
};
this.setItemsPerPage = function(instanceId, val) {
instances[instanceId].itemsPerPage = val;
};
this.getItemsPerPage = function(instanceId) {
return instances[instanceId].itemsPerPage;
};
this.setCollectionLength = function(instanceId, val) {
instances[instanceId].collectionLength = val;
};
this.getCollectionLength = function(instanceId) {
return instances[instanceId].collectionLength;
};
this.setAsyncModeTrue = function(instanceId) {
instances[instanceId].asyncMode = true;
};
this.setAsyncModeFalse = function(instanceId) {
instances[instanceId].asyncMode = false;
};
this.isAsyncMode = function(instanceId) {
return instances[instanceId].asyncMode;
};
}
/**
* This provider allows global configuration of the template path used by the
* dir-pagination-controls directive.
*/
function paginationTemplateProvider() {
var templatePath = 'app.common.directives.dirPagination.template';
var templateString;
/**
* Set a templateUrl to be used by all instances of <dir-pagination-controls>
* @param {String} path
*/
this.setPath = function(path) {
templatePath = path;
};
/**
* Set a string of HTML to be used as a template by all instances
* of <dir-pagination-controls>. If both a path *and* a string have been set,
* the string takes precedence.
* @param {String} str
*/
this.setString = function(str) {
templateString = str;
};
this.$get = function() {
return {
getPath: function() {
return templatePath;
},
getString: function() {
return templateString;
}
};
};
}
})();