angular-presentation-ude



angular-presentation-ude

1 0


angular-presentation-ude

A Reveal.js Presentation for the Ultimate Developer Event Conference, 2013

On Github wadetandy / angular-presentation-ude

Object Oriented Directives in Angular.js

Chaz Chandler

Wade Tandy

Who Are We?

Where We're Coming From

  • Ruby on Rails
  • Angular.js
  • Many different single-page Angular apps
  • Each app deals with different data and has slightly different needs
  • Still want to share common components

What do we mean by components?

Angular.js directives are awesome!

Extend HTML vocabulary Declarative style makes page logic easy to follow "Write once, reuse everywhere"

Angular.js directives are awful!

Poor documentation Challenging for new developers No directive inheritance Difficult to extend without entering "options hell"

Case Study: Pagination

  • Everybody needs pagination at some point
  • Even a simple paginator has nontrivial logic
  • Different types of data may require different pagination techniques

A Basic extensible directive

angular.module('ui.bootstrap.pagination', [])
.constant('paginationConfig', {
  itemsPerPage: 10,
  boundaryLinks: false,
  directionLinks: true,
  firstText: 'First',
  previousText: 'Previous',
  nextText: 'Next',
  lastText: 'Last',
  rotate: true
})

.controller('PaginationController', [
  '$scope', '$attrs', '$parse', '$interpolate',
  function ($scope, $attrs, $parse, $interpolate) {
  var self = this,
      setNumPages = $attrs.numPages ? $parse($attrs.numPages).assign : angular.noop;

  this.init = function(defaultItemsPerPage) {
    if ($attrs.itemsPerPage) {
      $scope.$parent.$watch($parse($attrs.itemsPerPage), function(value) {
        self.itemsPerPage = parseInt(value, 10);
        $scope.totalPages = self.calculateTotalPages();
      });
    } else {
      this.itemsPerPage = defaultItemsPerPage;
    }
  };

  this.noPrevious = function() {
    return this.page === 1;
  };
  this.noNext = function() {
    return this.page === $scope.totalPages;
  };

  this.isActive = function(page) {
    return this.page === page;
  };

  this.calculateTotalPages = function() {
    var totalPages = this.itemsPerPage < 1 ? 1 : Math.ceil($scope.totalItems / this.itemsPerPage);
    return Math.max(totalPages || 0, 1);
  };

  this.getAttributeValue = function(attribute, defaultValue, interpolate) {
    return angular.isDefined(attribute) ? (interpolate ? $interpolate(attribute)($scope.$parent) : $scope.$parent.$eval(attribute)) : defaultValue;
  };

  this.render = function() {
    this.page = parseInt($scope.page, 10) || 1;
    if (this.page > 0 && this.page <= $scope.totalPages) {
      $scope.pages = this.getPages(this.page, $scope.totalPages);
    }
  };

  $scope.selectPage = function(page) {
    if ( ! self.isActive(page) && page > 0 && page <= $scope.totalPages) {
      $scope.page = page;
      $scope.onSelectPage({ page: page });
    }
  };

  $scope.$watch('page', function() {
    self.render();
  });

  $scope.$watch('totalItems', function() {
    $scope.totalPages = self.calculateTotalPages();
  });

  $scope.$watch('totalPages', function(value) {
    setNumPages($scope.$parent, value); // Readonly variable

    if ( self.page > value ) {
      $scope.selectPage(value);
    } else {
      self.render();
    }
  });
}])

