Writing Testable Application in AngularJS – Part 4


In the last post, we’ve seen how to leverage services in Angular to avoid duplicating code and how easy it is to test them. At the moment, our template does not interact with the player. In FIB template created previously, the submit button should get enabled once all inputs are filled, tryagain button will be activated after submitting, and if all 3 attempts lapse, we’ll enable showans button.

As you can see that footer bar does/can not come under FibCtrl so a hackish approach for this problem is to define Boolean variables on the $rootScope to toggle those buttons. Off-course It’ll work but its not elegant because all variables will be in global state.

Getting out of $rootScope hell

A better approach for such problem is to create a custom service and inject it into a controller. You can even share data between controllers.
Lets create a new service named checker. As usual, following command would generate checker.js and its test file.

$ yo angular:service checker

Then we’ll modify it as:

angular.module('eShellApp').factory('checker', function ($location) {
  return {
    // Enable/Disable submit button
    isSubmit: false,

    // Enable/Disable try again button
    isTryagain: false,

    // Enable/Disable show answer button
    isShowans: false,

    // Track attempt. Should increase on every try
    attempt: 1,

    // Max attempts
    totalAttempts: 3,

    // Click handler for submit button
    submit: function () { },

    // Click handler for try again button
    tryagain: function () { },

    // Click handler for show answer button
    showAns: function () { },

    // Getter for all above properties/methods
    getProperty: function(property) {
      return this[property];
    },

    // Setter for all above properties/methods
    setProperty: function(property, value) {
      this[property] = value;
    },

    // Invoke click handlers submit()/tryagain()/showans() after clicking buttons
    invoke: function(property) {
      this[property]();
    },

    // reset all per template, will be called from controllers
    reset: function($scope) {
      this.setProperty('isSubmit', false);
      this.setProperty('isTryagain', false);
      this.setProperty('isShowans', false);
      this.setProperty('submit', $scope['submit']);
      this.setProperty('tryagain', $scope['tryagain']);
      this.setProperty('showans', $scope['showans']);
      this.setProperty('attempt', 1);
      this.setProperty('currentTemplate', parseInt($location.path().substring(1), false));
    }
  };
});

We’ll quickly test the above service to make it future proof. Here jamine’s spyOn method creates a spy for checker.submit() method. And when we call checker.invoke(‘submit’), it calls checker.submit() from within. Finally we just have to check whether checker.submit() has been called or not.

describe('Service: checker', function () {

  // load the service's module
  beforeEach(module('eShellApp'));

  // instantiate service
  var checker;
  beforeEach(inject(function (_checker_) {
    checker = _checker_;
  }));

  it('should get and set properties', function () {
    expect(checker.getProperty('isSubmit')).toBeFalsy();
    checker.setProperty('isSubmit', true);
    expect(checker.getProperty('isSubmit')).toBeTruthy();
  });

  it('should call method properties', function() {
    spyOn(checker, 'submit').andCallFake(function() { });
    checker.invoke('submit');
    expect(checker.submit).toHaveBeenCalled();
  });
});

Creating a controller without route

Finally we’ll create a new controller named footer which will consume checker service.

$ yo angular:controller footer

lets modify footer.js. isEnable() method enables/disables buttons. invoke() method calls checker.invoke() which then call methods defined in the template controller.

angular.module('eShellApp').controller('FooterCtrl', function ($scope, checker) {
  $scope.isEnable = function(button) {
    return checker.getProperty(button);
  };

  $scope.invoke = function(button) {
    checker.invoke(button);
  };

  $scope.attempt = function() {
    return checker.getProperty('attempt');
  };

  $scope.totalAttempts = function() {
    return checker.getProperty('totalAttempts');
  };
});

We’ll update footer bar in index.html to use above methods. ng-disabled enables/disables a button based on expression. ng-click lets you attach click handler the natural way.

<div class="navbar navbar-inverse navbar-fixed-bottom ng-cloak" ng-controller="FooterCtrl">
  <div class="navbar-inner">
    <div class="container">
      <div class="btn-group pull-left">
        <button class="btn btn-inverse input-small" value="Attempt {{attempt()}} of {{totalAttempts()}}" disabled></button>
      </div>

      <div class="btn-group pull-left">
        <button class="btn btn-primary" type="button" ng-disabled="!isEnable('isSubmit')" ng-click="invoke('submit')">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Submit&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</button>
        <button class="btn btn-warning" type="button" ng-disabled="!isEnable('isTryagain')" ng-click="invoke('tryagain')">&nbsp;&nbsp;&nbsp;Try Again&nbsp;&nbsp;&nbsp;</button>
        <button class="btn btn-success" type="button" ng-disabled="!isEnable('isShowans')" ng-click="invoke('showans')">Show Answer</button>
      </div>
      <div class="btn-group pull-right">
        <button class="btn">&laquo;</button>
        <button class="btn">1 of 4</button>
        <button class="btn">&raquo;</button>
      </div>
    </div>
  </div>
</div>

You may remember that we’d attached validate() method on ng-change to all input boxes in FIB template to toggle submit button. Lets define that method:

$scope.validate = function(isValid) {
   checker.setProperty('isSubmit', isValid);
};

Now define click handlers for submit, tryagain and show answer buttons. In submit handler, we just validate input boxes and make them readonly. For example, every input box has input_0 as model, input_0_valid tracks the validity of the model and input_0_readonly toggles readonly html attribute on input boxes. Try again and show answer handlers remove invalid and show valid answers respectively.

  eywa.worship('data/fib.json').then(function(data) {
    $scope.data = data.data;
  });

  $scope.validate = function(isValid) {
    checker.setProperty('isSubmit', isValid);
  };

  $scope.setClass = function(param) {
    if (!angular.isDefined(param)) return '';
    return param === true ? 'success' : 'error';
  };

  $scope.submit = function() {
    var isValid,
        totalValid = 0,
        attemptsLapse = checker.getProperty('attempt') >= checker.getProperty('totalAttempts');

    $scope.data.choices.forEach(function(choice, index) {
      isValid = choice.val2.split(',').indexOf($scope['input_' + index]) !== -1;

      if (isValid) totalValid++;

      $scope['input_' + index + '_valid'] = isValid;
      $scope['input_' + index + '_readonly'] = true;
    });

    checker.setProperty('isTryagain', totalValid !== $scope.data.choices.length && !attemptsLapse);
    checker.setProperty('isShowans', totalValid !== $scope.data.choices.length && attemptsLapse);
    checker.setProperty('isSubmit', false);
  };

  $scope.tryagain = function() {
    $scope.data.choices.forEach(function(choice, index) {
      if (!$scope['input_' + index + '_valid']) {
        $scope['input_' + index + '_valid'] = undefined;
        $scope['input_' + index + '_readonly'] = false;
        $scope['input_' + index] = '';
      }
    });
    checker.setProperty('isTryagain', false);
    checker.setProperty('attempt', checker.getProperty('attempt') + 1);
  };

  $scope.showans = function() {
    $scope.data.choices.forEach(function(choice, index) {
      if (!$scope['input_' + index + '_valid']) {
        $scope['input_' + index + '_valid'] = true;
        $scope['input_' + index + '_readonly'] = true;
        $scope['input_' + index] = choice.val2.split(',')[0];
      }
    });
    checker.setProperty('isShowans', false);
  };

  // Reset footer bar on Template load
  checker.reset($scope);

checker.reset() method simply sets attempt to 1, disables all buttons, etc. when a template loads.
If an input has a valid model value, it will be highlighted in green or in red otherwise. For that purpose, we’ve setClass() method defined above and need to use in ng-class directive inside parseBracket.js directive.

angular.module('eShellApp').directive('parseBracket', function ($compile) {
  return {
    .
    .
    new RegExp(choice.val1.replace(/\[/g, '\\[').replace(/\]/g, '\\]'), 'g'),
    '<span class="control-group" ng-class="setClass(input_' + index + '_valid)">' +
    '<input type="text" ' +
      'class="input-medium" ' +
      'autocorrect="off" ' +
      'autocapitalize="off" ' +
      'autocomplete="off" ' +
      'ng-change="validate(frmFIB.$valid)" ' +
      'ng-readonly="input_' + index + '_readonly" ' +
      'ng-model="input_' + index + '"' +
      'required />' +
     '</span>'
    .
    .
  };
});

As you could see in checker.js service, we’d already defined currentTemplate and totalTemplates variables for a reference. Now in footer.js, we’ll add 4 more methods:

App.controller('FooterCtrl', function ($scope, checker, $location) {
  $scope.currentTemplate = function() {
    return checker.getProperty('currentTemplate');
  };

  $scope.totalTemplates = function() {
    return checker.getProperty('totalTemplates');
  };

  $scope.prevTemplate = function() {
    $location.path('/' + (checker.getProperty('currentTemplate') - 1));
  };

  $scope.nextTemplate = function() {
    $location.path('/' + (checker.getProperty('currentTemplate') + 1));
  };
});

The $location service parses the URL in the browser address bar and makes the URL available to your application.

And update index.html:

   <div class="navbar navbar-inverse navbar-fixed-bottom ng-cloak" ng-controller="FooterCtrl">
      <div class="navbar-inner">
        <div class="container">
          .
          .
          .
          .   
          <div class="btn-group pull-right">
            <button class="btn" ng-disabled="currentTemplate() == 1" ng-click="prevTemplate()">&laquo;</button>
            <button class="btn" ng-bind-template="{{currentTemplate()}} of {{totalTemplates()}}">? of ?</button>
            <button class="btn" ng-disabled="currentTemplate() == totalTemplates()" ng-click="nextTemplate()">&raquo;</button>
          </div>
        </div>
      </div>
    </div>

Here, ng-bind-template directive specifies that the element text should be replaced with the template provided. Unlike ngBind the ngBindTemplate can contain multiple {{ }} expressions.

Wrap up

In the next post, we’ll see how to make the application production ready using grunt build.. Checkout Part 5.

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s