Bradley Braithwaite
1 import {
2 Learning,
3 JavaScript,
4 AngularJS
5 } from 'Brad on Code.com'
6 |
Check out my online course: AngularJS Unit Testing in-depth with ngMock.

Refactoring a JavaScript Single Page App - Part 3 - Using AngularJS

Part 3 of a JavaScript refactoring tutorial. Integrating AngularJS.

08 May 2015 Free Software Tutorials by Bradley Braithwaite

This tutorial follows on from part 2 where we began using AngularJS.

Here we wrap up the migration. There are three main groups of functionality left to migrate.

  1. Movie control - displaying the movie detail and plotting position markers on the map.
  2. Location control - what happens when clicking on a position marker.
  3. Rendering the Google Map - initialising the Google Map API.

1. Movie Control

To recap, here’s what we had for the movie control: movie-control.js.

The code migrated into an angular controller is below (inline comments are included to detail main points):

'use strict';

angular.module('sfMovies').controller('movieController', ['$rootScope', '$scope', '$http', 'mapService', 'omdbService', 
    function($rootScope, $scope, $http, mapService, omdbService) {
  
  // this listens to the event triggered by the search control in the last tutorial
  $rootScope.$on('movieSelected', function(event, movie) {

  	// Set properties of the map service singleton instance
    mapService.setOptions({ streetViewControl: false, zoomControl: true });
    mapService.clearMarkers();

    // sets the selected movie on the scope object 
    $scope.movie = movie;
    // used by the view to show the movie detail template
    $scope.showMovie = true;

    // gets addtional movie content from the omdb service
    omdbService.getContent(movie)
      .then(function(content) {
        angular.extend($scope.movie, content);
      }, function() {
        console.log('error handler logic here...');
      });

    // makes an http request to get the geo locations for the selected movie
    $http.get('/movies/locations?title=' +
        encodeURIComponent(movie.title) + 
        '&director=' + 
        encodeURIComponent(movie.director)).
      success(function(data) {
        data.locations.forEach(function(l) {
          mapService.plotLocation(l.geo.lat, l.geo.lng, function() {
          	// upon position marker click, raise event and pass location geo detail
            $rootScope.$emit('locationSelected', l);
          });
        });
      }).
      error(function(data) {
        console.log(data);
      });
      
  });
}]);

The injected dependencies $rootScope and $scope were discussed in the last tutorial. In this controller we have some new dependencies:

  • $http
  • mapService
  • omdbService

$http is an angular service (note the $ prefix naming convention to determine default angular services) that provides the functionality for making http requests. mapService and omdbService are more interesting, since they are custom services that are part of the application.

In part 1 we created a map service abstraction, this step takes that code and wraps in the in appropriate code to make it into an angular service. Remembering that angular services are singletons, we can use the map service in any of our controllers and the map state will be self-contained with the map service.

The shell for creating an angular service is:

'use strict';

angular.module('sfMovies').service('mapService', function() {

	return {
		// logic removed for brevity.
	}

});

The omdbService is a wrapper for the omdb-client library that allows this library to be consumed as an angular service across the application. You can read a tutorial about the OMDB Client library that was created to run both server and client side.

How is the Movie Data HTML displayed?

The HTML for the movie detail has been moved to the index.html. In the controller we saw that the movie detail was set on the scope object: $scope.movie = movie;. The HTML that is for the movie data is as follows:

<div class="movie_detail" ng-show="showMovie" ng-cloak>
    <img ng-src="" alt="poster" align="right" width="80" height="119" ng-show="movie.poster" />
    <h2>{{ movie.title }}</h2>
    <em>{{ movie.releaseYear }}</em>, Director: <em>{{ movie.director }}</em>
    <br><br>
    Staring: {{ movie.actors }}
    <br><br>
    Rating: {{ movie.rating }}
    <br><br>
    Genre: {{ movie.genre }}
    <br><br>
    Plot: {{ movie.plot }}
</div>

The key features of this HTML fragment are:

  • The usage of angular expressions in the {{ braces }} to be replaced with model values.
  • The use of ng-show allows the container div to be visible/not visible based on boolean property on the scope object called showMovie.
  • ng-cloak is used to prevent a flicker where the template is visible for the initial page load. If you use ng-cloak, don’t forget to add this to the CSS style.

