Ståle Pettersen
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
| 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. |
<html ng-app>
<script src="lib/angular.min.js"/>
<h1>Hello world!</h1>
</html>
var weatherApp = angular.module('weatherApp', []);
<html>
<body>
<script src="lib/angular.min.js"/>
<script src="js/app.js"/>
<div ng-app="weatherApp">
<!-- Angular World -->
</div>
</body>
</html>
Exposes data and helper functions to view
weatherApp.controller('WeatherController', function($scope) {
$scope.heading = 'Weather forecast';
$scope.searchTerm = 'Oslo';
});
<div ng-app="weatherApp">
<div ng-controller="WeatherController">
{{heading}}
</div>
</div>
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...
All $scopes are children of $rootScope.
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.
<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"/>
$scope changes detected by AngularJS if changed within framework.
$scope.name = 'Ståle' or $('#name').val('Ståle') will not update DOM.
$scope.name = 'Ståle'; $scope.$apply(); will update DOM.
Directives (aka components): Extending HTML with your own types, can be simple or crazy complex.
<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>
<input
ng-model="{string}"
[name="{string}"]
[required]
[ng-required="{boolean}"]
[ng-minlength="{number}"]
[ng-maxlength="{number}"]
[ng-pattern="{string}"]
[ng-change="{string}"] />
<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>
<h1>{{ heading | uppercase }}</h1>
<li ng-repeat="r in res | orderBy:'title':reverse | limitTo:10 | filter:query ">
<p>{{r.title}} {{r.date}}</p>
</li>
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('
');
};
})
;
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
line.');
}));
});
});
Only place DOM manipulation is allowed.
myApp.directive('myDirective', function(dep1, dep2) {
return {
replace: true,
restrict: 'EACM',
priority: 0,
templateUrl: 'directive.html',
template: '',
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) {..}
}
}
});
E - Element name: <my-directive></my-directive>A - Attribute: <div my-directive="exp"></div>C - Class: <div class="my-directive: exp;"></div>M - Comment: <!-- directive: my-directive exp -->DateFormatter directive:
// 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));
});
}
};
})
;
Mainly for directives.
.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
});
}
});
weatherApp.module('weatherDirectives')
.directive('waTabindex', function() {
return function(scope, element, attrs) { // link fn
scope.$watch(attrs.ngTabindex, function (newVal) {
element.attr('tabindex', newVal);
});
};
});
<input name="someInput" wa-tabindex="{{tab.index}}" />
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");
});
});
<input ng-model="degrees" smart-float/>
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;
}
});
}
};
Abstraction on top of $http, angular-resources.js is not bundled in angular.js.
First empty object/list, then backend result.
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)
angular.module('csrfTokenSetup', [])
.config(function ($httpProvider) {
$httpProvider.defaults.headers.post['X-Requested-By'] = $('[name=csrf-token]').attr('content');
});
Mock responses for development with mocked backeds (or testing)
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();
});
<script src="mocks/mockServices.js"></script> <div ng-app="weatherAppDev"/> <-- 'weatherApp' for no mocks -->
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);
});
});
Default implementation calls $log.error.
weatherApp.factory('$exceptionHandler', function($injector) {
return function(exception, cause) {
var http = $injector.get('$http');
http.post('/errors', { error: exception });
};
});
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);
});
Loads partials into <ng-view/>.
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);
});
weatherApp.run(function (TrackingService, $rootScope) {
$rootScope.$on(
'$routeChangeStart',
TrackingService.trackEvent
);
})
$rootScope.$broadcast('Event for children', args);
$scope.$emit('Event for parents', args);
$scope.$on('Event for parents', fn(event, args) );
describe('Weather E2E Test', function() {
it('should navigate to start page', function(){
browser().navigateTo('/');
});
it('should enter City', function(){
input('search.city').enter('Oslo');
});
});
karma start weather-e2e.conf.js>
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 = {
'/':'http://localhost:30000/'
};