Initialiser une application AngularJS avec Yeoman, Express et JewelryBox, sous Mac OS X

Yeoman a été créé dans l’optique de faciliter l’initialisation de web apps JavaScript, en regroupant un ensemble de 3 utilitaires tournant sous Node.js :

  • Yo : initialise la structure de la web app.
  • Grunt : déclenche les phases de test, preview et build de la web app.
  • Bower : assure la gestion des dépendances.

Express est un framework d’application web fonctionnant lui aussi sous Node.js.

Lorsque l’on génère le squelette d’une web app angular, Yo propose de gérer le design avec Twitter Bootstrap et des feuilles de style SASS. Les feuilles de style SASS, bien que très pratiques, doivent être compilées en CSS. Un framework open-source Ruby fait ça très bien et se nomme Compass.

Ruby est installé par défaut sous OS X, mais il est préférable, comme sous d’autres OS d’avoir recours à RVM, un outil en ligne de commande qui installe, gère et permet de travailler facilement avec plusieurs environnements Ruby différents.
Autre outil particulièrement pratique et complémentaire à RVM, il s’agit de Homebrew, un gestionnaire de packages fonctionnant sous Ruby.
L’association de RVM et Homebrew offre la possibilité de créer un environnement de travail personnalisé sans être amené à polluer par accident les librairies systèmes.

Pour installer et utiliser RVM il existe un client graphique très pratique : JewelryBox. Il offre une vision globale de tous les environnements Ruby disponibles et/ou installés ainsi que des gems associées.

Donc, avant de pouvoir s’amuser avec Yeoman pour générer une web app Angular/Twitter Bootstrap/SASS, il y a un petit travail de configuration d’environnement à réaliser.

Mise à jour de XCode

Passer à la version 4.6.1 et installer les « Command line tools » (menu préférences de XCode).

Installation de JewerlyBox

Télécharger l’archive ici, la décompresser et déposer le package dans Applications.

Installation de RVM, Homebrew et de ruby 2.0.0-p0

Pour RVM il suffit d’ouvrir JewerlyBox, une seule action est alors possible, « installer RVM », donc allons-y.
Si tout s’est bien passé vous devriez obtenir l’écran suivant.

Aller ensuite dans l’onglet Requirements du Dashboard. L’installation de Homebrew et des librairies nécessaires à RVM pour bien fonctionner est déclenché automatiquement.

Avant d’ajouter un environnement Ruby il faut réaliser une petite manip dans un Terminal pour configurer RVM :

$ echo '[[ -s "$HOME/.rvm/scripts/rvm" ]] && source "$HOME/.rvm/scripts/rvm"' >> .bashrc
$ source .bash_rc
$ rvm autolibs enable # Autorise l'installation automatique des dépendances

Pour éviter de passer du temps à résoudre les « -bash: rvm: command not found » à chaque nouvelle ouverture de Terminal :

$ echo 'if [ -f $HOME/.bashrc ]; then
   source $HOME/.bashrc
fi' >> $HOME/.bash_profile

Sous Mac, Terminal charge le .bash_profile et ne tient pas compte du .bashrc.

Revenir ensuite, à JewerlyBox, aller dans le panel « Add Ruby », sélectionner la version 2.0.0-p0 et cliquer sur « Install ».

JewelryBox va alors résoudre les dépendances nécessaires à la compilation de Ruby, configurer l’environnement Ruby 2.0.0, puis compiler Ruby.

Ce processus prend du temps c’est le moment d’aller prendre un café.

Une fois l’écran ci-dessous obtenu c’est gagné, Ruby 2.0.0 est installé, on peut passer à la suite.

Installation de Compass

Ouvrir un Terminal puis sélectionner l’environnement ruby 2.0.0 :

$ which ruby
/usr/bin/ruby
$ rvm 2.0.0
$ which ruby
/$HOME/.rvm/rubies/ruby-2.0.0-p0/bin/ruby

L’installation de compass se réalise tout simplement comme ceci :

$ gem install compass

Installation de Node.js

Node.js est installé au sein de RVM via :

$ brew install node

Installation yo, grunt, bower

Le gestionnaire de packages npm de Node.js est là pour nous simplifier la tâche :