.directive('pagination', ['$parse', 'paginationConfig', function($parse, config) {
  return {
    restrict: 'EA',
    scope: {
      page: '=',
      totalItems: '=',
      onSelectPage:' &'
    },
    controller: 'PaginationController',
    templateUrl: 'template/pagination/pagination.html',
    replace: true,
    link: function(scope, element, attrs, paginationCtrl) {

      // Setup configuration parameters
      var maxSize,
      boundaryLinks  = paginationCtrl.getAttributeValue(attrs.boundaryLinks,  config.boundaryLinks      ),
      directionLinks = paginationCtrl.getAttributeValue(attrs.directionLinks, config.directionLinks     ),
      firstText      = paginationCtrl.getAttributeValue(attrs.firstText,      config.firstText,     true),
      previousText   = paginationCtrl.getAttributeValue(attrs.previousText,   config.previousText,  true),
      nextText       = paginationCtrl.getAttributeValue(attrs.nextText,       config.nextText,      true),
      lastText       = paginationCtrl.getAttributeValue(attrs.lastText,       config.lastText,      true),
      rotate         = paginationCtrl.getAttributeValue(attrs.rotate,         config.rotate);

      paginationCtrl.init(config.itemsPerPage);

      if (attrs.maxSize) {
        scope.$parent.$watch($parse(attrs.maxSize), function(value) {
          maxSize = parseInt(value, 10);
          paginationCtrl.render();
        });
      }

      // Create page object used in template
      function makePage(number, text, isActive, isDisabled) {
        return {
          number: number,
          text: text,
          active: isActive,
          disabled: isDisabled
        };
      }

      paginationCtrl.getPages = function(currentPage, totalPages) {
        var pages = [];

        // Default page limits
        var startPage = 1, endPage = totalPages;
        var isMaxSized = ( angular.isDefined(maxSize) && maxSize < totalPages );

        // recompute if maxSize
        if ( isMaxSized ) {
          if ( rotate ) {
            // Current page is displayed in the middle of the visible ones
            startPage = Math.max(currentPage - Math.floor(maxSize/2), 1);
            endPage   = startPage + maxSize - 1;

            // Adjust if limit is exceeded
            if (endPage > totalPages) {
              endPage   = totalPages;
              startPage = endPage - maxSize + 1;
            }
          } else {
            // Visible pages are paginated with maxSize
            startPage = ((Math.ceil(currentPage / maxSize) - 1) * maxSize) + 1;

            // Adjust last page if limit is exceeded
            endPage = Math.min(startPage + maxSize - 1, totalPages);
          }
        }

        // Add page number links
        for (var number = startPage; number <= endPage; number++) {
          var page = makePage(number, number, paginationCtrl.isActive(number), false);
          pages.push(page);
        }

        // Add links to move between page sets
        if ( isMaxSized && ! rotate ) {
          if ( startPage > 1 ) {
            var previousPageSet = makePage(startPage - 1, '...', false, false);
            pages.unshift(previousPageSet);
          }

          if ( endPage < totalPages ) {
            var nextPageSet = makePage(endPage + 1, '...', false, false);
            pages.push(nextPageSet);
          }
        }

        // Add previous & next links
        if (directionLinks) {
          var previousPage = makePage(currentPage - 1, previousText, false, paginationCtrl.noPrevious());
          pages.unshift(previousPage);

          var nextPage = makePage(currentPage + 1, nextText, false, paginationCtrl.noNext());
          pages.push(nextPage);
        }

        // Add first & last links
        if (boundaryLinks) {
          var firstPage = makePage(1, firstText, false, paginationCtrl.noPrevious());
          pages.unshift(firstPage);

          var lastPage = makePage(totalPages, lastText, false, paginationCtrl.noNext());
          pages.push(lastPage);
        }

        return pages;
      };
    }
  };
}])

.constant('pagerConfig', {
  itemsPerPage: 10,
  previousText: '« Previous',
  nextText: 'Next »',
  align: true
})

