/** * dirPagination - AngularJS module for paginating (almost) anything. * * * 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 */ (function() { /** * Config */ var moduleName = 'angularUtils.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 // Adding the '_' as a prefix resolves an issue where paginationId might be have a digit as its first char // See https://github.com/michaelbromley/angularUtils/issues/400 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('angularUtils.directives.dirPagination.template', ''); } 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 = 'angularUtils.directives.dirPagination.template'; var templateString; /** * Set a templateUrl to be used by all instances of * @param {String} path */ this.setPath = function(path) { templatePath = path; }; /** * Set a string of HTML to be used as a template by all instances * of . 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; } }; }; } })();