$ npm install -g yo grunt-cli bower

Les liens vers les binaires de yo, grunt et bower sont définis dans ~/.rvm/share/npm/bin il faut donc les ajouter à la variable PATH.

Or, l’instruction PATH=$PATH:$HOME/.rvm/share/npm/bin dans le .bashrc est écrasée lorsque l’on exécute rvm 2.0.0.
En cause, les scripts de démarrage de RVM. Ils nettoient PATH de toutes les valeurs qui contiennent $HOME/.rvm.

Pour palier à cela, on utilise donc un ‘hook’ RVM :

$ echo 'PATH=$PATH:$HOME/.rvm/share/npm/bin # Add npm libraries to PATH' >>
  $HOME/.rvm/hooks/after_use_add_npm_libraries_to_path
$ chmod +x $HOME/.rvm/hooks/after_use_add_npm_libraries_to_path

Autre point, il n’est pas nécessaire de sélectionner un environnement ruby pour profiter des outils installés au sein de RVM.
L’instruction [[ -s "$HOME/.rvm/scripts/rvm" ]] && source "$HOME/.rvm/scripts/rvm que l’on a rajouté dans le .bashrc précédemment permet d’ajouter à la variable PATH les scripts et binaires présents dans $HOME/.rvm/bin dont brew et node.

Pour pouvoir quand même profiter de yo, grunt et bower sans utiliser Ruby ni Compass, la solution que j’ai trouvée est de charger le hook depuis .bashrc :

$ echo 'source $HOME/.rvm/hooks/after_use_add_npm_libraries_to_path' >> $HOME/.bashrc

Lorsque l’on exécutera de nouveau rvm 2.0.0 le hook sera écrasé puis de nouveau rechargé.
Si quelqu’un a une meilleure solution, je suis preneur.

It’s Yeoman time!

Pour reprendre l’exemple de l’application phare d’Oxiane, la Cave à vin, je souhaite créer une web app qui liste l’ensemble des bouteilles d’une cave.

La première étape consiste donc à initialiser la web app Angular via Yeoman.

$ mkdir $HOME/path_to/cave-a-vin && cd $HOME/path_to/cave-a-vin
$ npm install generator-angular generator-karma  # installation des générateurs angular et karma
$ yo angular                     # initialisation d'une web app AngularJS
$ npm install && bower install   # installation des dépendances par défaut

yo angular pose une série de questions auxquelles je réponds à toutes ‘Y’.
La web app nouvellement créée fonctionne ainsi avec AngularJS, Twitter Bootstrap et SASS.

La structure de ma web app est définie, je vais donc pouvoir tester son fonctionnement, pour cela, trois instructions importantes, à retenir :

grunt test                     # teste de la web app via karma
grunt server                   # pré-visualisation de la web app
grunt                          # build de la web app

J’ouvre donc un terminal et exécute grunt server. Et là, c’est le drame :

$ grunt server
Running "clean:server" (clean) task

Running "coffee:dist" (coffee) task

Running "compass:server" (compass) task
Warning: You need to have Ruby and Compass installed and in your system PATH for this task to work. 
More info: https://github.com/gruntjs/grunt-contrib-compass Use --force to continue.

Aborted due to warnings.

Bon, j’ai simplement oublié de lancer rvm 2.0.0.

Ceci étant fait, je relance grunt server, un browser s’ouvre automatiquement sur http://localhost:9000 et je peux admirer ma toute nouvelle web app.

La liste des frameworks installés c’est sympa, mais je veux afficher le contenu d’une cave à vin.

Je commence d’abord par modifier la vue cave-a-vin/app/views/main.html.

Cave à Vin

Nom Millésime Région Appellation Couleur Cépage Producteur
{{bouteille.nom}} {{bouteille.millesime}} {{bouteille.region}} {{bouteille.appellation}} {{bouteille.couleur}} {{bouteille.cepage}} {{bouteille.producteur}}

Notez bien la directive Angular ng-repeat. Elle contient la propriété bouteilles qui sera alimentée par le controller associé à cette vue.

grunt s’occupe de scanner les modifications sur les fichiers de la web app et rafraîchit la page automatiquement. Aucunement besoin de tout relancer.