.directive('pager', ['pagerConfig', function(config) {
  return {
    restrict: 'EA',
    scope: {
      page: '=',
      totalItems: '=',
      onSelectPage:' &'
    },
    controller: 'PaginationController',
    templateUrl: 'template/pagination/pager.html',
    replace: true,
    link: function(scope, element, attrs, paginationCtrl) {

      // Setup configuration parameters
      var previousText = paginationCtrl.getAttributeValue(attrs.previousText, config.previousText, true),
      nextText         = paginationCtrl.getAttributeValue(attrs.nextText,     config.nextText,     true),
      align            = paginationCtrl.getAttributeValue(attrs.align,        config.align);

      paginationCtrl.init(config.itemsPerPage);

      // Create page object used in template
      function makePage(number, text, isDisabled, isPrevious, isNext) {
        return {
          number: number,
          text: text,
          disabled: isDisabled,
          previous: ( align && isPrevious ),
          next: ( align && isNext )
        };
      }

      paginationCtrl.getPages = function(currentPage) {
        return [
          makePage(currentPage - 1, previousText, paginationCtrl.noPrevious(), true, false),
          makePage(currentPage + 1, nextText, paginationCtrl.noNext(), false, true)
        ];
      };
    }
  };
}]);
  • Project-wide extensibility
  • Need to add configuration option for each instance-specific customization
  • Hard to test - Must test DOM changes

A (Hopefully) Better Way

Object-Oriented Directives

Thin directive delegates to object factories Factories can be subclassed using Angular Injector Most tests written for Plain Old Javascript Objects Offer hooks instead of configuration options

Follow along: github.com/wadetandy/bb-paginate

Write Thin Directives

app.directive 'bbPaginate', ['Paginator', (Paginator) ->
  template: """
    <ul>
      <li ng-repeat="page in paginator.pages" ng-class="page.listItemClasses()">
        <a href="" ng-click="selectPage(page)">{{page.text}}</a>
      </li>
    </ul>
  """
  scope:
    pagination: "=bbPaginate"
  link: ($scope, $element, $attrs) ->
    $scope.paginator = new Paginator($scope.pagination)

    $scope.$watch 'pagination', $scope.paginator.redraw, true
]

Subclass Factories

app.factory 'StandardPaginator', ['Page', 'PrevPage', 'NextPage', (Page, PrevPage, NextPage) ->
  class StandardPaginator
    # do stuff

app.factory 'CustomPaginator', ['StandardPaginator', 'SpecialPage', (StandardPaginator, SpecialPage) ->
  class CustomPaginator extends StandardPaginator
    # do stuff

app.directive 'bbPaginate', ['$injector', ($injector) ->
  # ... skip some stuff
  link: ($scope, $element, $attrs) ->
    paginator        = $attrs.paginator || 'StandardPaginator'
    klass            = $injector.get(paginator)
    $scope.paginator = new klass($scope.pagination)

    # ... more stuff
]
<div bb-paginate paginator="CustomPaginator">

Test Plain Old Javascript Objects

describe 'Paginator', ->
  described_class = null

  beforeEach ->
    module('bbPaginate')
    inject (Paginator) ->
      described_class = Paginator

  describe '#new', ->
    it 'should create a paginator instance if required attributes are provided', ->
      subject = new described_class
        current_page:  1
        per_page:      20
        total_entries: 100
      expect(subject.isValid()).toBeTruthy()

Offer Hooks, Not Configuration Options

class PaginationController
  constructor: (@scope, @attrs) ->
    @_pageSelectHandlers = []

  onPageSelect: (fn) =>
    @_pageSelectHandlers.push(fn)
  # ...

app.directive 'bbPaginate', ['Paginator', (Paginator) ->
  controller: ['$scope', '$attrs', PaginationController]
  # ...

app.directive 'bbPaginateScrollTo', ->
  require: 'bbPaginate'
  link: ($scope, $element, $attrs, bbPaginateCtrl) ->
    bbPaginateCtrl.onPageSelect (page) ->
      $.scrollTo($attrs.bbPaginateScrollTo, 500)

Demo

Standard Pagination

Standard Pagination with 5 pages

Indefinite Pagination

We're hiring

jobs.bloomberg.com

Find Our Code

github.com/wadetandy/bb-paginate

Follow Us

github.com/chazchandler

github.com/wadetandy

@wadetandy