« HE:labs
HE:labs

First steps on building a Single Page App with AngularJS

Postado por Fábio Rehm em 13/06/2014

I've been willing to try out AngularJS for a long time and on this post I'll talk about my journey building rubygems-charts, a simple Single Page Application that fetches RubyGems downloads count from the RubyGems API for each released version and displays that data on a chart.

For a sneak peek at what we're going to build, check out the live demo.

Why AngularJS?

I've done a lot of Backbone on the past and I remember having to write a lot of boilerplate code to make things work, being data binding a big chunk of it. I know that these days there are nice plugins for Backbone that solves this problem but I really wanted to learn something new. AngularJS comes with support for data binding out of the box and is actually one of its strengths, so I picked it as my next MV* JavaScript framework to learn.

Also, from its website:

HTML is great for declaring static documents, but it falters when we try to use it for declaring dynamic views in web-applications. AngularJS lets you extend HTML vocabulary for your application. The resulting environment is extraordinarily expressive, readable, and quick to develop.

A little bit about the framework

Angular is a complete client-side solution that "is not a single piece in the overall puzzle of building the client-side of a web application. It handles all of the DOM and AJAX glue code you once wrote by hand and puts it in a well-defined structure".

The framework is made out of many different "things" but here's a list of concepts that you need to be familiar with to better understand this post:

Concept Description
View What the user sees (the DOM).
Directive Markers on a DOM element (such as an attribute, element name, comment or CSS class) that tell AngularJS's HTML compiler to attach a specified behavior to that DOM element or transform the DOM element and its children.
Template HTML with additional markup that gets rendered into a view.
Controller The business logic behind views.
Service Reusable business logic independent of views.
Model The data shown to the user in the view and with which the user interacts.
Scope Context where the model is stored so that controllers, directives and expressions can access it.
Expression How you access variables and functions from the scope.
Dependency injection A software design pattern that deals with how components get hold of their dependencies.
Router A service that is used for deep-linking URLs to controllers and views. It's a module that is distributed separately from the core Angular framework.

To find out about other framework concepts please check the Developer Guide

Initial setup

First of all, make sure you have node.js and npm installed as they'll be needed to scaffold the application and to run the web server.

With node.js and npm in place, we'll need to install Yeoman and its angular generator so that we can scaffold our app:

1 npm install yo generator-angular -g

Generate the application

To scaffold the app, all we need to do is create the new project folder, cd into it and run yo angular. In other words:

1 mkdir rubygems-charts && cd rubygems-charts
2 yo angular

The generator will ask you a couple questions to scaffold the app and I recommend that you answer "No" to use Sass and Compass when asked, unless you know what they are and are willing to use them. Also, please make sure you keep the angular-route module option selected when asked which AngularJS modules you'd like to include since we'll be using it on rubygems-charts.

Once the bootstrapping process is done, we should be able to fire up a web server with grunt serve and visit http://localhost:9000 to see a "Hello world" page like the one below:

image

Fetching RubyGems API data and displaying it to the user

Instead of building the chart right away, we'll start by reading the Gem name using a form and render the downloads stats data in a <ul> to make things simpler so we can better understand how the pieces fit together.

Preparing our view to render the data

Given that we already have a list of downloads stats for each released version of a given RubyGem on a controller's scope (we'll get there in a few ;), we can change the template at app/views/main.html to something like this:

1 <ul>
2   <li ng-repeat="version in rubyGemVersions">
3     {{version.number}} was downloaded {{version.downloads_count}} times
4   </li>
5 </ul>

When rendering that template, the ng-repeat directive will perform a loop over the rubyGemVersions model available on our controller scope and will assign each element of the array into the stats variable within the ng-repeat "block". The {{<EXPRESSION>}} is how we tell Angular to render some attribute of our version JavaScript object into the HTML.

If you try refreshing the page with the code above, you'll notice that nothing gets displayed since we haven't set the rubyGemVersions variable anywhere. Before getting to loading the data from the RubyGems API, let's first make sure our template works with some fake data. So open up your app/scripts/controllers/main.js and change it to:

1 angular.module('rubygemsChartsApp')
2   .controller('MainCtrl', function ($scope) {
3     $scope.rubyGemVersions = [
4       { number: "1.0.0", downloads_count: 123 },
5       { number: "2.0.0", downloads_count: 456 }
6     ]
7   });

In case you are wondering where does that $scope variable comes from, it's AngularJS that "injects" it into our controller when building it. The framework is smart enough to read the variable name that we used on the function signature and do its "magic" to prepare the arguments required for it. For more information about how that works, please have a look at AngularJS docs for dependency injection.

The result of that is something that should look like this:

image

Hooking up the controller with real data from the RubyGems API

Now that we know how to connect our controller with the view, let's move on to loading the data from the real RubyGems API into our list.

The data that we want to use comes from the Gem versions endpoint of the RubyGems API but unfortunately we can't access it directly from the browser because of the same origin policy. Luckily, there is already a service built by @i2bskn available at http://rubygems-jsonp.herokuapp.com that wraps the RubyGems API for JSONP so we can work around that.

To abstract our API calls from the controller, we'll create a custom service that will fetch our data and will act as a wrapper around AngularJS' built-in $http service that is used for communication with remote HTTP servers. To make things easier, we'll use Yeoman's angular generator to create the service with yo angular:service RubyGemsApi, then we'll change the app/scripts/services/rubygemsapi.js file to:

 1 angular.module('rubygemsChartsApp')
 2   .factory('RubyGemsApi', function($http) {
 3     var rgApi = {};
 4 
 5     rgApi.fetchDownloadStats = function(gemName) {
 6       var url = 'http://rubygems-jsonp.herokuapp.com/versions/' + gemName + '?callback=JSON_CALLBACK';
 7       return $http({
 8         method: 'JSONP',
 9         url:    url
10       });
11     };
12 
13     return rgApi;
14   });

With the first two lines, we get hold of our app's module and register our service (RubyGemsApi) into it. Notice that we pass $http as a parameter to the service constructor. As with the $scope argument provided to the controller above, this tells Angular that our service depends on the $http service so that we don't have to worry about creating it ourselves.

To make use of RubyGemsApi on our controller, it's just a matter of adding the service as a parameter to the controller initializer function and call the fetchDownloadStats method with a gem name:

1 angular.module('rubygemsChartsApp')
2   .controller('MainCtrl', function ($scope, RubyGemsApi) {
3     $scope.rubyGemVersions = [];
4     RubyGemsApi.fetchDownloadStats('letter_opener').success(function(response) {
5       $scope.rubyGemVersions = response.data;
6     });
7   });

Now if you reload the page you'll see the numbers for the letter_opener gem coming directly from the API.

image

Reading user input from a form

Loading the stats for a single gem is not very exciting, let's create a form to ask the user which gem he / she will like to see the stats for.

First, we'll add a form on top of the template at app/views/main.html:

1 <form ng-submit="loadGemStats()">
2   <input type="text" ng-model="gemName">
3 </form>

And we'll change the controller to read the value from the form's textbox when it gets submitted:

1 angular.module('rubygemsChartsApp')
2   .controller('MainCtrl', function ($scope, RubyGemsApi) {
3     $scope.rubyGemVersions = [];
4     $scope.loadGemStats = function() {
5       RubyGemsApi.fetchDownloadStats($scope.gemName).success(function(response) {
6         $scope.rubyGemVersions = response.data;
7       });
8     }
9   });

Notice that even though we don't declare the gemName variable within our $scope, it gets "automagically" set by AngularJS because of the ng-model directive. Now go back to the browser and try using the form, if all goes well you should see a list of download stats under the form we've created after filling in the input and hitting enter.

Plotting user provided information on a chart

Cool, we are able to read the gem name from a form, load the data from the API. But what about the chart? To make things easier, we'll use an Angular plugin called angular-google-chart that provides a google-chart directive we can use in our apps so that we don't have to reinvent the wheel.

Installing and configuring angular-google-chart

I haven't mentioned it yet, but the application we are working on is using Bower to manage our front-end dependencies. Bower is a package manager that helps you find and install your application dependencies (like CSS frameworks and JS libraries) without the need to manually download and update them. Bower should have been installed alongside the application bootstrap, but if it is not, you can run npm install bower -g.

With Bower in place, we'll run bower install angular-google-chart --save to install the package and we'll update the app/index.html file to include its JS file. Search for a bower:js HTML comment "block" within that file and add a reference to the angular-googler-chart JS file:

1 <!-- bower:js -->
2 <!-- ... other bower references here ... -->
3 <script src="bower_components/angular-google-chart/ng-google-chart.js"></script>
4 <!-- endbower -->

This will load the JS for the component, but we also need to tell Angular that our app depends on it before using it. For that, we need to change our app's module definition at app/scripts/app.js to load the googlechart module alongside other dependencies.

On app/scripts/app.js, we'll find some code like this:

1 angular
2   .module('rubygemsChartsApp', [
3     // ... other dependencies ...
4     'ngRoute'
5   ])

That will vary depending on which services you've selected when scaffolding the app with Yeoman, but to load the googlechart module, it's a matter of adding a new string to that array and we should be good to go:

1 angular
2   .module('rubygemsChartsApp', [
3     // ... other dependencies ...
4     'ngRoute',
5     'googlechart'
6   ])

Building the chart with RubyGems data

Given that the Google Chart module is properly set up, we can now replace our ugly <ul> with a nice looking chart, so open up app/views/main.html and change the unordered list to:

1 <div google-chart chart="rubyGemVersionsChart"></div>

Change the controller's code to build the proper data for the chart:

 1 angular.module('rubygemsChartsApp')
 2   .controller('MainCtrl', function ($scope, RubyGemsApi) {
 3     $scope.loadGemStats = function() {
 4       RubyGemsApi.fetchDownloadStats($scope.gemName).success(function(response) {
 5         var rows = [];
 6         for (var i = 0; i < response.data.length; i++) {
 7           var version = response.data[i];
 8           rows.unshift({
 9             c: [ { v: version.number }, { v: version.downloads_count } ]
10           });
11         }
12 
13         $scope.rubyGemVersionsChart = {
14           type: 'LineChart',
15           data: {
16             cols: [
17               {id: "t", label: "Versions",  type: "string"},
18               {id: "s", label: "Downloads", type: "number"}
19             ],
20             rows: rows
21           }
22         };
23       });
24     }
25   });

And voilà:

image

Sharing the chart with others

At this point we are able to display the data on the chart, but what if we wanted to tweet about it? Right now there is no way we can do that because in order to build the chart we need to use the form and the gem name is not embedded on the URL. To fix that we are going to make use of AngularJS's Router.

Setting things up is pretty easy, first we'll have to add a new route at app/scripts/app.js:

 1 angular
 2   .module('rubygemsChartsApp', [
 3     // ...
 4   ])
 5   .config(function ($routeProvider) {
 6     $routeProvider
 7       // This is the new route
 8       .when('/:gemName', {
 9         templateUrl: 'views/main.html',
10         controller: 'MainCtrl'
11       })
12       // ... other routes go here
13   });

With that in place we'll have a variable gemName set on the $routeParams service and to trigger a redirect, we'll use the $location service. Thanks to AngularJS' magic, changing the controller to work with those services is pretty easy and is commented out on the snippet below:

 1 angular.module('rubygemsChartsApp')
 2   // Here we added the $location and $routeParams services
 3   .controller('MainCtrl', function ($scope, $location, $routeParams, RubyGemsApi) {
 4     // Here we renamed the method from loadGemStats to buildChart
 5     $scope.buildChart = function() {
 6       // ...same code as before to build the chart...
 7     };
 8     // Trigger a redirect when the form is submitted
 9     $scope.loadGemStats = function() {
10       $location.path($scope.gemName);
11     };
12     // Set the form input value
13     $scope.gemName = $routeParams.gemName;
14     // Load the chart if a gemName is set
15     if ($scope.gemName) {
16       $scope.buildChart($scope.gemName);
17     }
18   });

Now when you visit http://localhost:9000/#/rails, you'll see that the chart is built right away without the need to submit the form!

Deploy

Now that the app is "done", it's time to go live and deploy it somewhere.