Mon tableau est vide, il faut donc que je l’alimente, je modifie donc le controller cave-a-vin/app/scripts/controllers/main.js

'use strict';

angular.module('caveAVinApp')
  .controller('MainCtrl', function ($scope) {
    $scope.bouteilles = [
        {
            nom: "vin1",
            millesime: "millesime1",
            region: "region1",
            appellation: "appellation1",
            couleur: "couleur1",
            cepage: "cepage1",
            producteur: "producteur1"
        }
    ];
  });

On bascule côté browser pour vérifier que tout est bien câblé.

Maintenant je souhaiterais pouvoir appeler un service web pour me fournir la liste des bouteilles.
Je crée donc un répertoire cave-a-vin/server dans lequel je stocke :

  • cave-a-vin.json : liste des bouteilles de ma cave.
  • cors.js : script autorisant les appels REST inter-domaines.
  • cave-a-vin.js : script Node.js contenant la définition de mon service web et l’initialisation d’un serveur web sur le port 1234.

Fichier cave-a-vin.json :

[
    {
        "id" : "1",
        "nom": "Château Pierre Bise Beaulieu",
        "millesime": "2004",
        "region": "Loire",
        "appellation": "Coteaux du Layon",
        "couleur": "Blanc",
        "cepage": "Chenin",
        "producteur": "Château Pierre Bise"
    },
    {
        "id" : "2",
        "nom": "Château Pierre Bise",
        "millesime": "2001",
        "region": "Loire",
        "appellation": "Clos de Coulaine",
        "couleur": "Blanc",
        "cepage": "Chenin",
        "producteur": "Château Pierre Bise"
    },
    {
        "id" : "3",
        "nom": "Chateau Lafitte",
        "millesime": "2012",
        "region": "Bordeaux",
        "appellation": "St Emilion",
        "couleur": "Rouge",
        "cepage": "Syrah",
        "producteur": "Lafite"
    }
]

Fichier cave-a-vin.js :

var express = require('express');
var cors = require("./cors");
var fs = require('fs');

var server = express();
server.use(express.bodyParser());
server.use(cors.express);

function notSupported(res) {
    res.status(400).send('Method ' + req.method + 'not supported');
}

function newReadFileCallback(res) {
    return function (err, data) {
        if (err) {
            console.log(err);
            res.send({});
        }
        res.send(JSON.parse(data));
    }
}

server.all('/cave-a-vin', function (req, res) {
    console.log('/cave-a-vin', req.method, req.body);
    switch (req.method) {
        case 'GET':
            fs.readFile('./cave-a-vin.json', 'utf8', newReadFileCallback(res));
            break;
        default:
            notSupported(res);
    }
});

console.log('Starting cave-a-vin handler.');
server.listen(1234);

cave-a-vin.js requiert Express, il faut donc installer ce module via npm et le lié à l’application :

$ npm install -g express
$ npm link express

Fichier cors.js :

exports.express = function(req, res, next) {
    res.header('Access-Control-Allow-Origin', '*');
    res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS');
    res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With');
    res.header('Access-Control-Max-Age', 60 * 60 * 24 * 365);

    // intercept OPTIONS method
    if ('OPTIONS' == req.method) {
        res.send(200);
    }
    else {
        next();
    }
};

Pour démarrer le serveur :

$ node server/cave-a-vin.js

Pour vérifier son bon fonctionnement, ouvrir un browser sur http://localhost:1234.

Il faut maintenant modifier le code côté client pour faire appel au web service.

Tout d’abord dans cave-a-vin/app/scripts/app.js où l’on définit un service Angular : CaveAVinService.
Ce service utilise la ressource représentée par l’url 'http://localhost:1234/cave-a-vin' correspondant à notre web service.

'use strict';

angular.module('caveAVinApp', ['ngResource'])
    .factory('CaveAVinService', function ($resource) {
        return $resource('http://localhost\\:1234/cave-a-vin')
    })
    .config(function ($routeProvider) {
        $routeProvider
            .when('/', {
                templateUrl: 'views/main.html',
                controller: 'MainCtrl'
            })
            .otherwise({
                redirectTo: '/'
            });
    });

