#+TITLE: AngularJS Hello World #+AUTHOR: Rudolf Olah #+EMAIL: omouse@gmail.com * Before We Begin ** Requirements Our hello world project has the following requirements: - [[http://nodejs.org/][Node.js]] and [[https://npmjs.org/][NPM (Node Package Manager)]] - [[http://angularjs.org/][AngularJS]] - [[http://karma-runner.github.io/][Karma]] test runner - [[http://pivotal.github.io/jasmine/][Jasmine]] unit test framework Note: Node.js is only used for running Karma and Jasmine. ** Install and Set Up *** Project Structure AngularJS doesn't impose any particular project structure on you. For this tutorial, the following project structure is used: - lib/ - images/ - js/ - helloworld/ - styles/ - tests/ JavaScript source code is in =js/=, the CSS is in =styles/= and any images go into =images/=. Test code is in =tests/=. The =lib/= directory is where JavaScript and CSS libraries are placed so that we don't get confused between our code and external code that others have written. *** Install AngularJS Download [[http://www.angularjs.org/][AngularJS]] and place it in the =lib/= directory. #+name: load-javascripts #+begin_src html #+end_src #+name: load-stylesheets #+begin_src html #+end_src *** Install the Karma test runner and Jasmine test framework We create a =package.json= so that we can list the dependencies of our project which lets us use =npm= to automatically install them. #+name: package.json #+begin_src json :tangle package.json :padline no { "name": "learning-angularjs", "version": "0.0.1", "private": true, "dependencies": { "karma": "0.10.2", "karma-firefox-launcher": "0.1.0", "karma-jasmine": "0.1.3" } } #+end_src [[http://karma-runner.github.io/][Karma]] is a test runner and it uses the [[http://pivotal.github.io/jasmine/][Jasmine]] test framework. Karma lets us run unit tests and end to end tests. The Jasmine test framework gives us functions for unit testing. *** Setting up Karma for Unit Testing Before we can get to unit testing the controller, we have to create configuration file for Karma. We want to set Karma to use Jasmine as our test framework: #+name: karma-config-test-framework #+begin_src json 'frameworks': [ 'jasmine' ] #+end_src And we also want to be able to run the tests within the Firefox and Chrome browsers if they're available: #+name: karma-config-browsers #+begin_src json 'browsers': [ 'Chrome', 'Firefox' ] #+end_src We want Karma to watch all of our library, source and unit-test files and to reload them when they're updated. The order of the files is important and determines the order they're loaded in. You will want the AngularJS library to load first, followed by the source code, and then finally the unit-test files. #+name: karma-config-files #+begin_src json 'files': [ // Angular files 'lib/angular-1.0.8/angular.js', 'lib/angular-1.0.8/angular-*.js', // Hello World source code 'js/helloworld/app.js', 'js/helloworld/controllers.js', // Hello World test files 'tests/*.js' ] #+end_src We also need to exclude the angular scenario source file. It's used for end-to-end/integration testing and isn't needed for our unit tests: #+name: karma-config-exclude #+begin_src json 'exclude': [ 'lib/angular-1.0.8/angular-scenario.js' ] #+end_src Here's how it looks put together: #+name: karma-config #+begin_src json :tangle karma.conf.js :noweb yes :padline no module.exports = function (config) { config.set({ <>, <>, <>, <> }); }; #+end_src *** Running the Karma Unit Tests You can run the unit tests like this: #+begin_src sh ./node_modules/karma/bin/karma start karma.conf.js --auto-watch #+end_src If Karma is installed globally, you can run it like this: #+begin_src sh karma start karma.conf.js --auto-watch #+end_src The =--auto-watch= option will run tests as soon as any source files have changed. You can add =autoWatch: true= to the karma configuration setup so that you don't have to add =--auto-watch= on the command-line. * Learning AngularJS ** Concepts *** Directives Directives are specialized reusuable HTML elements. They contain code for manipulating the DOM and for adding events. **** Example: ng-repeat For example, the ng-repeat directive will repeat whatever child elements it contains. #+begin_src html

{{ person.fullName }}

{{ person.firstName }} is {{ person.age }} years old.

Address: {{ person.address }}

#+end_src Expands into: #+begin_src html

Alice Example

Alice is 30 years old.

Address: 1 Some St., Boston, MA, United States of America

Bob Sample

Bob is 32 years old.

Address: 876 Another St., Toronto, ON, Canada

#+end_src **** Example: ng-click Another example; the ng-click directive won't modify the HTML but it will bind a function to the click event on the element. #+begin_src html #+end_src Expands into the same HTML but when you click, an alert will pop up that says "hello world": #+begin_src html #+end_src *** Controllers A controller is an object that controls what data is displayed in the app and how the user can interact with the app. A controller can fetch data from a server and put it into its scope so that the data can be displayed. When a user clicks on a button, the controller contains the function that will be executed on that click. *** Expressions Angular expressions is code within within curly braces. You can put math expressions in them, refer to variables within the controller's scope, and run variables through filters among other things. *** Filters Filters are functions for reformatting variables or other data that is passed to them. The most commonly used filters are for formatting how a date is displayed and for displaying a decimal number as a currency. **** Example: currency #+begin_src html {{ '25.09' | currency }} #+end_src After filtering it turns into this: #+begin_src html $25.09 #+end_src ** Defining the Hello World module To begin with, we must create a module that represents the whole app. Creating a module helps us avoid polluting the global namespace. #+name: helloworld-app #+begin_src javascript :tangle js/helloworld/app.js :noweb yes :padline no angular.module('helloworldApp', []); #+end_src ** Using the Hello World module After defining the app's module, we can use it on our site by specifiying it as the value for the =ng-app= attribute in the top-level =html= element: #+name: helloworld-app-html #+begin_src html #+end_src This will give us access to any controllers, directives, services, filters and other objects that are part of the helloworldApp. Note: There can only be one ng-app declared on a page. ** Defining a Controller We're going to define our own controller, the HelloworldCtrl, which contains a list of books. Each book has a title and a price that we're going to display. We're going to be able to sort the books by their title and price and we're also going to be able to search through the book titles. #+name: helloworld-controller-books #+begin_src javascript this.books = [ { 'title': 'Mastering Web Application Development with AngularJS', 'price': '35.99' }, { 'title': 'AngularJS for Dummies', 'price': '10.95' }, { 'title': 'Learn AngularJS in 24 Hours', 'price': '29.00' }, { 'title': 'Learn AngularJS in 24 Hours, 2nd Edition', 'price': '9.95' } ]; var books = this.books; $scope.books = books; #+end_src Typically, this data would be fetched from a server using a REST API. In this tutorial, we're simplifying things and providing all the data to the controller that we want to work with. To make unit testing easier, we add the mock data as a property of the controller object. A reference to the mock data is stored so that there are no binding issues when using the =this= keyword (A List Apart has a good article on this, [[http://alistapart.com/article/getoutbindingsituations]["Getting Out of Binding Situations in JavaScript"]]). *** Sorting and Searching the Books The list of books can be sorted by the title or the price. Books that include =booksTitleContains= in their title will be visible, we're going to use an empty string or null to specify whether to search the books by title or to display all books in the list. #+name: helloworld-controller-books-search/sort-scope #+begin_src javascript $scope.booksSortedBy = 'title-ascending'; $scope.booksTitleContains = ''; #+end_src Then we have to sort and search the list of books so that we know what to display: #+name: helloworld-controller-books-search/sort-function #+begin_src javascript var searchAndSortBooks = function () { var i; var searchTitleRegExp; var result = []; // Searching for titles containing the search string if ($scope.booksTitleContains && $scope.booksTitleContains != '') { searchTitleRegExp = new RegExp($scope.booksTitleContains, 'i'); for (i = 0; i < books.length; i++) { if (searchTitleRegExp.test(books[i].title)) { result.push(books[i]); } } } else { result = books; } // Sorting the books if ($scope.booksSortedBy.match(/title/)) { $scope.books.sort(function (a, b) { if (a.title < b.title) { return -1; } else if (a.title > b.title) { return 1; } return 0; }); } else if ($scope.booksSortedBy.match(/price/)) { $scope.books.sort(function (a, b) { if (parseFloat(a.price) < parseFloat(b.price)) { return -1; } else if (parseFloat(a.price) > parseFloat(b.price)) { return 1; } return 0; }); } if ($scope.booksSortedBy.match(/descending/)) { $scope.books.reverse(); } $scope.books = result; }; searchAndSortBooks(); #+end_src When either the =booksSortedBy= or =booksTitleContains= scope variables change, we trigger the function and update the list of books that are displayed. To do that, we use the =$watch= method in the =$scope= object. It will watch for changes in the given expression and execute whatever callback function we provide. #+name: helloworld-controller-books-search/sort-watch #+begin_src javascript $scope.$watch( 'booksSortedBy + "," + booksTitleContains', function (newValue, oldValue) { searchAndSortBooks(); } ); #+end_src Initially, the expression that is watched will look like this: #+begin_src javascript title-ascending, #+end_src When we start entering a title to search for, the expression being watched will change: #+begin_src javascript title-ascending,simple title #+end_src When we change how we sort the books, the expression again will change: #+begin_src javascript price-descending,my favourite book #+end_src Whenever that expression changes, the callback function that we provided will be executed. *** Putting it all together We create the controller as part of the =helloworldApp= module. We specify the =$scope= as a dependency and then we pass in a function that defines the controller. #+name: helloworld-controllers #+begin_src javascript :tangle js/helloworld/controllers.js :noweb yes :padline no angular.module('helloworldApp').controller( 'HelloworldCtrl', // name of the controller [ '$scope', // DI (Dependency Injection) modules function ($scope) { // definition of the controller <> <> <> <> } ] ); #+end_src ** Using the Controller Once we have defined the controller, we can use it in our app. The =HelloworldCtrl= is our top-level controller. #+name: helloworld-controller-div-html #+begin_src html
#+end_src *** Displaying the books We're going to display the list of books in a table using the Angular's [[http://docs.angularjs.org/api/ng.directive:ngRepeat][ng-repeat]] directive which will loop through each book. We can use the [[http://docs.angularjs.org/api/ng.directive:ngBind][ng-bind]] directive to display the book's title or price, or we can use an [[http://docs.angularjs.org/guide/expression][Angular expression]]. We'll use both, =ng-bind= for the title and an expression for the price. The price will be formatted using the [[http://docs.angularjs.org/api/ng.filter:currency][currency filter]]. #+name: helloworld-controller-books-list-html #+begin_src html
Title Price
{{ book.price | currency }}
#+end_src *** Sorting the books To sort the books, there will be a drop down menu. It contains all possible sorting options. When one of these options is selected, the variable =booksSortedBy= in the =HelloworldCtrl= controller's scope will be updated to whatever the value of the option is. #+name: helloworld-controller-books-sort-html #+begin_src html