2. Location Control

To recap, here’s what we had for the location control: location-control.js.

The equivalent angular controller code looks as follows:

'use strict';

angular.module('sfMovies').controller('locationController', ['$rootScope', '$scope', 'mapService', 
    function($rootScope, $scope, mapService) {

  // listens to click event of a position marker
  $rootScope.$on('locationSelected', function(event, location) {
    mapService.zoomView(location.geo.lat, location.geo.lng);

    // the apply method updates the view/html template with the updated scope detail
    $scope.$apply(function() {
      $scope.location = location;
      $scope.$parent.showMovie = false;
      $scope.showLocation = true;
    });

  });

  // click event for the 'back to movie detail view'
  $scope.backToMovie = function() {
    mapService.reset();
    $scope.$parent.showMovie = true;
    $scope.showLocation = false;
  };

  // click event for the 'satelite view' button
  $scope.sateliteView = function() {
    mapService.sateliteView($scope.location.geo.lat, $scope.location.geo.lng);
    $scope.isSateliteView = true;
  };

  // click event for the 'road map view' button
  $scope.roadmapView = function() {
    mapService.roadmapView($scope.location.geo.lat, $scope.location.geo.lng);
    $scope.isSateliteView = false;
  };

  // click event for the 'street view' button
  $scope.streetView = function() {
    mapService.streetView($scope.location.geo.lat, $scope.location.geo.lng);
  };
  
}]);

This builds upon points previously detailed such as listening to events and setting properties on the scope object, however this controller in particular addresses click event code that we encountered in the older. For example, we had code such as:

sateliteViewButton.addEventListener('click', function() {

  if (this.value === 'Satelite View') {
    window.map.sateliteView(location.geo.lat, location.geo.lng);
    this.value = 'Back to Roadmap';
  } else {
    window.map.roadmapView(location.geo.lat, location.geo.lng);
    this.value = 'Satelite View';
  }
  
});

The angular way of handling click events is to declare functions on the scope object, for example:

$scope.streetView = function() {
  mapService.streetView($scope.location.geo.lat, $scope.location.geo.lng);
};

We also need to add attributes to the template to bind the HTML elements to the intended function on the scope object. Here is the HTML/view for this controller:

<div class="location_detail" ng-show="showLocation" ng-controller="locationController" ng-cloak>
  <h2>{{ location.location }}</h2>
  <img src="https://maps.googleapis.com/maps/api/streetview?size=120x120&location=," align="right">
  <input type="button" value="Satelite View" ng-show="!isSateliteView" ng-click="sateliteView()">
  <input type="button" value="Roadmap View" ng-show="isSateliteView" ng-click="roadmapView()">
  <input type="button" value="Street View" ng-click="streetView()">
  <input type="button" value="Back to Movie" ng-click="backToMovie()">
</div>

The key point of this HTML example is the use of the ngClick directive e.g. ng-click=”streetView().

3. Rendering the Google Map

The final piece of functionality that holds the app together is the integration of the Google Map.

The previous approach

To recap what we had, the container div below was used by the JavaScript code to initialise the map and add div elements e.g. for the search control to the map:

<div id="map-canvas"></div>

The older code had JavaScript such as the following, where dynamically created DIV elements were added to the map instance in the designated position:

var mapDiv = document.getElementById('map-canvas');
map = new window.MapService(window.google, mapDiv);

new window.SearchControl(searchControlDiv);
map.addView(searchControlDiv, 'TOP_LEFT');

The Angular approach

The following HTML from the index.html of the angular app replaces the previous DIV element and associated JavaScript code to load and add controls to the Google Map:

<google-map>
	<div class="top" data-position="TOP_LEFT">
	  <boc-autocomplete data-url="/movies/search" data-stext="title" data-svalue="releaseYear" data-sparam="q" placeholder="Enter Film Title..." ng-controller="searchController"></boc-autocomplete>
	</div>
</google-map>