Enfin, il suffit de faire appel à CaveAVinService dans le controller cave-a-vin/app/scripts/controllers/main.js pour pouvoir afficher les données récupérées depuis le serveur.

'use strict';

angular.module('caveAVinApp')
  .controller('MainCtrl', function ($scope, CaveAVinService) {
    $scope.bouteilles = CaveAVinService.query();
  });

Dernier coup d’oeil sur le browser.

Maintenant que le layout est correct, qu’en est-il des tests unitaires ?

$ grunt test
...
Running "karma:unit" (karma) task
INFO [karma]: Karma server started at http://localhost:8080/
INFO [launcher]: Starting browser Chrome
WARN [launcher]: Chrome have not captured in 5000 ms, killing.
INFO [launcher]: Trying to start Chrome again.
INFO [Chrome 26.0 (Mac)]: Connected on socket id sFJBVG59APBGDT7PkExe
Chrome 26.0 (Mac) Controller: MainCtrl should attach awesome things to the scope FAILED
	TypeError: Cannot read property 'length' of undefined
	    at null. ($HOME/cave-a-vin/test/spec/controllers/main.js:26:31)
Chrome 26.0 (Mac): Executed 1 of 1 (1 FAILED) (0.584 secs / 0.047 secs)
Warning: Task "karma:unit" failed. Use --force to continue.

Aborted due to warnings.

On s’aperçoit que les tests ne fonctionnent plus, normal puisque l’on a modifié le contenu du squelette créé par Yo.

Il faut donc les faire évoluer en conséquence.

Le fichier à modifier est cave-a-vin/test/spec/controllers/main.js.

'use strict';

describe('Controller: MainCtrl', function () {

  // load the controller's module
  beforeEach(module('caveAVinApp'));

  var MainCtrl,scope,CaveAVinServiceMock;

  // Initialize the controller and a mock scope
  beforeEach(inject(function ($controller, $rootScope) {
    scope = $rootScope.$new();
      CaveAVinServiceMock = new function(){
        this.query = function(){
            return [{nom:'vin1'},{nom:'vin2'},{nom:'vin3'}]
        }
    };

    MainCtrl = $controller('MainCtrl', {
      $scope: scope,
      CaveAVinService: CaveAVinServiceMock
    });
  }));

  it('devrait attaché la liste de bouteilles au scope', function () {
    expect(scope.bouteilles.length).toBe(3);
    expect(scope.bouteilles[0].nom).toBe("vin1")
  });
});

Le test est simple, il s’agit de s’assurer que le $scope de MainCtrl est bien initialisé lors du démarrage de la web app.
Pour vérifier cela, je crée un mock de CaveAVinService dont la fonction query() retourne trois bouteilles. Ce mock est ensuite injecté dans le controller MainCtrl.
Deux assertions testent ensuite si tout s’est correctement déroulé.

EDIT: Comme on utilise la directive Angular ngRessource il faut prendre en compte le module angular-resource dans la configuration de Karma (cave-a-vin/karma.conf.js), le runner des tests.

...
// list of files / patterns to load in the browser
files = [
  JASMINE,
  JASMINE_ADAPTER,
  'app/components/angular/angular.js',
  'app/components/angular-mocks/angular-mocks.js',
  'app/components/angular-resource/angular-resource.js', // Ajout module angular-resource
  'app/scripts/*.js',
  'app/scripts/**/*.js',
  'test/mock/**/*.js',
  'test/spec/**/*.js'
];
...

Je relance un grunt test.

$ grunt test
...
Running "karma:unit" (karma) task
INFO [karma]: Karma server started at http://localhost:8080/
INFO [launcher]: Starting browser Chrome
WARN [launcher]: Chrome have not captured in 5000 ms, killing.
INFO [launcher]: Trying to start Chrome again.
INFO [Chrome 26.0 (Mac)]: Connected on socket id mg8qmtJxccfLzaDOknbn
Chrome 26.0 (Mac): Executed 1 of 1 SUCCESS (0.316 secs / 0.045 secs)

Done, without errors.

Cette fois-ci, tout est vert.

Et voilà, la web app est initialisé correctement, il nous faut maintenant ajouter des opérations CRUD et de la persistance.

Ce sera l’objet d’un autre article.

To be continued…