Sort by:

#+end_src When you select "Price: High to Low", the value of =$scope.booksSortedBy= is "price-descending". *** Searching the books When we enter text into the search box, the =booksTitleContains= variable in the scope will be set to whatever value we entered. Since we're watching the value, the search will automatically be executed. The clear button makes it faster to stop the search and to display all books. #+name: helloworld-controller-books-search-html #+begin_src html

#+end_src *** Putting it all together Putting it all together we get: #+name: helloworld-controller-html #+begin_src html <>

Hello World!

Books

<> <> <>
#+end_src ** Unit Testing the Controller There are a few moving parts in our controller; we can sort the list of books, and we can search for them by title. We need to test both of these to make sure that they work and we can do this manually, by loading up the page and clicking on and off different buttons and visually confirming that the list of books is correct. This can quickly become tedious when you start adding many more controllers. So we want to automate the testing by writing some unit tests. *** Setup Before Each Test Before each test we want to setup the controller: #+name: helloworld-controller-unit-test-setup #+begin_src javascript var scope; var ctrl; beforeEach(module('helloworldApp')); beforeEach(inject(function ($rootScope, $controller) { scope = $rootScope.$new(); ctrl = $controller('HelloworldCtrl', { $scope: scope }); })); #+end_src The =module= and =inject= functions are defined at the global level and within the =angular= object. The [[http://docs.angularjs.org/api/angular.mock.module][module]] function gets the module prepared for testing. The [[http://docs.angularjs.org/api/angular.mock.inject][inject]] function injects dependencies which provide the functionality required, it allows mock objects to be used when testing. The =$rootScope= dependency provides the function to define a new scope for the controller. The =$controller= dependency provides a way to look up and create an instance of a controller. *** Test to Make Sure the Controller Exists Here's how we test to make sure that the controller, =HelloworldCtrl=, exists in the module, =helloworldApp=: #+name: helloworld-controller-unit-test-existence #+begin_src javascript it('should have a HelloworldCtrl controller', function () { expect(ctrl).not.toBe(null); }); #+end_src *** Tests for Book Sorting Here's how we test the book sorting. #+name: helloworld-controller-unit-test-sorting #+begin_src javascript it('sorts books by title in ascending order', function () { var i; scope.booksSortedBy = 'title-ascending'; scope.$apply(); for (i = 0; i < scope.books.length - 1; i++) { expect(scope.books[i].title).toBeLessThan(scope.books[i + 1].title); } }); it('sorts books by title in descending order', function () { var i; scope.booksSortedBy = 'title-descending'; scope.$apply(); for (i = 0; i < scope.books.length - 1; i++) { expect(scope.books[i].title).toBeGreaterThan(scope.books[i + 1].title); } }); it('sorts books by price in ascending order', function () { var i; scope.booksSortedBy = 'price-ascending'; scope.$apply(); for (i = 0; i < scope.books.length - 1; i++) { expect(parseFloat(scope.books[i].price)).toBeLessThan(parseFloat(scope.books[i + 1].price)); } }); it('sorts books by price in descending order', function () { var i; scope.booksSortedBy = 'price-descending'; scope.$apply(); for (i = 0; i < scope.books.length - 1; i++) { expect(parseFloat(scope.books[i].price)).toBeGreaterThan(parseFloat(scope.books[i + 1].price)); } }); #+end_src Note that after changing a scope variable, we have to call [[http://docs.angularjs.org/api/ng.$rootScope.Scope#$apply][the $apply method]]. This is part of the Angular life cycle and it handles exceptions and then executes any =$watch= expressions that we have setup. *** Tests for Book Searching Here's how we test book searching: #+name: helloworld-controller-unit-test-searching #+begin_src javascript it('lists all books when searching for null or an empty string', function () { scope.booksTitleContains = ''; scope.$apply(); expect(scope.books.length).toBe(ctrl.books.length); scope.booksTitleContains = null; scope.$apply(); expect(scope.books.length).toBe(ctrl.books.length); }); it('lists only books that contain the search string in their title', function () { scope.booksTitleContains = 'Web'; scope.$apply(); expect(scope.books.length).toBe(1); expect(scope.books[0].title).toMatch('Web'); scope.booksTitleContains = 'AngularJS'; scope.$apply(); expect(scope.books.length).toBe(ctrl.books.length); expect(scope.books[1].title).toMatch('AngularJS'); scope.booksTitleContains = 'Learn AngularJS'; scope.$apply(); expect(scope.books.length).toBe(2); expect(scope.books[0].title).toMatch('Learn AngularJS'); }); #+end_src Note that again, after changing the $scope variable, we have to call $apply to go through the Angular life cycle. *** Putting it all together #+name: helloworld-controller-unit-tests #+begin_src javascript :tangle tests/helloworld.controllers.js :noweb yes :padline no describe('Hello World Controller', function () { <> <> <> <> }); #+end_src ** Defining a Directive ** Unit Testing the Directive ** End to End Testing * Putting it all together #+name: helloworld-html #+begin_src html :tangle helloworld.html :noweb yes :padline no <> Hello Angular.JS <> <> <> #+end_src