The key feature of this approach is the HTML allows us to declaratively add a specific google-map element, with the controls to be added to the map also contained within the element itself via HTML using custom attributes to specify the intended map position (TOP_LEFT). This builds upon the use of custom directives discussed in the previous tutorial.

As in the search control example in the last tutorial, we need to add JavaScript code to create the google-map custom directive:

angular.module('sfMovies').directive('googleMap', ['googleMapLoader', 'mapService', 
  function(googleMapLoader, mapService) {
  return {
    replace: true,
    restrict: 'E',
    link: function($scope, $element) {
      var mapControls = [];
      var children = $element.children();
      for (var i = 0; i < children.length; i++) {
        mapControls.push(children[i]);
      }
      
      googleMapLoader.then(function(maps) {       
        $element.html('<div id="map-canvas"></div>');
        mapService.initialize(maps, $element[0].children[0]);
        mapControls.forEach(function(c) {
          mapService.addControl(c, c.getAttribute('data-position'));
        });
      });
    }
  };
}]);

Taken from google-map.js of the angular app.

The googleMapLoader dependency used by the directive is a service that asynchronously loads the Google Maps API, which is the recommended approach as per the Google Maps API Docs. Here is the code:

'use strict';

angular.module('sfMovies').service('googleMapLoader', ['$window', '$document', '$q', function($window, $document, $q) {
  var isLoaded = function() {
    return angular.isDefined($window.google) && 
      angular.isDefined($window.google.maps);
  };

  var deferred = $q.defer();

  var addGoogleScript = function() {
    var tempInitFunctionName = 'initialize' + Math.round(Math.random() * 1000);

    $window[tempInitFunctionName] = function() {
      $window[tempInitFunctionName] = null;
      deferred.resolve(window.google.maps);
    };

    var script = $document[0].createElement('script');
    script.type = 'text/javascript';
    script.src = 'https://maps.googleapis.com/maps/api/js?callback=' + 
      tempInitFunctionName;
    return $document[0].body.appendChild(script);
  };

  if (isLoaded()) {
    deferred.resolve(window.google.maps);
  } else {
    addGoogleScript();
  }

  return deferred.promise;
}]);

Key points of this service:

  • $window - is an angular wrapper for the window object for the browser. This is the best practice for using the window object.
  • $document - is an angular wrapper for the document object for the browser. This is the best practice for using the document object.
  • $q - is the built-in angular service for using promises. A promise adds the .then() api when working with asynchronous code.

Taken from google-map-loader.js of the angular app.

The use of the googleMap directive and googleMapLoader service makes the <google-map> element self-contained. The HTML element can be added to the page and it load the necessary 3rd party JavaScript and initialise itself. It means that we also no longer need the JavaScript include we had before i.e. we can remove this script include:

 <script type="text/javascript" src="https://maps.googleapis.com/maps/api/js?sensor=false"></script>

Conclusion

As in the previous tutorial, some complexities are introduced with this approach. However, what we gain in exchange is:

  • Declarative HTML allowing complex controls to be expressed simply via HTML.
  • Controller logic with clear separation of concerns.
  • Modular code that offers easy code re-use.
  • Clear design pattern and structure.
  • Testable code.

We haven’t covered testing in this tutorial, but as angular has been designed to be easily testable, tests could be added to this code base without having to make further changes to the code base to support tests. For all the controllers such as the following code snippet, the injection of services such as $scope, $window, mapService etc make these dependencies easy to mock for tests.

'use strict';

angular.module('sfMovies').controller('exampleController', ['$scope', '$window', 'mapService', 
    function($scope, $window, mapService) {

    // logic removed for brevity
  
}]);

You can read more about getting started with unit testing in angular via my post Getting started with Unit Testing for AngularJS. You can expect more tutorials about Unit Testing with AngularJS in the future, so be sure to subscribe to the RSS feed as to not miss out.

See the source code for the Angular branch.

SHARE
Don't miss out on the free technical content:

Subscribe to Updates

CONNECT WITH BRADLEY

Bradley Braithwaite Software Blog Bradley Braithwaite is a software engineer who works for search engine start-ups. He is a published author at pluralsight.com. He writes about software development practices, JavaScript, AngularJS and Node.js via his website . Find out more about Brad. Find him via: