Primeros pasos en AngularJS


Llevo desde el verano investigando Yui... Para terminar pasándome a AngularJS.

Uno de los primeros problemas que veo cuando se comienza una página web es gestionar la barra de navegación. El simple hecho de resaltar la opción de menú seleccionada es un problema... Salvo que utilices AngularJS.

En este artículo veremos cómo crear un proyecto AngularJS con Bootstrap desde cero, y le añadiremos una barra de herramientas completamente funcional.

Qué necesitamos

Lo primero que vamos a necesitar es tener intalado nodejs y utilizar npm para instalar yeoman. Por si estuviera obsoleto, podemos forzar la instalación de yo, bower y grunt, que es en definitiva lo que vamos a utilizar; Y aprovechamos también para instalar el generador de AngularJS para yo:

# npm install -g yeoman yo bower grunt generator-angular

Este artículo

En este artículo voy a mostrar los pasos mínimos. No voy a mostrar el contenido completo de archivos, sino las modificaciones respecto a lo que ya hace yeoman.

Además, irá ligado a un proyecto en github, donde podéis verlo completo o paso a paso.

Inicialización

Nada más sencillo:

$ mkdir example
$ cd example
$ yo angular

Y os irá realizando una serie de preguntas. Yo he elegido no utilizar compass pero sí Bootstrap y todos los módulos de AngularJS.

Con esto ya tendremos la web completamente montada. Tenemos incluso un servidor que podemos iniciar fácilmente (se nos abrirá un navegador):

$ grunt serve

Sin embargo, podréis comprobar que la barra de estado no funciona, ya que siempre nos redirecciona a la página de inicio. Dejad el servidor corriendo en segundo plano; nos actualizará el navegador con cada cambio.

Creando vistas

Vamos a comenzar a crear las vistas. Necesitamos la de 'about' y la de 'contact':

$ yo angular:view about
$ yo angular:view contact

Vamos a darles uso: abrimos el archivo app/scripts/app.js, que contiene las rutas, y lo dejamos similar a éste:

$routeProvider
  .when('/', {
    templateUrl: 'views/main.html',
    controller: 'MainCtrl'
  })
  .when('/about', {
    templateUrl: 'views/about.html',
    controller: 'MainCtrl'
  })
  .when('/contact', {
    templateUrl: 'views/contact.html',
    controller: 'MainCtrl'
  })
  .otherwise({
    redirectTo: '/'
  });

Es decir, añadimos las nuevas rutas.

Ahora movemos un trozo de código que hay en app/views/main.html a app/index.html, que es el correspondiente a la barra de navegación:

<div class="header">
  <ul class="nav nav-pills pull-right">
    <li class="active"><a ng-href="#">Home</a></li>
    <li><a ng-href="#/about">About</a></li>
    <li><a ng-href="#/contact">Contact</a></li>
  </ul>
  <h3 class="text-muted">example</h3>
</div>

Veréis que he aprovechado para añadir los enlaces nuevos. Colocadlo justo después de la etiqueta <body>.

Con esto ya nos funcionará la barra de navegación, aunque aún no actualizará el estado actual.

Nuestro primer controlador

Lo primero que necesitamos es un esqueleto:

$ yo angular:controller navbar

Eso nos habrá creado el archivo app/scripts/controllers/navbar.js que tenemos que editar y dejarlo tal que así:

    'use strict';

    angular.module('exampleApp')
      .controller('NavbarCtrl', function ($scope, $location) {
        $scope.isActive = function (viewLocation) {
          return (viewLocation === $location.path());
        };
      });

Además, habrá modificado el app/index.html, para añadir el nuevo controlador, lo que nos viene de perlas. De todas maneras, tenemos que realizar un par de pequeñas modificaciones en el código de la barra de navegación, para asociarla al controlador y para usar la función isActive():

<div class="header">
  <ul class="nav nav-pills pull-right" ng-controller="NavbarCtrl">
    <li ng-class="{active: isActive('/') }"><a ng-href="#">Home</a></li>
    <li ng-class="{active: isActive('/about') }"><a ng-href="#/about">About</a></li>
    <li ng-class="{active: isActive('/contact') }"><a ng-href="#/contact">Contact</a></li>
  </ul>
  <h3 class="text-muted">example</h3>
</div>

Y nuestra barra de navegación es funcional.

Tests unitarios

Hay algo que es imprescindible: Hacer tests. Por ello vamos a ver cómo lanzar los tests unitarios.

Lo primero será asegurarnos de tener la variable CHROME_BIN o exportarla:

$ export CHROME_BIN=$(which chromium)

A continuación modificaremos el archivo de configuración de los tests unitarios. En concreto, queremos que se lancen en el puerto 9876 y que se lancen automáticamente. Por ello modificamos el archivo karma.conf.js:

// [...]
port: 9876,
autoWatch: true,
// [...]

Y ahora lo lanzamos:

$ karma karma.conf.js

Veremos que hay un test fallando y que se queda en espera. Es como debe ser. Ahora modificamos los tests, que están en el archivo test/spec/controllers/navbar.js:

