blob: 97f7a1f3a00e31dcf0c764d35076adfd54bfc2f5 [file] [log] [blame]
beccabroekbd500cd2018-12-03 15:53:09 -06001/**
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)">&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>');
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})();