1/** 2 * dirPagination - AngularJS module for paginating (almost) anything. 3 * https://github.com/michaelbromley/angularUtils 4 * 5 * 6 * Credits 7 * ======= 8 * 9 * Daniel Tabuenca: 10 * https://groups.google.com/d/msg/angular/an9QpzqIYiM/r8v-3W1X5vcJ for the idea 11 * on how to dynamically invoke the ng-repeat directive. 12 * 13 * I borrowed a couple of lines and a few attribute names from the AngularUI 14 * Bootstrap project: 15 * https://github.com/angular-ui/bootstrap/blob/master/src/pagination/pagination.js 16 * 17 * Copyright 2014 Michael Bromley <michael@michaelbromley.co.uk> 18 */ 19 20(function() { 21 22/** 23 * Config 24 */ 25var moduleName = 'app.common.directives.dirPagination'; 26var DEFAULT_ID = '__default'; 27 28/** 29 * Module 30 */ 31angular.module(moduleName, []) 32 .directive( 33 'dirPaginate', 34 ['$compile', '$parse', 'paginationService', dirPaginateDirective]) 35 .directive('dirPaginateNoCompile', noCompileDirective) 36 .directive( 37 'dirPaginationControls', 38 [ 39 'paginationService', 'paginationTemplate', 40 dirPaginationControlsDirective 41 ]) 42 .filter('itemsPerPage', ['paginationService', itemsPerPageFilter]) 43 .service('paginationService', paginationService) 44 .provider('paginationTemplate', paginationTemplateProvider) 45 .run(['$templateCache', dirPaginationControlsTemplateInstaller]); 46 47function dirPaginateDirective($compile, $parse, paginationService) { 48 return { 49 terminal: true, 50 multiElement: true, 51 priority: 100, 52 compile: dirPaginationCompileFn 53 }; 54 55 function dirPaginationCompileFn(tElement, tAttrs) { 56 var expression = tAttrs.dirPaginate; 57 // regex taken directly from 58 // https://github.com/angular/angular.js/blob/v1.4.x/src/ng/directive/ngRepeat.js#L339 59 var match = expression.match( 60 /^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+track\s+by\s+([\s\S]+?))?\s*$/); 61 62 var filterPattern = 63 /\|\s*itemsPerPage\s*:\s*(.*\(\s*\w*\)|([^\)]*?(?=\s+as\s+))|[^\)]*)/; 64 if (match[2].match(filterPattern) === null) { 65 throw 'pagination directive: the \'itemsPerPage\' filter must be set.'; 66 } 67 var itemsPerPageFilterRemoved = match[2].replace(filterPattern, ''); 68 var collectionGetter = $parse(itemsPerPageFilterRemoved); 69 70 addNoCompileAttributes(tElement); 71 72 // If any value is specified for paginationId, we register the un-evaluated 73 // expression at this stage for the benefit of any dir-pagination-controls 74 // directives that may be looking for this ID. 75 var rawId = tAttrs.paginationId || DEFAULT_ID; 76 paginationService.registerInstance(rawId); 77 78 return function dirPaginationLinkFn(scope, element, attrs) { 79 // Now that we have access to the `scope` we can interpolate any 80 // expression given in the paginationId attribute and potentially register 81 // a new ID if it evaluates to a different value than the rawId. 82 var paginationId = 83 $parse(attrs.paginationId)(scope) || attrs.paginationId || DEFAULT_ID; 84 85 // (TODO: this seems sound, but I'm reverting as many bug reports followed 86 // it's introduction in 0.11.0. Needs more investigation.) In case rawId 87 // != paginationId we deregister using rawId for the sake of general 88 // cleanliness before registering using paginationId 89 // paginationService.deregisterInstance(rawId); 90 paginationService.registerInstance(paginationId); 91 92 var repeatExpression = getRepeatExpression(expression, paginationId); 93 addNgRepeatToElement(element, attrs, repeatExpression); 94 95 removeTemporaryAttributes(element); 96 var compiled = $compile(element); 97 98 var currentPageGetter = 99 makeCurrentPageGetterFn(scope, attrs, paginationId); 100 paginationService.setCurrentPageParser( 101 paginationId, currentPageGetter, scope); 102 103 if (typeof attrs.totalItems !== 'undefined') { 104 paginationService.setAsyncModeTrue(paginationId); 105 scope.$watch( 106 function() { 107 return $parse(attrs.totalItems)(scope); 108 }, 109 function(result) { 110 if (0 <= result) { 111 paginationService.setCollectionLength(paginationId, result); 112 } 113 }); 114 } else { 115 paginationService.setAsyncModeFalse(paginationId); 116 scope.$watchCollection( 117 function() { 118 return collectionGetter(scope); 119 }, 120 function(collection) { 121 if (collection) { 122 var collectionLength = (collection instanceof Array) ? 123 collection.length : 124 Object.keys(collection).length; 125 paginationService.setCollectionLength( 126 paginationId, collectionLength); 127 } 128 }); 129 } 130 131 // Delegate to the link function returned by the new compilation of the 132 // ng-repeat 133 compiled(scope); 134 135 // (TODO: Reverting this due to many bug reports in v 0.11.0. Needs 136 // investigation as the principle is sound) When the scope is destroyed, 137 // we make sure to remove the reference to it in paginationService so that 138 // it can be properly garbage collected scope.$on('$destroy', function 139 // destroyDirPagination() { 140 // paginationService.deregisterInstance(paginationId); 141 // }); 142 }; 143 } 144 145 /** 146 * If a pagination id has been specified, we need to check that it is present 147 * as the second argument passed to the itemsPerPage filter. If it is not 148 * there, we add it and return the modified expression. 149 * 150 * @param expression 151 * @param paginationId 152 * @returns {*} 153 */ 154 function getRepeatExpression(expression, paginationId) { 155 var repeatExpression, 156 idDefinedInFilter = 157 !!expression.match(/(\|\s*itemsPerPage\s*:[^|]*:[^|]*)/); 158 159 if (paginationId !== DEFAULT_ID && !idDefinedInFilter) { 160 repeatExpression = expression.replace( 161 /(\|\s*itemsPerPage\s*:\s*[^|\s]*)/, '$1 : \'' + paginationId + '\''); 162 } else { 163 repeatExpression = expression; 164 } 165 166 return repeatExpression; 167 } 168 169 /** 170 * Adds the ng-repeat directive to the element. In the case of multi-element 171 * (-start, -end) it adds the appropriate multi-element ng-repeat to the first 172 * and last element in the range. 173 * @param element 174 * @param attrs 175 * @param repeatExpression 176 */ 177 function addNgRepeatToElement(element, attrs, repeatExpression) { 178 if (element[0].hasAttribute('dir-paginate-start') || 179 element[0].hasAttribute('data-dir-paginate-start')) { 180 // using multiElement mode (dir-paginate-start, dir-paginate-end) 181 attrs.$set('ngRepeatStart', repeatExpression); 182 element.eq(element.length - 1).attr('ng-repeat-end', true); 183 } else { 184 attrs.$set('ngRepeat', repeatExpression); 185 } 186 } 187 188 /** 189 * Adds the dir-paginate-no-compile directive to each element in the tElement 190 * range. 191 * @param tElement 192 */ 193 function addNoCompileAttributes(tElement) { 194 angular.forEach(tElement, function(el) { 195 if (el.nodeType === 1) { 196 angular.element(el).attr('dir-paginate-no-compile', true); 197 } 198 }); 199 } 200 201 /** 202 * Removes the variations on dir-paginate (data-, -start, -end) and the 203 * dir-paginate-no-compile directives. 204 * @param element 205 */ 206 function removeTemporaryAttributes(element) { 207 angular.forEach(element, function(el) { 208 if (el.nodeType === 1) { 209 angular.element(el).removeAttr('dir-paginate-no-compile'); 210 } 211 }); 212 element.eq(0) 213 .removeAttr('dir-paginate-start') 214 .removeAttr('dir-paginate') 215 .removeAttr('data-dir-paginate-start') 216 .removeAttr('data-dir-paginate'); 217 element.eq(element.length - 1) 218 .removeAttr('dir-paginate-end') 219 .removeAttr('data-dir-paginate-end'); 220 } 221 222 /** 223 * Creates a getter function for the current-page attribute, using the 224 * expression provided or a default value if no current-page expression was 225 * specified. 226 * 227 * @param scope 228 * @param attrs 229 * @param paginationId 230 * @returns {*} 231 */ 232 function makeCurrentPageGetterFn(scope, attrs, paginationId) { 233 var currentPageGetter; 234 if (attrs.currentPage) { 235 currentPageGetter = $parse(attrs.currentPage); 236 } else { 237 // If the current-page attribute was not set, we'll make our own. 238 // Replace any non-alphanumeric characters which might confuse 239 // the $parse service and give unexpected results. 240 // See https://github.com/michaelbromley/angularUtils/issues/233 241 var defaultCurrentPage = 242 (paginationId + '__currentPage').replace(/\W/g, '_'); 243 scope[defaultCurrentPage] = 1; 244 currentPageGetter = $parse(defaultCurrentPage); 245 } 246 return currentPageGetter; 247 } 248} 249 250/** 251 * This is a helper directive that allows correct compilation when in 252 * multi-element mode (ie dir-paginate-start, dir-paginate-end). It is 253 * dynamically added to all elements in the dir-paginate compile function, and 254 * it prevents further compilation of any inner directives. It is then removed 255 * in the link function, and all inner directives are then manually compiled. 256 */ 257function noCompileDirective() { 258 return {priority: 5000, terminal: true}; 259} 260 261function dirPaginationControlsTemplateInstaller($templateCache) { 262 $templateCache.put( 263 'app.common.directives.dirPagination.template', 264 '<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)">«</a></li><li ng-if="directionLinks" ng-class="{ disabled : pagination.current == 1 }"><a href="" ng-click="setCurrent(pagination.current - 1)">‹</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)">›</a></li><li ng-if="boundaryLinks" ng-class="{ disabled : pagination.current == pagination.last }"><a href="" ng-click="setCurrent(pagination.last)">»</a></li></ul>'); 265} 266 267function dirPaginationControlsDirective(paginationService, paginationTemplate) { 268 var numberRegex = /^\d+$/; 269 270 var DDO = { 271 restrict: 'AE', 272 scope: 273 {maxSize: '=?', onPageChange: '&?', paginationId: '=?', autoHide: '=?'}, 274 link: dirPaginationControlsLinkFn 275 }; 276 277 // We need to check the paginationTemplate service to see whether a template 278 // path or string has been specified, and add the `template` or `templateUrl` 279 // property to the DDO as appropriate. The order of priority to decide which 280 // template to use is (highest priority first): 281 // 1. paginationTemplate.getString() 282 // 2. attrs.templateUrl 283 // 3. paginationTemplate.getPath() 284 var templateString = paginationTemplate.getString(); 285 if (templateString !== undefined) { 286 DDO.template = templateString; 287 } else { 288 DDO.templateUrl = function(elem, attrs) { 289 return attrs.templateUrl || paginationTemplate.getPath(); 290 }; 291 } 292 return DDO; 293 294 function dirPaginationControlsLinkFn(scope, element, attrs) { 295 // rawId is the un-interpolated value of the pagination-id attribute. This 296 // is only important when the corresponding dir-paginate directive has not 297 // yet been linked (e.g. if it is inside an ng-if block), and in that case 298 // it prevents this controls directive from assuming that there is no 299 // corresponding dir-paginate directive and wrongly throwing an exception. 300 var rawId = attrs.paginationId || DEFAULT_ID; 301 var paginationId = scope.paginationId || attrs.paginationId || DEFAULT_ID; 302 303 if (!paginationService.isRegistered(paginationId) && 304 !paginationService.isRegistered(rawId)) { 305 var idMessage = 306 (paginationId !== DEFAULT_ID) ? ' (id: ' + paginationId + ') ' : ' '; 307 if (window.console) { 308 console.warn( 309 'Pagination directive: the pagination controls' + idMessage + 310 'cannot be used without the corresponding pagination directive, which was not found at link time.'); 311 } 312 } 313 314 if (!scope.maxSize) { 315 scope.maxSize = 9; 316 } 317 scope.autoHide = scope.autoHide === undefined ? true : scope.autoHide; 318 scope.directionLinks = angular.isDefined(attrs.directionLinks) ? 319 scope.$parent.$eval(attrs.directionLinks) : 320 true; 321 scope.boundaryLinks = angular.isDefined(attrs.boundaryLinks) ? 322 scope.$parent.$eval(attrs.boundaryLinks) : 323 false; 324 325 var paginationRange = Math.max(scope.maxSize, 5); 326 scope.pages = []; 327 scope.pagination = {last: 1, current: 1}; 328 scope.range = {lower: 1, upper: 1, total: 1}; 329 330 scope.$watch('maxSize', function(val) { 331 if (val) { 332 paginationRange = Math.max(scope.maxSize, 5); 333 generatePagination(); 334 } 335 }); 336 337 scope.$watch( 338 function() { 339 if (paginationService.isRegistered(paginationId)) { 340 return (paginationService.getCollectionLength(paginationId) + 1) * 341 paginationService.getItemsPerPage(paginationId); 342 } 343 }, 344 function(length) { 345 if (0 < length) { 346 generatePagination(); 347 } 348 }); 349 350 scope.$watch( 351 function() { 352 if (paginationService.isRegistered(paginationId)) { 353 return (paginationService.getItemsPerPage(paginationId)); 354 } 355 }, 356 function(current, previous) { 357 if (current != previous && typeof previous !== 'undefined') { 358 goToPage(scope.pagination.current); 359 } 360 }); 361 362 scope.$watch( 363 function() { 364 if (paginationService.isRegistered(paginationId)) { 365 return paginationService.getCurrentPage(paginationId); 366 } 367 }, 368 function(currentPage, previousPage) { 369 if (currentPage != previousPage) { 370 goToPage(currentPage); 371 } 372 }); 373 374 scope.setCurrent = function(num) { 375 if (paginationService.isRegistered(paginationId) && 376 isValidPageNumber(num)) { 377 num = parseInt(num, 10); 378 paginationService.setCurrentPage(paginationId, num); 379 } 380 }; 381 382 /** 383 * Custom "track by" function which allows for duplicate "..." entries on 384 * long lists, yet fixes the problem of wrongly-highlighted links which 385 * happens when using "track by $index" - see 386 * https://github.com/michaelbromley/angularUtils/issues/153 387 * @param id 388 * @param index 389 * @returns {string} 390 */ 391 scope.tracker = function(id, index) { 392 return id + '_' + index; 393 }; 394 395 function goToPage(num) { 396 if (paginationService.isRegistered(paginationId) && 397 isValidPageNumber(num)) { 398 var oldPageNumber = scope.pagination.current; 399 400 scope.pages = generatePagesArray( 401 num, paginationService.getCollectionLength(paginationId), 402 paginationService.getItemsPerPage(paginationId), paginationRange); 403 scope.pagination.current = num; 404 updateRangeValues(); 405 406 // if a callback has been set, then call it with the page number as the 407 // first argument and the previous page number as a second argument 408 if (scope.onPageChange) { 409 scope.onPageChange( 410 {newPageNumber: num, oldPageNumber: oldPageNumber}); 411 } 412 } 413 } 414 415 function generatePagination() { 416 if (paginationService.isRegistered(paginationId)) { 417 var page = 418 parseInt(paginationService.getCurrentPage(paginationId)) || 1; 419 scope.pages = generatePagesArray( 420 page, paginationService.getCollectionLength(paginationId), 421 paginationService.getItemsPerPage(paginationId), paginationRange); 422 scope.pagination.current = page; 423 scope.pagination.last = scope.pages[scope.pages.length - 1]; 424 if (scope.pagination.last < scope.pagination.current) { 425 scope.setCurrent(scope.pagination.last); 426 } else { 427 updateRangeValues(); 428 } 429 } 430 } 431 432 /** 433 * This function updates the values (lower, upper, total) of the 434 * `scope.range` object, which can be used in the pagination template to 435 * display the current page range, e.g. "showing 21 - 40 of 144 results"; 436 */ 437 function updateRangeValues() { 438 if (paginationService.isRegistered(paginationId)) { 439 var currentPage = paginationService.getCurrentPage(paginationId), 440 itemsPerPage = paginationService.getItemsPerPage(paginationId), 441 totalItems = paginationService.getCollectionLength(paginationId); 442 443 scope.range.lower = (currentPage - 1) * itemsPerPage + 1; 444 scope.range.upper = Math.min(currentPage * itemsPerPage, totalItems); 445 scope.range.total = totalItems; 446 } 447 } 448 function isValidPageNumber(num) { 449 return ( 450 numberRegex.test(num) && (0 < num && num <= scope.pagination.last)); 451 } 452 } 453 454 /** 455 * Generate an array of page numbers (or the '...' string) which is used in an 456 * ng-repeat to generate the links used in pagination 457 * 458 * @param currentPage 459 * @param rowsPerPage 460 * @param paginationRange 461 * @param collectionLength 462 * @returns {Array} 463 */ 464 function generatePagesArray( 465 currentPage, collectionLength, rowsPerPage, paginationRange) { 466 var pages = []; 467 var totalPages = Math.ceil(collectionLength / rowsPerPage); 468 var halfWay = Math.ceil(paginationRange / 2); 469 var position; 470 471 if (currentPage <= halfWay) { 472 position = 'start'; 473 } else if (totalPages - halfWay < currentPage) { 474 position = 'end'; 475 } else { 476 position = 'middle'; 477 } 478 479 var ellipsesNeeded = paginationRange < totalPages; 480 var i = 1; 481 while (i <= totalPages && i <= paginationRange) { 482 var pageNumber = 483 calculatePageNumber(i, currentPage, paginationRange, totalPages); 484 485 var openingEllipsesNeeded = 486 (i === 2 && (position === 'middle' || position === 'end')); 487 var closingEllipsesNeeded = 488 (i === paginationRange - 1 && 489 (position === 'middle' || position === 'start')); 490 if (ellipsesNeeded && (openingEllipsesNeeded || closingEllipsesNeeded)) { 491 pages.push('...'); 492 } else { 493 pages.push(pageNumber); 494 } 495 i++; 496 } 497 return pages; 498 } 499 500 /** 501 * Given the position in the sequence of pagination links [i], figure out what 502 * page number corresponds to that position. 503 * 504 * @param i 505 * @param currentPage 506 * @param paginationRange 507 * @param totalPages 508 * @returns {*} 509 */ 510 function calculatePageNumber(i, currentPage, paginationRange, totalPages) { 511 var halfWay = Math.ceil(paginationRange / 2); 512 if (i === paginationRange) { 513 return totalPages; 514 } else if (i === 1) { 515 return i; 516 } else if (paginationRange < totalPages) { 517 if (totalPages - halfWay < currentPage) { 518 return totalPages - paginationRange + i; 519 } else if (halfWay < currentPage) { 520 return currentPage - halfWay + i; 521 } else { 522 return i; 523 } 524 } else { 525 return i; 526 } 527 } 528} 529 530/** 531 * This filter slices the collection into pages based on the current page number 532 * and number of items per page. 533 * @param paginationService 534 * @returns {Function} 535 */ 536function itemsPerPageFilter(paginationService) { 537 return function(collection, itemsPerPage, paginationId) { 538 if (typeof (paginationId) === 'undefined') { 539 paginationId = DEFAULT_ID; 540 } 541 if (!paginationService.isRegistered(paginationId)) { 542 throw 'pagination directive: the itemsPerPage id argument (id: ' + 543 paginationId + ') does not match a registered pagination-id.'; 544 } 545 var end; 546 var start; 547 if (angular.isObject(collection)) { 548 itemsPerPage = parseInt(itemsPerPage) || 9999999999; 549 if (paginationService.isAsyncMode(paginationId)) { 550 start = 0; 551 } else { 552 start = 553 (paginationService.getCurrentPage(paginationId) - 1) * itemsPerPage; 554 } 555 end = start + itemsPerPage; 556 paginationService.setItemsPerPage(paginationId, itemsPerPage); 557 558 if (collection instanceof Array) { 559 // the array just needs to be sliced 560 return collection.slice(start, end); 561 } else { 562 // in the case of an object, we need to get an array of keys, slice 563 // that, then map back to the original object. 564 var slicedObject = {}; 565 angular.forEach(keys(collection).slice(start, end), function(key) { 566 slicedObject[key] = collection[key]; 567 }); 568 return slicedObject; 569 } 570 } else { 571 return collection; 572 } 573 }; 574} 575 576/** 577 * Shim for the Object.keys() method which does not exist in IE < 9 578 * @param obj 579 * @returns {Array} 580 */ 581function keys(obj) { 582 if (!Object.keys) { 583 var objKeys = []; 584 for (var i in obj) { 585 if (obj.hasOwnProperty(i)) { 586 objKeys.push(i); 587 } 588 } 589 return objKeys; 590 } else { 591 return Object.keys(obj); 592 } 593} 594 595/** 596 * This service allows the various parts of the module to communicate and stay 597 * in sync. 598 */ 599function paginationService() { 600 var instances = {}; 601 var lastRegisteredInstance; 602 603 this.registerInstance = function(instanceId) { 604 if (typeof instances[instanceId] === 'undefined') { 605 instances[instanceId] = {asyncMode: false}; 606 lastRegisteredInstance = instanceId; 607 } 608 }; 609 610 this.deregisterInstance = function(instanceId) { 611 delete instances[instanceId]; 612 }; 613 614 this.isRegistered = function(instanceId) { 615 return (typeof instances[instanceId] !== 'undefined'); 616 }; 617 618 this.getLastInstanceId = function() { 619 return lastRegisteredInstance; 620 }; 621 622 this.setCurrentPageParser = function(instanceId, val, scope) { 623 instances[instanceId].currentPageParser = val; 624 instances[instanceId].context = scope; 625 }; 626 this.setCurrentPage = function(instanceId, val) { 627 instances[instanceId].currentPageParser.assign( 628 instances[instanceId].context, val); 629 }; 630 this.getCurrentPage = function(instanceId) { 631 var parser = instances[instanceId].currentPageParser; 632 return parser ? parser(instances[instanceId].context) : 1; 633 }; 634 635 this.setItemsPerPage = function(instanceId, val) { 636 instances[instanceId].itemsPerPage = val; 637 }; 638 this.getItemsPerPage = function(instanceId) { 639 return instances[instanceId].itemsPerPage; 640 }; 641 642 this.setCollectionLength = function(instanceId, val) { 643 instances[instanceId].collectionLength = val; 644 }; 645 this.getCollectionLength = function(instanceId) { 646 return instances[instanceId].collectionLength; 647 }; 648 649 this.setAsyncModeTrue = function(instanceId) { 650 instances[instanceId].asyncMode = true; 651 }; 652 653 this.setAsyncModeFalse = function(instanceId) { 654 instances[instanceId].asyncMode = false; 655 }; 656 657 this.isAsyncMode = function(instanceId) { 658 return instances[instanceId].asyncMode; 659 }; 660} 661 662/** 663 * This provider allows global configuration of the template path used by the 664 * dir-pagination-controls directive. 665 */ 666function paginationTemplateProvider() { 667 var templatePath = 'app.common.directives.dirPagination.template'; 668 var templateString; 669 670 /** 671 * Set a templateUrl to be used by all instances of <dir-pagination-controls> 672 * @param {String} path 673 */ 674 this.setPath = function(path) { 675 templatePath = path; 676 }; 677 678 /** 679 * Set a string of HTML to be used as a template by all instances 680 * of <dir-pagination-controls>. If both a path *and* a string have been set, 681 * the string takes precedence. 682 * @param {String} str 683 */ 684 this.setString = function(str) { 685 templateString = str; 686 }; 687 688 this.$get = function() { 689 return { 690 getPath: function() { 691 return templatePath; 692 }, 693 getString: function() { 694 return templateString; 695 } 696 }; 697 }; 698} 699})(); 700