The application is basically a set of HTML, CSS and JavaScript files that are generated using grunt build, when you run that command, everything gets compiled and dropped on the dist folder of your project. Hosting them on an Apache or nginx server is a matter of uploading the files to the appropriate directory. Deploying to GitHub Pages is as simple as creating a gh-pages branch on your git repository and committing + pushing the files generated over there.

I chose to deploy it to Heroku but it required a few changes to the app so that I can let it do the compilation and I don't have to worry about committing generated files into source control.

Building the app after a git push on Heroku

First thing we'll do is customize the build process in order to install bower dependencies and compile the application assets. Looking at Heroku's documentation for node.js apps, I found out that we can tell it to run some commands after the default npm install with the postinstall config of the scripts section of the package.json file:

1 {
2   "name": "rubygemscharts",
3   // ... other configs here ...
4   "scripts": {
5     "test": "grunt test",
6     // This will tell heroku to install our Bower dependencies and build the app
7     "postinstall": "bower install && grunt build"
8   }
9 }

Another thing we'll need to do is to npm install grunt-cli bower --save and change the package.json "dependencies" config to include Grunt related packages as they are currently set to development only and will have their installation skipped on Heroku by default because of the --production flag that gets provided to npm install.

Serving static files

We are almost ready to push the app to Heroku, but since it does not know how to serve the static files out of the box, we'll use node-static to run a simple static file server.

To set things up, run npm install node-static --save and add static -p $PORT dist to the "start" config of your "scripts" section of the package.json file.

The final package.json

The versions you'll end up having on your package.json will vary, but this is how your package.json should look like:

{
  "name": "rubygemscharts",
  "version": "0.0.0",
  "dependencies": {
    "bower": "^1.3.5",
    "grunt": "~0.4.1",
    "grunt-autoprefixer": "~0.4.0",
    "grunt-bower-install": "~1.0.0",
    "grunt-cli": "^0.1.13",
    "grunt-concurrent": "~0.5.0",
    "grunt-contrib-clean": "~0.5.0",
    "grunt-contrib-concat": "~0.3.0",
    "grunt-contrib-connect": "~0.5.0",
    "grunt-contrib-copy": "~0.4.1",
    "grunt-contrib-cssmin": "~0.7.0",
    "grunt-contrib-htmlmin": "~0.1.3",
    "grunt-contrib-imagemin": "~0.3.0",
    "grunt-contrib-jshint": "~0.7.1",
    "grunt-contrib-uglify": "~0.2.0",
    "grunt-contrib-watch": "~0.5.2",
    "grunt-google-cdn": "~0.2.0",
    "grunt-karma": "^0.8.3",
    "grunt-newer": "~0.6.1",
    "grunt-ngmin": "~0.0.2",
    "grunt-rev": "~0.1.0",
    "grunt-svgmin": "~0.2.0",
    "grunt-usemin": "~2.0.0",
    "jshint-stylish": "~0.1.3",
    "load-grunt-tasks": "~0.4.0",
    "node-static": "^0.7.3",
    "time-grunt": "~0.2.1"
  },
  "devDependencies": {
    "karma": "^0.12.16",
    "karma-jasmine": "^0.1.5",
    "karma-phantomjs-launcher": "^0.1.4"
  },
  "engines": {
    "node": ">=0.10.0"
  },
  "scripts": {
    "test": "grunt test",
    "start": "static -p $PORT dist",
    "postinstall": "bower install && grunt build"
  }
}

Now you should be a git push heroku master && heroku ps:scale web=1 away from having your app up and running on Heroku.

Conclusion

This was a looong post, but I hope you enjoyed reading it and that it has given you a good introduction on the basic concepts of building a single page application using AngularJS. Overall, I found it to be a pretty easy to use framework and I hope that I'll have a chance to use it on a real app soon.

Bear in mind, though, that the framework is very powerful and we've barely scratched the surface in terms of what it has to offer and we didn't analyze anything in great depth. If you are interested on learning more about Angular, I recommend reading its great tutorial available at https://docs.angularjs.org/tutorial.

Sources for the app can be found at https://github.com/fgrehm/rubygems-charts and you can try it out on http://rubygems-charts.herokuapp.com.

Compartilhe

Sabia que nosso blog agora está no Medium? Confira Aqui!