AngularJS Workshop

Ståle Pettersen

AngularJS Workshop

Ståle Pettersen / @kozmic

May 14th, 2013

Lates version of slides at: http://kozmic.github.com/angularjs-workshop/

One version of the weather app: https://github.com/kozmic/angularjs-workshop

  1. AngularJS stuff
    • Tools
    • Bootstrapping Angular
    • Controllers
    • Binding
    • Hands on!
    • Forms
    • Directives, filters, routing and more!
    • Hands on!
    • f00dz

Disclaimer:

I consider myself an AngularJS n00b

...even with 9 months experience

...I learn something new each week

...there will be questions I can't answer

...we'll Google together! :)

Source: Ben Nadel

AngularJS is a drama queen!

Tools

Useful resources

MVC-ish

Model: Plain Javascript
View: HTML templates (including custom components) bound to model
Controller: Exposes the model to the view by setting model on $scope which is watched for changes. Controller usually has helper methods to view.

Hello world

HTML
1
2
3
4
<html ng-app>
  <script src="lib/angular.min.js"/>
  <h1>Hello world!</h1>
</html>

Basic app

app.js
1
var weatherApp = angular.module('weatherApp', []);
HTML
1
2
3
4
5
6
7
8
9
10
<html>
<body>
    <script src="lib/angular.min.js"/>
    <script src="js/app.js"/>
 
    <div ng-app="weatherApp">
        <!-- Angular World -->
    </div>
</body>
</html>

Basic controller with binding

Exposes data and helper functions to view

controller/WeatherController.js
1
2
3
4
5
weatherApp.controller('WeatherController', function($scope) {
 
    $scope.heading = 'Weather forecast';
    $scope.searchTerm = 'Oslo';
});
HTML
1
2
3
4
5
6
7
8
<div ng-app="weatherApp">
    <div ng-controller="WeatherController">
        {{heading}}
        <form>
            <input ng-model="searchTerm">
        </form>
    </div>
</div>

Dependency injection

1
2
3
4
5
6
7
8
angualar.module('myModule', [dep])
  .config(function(dep){})
  .run(function(dep) {});
  .service('serviceName', function(dep) {})
  .factory('factoryName', function(dep) {})
  .directive('directiveName', function(dep) {})
  .filter('filterName', function(dep) {})
  // and more...

Scopes

All $scopes are children of $rootScope.

Binding concepts

The AngularJS $scope variable acts as a big key/value storage hash which is internally looped on a timeout interval and dirty checked against it's former value(s) each time a digestion occurs.

Binding examples (directives)

1
2
3
4
5
6
7
8
9
10
11
<h1>{{model}}</h1>
 
<span ng-bind="model" /> <-- ngCloak fix -->
 
<input ng-model="model" />
 
<div ng-bind-html="model" /> <-- ngSanitize -->
 
<div ng-bind-html-unsafe="model" /> <-- ngSanitize -->
 
<CmsText data-key=helpText.name"/>

Gotcha

Try

Built in directives

Directives (aka components): Extending HTML with your own types, can be simple or crazy complex.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<li ng-repeat="result in results">
    <p>{{result.title}} {{result.date}}</p>
</li>
 
<div ng-show="searchResults">
    {{searchResults}}
</div>
 
<div ng-include src="'templates/search.html'"></div>
 
<ng-pluralize count="searchResults.length"
              when="{'0': 'No results.',
                   'one': 'Only one result for your search..',
                 'other': '{} results found.'}" />
 
<div ng-class="{answered: question.answer}">
 
<h2 ng-show="title">{{title}}</h2>

Example: Built in input directive

1
2
3
4
5
6
7
8
9
<input
    ng-model="{string}"
   [name="{string}"]
   [required]
   [ng-required="{boolean}"]
   [ng-minlength="{number}"]
   [ng-maxlength="{number}"]
   [ng-pattern="{string}"]
   [ng-change="{string}"] />

Forms

1
2
3
4
5
6
7
8
9
10
11
<form name="weatherForm" ng-submit="submitForm()">
    <input name="city" type="input"
           placeholder="city"
           ng-model="weather.city"
           ng-required="true">
 
    <span ng-show="weatherForm.city.$error.required">Required</span>
 
    <input type="radio" ng-model="weather.unit" value="celcius"/> Celcius
    <input type="radio" ng-model="weather.unit" value="fahrenheit"/> Fahrenheit
</form>

Filters

1
2
3
4
5
6
<h1>{{ heading | uppercase }}</h1>
<li ng-repeat="r in res | orderBy:'title':reverse | limitTo:10 | filter:query ">
   <p>{{r.title}} {{r.date}}</p>
</li>

Custom filters

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
weatherApp.module('weatherFilters', [])
 
  // <span>{{ temperature | postfix:' C' }}</span>
  .filter('postfix',function(){
        return function(value, postfix) {
            return !!value ? value + postfix : value;
        };
  })
 
  // <div ng-bind-html="textWithNewlines | nl2br"/>
  .filter('nl2br', function() {
        return function(input) {
            return ('' + input).split('\n').join('<br>');
        };
  })
;

Testing filters

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
describe('weatherFilters', function() {
 
beforeEach(angular.mock.module('weatherFilters'));
 
describe('nl2br filter', function() {
 
    it('should not modify string without a newline', inject(function($filter) {
        var input = 'my input line.';
        var nl2brFilter = $filter('nl2br')(input);
        expect(nl2brFilter).toBe(input);
    }));
 
    it('should replace newlines with a br tag', inject(function($filter) {
        var input = 'my input\n line.';
        var nl2brFilter = $filter('nl2br')(input);
        expect(nl2brFilter).toBe('my input<br> line.');
    }));
});
});

Custom directives: Two categories

Directive API

Only place DOM manipulation is allowed.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
myApp.directive('myDirective', function(dep1, dep2) {
  return {
    replace: true,
    restrict: 'EACM',
    priority: 0,
    templateUrl: 'directive.html',
    template: '<div></div>',
    scope: { localName:'@' },
    controller: function($scope, $element, $attrs, $transclude, dep1) {},
    transclude: true,
    compile: function compile(tElement, tAttrs, transclude) {
    },
    link: function postLink(scope, iElement, iAttrs) {
      scope.$watch('name', function(newVal, oldVal) {..}
    }
  }
});

Using directives

Custom directive

DateFormatter directive:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Example usage:  <p class="DateFormatter" data-dato="{{myDate}}" />
weatherApp.directive('DateFormatter', function(DateUtilService) {
    return {
        replace: false
        restrict: 'C',
        link: function($scope, el, attrs) {
            var inputFormat = attrs.inputFormat ? attrs.inputFormat : 'd.M.yyyy';
            var outputFormat = attrs.outputFormat ? attrs.outputFormat : 'd. MMMM yyyy';
 
            attrs.$observe('dato', function(dato) {
                el.text(DateUtilService.convertFormat(dato, inputFormat, outputFormat));
            });
        }
    };
})
;

$watch and $observe

Mainly for directives.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.directive('custom', function () {
  return {
    link: function (scope, iElement, iAttrs) {
 
      // Attributes only (interpolated)
      iAttrs.$observe('myAttr', function (value) {
        scope.myAttrValue = value;
      });
 
      // Scope and attributes
      scope.$watch(iAttrs['myAttr'], function (newVal, oldVal, scope) {
        scope.myAttrValue = value;
      }, true); // Object equality vs reference
    });
  }
});

Custom directive

1
2
3
4
5
6
7
8
9
10
11
weatherApp.module('weatherDirectives')
 
  .directive('waTabindex', function() {
 
        return function(scope, element, attrs) { // link fn
            scope.$watch(attrs.ngTabindex, function (newVal) {
                element.attr('tabindex', newVal);
            });
        };
    });
Usage:
1
2
<input name="someInput" wa-tabindex="{{tab.index}}" />

Testing directive

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
describe("wa-tabindex directive test", function () {
var $compile, $rootScope, element;
beforeEach(angular.mock.module('weatherDirectives'));
beforeEach(inject(function ($c, $r) {
        $compile = $c;
        $rootScope = $r;
    }
));
 
beforeEach(function () {
    element = $compile('<div><input wa-tabindex="index"></input></div>')($rootScope);
    $rootScope.tabindex = 1337;
    $rootScope.$digest();
});
 
it("should write tabindex on initialization", function () {
    expect(angular.element(element.html()).attr('tabindex')).toEqual("1337");
});
 
it("should update tabindex", function () {
    $rootScope.tabindex = 1338;
    $rootScope.$digest();
    expect(angular.element(element.html()).attr('tabindex')).toEqual("1338");
});
});

Customizing formatter/parser

Form validation directive

1
<input ng-model="degrees" smart-float/>
Customer parser for float:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var FLOAT_REGEXP = /^\-?\d+((\.|\,)\d+)?$/;
 
app.directive('smartFloat', function() {
  return {
    require: 'ngModel',
    link: function(scope, elm, attrs, ctrl) {
 
      ctrl.$parsers.unshift(function(viewValue) {
 
        if (FLOAT_REGEXP.test(viewValue)) {
          ctrl.$setValidity('float', true);
          return parseFloat(viewValue.replace(',', '.'));
 
        } else {
          ctrl.$setValidity('float', false);
          return undefined;
        }
      });
    }
  };

$http

Returns promise with success and error (no complete). Use $resource instead of $http.

1
2
3
4
5
6
// method, url, params, headers, transformers, cache, timeout
$http(config)
 
$http.get('/weather').success(successCallback);
$http.post('/weather', data).success(successCallback);

Resources

Abstraction on top of $http, angular-resources.js is not bundled in angular.js.

First empty object/list, then backend result.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
weatherApp.factory('WeatherService', function($resource) {
  return $resource('/weather/:city',{ city: '@city' });
}
 
weatherApp.controller('a', function($scope, WeatherService){}
 
WeatherService.get({city: 'Oslo'})   // GET, isArray=false
WeatherService.query({city: 'Oslo'}) // GET, isArray=true
WeatherService.save(myWeather)       // POST
WeatherService.save({ city: 'Oslo' }, body)  // POST
WeatherService.remove(myWeather)     // DELETE
WeatherService.delete(myWeather)     // DELETE
 
var Oslo = WeatherService.get({city: 'Oslo'})
Oslo.temp = 100;
Oslo.$save();
 
// With callbacks, not typically used:
WeatherService.save(myWeather, success, error)

CSRF token in all POST requests

1
2
3
4
5
angular.module('csrfTokenSetup', [])
    .config(function ($httpProvider) {
        $httpProvider.defaults.headers.post['X-Requested-By'] = $('[name=csrf-token]').attr('content');
    });

Dev/Mock/Test $http/$resource

Mock responses for development with mocked backeds (or testing)

mockServices.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var weatherAppDev = angular.module('weatherAppDev', ['weatherApp', 'ngMockE2E']);
weatherAppDev.run(function ($httpBackend) {
 
    $httpBackend.whenGET('/weather\/\w*$').respond(window.weatherResult_Oslo);
    $httpBackend.whenPOST('/weather$').respond(function (method, url, data, headers) {
        if (angular.fromJson(data).weather.wind === 100) {
            return ['500', '', ''];
        } else {
            return ['200', window.weatherResult_Oslo, ''];
        }
    });
 
 
    $httpBackend.whenGET(/.*/).passThrough();
    $httpBackend.whenPOST(/.*/).passThrough();
});
Switch to mock:
1
2
3
<script src="mocks/mockServices.js"></script>
<div ng-app="weatherAppDev"/> <-- 'weatherApp' for no mocks -->

Setup for testing controller with mocked backend

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
describe('WeatherController', function() {
 
  describe('WeatherController', function(){
    var scope, ctrl, httpBackend, defaultCity = 'Oslo';
 
    beforeEach(inject(function($httpBackend, $rootScope, $controller) {
      httpBackend = $httpBackend;
      httpBackend.expectJSONP('/weather?q=' + defaultCity).
          respond({city: 'Oslo', weather: 'Sun' });
 
      scope = $rootScope.$new();
      scope.search = { city: defaultCity };
      ctrl = $controller(PhoneListCtrl, {$scope: scope});
 
      httpBackend.flush();
    }));
 
    it('should fetch results for Oslo at initialization', function() {
        expect(scope.result.city).toBe(defaultCity);
    });
});

Wrappers

Error handling

Default implementation calls $log.error.

1
2
3
4
5
6
7
weatherApp.factory('$exceptionHandler', function($injector) {
 
    return function(exception, cause) {
        var http = $injector.get('$http');
        http.post('/errors', { error: exception });
    };
});

Generic interceptor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
weatherApp.config(function ($httpProvider) {
    var errorInterceptor = function ($q, $location) {
 
        function success(response) {
            return response;
        }
 
        function error(response) {
            if (response.status < 200 || response.status >= 400) {
                $location.path("/error");
                return $q.reject(response);
            }
        }
 
        return function (promise) {
            return promise.then(success, error);
        };
 
    };
 
    $httpProvider.responseInterceptors.push(errorInterceptor);
});
 
            

Routes

Loads partials into <ng-view/>.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
angular.module('myRoutes',[]).config(function($routeProvider, $locationProvider) {
  $routeProvider
    .when('/weather', {
      templateUrl: 'search.html',
      controller: SearchCtrl
    })
    .when('/weather/:city', {
        templateUrl: 'result.html',
        controller: ResultCtrl
    })
 
    .when('/404', { templateUrl: '404.html' })
 
    .otherwise( { redirectTo: '/404' });
 
    $locationProvider.html5Mode(true);
});

Listen on route change events

1
2
3
4
5
6
7
weatherApp.run(function (TrackingService, $rootScope) {
  $rootScope.$on(
    '$routeChangeStart',
    TrackingService.trackEvent
  );
})

Events

1
2
3
$rootScope.$broadcast('Event for children', args);
$scope.$emit('Event for parents', args);
$scope.$on('Event for parents', fn(event, args) );

End to end test (e2e)

1
2
3
4
5
6
7
8
9
10
describe('Weather E2E Test', function() {
 
    it('should navigate to start page', function(){
        browser().navigateTo('/');
    });
 
    it('should enter City', function(){
        input('search.city').enter('Oslo');
    });
});

End to end test karma config

karma start weather-e2e.conf.js>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
files = [
    ANGULAR_SCENARIO,
    ANGULAR_SCENARIO_ADAPTER,
    /* E2E Test*/
    '../test/e2e/weather-e2e-test.js'
];
 
// CI:
singleRun = true;
autoWatch = false;
browsers = ['PhantomJS'];
 
// Local:
//autoWatch = true;
//singleRun = false;
//browsers = ['Chrome'];
 
urlRoot = '/__karma/';
 
proxies = {
};

Tasks

https://github.com/kozmic/angularjs-workshop