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)">&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})();
700