This tutorial follows on from part 1 where original the code was organised into a more manageable state.
The aim of this tutorial is to see how using AngularJS for this app can add some structure to the less than ideal parts remaining after the first round of refactoring. If you’ve never used AngularJS before, it doesn’t matter so much since we are concerned mainly about the fundamental principles rather than specific syntax.
This part addresses the remaining problems with the code base:
- Everything on the global window object - cluttering window.* with functions.
- Lack of clearly defined structure - if we are to add new functionality, what are the rules concerning where we should put it?
- Code to dynamically create HTML elements (document.createElement etc) still in some places.
Using AngularJS
The next step is to migrate the last set of re-factoring into the AngularJS framework addressing how it could be used to address the the problems previously stated.
To get started, we need to add AngularJS to project via bower:
bower install angular --save
Then add the include to index.html:
<script type="text/javascript" src="/bower_components/angular/angular.js"></script>
To breakdown the improvements, let’s take the existing SearchControl as an example. To recap, here’s the code:
window.SearchControl = function(controlDiv) {
var movieClicked = function(item) {
window.showMovieDetail(item);
};
var searchInput = document.createElement('input');
searchInput.type = 'search';
searchInput.name = 'q';
searchInput.id = 'q';
searchInput.placeholder = 'Enter film title...';
controlDiv.appendChild(searchInput);
new window.Autocomplete(searchInput, {
url: '/movies/search',
param: 'q',
label: 'title',
value: 'releaseYear',
select: function(item) {
movieClicked(item);
}
});
var resultsUI = document.createElement('div');
resultsUI.id = 'films_results';
controlDiv.appendChild(resultsUI);
};
The main responsibilities of the SearchControl example we are using, are:
- Initialising the <input type=”search”> control and attaching the autocomplete behaviour.
- Creating and adding the necessary containing DIV elements to the control.
- Handling the event when a user clicks a movie, and calling the set movie functionality.
1. Everything on the global window object
The function SearchControl itself is on the global window object as well as other functions it calls (window.showMovieDetail(…)). Why is this bad? It’s not a major problem for a small app like this, but as an application grows, if everything is placed at the root object it would be un-organised and possibly lead to naming collisions. If you are a C# or Java dev, imagine a large set of class libraries without namespaces!
An angular “module” is a way of grouping things that make up an app. It’s often described as being a bit like namespacing in C# or Java, although that’s not quite accurate as naming conflicts are not avoided between objects if they are in different modules. For this app, we will define a new module called ‘sfMovies’:
angular.module('sfMovies', []);
This allows us to attach other components for the app to the sfMovies module and not the global window object, which keeps everything better contained.
We then add a second line, to load our angular module once the DOM is ready:
angular.element(document).ready(function() {
angular.bootstrap(document, ['sfMovies']);
});
2. Lack of clearly defined structure
AngularJS encourages the MVC design pattern by convention. In fact it describes itself as being a MV* framework, where the * represents ‘whatever’, but we will stick with MVC for now to keep things simple.
The main components when using this approach are:
- Controllers - for coordinating user interactions with the business logic,
- Model - represents the data shared between the view and the controller.
- View - the view with which the user interacts.
- Services - self contained business logic.
Using this approach means that the code we had previously in search-control.js, home-control.js, location-control.js etc would be moved into an angular controller.
Using our SearchControl example, the first thing we need to do is create a new angular controller. The controller is where we place the code that wires up user interactions with the business logic:
angular.module('sfMovies').controller('searchController', function() {
// logic to go here...
});
Note that we add this controller to the module called ‘sfMovies’, which we talked about in the previous step.
The map service we refactored in the last tutorial is an ideal candidate for being moved into an angular service as the name suggests.
The structure to create a service is as follows:
'use strict';
angular.module('sfMovies').service('mapService', function() {
return {
// business logic goes here
}
});
See the full code of the map service as an angular service.
The idea is that specific business logic will be contained in these service functions, with unique names, which can then be used by other parts of the application by referencing them by name.
We could update our controller to use the map service as follows:
~~~ javascript
angular.module('sfMovies').controller('searchController', ['mapService', function(mapService) {
// logic to go here...
mapService.someFunction();
}]);
The service is passed to the controller as a function argument, and angular will inject the service instance based on the name since angular has dependency injection built-in
3. Code to dynamically create HTML elements is sprinkled around code
In part 1, we made an improvement to how we were managing HTML fragments by centralising the markup into the templates.js file. But the side effect is the code such as the following, to append the controls/html.
Now we have to make some fundamental changes to conform to the angular way of doing things. Be warned that there is some complexity ahead, I encourage you to stick with it in order to get a feel for the fundamental angular concepts rather than taking deep technical dives and getting lost in the technical terms.
Let’s start with this block of code from the SearchControl:
var searchInput = document.createElement('input');
searchInput.type = 'search';
searchInput.name = 'q';
searchInput.id = 'q';
searchInput.placeholder = 'Enter film title...';
controlDiv.appendChild(searchInput);
new window.Autocomplete(searchInput, {
url: '/movies/search',
param: 'q',
label: 'title',
value: 'releaseYear',
select: function(item) {
movieClicked(item);
}
});
Rather than creating controls via JavaScript in this way, the angular way is to extend the DOM with custom HTML syntax. These are called directives: Directives let you invent new HTML syntax, specific to your application.
Consider if anywhere an autocomplete control was needed, we could just add this to the HTML and it would just work?
<boc-autocomplete
data-url="/movies/search"
data-stext="title"
data-svalue="releaseYear"
data-sparam="q"
placeholder="Enter Film Title..."
ng-controller="searchController">
</boc-autocomplete>
With angular, you can do exactly this! We would need to write some directive code to enable this, so that the app knows what to do with the <boc-autocomplete> element. Here’s the code that will detect this element, replace the contents of the HTML and initialise the autocomplete library:
angular.module('sfMovies').directive('bocAutocomplete', function() {
// create a random id, to avoid collisions
var id = 'bocautocomplete' + Math.round(Math.random() * 1000);
return {
replace: true,
restrict: 'E',
template: '<input type="search" id="' + id + '" name="' + id + '">',
link: function(scope, element, attrs) {
new window.Autocomplete(element[0], {
url: attrs.url,
param: attrs.sparam,
label: attrs.stext,
value: attrs.svalue,
select: function(item) {
scope.selected(item);
}
});
}
};
});
Once this code is executed, the HTML element <boc-autocomplete> would be replaced with something similar to the below when the app starts up, and would function as the autocomplete control:
<input type="search" id="" name="" placeholder="Enter Film Title...">
A key point from the directive code is the following line of code that calls the function selected on the scope object, passing the item that has been selected via the autocomplete control (a movie in this case):
select: function(item) {
scope.selected(item);
}
What’s the scope object? The scope object is part of angular and acts as the glue between the view and the controller. In this example, the scope object is shared between the directive and the controller it’s linked to.
In the HTML, we had the attribute ng-controller=”searchController” which links this particular direction to the searchController we created earlier.
If we switch back to the controller we created, we can update it to handle this selected function call:
angular.module('sfMovies').controller('searchController', ['$scope',
function($scope) {
$scope.selected = function(movie) {
// do something with the selected movie...
};
}]);
We’ve added the scope object as a parameter that’s injected into our controller. This will be the same scope object used by the directive since we linked the controller by its name.
- The scope object name is prefixed with a $, as that is a naming convention to indicate objects that are part of angular.
To recap:
- We created a directive to convert our custom control into an HTML input field, initialised with the autocomplete functionality.
- We created a controller that is linked by its name to an instance of the directive.
- The scope object is part of the angular framework, and is used to link the directive and controller together so that we can share state and manage interactions.
This all sounds a little bit complex? I guess it is! But the trade off is making things re-usable via the custom HTML element. We could now re-use the directive code across our application. We can add the HTML element, create a matching controller and write our logic without worrying about the underlying plumbing. And if we wanted to change what autocomplete control we use? Simple, just update the directive code we wrote.
Example using multiple directives:
<boc-autocomplete
data-url="/movies/search"
data-stext="title"
data-svalue="releaseYear"
data-sparam="q"
placeholder="Enter Film Title..."
ng-controller="movieSearchController">
</boc-autocomplete>
<boc-autocomplete
data-url="/countries/search"
data-stext="countryName"
data-svalue="countryId"
data-sparam="name"
placeholder="Enter Country Name..."
ng-controller="countrySearchController">
</boc-autocomplete>
The corresponding controllers:
angular.module('sfMovies').controller('movieSearchController', ['$scope',
function($scope) {
$scope.selected = function(movie) {
// do something with the selected movie...
};
}]);
angular.module('sfMovies').controller('countrySearchController', ['$scope',
function($scope) {
$scope.selected = function(country) {
// do something with the selected country...
};
}]);
Continuing along with the search control we began with, the last part of this code we need to address is calling the functionality to display a selected movie, here’s the existing code:
var movieClicked = function(item) {
window.showMovieDetail(item);
};
The line in question is window.showMovieDetail(item);. This is not ideal, since as we said before, we don’t want to call things that are on the global window object. What we need to do is call a different controller that passes the selected movie.
An option is to raise an event when a movie is selected, that other controllers could also listen to and act upon. We can achieve this by using angular events:
'use strict';
angular.module('sfMovies').controller('searchController', ['$rootScope', '$scope',
function($rootScope, $scope) {
$scope.selected = function(movie) {
$rootScope.$emit('movieSelected', movie);
};
}]);
$rootScope is a global scope object that all controllers can access, rather than using the $scope object that is specific to this control which cannot be seen by other controllers (unless they are children of a controller). All controllers can listen to the movieSelected event. This gives us a mechanism of communicating with other controllers across the application without calling them directly via the window object.
A controller listening for this event would look as follows:
'use strict';
angular.module('sfMovies').controller('movieController', ['$rootScope', '$scope',
function($rootScope, $scope) {
$rootScope.$on('movieSelected', function(event, movie) {
// do something with the movie that was selected...
});
}]);
We’ve covered some complexity in working through this change. But we have been able to take the controller logic from this:
window.SearchControl = function(controlDiv) {
var movieClicked = function(item) {
window.showMovieDetail(item);
};
var searchInput = document.createElement('input');
searchInput.type = 'search';
searchInput.name = 'q';
searchInput.id = 'q';
searchInput.placeholder = 'Enter film title...';
controlDiv.appendChild(searchInput);
new window.Autocomplete(searchInput, {
url: '/movies/search',
param: 'q',
label: 'title',
value: 'releaseYear',
select: function(item) {
movieClicked(item);
}
});
var resultsUI = document.createElement('div');
resultsUI.id = 'films_results';
controlDiv.appendChild(resultsUI);
};
To this:
'use strict';
angular.module('sfMovies').controller('searchController', ['$rootScope', '$scope',
function($rootScope, $scope) {
$scope.selected = function(movie) {
$rootScope.$emit('movieSelected', movie);
};
}]);
There’s no doubt we have introduced some complexity to achieve this, but the trade of is that we end up with a controller simply focused on the logic to connect view interactions with businesss logic i.e. what should happen when a movie is selected from the drop down list.
In the next tutorial we will migrate more of the code to the angular way.
See the AngularJS documentation for more information if some of these terms are not so familiar (or ask me in the comments, via twitter etc).
Get the Source Code
See the source code for the Angular branch.
Part 3
See Part 3: Refactoring a JavaScript Single Page App - Part 3, AngularJS