'use strict';

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

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

  var NavbarCtrl,
    scope,
    mocklocation;

  // Initialize the controller and a mock scope
  beforeEach(inject(function ($controller, $rootScope, _$location_) {
    mocklocation = _$location_;
    scope = $rootScope.$new();
    NavbarCtrl = $controller('NavbarCtrl', {
      $scope: scope,
      $location: mocklocation
    });
  }));

  it('should return True when the location matches', function () {
    mocklocation.path('/');
    expect(scope.isActive('/')).toBe(true);
  });

  it('should return False when the location does not matches', function () {
    mocklocation.path('/whatever');
    expect(scope.isActive('/')).toBe(false);
  });

  it('should return True when the location matches in a local path', function () {
    mocklocation.path('/#/');
    expect(scope.isActive('/')).toBe(false);
  });

  it('should return False when the location does not matches', function () {
    mocklocation.path('/#/whatever');
    expect(scope.isActive('/')).toBe(false);
  });
});

Como veis, hemos mockeado $location y hay 4 tests. Puede que los dos últimos os resulten extraños, por lo que los voy a explicar un poco.

Ya hemos visto que cuando pulsamos sobre el botón "contact" se accede a la ruta "/contact.html". Pero... ¿Qué ocurre si accedemos a "/contact.html" directamente, en un navegador diferente? Pues posiblemente nos dé un error de archivo no encontrado. Eso es así porque, realmente, no existe. Por eso se usa el truco "/#/contact.html", que le está diciendo al navegador que la ruta es "/", y así obtiene nuestro "index.html". Será AngularJS quien parsee la ruta completa y nos permita tratarla como si siempre hubiera sido "/contact.html".

Es importante probar que va a funcionar, con el fin de evitar disgustos :D

Veréis que cada vez que se modifica el archivo de tests se lanzan los tests automáticamente. ¡Precioso!

Tests end to end

Pero claro, no estamos comprobando que cuando se pulsan los botones se cargan las páginas correctamente... Eso son tests de aceptación, o end to end (abreviado e2e). Para ellas utilizaremos el archivo karma-e2e.conf.js, que tendremos que modificar ligeramente:

port: 9877,
singleRun: false,
proxies: {
    '/': 'http://localhost:9000/'
},
urlRoot: '_karma_'

Para estos tests hará falta tener un servidor corriendo. Para lanzarlo necesitamos que esté corriendo el servidor:

$ grunt start

Lanzar el servidor de karma:

$ karma start karma-e2e.conf.js

Eso nos abrirá el navegador. Lo dejamos por ahí funcionando y nos olvidamos de él. Lanzamos los tests en otra terminal:

$ karma run karma-e2e.conf.js

Y vemos cómo falla. Eso es porque no hay tests.

Creamos el archivo test/e2e/navbar.js por este contenido:

describe('Navigation Bar', function() {
    it('selects "Home" if location is /', function() {
        browser().navigateTo('/');

        expect(browser().location().path()).toBe('/');
        expect(element('.nav li:nth-child(1)').attr('class')).toContain('active');
        expect(element('.nav li:nth-child(2)').attr('class')).not().toBeDefined();
        expect(element('.nav li:nth-child(3)').attr('class')).not().toBeDefined();
    });

    it('selects "About" if location is /#/about', function() {
        browser().navigateTo('/#/about');

        expect(browser().location().path()).toBe('/about');
        expect(element('.nav li:nth-child(1)').attr('class')).not().toBeDefined();;
        expect(element('.nav li:nth-child(2)').attr('class')).toContain('active');
        expect(element('.nav li:nth-child(3)').attr('class')).not().toBeDefined();;
    });

    it('selects "Contact" if location is /#/contact', function() {
        browser().navigateTo('/#/contact');

        expect(browser().location().path()).toBe('/contact');
        expect(element('.nav li:nth-child(1)').attr('class')).not().toBeDefined();
        expect(element('.nav li:nth-child(2)').attr('class')).not().toBeDefined();
        expect(element('.nav li:nth-child(3)').attr('class')).toContain('active');
    });

});

Pasos siguientes

Lo siguiente sería crear dos barras de navegación diferentes o bien compartidas, de manera que se muestren dependiendo de si el usuario hizo login o no... pero eso será otra historia.

Además, creo que el escenario e2e se puede simplificar... Aquí agradecería la ayuda de alguien que sepa más que yo, lo que no es difícil. Por favor, si alguien cree que puede mejorar algún punto... ¡Que haga un pull-request!

Más información

La verdad es que el código lo he sacado de Stackoverflow. Lo que añado es toda la creación de la web utilizando yo y mostrar el ejemplo completo.

Si queréis aprender AngularJS de verdad, os invito a seguir el tutorial.

También recomiendo el libro "AngularJS", de Bradly Green (@bradlygreen) y Shyam Seshadri (@omniscient1), y el repositorio del libro, que son muy recomendables. Es en este libro donde me encontré con yeoman.


Comentarios

Comments powered by Disqus