lunes, 28 de marzo de 2016

Prácticas Recomendadas para Proyectos con AngularJS 1.x Parte IV: Recomendaciones para Producción


Introducción

En posts anteriores se ha venido desarrollando una aplicación web con AngularJS 1.5, aplicando las recomendaciones que han sido aceptadas por el propio equipo de Google tanto en estructura del proyecto, secciones y rutas como el estilo de desarrollo de componentes como Directivas, Constantes, Controladores y Servicios.

En esta ocasión se van aplicar otras recomendaciones para generar la aplicación para publicar en un ambiente de producción.

Copia de las Vistas

Actualmente la única vista (archivo HTML) que se está copiando a la carpeta "dist" al ejecutar la tarea de Gulp "build" es "index.html". Para copiar los demás archivos HTML se creará una nueva tarea en "gulpfile.js", muy similar a la tarea ya existente "copy:assets".

gulp-paths.json
{
  "app": "./app/",
  "js": ["**/*.js"],
  "css": ["**/*.css"],
  "views": ["**/*.html", "!index.html"],
  "dist": "./dist/"
}

En la línea 5 se adiciona la entrada "views" con expresiones Glob que permiten encontrar todos los archivos HTML y otra que excluye el archivo "index.html" para no sobrescribir este archivo al copiar los otros HTML.

gulpfile.js
...
// Copies the HTML files into the dist folder
gulp.task('copy:html', function () {
  return gulp.src(paths.views, {cwd: paths.app})
    .pipe(gulp.dest(paths.dist));
});
...
// Production build
gulp.task('build', function (done) {
  runSequence('jshint', 'jscs', 'clean:dist', 'compress', 'copy:assets', 'copy:html', done);
});
...

Se definió la tarea "copy:html", copiando los archivos que se indican en "paths.views" hacia "dist" y se incluyó como parte la tarea "build".


Inyección Explícita de Depedencias

Si en este momento se intenta generar y ejecutar la aplicación para producción usando el comando "gulp server-dist" se debe ver un error como el siguiente en la consola del navegador web:

Uncaught Error: [$injector:modulerr] Failed to instantiate module weatherApp due to:
Error: [$injector:modulerr] Failed to instantiate module weatherApp.home due to:
Error: [$injector:modulerr] Failed to instantiate module weatherApp.core due to:
Error: [$injector:modulerr] Failed to instantiate module weatherApp.blocks.router due to:
Error: [$injector:unpr] Unknown provider: t
http://errors.angularjs.org/1.5.0/$injector/unpr?p0=t
    at http://localhost:8080/js/vendor.min.js:4:27196
    at http://localhost:8080/js/vendor.min.js:5:15882
    at r (http://localhost:8080/js/vendor.min.js:5:14810)
    at i (http://localhost:8080/js/vendor.min.js:5:15110)
    at Object.s [as instantiate] (http://localhost:8080/js/vendor.min.js:5:15475)
    at i (http://localhost:8080/js/vendor.min.js:5:13419)
    at Object.provider (http://localhost:8080/js/vendor.min.js:5:13356)
    at r (http://localhost:8080/js/vendor.min.js:5:14198)
    at http://localhost:8080/js/vendor.min.js:5:14304
    at o (http://localhost:8080/js/vendor.min.js:4:27644)

Esto se debe a que todo el código JavaScript se está minificando y los nombres de las variables y parámetros se están acortando a una letra, incluyendo los que representan dependencias (como servicios por ejemplo), por lo que se trata de un error generado directamente por AngularJS y no por un error en la lógica de la aplicación.

Para solucionar este problema se incluirá el uso de "gulp-ng-annotate" minificar el código.

Como primer paso se debe instalar este plugin mediante npm:

npm install --save-dev gulp-ng-annotate

Luego se incluye su uso en la tarea de Gulp "compress" antes de aplicar el plugin "gulp-uglify" a los archivos JavaScript (recordando que en esta tarea también se minifican los archivos CSS):
...
// Compress into a single file the ones in between of blocks "build:xx" in index.html
gulp.task('compress', ['wiredep'], function () {
  return gulp.src('index.html', {cwd: paths.app})
    .pipe(plugins.useref({ searchPath: ['./', paths.app] }))
    .pipe(plugins.if('**/*.js', plugins.ngAnnotate()))
    .pipe(plugins.if('**/*.js', plugins.uglify({
      mangle: true
    }).on('error', plugins.util.log)))
    .pipe(plugins.if('**/*.css', plugins.cssnano()))
    .pipe(gulp.dest(paths.dist));
});
...

En la línea 5 de la tarea "compress" se incluyó el llamado "plugins.ngAnnotate()" justo antes de minificar el código JavaScript con "uglify".

Nota: Cabe recordar que no fue necesario incluir una variable manualmente para usar el plugin "gulp-ng-annotate" ya que al usar "gulp-load-plugins" todos los plugins que tengan el prefijo "gulp-" quedan disponibles en la variable "plugins".

Al ejecutar nuevamente la aplicación para producción podrá verse que ya funciona sin problemas.

Figura 1 - Aplicación con archivos para producción

Caché de Vistas (Template Cache)

Luego de navegar por las secciones "Home", "About" y consultar el clima en una ciudad en la figura 1 se puede ver señalado que se han realizado 13 peticiones HTTP, de las cuales 6 (casi la mitad) corresponden a la descarga de archivos HTML.

Localmente esto no parece ser un problema ya que la descarga se hace casi de inmediato y adicionalmente dependiendo del navegador y el servidor web usado es posible que estos archivos HTML sólo se descarguen una vez y luego se use una copia en la memoria caché del navegador web.

Sin embargo al tener la aplicación web en producción se tendrán variables adicionales como velocidad a Internet del usuario, capacidad y ancho de banda del servidor que expone la aplicación web, por lo que se debe disminuir lo más posible el número de llamados HTTP necesarios en la aplicación, en especial si se tienen páginas mucho más elaboradas que estas.

Para ello AngularJS dispone del servicio "$templateCache" el cual permite adicionar y tomar plantillas (templates) HTML para las vistas desde código JavaScript de una manera más rápida, sin embargo mantener código HTML dentro de código JavaScript hace que sea más difícil de entender y mantener.

Afortunadamente el plugin "gulp-angular-templatecache" permite tomar el contenido de los archivos HTML y crear automáticamente un archivo JavaScript con todas las vistas.

Antes de considerar esta opción, se debe tener en cuenta el número de secciones que tiene la aplicación, el tamaño de las vistas y la frecuencia con que los usuarios navegarán por la mayoría de dichas secciones ya que en caso de ser una aplicación de muchas secciones de las cuales cada usuario usará muy pocas, el archivo JavaScript generado con todas las vistas estaría descargando más datos de los que el usuario realmente necesita y no tener el ahorro de transferencia de datos esperado.

Primero se necesitará instalar "gulp-htmlmin" para minificar el código HTML (quitando espacios y saltos de línea innecesarios para los navegadores web), "gulp-angular-templatecache" para generar el archivo "templates.js" con las vistas HTML y "gulp-concat" para concatenar el contenido de dicho archivo en el archivo JavaScript final de la aplicación "scripts.min.js".

npm install gulp-htmlmin gulp-angular-templatecache gulp-concat --save-dev

gulp-paths.json
{
  "app": "./app/",
  "js": ["**/*.js"],
  "jsMin": "js/scripts.min.js",
  "css": ["**/*.css"],
  "views": ["**/*.html", "!index.html"],
  "templatesCache": "templates.js",
  "dist": "./dist/"
}

gulpfile.js
...
gulp.task('templates:build', function () {
  return gulp.src(paths.views, {cwd: paths.app})
    .pipe(plugins.htmlmin({collapseWhitespace: true}))
    .pipe(plugins.angularTemplatecache({
      module: 'weatherApp',
      moduleSystem: 'IIFE'
    }))
    .pipe(plugins.uglify({
      mangle: true
    }))
    .pipe(gulp.dest(paths.dist));
});

gulp.task('templates:concat', ['templates:build'], function () {
  return gulp.src([paths.jsMin, paths.templatesCache], {cwd: paths.dist})
    .pipe(plugins.concat(paths.jsMin))
    .pipe(gulp.dest(paths.dist));
});

gulp.task('templates:clean', ['templates:concat'], function () {
  return del(paths.dist + paths.templatesCache);
});
...
// Production build
gulp.task('build', function (done) {
  runSequence('jshint', 'jscs', 'clean:dist', 'compress', 'templates:clean', 'copy:assets', done);
});
...
  • Tarea copy:html: Dado que ahora en el código de producción tomará las vistas HTML de cache no se requiere copiar los archivos HTML a la carpeta "dist", por lo que la tarea "copy:html" debe borrarse.
  • Tarea templates:build: Genera el archivo "templates.js" (nombre por defecto) dentro de la carpeta "dist" (paths.dist) con el HTML minificado, indicando que hará parte del módulo principal de la aplicación (weatherApp) y que se usará Immediately Invoked Function Expression (IIFE), por lo cual también puede minificarse el código JavaScript generado usando "gulp-uglify".

    El archivo (sin minificar) debe verse de manera similar al siguiente:
    (function(){angular.module("weatherApp").run(["$templateCache", function($templateCache) {$templateCache.put("about/about.html","<wa-section-header wa-title=\"\'About this Site\'\" wa-subtitle=\"\'This site is just a demonstration for AngularJS 1.5 with an organized project structure using Gulp.\'\" class=\"about-header\"></wa-section-header>");
    $templateCache.put("home/home.html","<wa-section-header wa-title=\"\'Available Cities\'\" wa-subtitle=\"\'Select the city to check the current weather.\'\"></wa-section-header><div class=\"list-group\"><a ng-repeat=\"city in vm.cities | orderBy:\'name\'\" ui-sref=\"city({ cityId: city.id })\" class=\"list-group-item\">{{ city.name }} - {{city.country}}</a></div>");
    $templateCache.put("layout/footer.html","<footer class=\"footer\"><div class=\"container\"><p class=\"navbar-text\">Copyright &copy; 2016</p></div></footer>");
    $templateCache.put("layout/menu.html","<nav class=\"navbar navbar-inverse navbar-fixed-top\"><div class=\"container\"><div class=\"navbar-header\"><button type=\"button\" class=\"navbar-toggle collapsed\" data-toggle=\"collapse\" data-target=\"#navbar\" aria-expanded=\"false\" aria-controls=\"navbar\"><span class=\"sr-only\">Toggle navigation</span> <span class=\"icon-bar\"></span> <span class=\"icon-bar\"></span> <span class=\"icon-bar\"></span></button> <a class=\"navbar-brand\" href=\"#\">Weather Web App</a></div><div id=\"navbar\" class=\"collapse navbar-collapse\"><ul class=\"nav navbar-nav\"><li ui-sref-active=\"active\"><a ui-sref=\"home\">Home</a></li><li ui-sref-active=\"active\"><a ui-sref=\"about\">About</a></li></ul></div></div></nav>");
    $templateCache.put("home/city/city.html","<wa-section-header wa-title=\"vm.cityWeather.name\" wa-subtitle=\"vm.cityWeather.country\"></wa-section-header><div id=\"currentWeather\" ng-show=\"vm.cityWeather.image\"><img id=\"weatherImage\" ng-src=\"{{vm.cityWeather.image}}\"><br><strong>Weather</strong>: {{vm.cityWeather.description}}<br><strong>Temperature</strong>: {{vm.cityWeather.temperature}} &deg;C</div><br>Weather data from: <a href=\"http://openweathermap.org\" target=\"_blank\">OpenWeatherMap</a>");
    $templateCache.put("widgets/wa-section-header/wa-section-header.html","<div class=\"jumbotron text-center\"><h1>{{title}}</h1><p ng-show=\"subtitle\">{{subtitle}}</p></div>");}]);})();
    
  • Tarea templates:concat: Se toma el contenido de "templates.js" (paths.templatesCache) y se concatena al final de "dist/js/scripts.min.js" (paths.jsMin). A diferencia de otras tareas de Gulp definidas en este archivo, esta tiene como directorio actual (Current Working Directory - cwd) "./dist/" ya que los archivos con los que se trabajará en están en dicha carpeta.
  • Tarea templates:clean: Al finalizar "templates:concat" se borra el archivo "dist/templates.js".
  • Tarea build: Se borra de la lista la tarea "copy:html" y se adiciona "templates:clean"; las dependencias declaradas entre las tareas "templates:xyz" permiten que sólo sea necesario indicar esta última.
Ejecutando nuevamente la aplicación (comando gulp server-dist) podrá verse que la cantidad de peticiones HTTP se reduce a la mitad haciendo la misma navegación que el ejemplo de la figura 1.

Figura 2 - Aplicación con archivos para producción con TemplateCache


Deshabilitar Información de Debug

Siguiendo una de las recomendaciones que se hace en la documentación oficial de AngularJS para poner las aplicaciones en producción es deshabilitar la información de debug que el framework adiciona a varios de sus componentes, ya que mucha de esta información sólo es usada para pruebas, lo cual puede ayudar a mejorar el rendimiento de la aplicación.

Recordando el contenido del archivo "app.config.js", allí se tienen dos condiciones que permiten habilitar o deshabilitar esta información:

app/app.config.js
(function() {
  'use strict';

  angular
    .module('weatherApp')
    .config(configure);

  /* @ngInject */
  function configure($compileProvider, $logProvider, $httpProvider) {
    // Replaced by Gulp build task
    $compileProvider.debugInfoEnabled('@@debugInfoEnabled' !== 'false');
    $logProvider.debugEnabled('@@debugLogEnabled' !== 'false');

    $httpProvider.interceptors.push('HttpInterceptor');
  }
})();

  • Línea 11: Condición para deshabilitar la información de debug en la aplicación.
  • Línea 12: Condición para deshabilitar la salida de debug en la consola del navegador enviada mediante "$log.debug()". Es común que en las aplicaciones se envíe mucha información a la consola sólo para ver su resultado en el navegador durante el desarrollo (por ejemplo respuestas de llamados a servicios web), pero dicha información es innecesaria en producción así que de esta manera puede deshabilitar esta salida sin tener que modificar directamente el código de la aplicación.


Las cadenas "@@debugInfoEnabled" y "@@debugLogEnabled" pueden ser reemplazadas usando el plugin "gulp-replace-task":

npm install gulp-replace-task --save-dev

gulpfile.js
...
// Compress into a single file the ones in between of blocks "build:xx" in index.html
gulp.task('compress', ['wiredep'], function () {
  return gulp.src('index.html', {cwd: paths.app})
    .pipe(plugins.useref({ searchPath: ['./', paths.app] }))
    .pipe(plugins.if('*/scripts.min.js', plugins.replaceTask({
      patterns: [
        {
          match: 'debugInfoEnabled',
          replacement: 'false'
        },
        {
          match: 'debugLogEnabled',
          replacement: 'false'
        }
      ]
    })))
    .pipe(plugins.if('**/*.js', plugins.ngAnnotate()))
    .pipe(plugins.if('**/*.js', plugins.uglify({
      mangle: true
    }).on('error', plugins.util.log)))
    .pipe(plugins.if('**/*.css', plugins.cssnano()))
    .pipe(gulp.dest(paths.dist));
});
...

En este caso se adicionó que al momento de procesar el archivo con el código JavaScript de la aplicación (scripts.min.js) se busque en este las cadenas mencionadas y se reemplacen por "false" para que se cumpla la condición indicada en "app.config.js".

Conclusiones

En este momento ya se tiene una aplicación web desarrollada con AngularJS lista para desplegarse en cualquier servidor web (Apache, http://nginx.org/, ExpressJS, o inclusive contenedores como Tomcat), bien sea con los archivos HTML copiados normalmente o incluidos en el "$templateCache".

Cabe anotar que para aplicar las recomendaciones indicadas no se requirió modificar el código de la aplicación como tal (a excepción de las dos líneas de código que de hecho ya estaban en "app.config.js"), sino que fueron tareas de Gulp modificadas o adicionadas, con lo cual se comprueba la versatilidad y conveniencia de esta herramienta de construcción de proyectos.

En un próximo post se concluirá esta serie con el tema de las pruebas para la aplicación. El proyecto de la aplicación se puede descargar desde: https://github.com/guillermo-varela/angularjs-demo/tree/part-iv

Referencias

https://github.com/johnpapa/angular-styleguide/tree/master/a1
https://docs.angularjs.org/guide/production
https://docs.angularjs.org/api/ng/provider/$logProvider
https://www.npmjs.com/package/gulp-ng-annotate
https://docs.angularjs.org/api/ng/service/$templateCache

miércoles, 16 de marzo de 2016

Prácticas Recomendadas para Proyectos con AngularJS 1.x Parte III: Manejo de Errores y Documentación


Introducción

En posts anteriores se ha venido desarrollando una aplicación web con AngularJS 1.5, aplicando las recomendaciones que han sido aceptadas por el propio equipo de Google tanto en estructura del proyecto, secciones y rutas como el estilo de desarrollo de componentes como Directivas, Constantes, Controladores y Servicios.

En esta ocasión se van aplicar otras recomendaciones para controlar de manera consistente los errores que se presenten y adicionalmente la generación de la documentación del proyecto.

Manejo de Errores/Excepciones

Errores no Controlados

Debido a la naturaleza dinámica y no tipada de JavaScript es posible que algunos errores se escapen a la verificación de código realizada por JSHint y JSCS. Por ejemplo si al consultar el clima se usa un método que no existe en "$http", al generarse el error no se invoca la función "checkCityError" sino que este aparece en la consola del navegador web.

app/core/weather.service.js
...
      $http.get('http://api.openweathermap.org/data/2.5/weather', {
          params: {
            id: cityId,
            units: 'metric',
            appid: openWheaterAppId
          }
        })
        .thens(checkCityComplete)
        .catch(checkCityError);
...

En este ejemplo se reemplaza la invocación de la función "then" por la inexistente "thens". Al consultar el clima de una ciudad en la aplicación podrá verse el error en la consola.

Figura 1 - Error no controlado

Para tener control sobre este tipo de errores en la aplicación se implementará un mecanismo para detectar este tipo de problemas en la aplicación y es registrar un decorador (decorator) para el servicio "$exceptionHandler" el cual se encarga de procesar los errores/excepciones no capturados. En el nuevo decorador se extenderá la funcionalidad de "$exceptionHandler" con la lógica que se desee.

app/blocks/exception/exception.module.js
(function() {
  'use strict';

  /**
   * @ngdoc overview
   * @name weatherApp.blocks.exception
   *
   * @description
   * Module for common exception handling.
   */
  angular.module('weatherApp.blocks.exception', []);
})();

app/blocks/exception/exception.handler.decorator.js
(function() {
  'use strict';

  angular
    .module('weatherApp.blocks.exception')
    .config(exceptionConfig);

  /* @ngInject */
  function exceptionConfig($provide) {
    $provide.decorator('$exceptionHandler', extendExceptionHandler);
  }

  /**
   * @ngdoc function
   * @name weatherApp.blocks.exception.function:extendExceptionHandler
   * @private
   *
   * @description
   * Extends the $exceptionHandler service to perform a more advanced AngularJS exception handling.
   *
   * @param {Object} $delegate - $exceptionHandler delegator.
   * @param {Object} $log - Service to log messages.
   * @returns {function} Function defining how to handle the exception.
   */
  /* @ngInject */
  function extendExceptionHandler($delegate, $log) {

    function handleException(exception, cause) {
      $delegate(exception, cause);

      var errorData = {
        exception: exception,
        cause: cause
      };

      var msg = 'Weather Web App Error: ' + exception.message;
      $log.debug(msg, errorData);
    }

    return handleException;
  }
})();

  • Línea 28: Se define la función "handleException" que se invocará cuando se presente un error controlado.
  • Línea 29: Al invocar "$delegate" con los parámetros originales se ejecuta el procesamiento original de "$exceptionHandler".
  • Línea 36-37: Para mantener el ejemplo sencillo, el procesamiento del error será simplemente usar el servicio "$log" para enviar el error a la consola con un mensaje como prefijo.


app/core/core.module.js
(function() {
  'use strict';

  /**
   * @ngdoc overview
   * @name weatherApp.core
   * @requires weatherApp.blocks.router
   *
   * @description
   * Module for the core bussiness logic and intra-app features.
   */
  angular.module('weatherApp.core', [
    /* Cross-app modules */
    'weatherApp.blocks.router',
    'weatherApp.widgets',
    'weatherApp.blocks.exception'
  ]);
})();

Aquí simplemente en la línea 16 se adiciona el nuevo módulo "weatherApp.blocks.exception".

Al ejecutar de nuevo la aplicación se podrá ver que el error ahora aparece con el prefijo indicado.

Figura 2 - Error ahora controlado por la aplicación

Manejo de Excepciones Centralizado

Para tener un manejo consistente de los errores generados en la aplicación se desarrollará un servicio que tome dichos errores y aplique una lógica definida y centralizada.

app/blocks/exception/exception.catcher.service.js
(function() {
  'use strict';

  angular
    .module('weatherApp.blocks.exception')
    .factory('ExceptionCatcher', ExceptionCatcher);

  /**
   * @ngdoc service
   * @name weatherApp.blocks.exception.service:ExceptionCatcher
   *
   * @description
   * Handles exceptions in the application.
   */
  /* @ngInject */
  function ExceptionCatcher($log) {
    var service = {
      catcher: catcher,
      httpCatcher: httpCatcher
    };

    return service;

    ///////////////

    /**
     * @ngdoc function
     * @name ExceptionCatcher#catcher
     * @methodOf weatherApp.blocks.exception.service:ExceptionCatcher
     *
     * @description
     * Handles an exception with its message.
     *
     * @param {string} event - Name of the event that caused the error.
     * @param {string} message - Message for the exception.
     * @param {object} reason - Object with more information about the error.
     */
    function catcher(event, message, reason) {
      $log.debug(event + ': ' + message, reason);
    }

    /**
     * @ngdoc function
     * @name ExceptionCatcher#httpCatcher
     * @methodOf weatherApp.blocks.exception.service:ExceptionCatcher
     *
     * @description
     * Handles HTTP errors.
     *
     * @param {Object} error - Error object.
     * @param {number} status - HTTP status code.
     * @param {Object} config - Object with the request data.
     */
    function httpCatcher(error, status, config) {
      var message = 'Error: ' + JSON.stringify(error) + '\n' + 'Status: ' + status + '\n';

      if (config && config.headers && config.headers.authorization) {
        config.headers.authorization = config.headers.authorization.replace(/./g, '*');
      }

      if (config.data && config.data.password) {
        config.data.password = config.data.password.replace(/./g, '*');
      }

      if (config.params && config.params.appid) {
        config.params.appid = config.params.appid.replace(/./g, '*');
      }

      catcher('HTTP_ERROR', message, config);
    }
  }
})();
  • Líneas 38-40: Se define la función "catcher" que será invocada cuando se maneje un error en la aplicación. Se tiene un parámetro "event" dado que algunas herramientas de monitoreo y reportes permiten asociar un error a un evento o categoría específico para diferenciar más fácilmente los errores entre sí. De momento esta función sólo envía los errores a la consola, pero una aplicación productiva puede usar servicios como Google AnalyticsFlurry Crash AnalyticsNew Relic, entre otras.
  • Líneas 54-66: Se tiene una función adicional "httpCatcher" para los errores específicos de llamados HTTP, ya que estos cuentan con información adicional (el código HTTP del estado y el objeto "config") y también por motivos de seguridad se deben ofuscar credenciales que puedan hacer parte de las peticiones (reemplazando cada caracter por asterisco). Al final se invoca "catcher" con la información del error debidamente procesada.

app/blocks/exception/exception.handler.decorator.js
(function() {
  'use strict';

  angular
    .module('weatherApp.blocks.exception')
    .config(exceptionConfig);

  /* @ngInject */
  function exceptionConfig($provide) {
    $provide.decorator('$exceptionHandler', extendExceptionHandler);
  }

  /**
   * @ngdoc function
   * @name weatherApp.blocks.exception.function:extendExceptionHandler
   * @private
   *
   * @description
   * Extends the $exceptionHandler service to perform a more advanced AngularJS exception handling.
   *
   * @param {Object} $delegate - $exceptionHandler delegator.
   * @param {Object} ExceptionCatcher - Exception handler.
   * @returns {function} Function defining how to handle the exception.
   */
  /* @ngInject */
  function extendExceptionHandler($delegate, ExceptionCatcher) {

    function handleException(exception, cause) {
      $delegate(exception, cause);

      var errorData = {
        exception: exception,
        cause: cause
      };

      var msg = 'Weather Web App Error: ' + exception.message;
      ExceptionCatcher.catcher('ANGULARJS_ERROR', msg, errorData);
    }

    return handleException;
  }
})();

  • Líneas 22 y 26: Se reemplaza la inyección del servicio "$log" por el nuevo servicio "ExceptionCatcher".
  • Línea 37: Se usa la función "ExceptionCatcher.catcher()" para procesar el error recibido.

Al revisar nuevamente la aplicación podrá verse que el error ya tiene como prefijo el evento "ANGULARJS_ERROR" asignado a los errores no controlados de AngularJS.

Figura 3 - Error con evento como prefijo

Para manejar los errores de las peticiones HTTP se creará un nuevo módulo con un servicio que haga las veces de interceptor.

app/blocks/http/http.module.js
(function() {
  'use strict';

  /**
   * @ngdoc overview
   * @name weatherApp.blocks.http
   *
   * @description
   * Module for common http operations.
   */
  angular.module('weatherApp.blocks.http', []);
})();

app/blocks/http/http.interceptor.service.js
(function() {
  'use strict';

  angular
    .module('weatherApp.blocks.http')
    .factory('HttpInterceptor', HttpInterceptor);

  /**
   * @ngdoc service
   * @name weatherApp.blocks.http.service:HttpInterceptor
   *
   * @description
   * Intercepts all HTTP requests.
   */
  /* @ngInject */
  function HttpInterceptor($q, ExceptionCatcher) {
    var service = {
      responseError: responseError
    };

    return service;

    ///////////////

    /**
     * @ngdoc function
     * @name HttpInterceptor#responseError
     * @methodOf weatherApp.blocks.http.service:HttpInterceptor
     *
     * @description
     * Handles all HTTP responses with error.
     *
     * @param {Object} rejection - Object with information about the error.
     */
    function responseError(rejection) {
      ExceptionCatcher.httpCatcher(rejection.data, rejection.status, rejection.config);
      return $q.reject(rejection);
    }
  }
})();
  • Línea 35: Se implementa la función "responseError" que será ejecutada automáticamente por AngularJS cuando se presente un error en la respuesta de una petición HTTP.
  • Línea 36: El error obtenido se procesa usando "ExceptionCatcher.httpCatcher()" que como se dijo anteriormente, procesará el error HTTP usando la lógica centralizada para control/reporte de errores.
  • Línea 37: Al final se propaga el error recibido usando "$q.reject()".

app/core/core.module.js
(function() {
  'use strict';

  /**
   * @ngdoc overview
   * @name weatherApp.core
   * @requires weatherApp.blocks.router
   *
   * @description
   * Module for the core bussiness logic and intra-app features.
   */
  angular.module('weatherApp.core', [
    /* Cross-app modules */
    'weatherApp.blocks.router',
    'weatherApp.widgets',
    'weatherApp.blocks.exception',
    'weatherApp.blocks.http'
  ]);
})();

app/app.config.js
(function() {
  'use strict';

  angular
    .module('weatherApp')
    .config(configure);

  /* @ngInject */
  function configure($compileProvider, $logProvider, $httpProvider) {
    // Replaced by Gulp build task
    $compileProvider.debugInfoEnabled('@@debugInfoEnabled' !== 'false');
    $logProvider.debugEnabled('@@debugLogEnabled' !== 'false');

    $httpProvider.interceptors.push('HttpInterceptor');
  }
})();

  • Línea 14: Se adiciona el interceptor en la configuración del módulo principal de la aplicación para que sea usado al fallar cualquier petición HTTP.

Al tenerse el manejo del error de las peticiones HTTP en el interceptor, ya no es necesario enviar a la consola el error capturado en "WeatherService.checkCity.checkCityError", por lo que el servicio quedaría de la siguiente manera:

app/core/weather.service.js
(function() {
  'use strict';

  angular
    .module('weatherApp.core')
    .factory('WeatherService', WeatherService);

  /**
   * @ngdoc service
   * @name weatherApp.core.service:WeatherService
   *
   * @description
   * Service with the weather operations.
   */
  /* @ngInject */
  function WeatherService($q, $http) {
    var openWheaterAppId = 'abc123';

    var service = {
      checkCity: checkCity
    };

    return service;

    ///////////////

    /**
     * @ngdoc function
     * @name WeatherService#checkCity
     * @methodOf weatherApp.core.service:WeatherService
     *
     * @description
     * Checks the weather on a given city.
     *
     * @param {number} cityId - Id of the city to check.
     * @returns {Promise} Promise that will be resolved with the current weather in the checked city.
     */
    function checkCity(cityId) {
      var deferred = $q.defer();

      $http.get('http://api.openweathermap.org/data/2.5/weather', {
          params: {
            id: cityId,
            units: 'metric',
            appid: openWheaterAppId
          }
        })
        .then(checkCityComplete)
        .catch(checkCityError);

      function checkCityComplete(response) {
        if (!response || !response.data || response.data.cod === '404') {
          checkCityError('City not found');
        } else {
          var cityWeather = {
            name: response.data.name,
            country: response.data.sys.country,
            description: response.data.weather[0].description,
            temperature: response.data.main.temp,
            image: 'http://openweathermap.org/img/w/' + response.data.weather[0].icon + '.png'
          };

          deferred.resolve(cityWeather);
        }
      }

      function checkCityError(error) {
        deferred.reject(error);
      }

      return deferred.promise;
    }
  }
})();

  • Línea 16: Se remueve la inyección del servicio "$window".
  • Líneas 67-69: El error ya no se envía a la consola, simplemente se indica que la promesa retornada originalmente ha generado un error.
Para probar que el interceptor está funcionando se puede indicar un Application ID (variable openWheaterAppId) erróneo y tratar de consultar el clima de una ciudad:

Figura 4 - Error en llamado HTTP controlado con credencial ofuscada

Generación de Documentación

Hasta el momento el código JavaScript se ha estado documentando usando las directivas/etiquetas de "ngdoc". Ahora se usará el plugin "gulp-ngdocs" para generar un sitio web con la documentación del código, similar a las páginas de Javadoc en Java.

npm install gulp-ngdocs --save-dev

gulpfile.js
...
// Generates the documentation
gulp.task('ngdocs', function () {
  var options = {
    title: "Weather Web App Documentation",
    html5Mode: false
  };
  return gulp.src(paths.js, {cwd: paths.app})
    .pipe(plugins.ngdocs.process(options))
    .pipe(gulp.dest('./docs'));
});

// Starts a server with the docs
gulp.task('server-docs', ['ngdocs'], function () {
  plugins.connect.server({
    root: './docs',
    hostname: '0.0.0.0',
    port: 8081
  });
});
...

  • Tarea ngdocs: Busca los archivos JavaScript del proyecto (paths.js) y genera la documentación usando las opciones indicadas en la variable "options", en la que se indica "html5Mode: false" para que la aplicación generada se pueda navegar sin tener que crear reglas adicionales en el servidor. Finalmente almacena esta documentación en la carpeta "docs".
  • Tarea server-docs: Inicia un servidor local luego de ejecutar la tarea "ngdocs" en el cual se puede ver la documentación generada. En este caso se usa el puerto 8081 ya que el 8080 ya está asociado al servidor local de la aplicación (tareas server y server-docs).

.gitignore


dist/
node_modules/
bower_components/
npm-debug.log
docs/

Nota: Este punto depende de las preferencias personales o los estándares de la organización ya que la documentación del proyecto puede generarse automáticamente y hay quienes no gustan tener archivos auto-generados en el repositorio de código, en cuyo caso se debe incluir la carpeta "docs/" en ".gitignore", pero para quienes prefieren tener la documentación siempre disponible en el repositorio pueden obviar esta entrada.


Al ejecutar el comando "gulp server-docs" y abrir la URL "http://localhost:8081" podrá verse la documentación del proyecto.

Figura 5 - Documentación del código del proyecto

Conclusiones

Aunque se trata de elementos relativamente opcionales para el funcionamiento de la aplicación, tener un manejo claro y consistente de los errores y una documentación actualizada permiten entender de manera más rápida y eficiente el funcionamiento actual de la aplicación, las posibles causas de errores que se presenten y su solución de manera más rápida.

En un próximo post se cubrirán algunas de las recomendaciones para generar aplicaciones para producción, por lo pronto el código del proyecto aquí desarrollado se puede descargar desde: https://github.com/guillermo-varela/angularjs-demo/tree/part-III

Referencias

https://docs.angularjs.org/api
https://github.com/johnpapa/angular-styleguide/blob/master/a1/README.md

sábado, 12 de marzo de 2016

Prácticas Recomendadas para Proyectos con AngularJS 1.x Parte II: Directivas, Constantes, Controladores y Servicios

Introducción

En el post anterior se cubrieron varias de las recomendaciones que se tienen para el desarrollo de aplicaciones web usando AngularJS 1.x.

Ahora se dará continuidad con más de aquellas recomendaciones para otros tipos de componentes de AngularJS.

Directivas

Dado que se tiene un encabezado común entre las secciones "Home" y "About" se aprovechará esto para definir una directiva que permita unificar este encabezado en un solo componente.

Aunque en este ejemplo se tiene una sola directiva, la recomendación es que cada directiva debe estar en un archivo propio, con el fin de que sea más fácil de mantener y compartir entre las varias secciones de la aplicación, aunque sí pueden estar dentro de un mismo módulo.

app/widgets/widgets.module.js
(function() {
  'use strict';

  /**
   * @ngdoc overview
   * @name weatherApp.widgets
   *
   * @description
   * Module for the UI widgets used by the app.
   */
  angular.module('weatherApp.widgets', []);

})();

app/widgets/wa-section-header/wa-section-header.directive.js
(function() {
  'use strict';

  angular
    .module('weatherApp.widgets')
    .directive('waSectionHeader', waSectionHeader);

  /**
   * @ngdoc directive
   * @name weatherApp.widgets.directive:waSectionHeader
   * @restrict EA
   *
   * @description
   * Renders the title and subtitle for sections in an consistent way.
   */
  function waSectionHeader() {
    var directive = {
      restrict: 'EA',
      scope: {
        title: '=waTitle',
        subtitle: '=waSubtitle'
      },
      templateUrl: 'widgets/wa-section-header/wa-section-header.html'
    };

    return directive;
  }
})();

  • Línea 6: Se usa el prefijo "wa" (weatherApp) para el nombre de la directiva para evitar que se pueda confundir con alguna otra que se tenga en una dependencia externa.
  • Línea 23: La plantilla HTML se ubica al lado de este archivo.

Esta directiva podrá usarse en una página HTML de una de las dos maneras siguientes:

Elemento
<wa-section-header wa-title="'Section Title'" wa-subtitle="'Subtitle'"></wa-section-header>

Atributo
<div wa-section-header wa-title="'Section Title'" wa-subtitle="'Subtitle'"></div>

Notas:

  • En el caso del uso como atributo, "div" se usa sólo como ejemplo, puede usarse cualquier otro elemento HTML.

app/widgets/wa-section-header/wa-section-header.html
<div class="jumbotron text-center">
  <h1>{{title}}</h1>
  <p ng-show="subtitle">{{subtitle}}</p>
</div>

app/core/core.module.js
(function() {
  'use strict';

  /**
   * @ngdoc overview
   * @name weatherApp.core
   * @requires weatherApp.blocks.router
   *
   * @description
   * Module for the core bussiness logic and intra-app features.
   */
  angular.module('weatherApp.core', [
    /* Cross-app modules */
    'weatherApp.blocks.router',
    'weatherApp.widgets'
  ]);
})();

Dado que el nuevo módulo "weatherApp.widgets" será usado en múltiples módulos (secciones), se incluye en "weatherApp.core" para que sea visible en estos.

app/home/home.html
<wa-section-header wa-title="'Available Cities'"
                   wa-subtitle="'Select the city to check the current weather.'">
</wa-section-header>

app/about/about.html
<wa-section-header wa-title="'About this Site'"
                   wa-subtitle="'This site is just a demonstration for AngularJS 1.5 with an organized project structure using Gulp.'"
                   class="about-header">
</wa-section-header>

.jscsrc
{
  "preset": "google",
  "maximumLineLength": {
    "value": 160,
    "allowComments": true,
    "allowRegex": true
  },
  "jsDoc": {
    "checkAnnotations": {
      "preset": "jsdoc3",
      "extra": {
        "ngdoc": true,
        "methodOf": true,
        "restrict": true
      }
    }
  }
}

Dado que "restrict" no hace parte de las etiquetas de JSDoc, se adiciona en la línea 14 para pasar la comprobación de código que realiza JSCS.

Al ejecutar la aplicación (mediante el comando gulp) podrá verse que no solamente se ve el encabezado de cada sección con el título y subtítulos esperados, sino que también en la sección "About" se conserva el estilo del subtítulo debido al uso del atributo "class" al definir la directiva en "about.html".

Figura 1 - Sección "About" con la nueva directiva

Constantes

Dado que la lista de ciudades disponibles para consultar el clima no va a variar ni se consultará de un servicio web externo, se tendrá esta lista (arreglo) como una constante en la aplicación.

Cuando se trata de constantes de uso general para toda la aplicación se podrán tener en un archivo "app/core/constants.js", sin embargo cuando sean de uso exclusivo y además necesario en un módulo específico estas deberán declararse en la definición de dicho módulo.

app/home/home.module.js
(function() {
  'use strict';

  /**
   * @ngdoc overview
   * @name weatherApp.home
   * @requires weatherApp.core
   *
   * @description
   * Module for the home section.
   */
  angular
    .module('weatherApp.home', ['weatherApp.core'])
    .constant('cities', [
      {'id': 5128581, 'name': 'New York City', 'country': 'US'},
      {'id': 5549222, 'name': 'Washington', 'country': 'US'},
      {'id': 5754005, 'name': 'Springfield', 'country': 'US'},
      {'id': 2643741, 'name': 'City of London', 'country': 'GB'},
      {'id': 2644210, 'name': 'Liverpool', 'country': 'GB'},
      {'id': 2653265, 'name': 'Chelsea', 'country': 'GB'},
      {'id': 3530597, 'name': 'Mexico City', 'country': 'MX'},
      {'id': 3531673, 'name': 'Cancun', 'country': 'MX'},
      {'id': 3435910, 'name': 'Buenos Aires', 'country': 'AR'},
      {'id': 3844421, 'name': 'Mendoza', 'country': 'AR'},
      {'id': 3688689, 'name': 'Bogota', 'country': 'CO'},
      {'id': 3687925, 'name': 'Cali', 'country': 'CO'},
      {'id': 3674962, 'name': 'Medellin', 'country': 'CO'},
      {'id': 3448439, 'name': 'Sao Paulo', 'country': 'BR'},
      {'id': 3451190, 'name': 'Rio de Janeiro', 'country': 'BR'}
    ]);

})();

Nota: En este caso la lista no es muy grande, pero en casos que sí lo sea quizás se prefiera tener un archivo ".json" por ejemplo en "app/assets/data", como lo hacen en el tutorial oficial de AngularJS para obtener la información de los teléfonos, aunque en dicho caso se deben tener dos consideraciones:


Controladores

El primer controlador de la aplicación será el que provea la lista de ciudades a la página HTML de la sección "Home".

app/home/home.controller.js
(function() {
  'use strict';

  angular
    .module('weatherApp.home')
    .controller('HomeController', HomeController);

  /**
   * @ngdoc controller
   * @name weatherApp.home.controller:HomeController
   *
   * @description
   * Controller for the 'Home' section.
   */
  /* @ngInject */
  function HomeController(cities) {
    var vm = this;

    vm.cities = cities;
  }
})();


  • Línea 16: Dado que la constante "cities" se definió también en el módulo "weatherApp.home" puede inyectarse como una dependencia del controlador.
  • Línea 17: Se define la variable "vm" (ViewModel) con el valor del operador "this" para permitir referenciar el controlador desde fragmentos de código anidados (callbacks por ejemplo) en donde "this" hace referencia a otro componente.
  • Línea 19: El controlador expondrá a la vista la lista (arreglo) de ciudades en la propiedad "cities" y se indica al inicio del controlador para facilitar su ubicación, en caso de tener más código en el controlador.


app/home/home.route.js
(function() {
  'use strict';

  angular
    .module('weatherApp.home')
    .run(appRun);

  /* @ngInject */
  function appRun(routerHelper) {
    routerHelper.configureStates(getStates(), '/home');
  }

  function getStates() {
    return [
      {
        state: 'home',
        config: {
          url: '/home',
          templateUrl: 'home/home.html',
          controller: 'HomeController',
          controllerAs: 'vm',
          data: {
            pageTitle: 'Home'
          }
        }
      }
    ];
  }
})();

Con respecto al archivo original, se adicionaron las líneas 20 y 21 para indicar el controlador que se usará para la vista en esta ruta y su alias (variable disponible), esto con el fin de poder usar diferentes combinaciones de controladores y vistas según la ruta.

app/home/home.html
<wa-section-header wa-title="'Available Cities'"
                   wa-subtitle="'Select the city to check the current weather.'">
</wa-section-header>

<div class="list-group">
  <a ng-repeat="city in vm.cities | orderBy:'name'" ui-sref="city({ cityId: city.id })" class="list-group-item">
    {{ city.name }} - {{city.country}}
  </a>
</div>

Ahora, adicional al encabezado, se está iterando el arreglo de ciudades con la directiva "ngRepeat", se ordena mediante el filtro "orderBy" y se crea dinámicamente el estado destino de cada enlace mediante la directiva "ui-sref", indicando como parámetro el ID de la ciudad.

Al abrir la página se debe tener un resultado como el siguiente:
Figura 2 - Lista de Ciudades

Cabe anotar que teniendo todos los archivos de la sección "Home" en la carpeta "app/home" se hace más fácil encontrar las vistas, controladores y rutas. También al tener solamente contenido sobre dicha sección cada archivo es más pequeño y específico, por lo que la aplicación permanece fácil de mantener, así se tengan muchas secciones, sin importar si se está usando un editor de texto básico (vi, Emacs, etc.), un editor más avanzado (Sublime Text, Brackets, Atom, Notepad++, etc.) o un IDE (WebStorm, Aptana, Visual Studio, etc.).

Servicios/Factories

Siguiendo las recomendaciones, en la implementación del código se usarán fábricas (factories) en lugar de servicios para la consulta a servicios externos o lógica de negocios, aunque se usará el término servicio (service) para referirse a estos dado que es semánticamente más cercano a su propósito.

Consulta del Clima

Para este ejemplo la consulta del clima actual en la ciudad seleccionada se hará a un servicio web público y gratuito: OpenWeatherMap

Se usa dicho servicio no solamente por tener una opción gratuita, sino por también para demostrar que AngularJS se puede usar como interfaz para cualquier servicio web, sin importar si fue desarrollado por el mismo equipo o el lenguaje de programación usado (en este caso no se conoce). Lo único que se necesita es que dicho servicio web exponga un API (idealmente usando JSON como formato de datos).

Anteriormente el servicio podía consultarse libremente, pero ahora se requiere registro para generar un Application ID, lo cual se puede hacer gratuitamente: http://openweathermap.org/appid

Implementación del Servicio

Una vez realizado el registro se puede implementar la consulta en la aplicación web.

app/core/weather.service.js
(function() {
  'use strict';

  angular
    .module('weatherApp.core')
    .factory('WeatherService', WeatherService);

  /**
   * @ngdoc service
   * @name weatherApp.core.service:WeatherService
   *
   * @description
   * Service with the weather operations.
   */
  /* @ngInject */
  function WeatherService($window, $q, $http) {
    var openWheaterAppId = 'abc123';

    var service = {
      checkCity: checkCity
    };

    return service;

    ///////////////

    /**
     * @ngdoc function
     * @name WeatherService#checkCity
     * @methodOf weatherApp.core.service:WeatherService
     *
     * @description
     * Checks the weather on a given city.
     *
     * @param {number} cityId - Id of the city to check.
     * @returns {Promise} Promise that will be resolved with the current weather in the checked city.
     */
    function checkCity(cityId) {
      var deferred = $q.defer();

      $http.get('http://api.openweathermap.org/data/2.5/weather', {
          params: {
            id: cityId,
            units: 'metric',
            appid: openWheaterAppId
          }
        })
        .then(checkCityComplete)
        .catch(checkCityError);

      function checkCityComplete(response) {
        if (!response || !response.data || response.data.cod === '404') {
          checkCityError('City not found');
        } else {
          var cityWeather = {
            name: response.data.name,
            country: response.data.sys.country,
            description: response.data.weather[0].description,
            temperature: response.data.main.temp,
            image: 'http://openweathermap.org/img/w/' + response.data.weather[0].icon + '.png'
          };

          deferred.resolve(cityWeather);
        }
      }

      function checkCityError(error) {
        $window.console.error(error);
        deferred.reject(error);
      }

      return deferred.promise;
    }
  }
})();

  • Línea 17: Se define una variable dentro del servicio con el ID generado en OpenWeatherMap. No se define como una constante, ya que no se requiere en otro componente dentro del módulo "weatherApp.core".
  • Línea 39: Usando el método "defer" se crea un objeto Deferred, el cual representa una tarea que se completará en el futuro.
  • Líneas 41-47: Mediante el método "get" de "$http" se consulta el clima actual de la ciudad seleccionada, usando el ID de la ciudad. El primer parámetro es la URL del servicio web mientras. La URL completa que se debe usar es "http://api.openweathermap.org/data/2.5/weather?id=CITY_ID&appid=APP_ID", pero en este caso no se indican los parámetros ya que el segundo es un objeto que contiene la llave "params" con los parámetros enviados en la URL (query string), lo cual es más fácil de leer que varias cadenas concatenadas.
  • Línea 48: Cuando la llamada HTTP finaliza con éxito se invocará la función "checkCityComplete".
  • Línea 49: Cuando la llamada HTTP finaliza con error (timeout o servicio en mantenimiento por ejemplo) se invocará la función "checkCityError".
  • Líneas 51-66: El método "get" de "$http" de hecho ya retorna una promesa, pero el objeto que esta contiene se tienen elementos propios de HTTP (headers, status, etc) y todos los atributos del objeto que responde la consulta del clima, por eso se usará la función "checkCityComplete" para retornar solamente los datos necesarios y debidamente procesados de ser necesario, como por ejemplo la URL completa del ícono del clima, usando el método "resolve".
  • Líneas 68-71: En caso de error, se envía el error a la consola del navegador web y con el método "reject" se notifica al componente que espera la respuesta a la promesa retornada acerca del error.

app/home/city/city.controller.js
(function() {
  'use strict';

  angular
    .module('weatherApp.home')
    .controller('CityController', CityController);

  /**
   * @ngdoc controller
   * @name weatherApp.home.controller:CityController
   *
   * @description
   * Controller for the 'City' section.
   */
  /* @ngInject */
  function CityController($rootScope, $stateParams, $window, WeatherService) {
    var vm = this;

    vm.cityWeather = {};

    activate();

    ///////////////

    function activate() {
      WeatherService.checkCity($stateParams.cityId)
        .then(checkCityComplete)
        .catch(checkCityError)
        .finally(checkCityFinally);
    }

    function checkCityComplete(data) {
      vm.cityWeather = data;
    }

    function checkCityError() {
      vm.cityWeather = {
        name: 'Unknown City'
      };
      $window.alert('Error checking the weather.');
    }

    function checkCityFinally() {
      $rootScope.pageTitle = vm.cityWeather.name + ' - ' + $rootScope.pageTitle;
    }
  }
})();

  • Línea 26: Usando el servicio "WeatherService" se consulta el clima de la ciudad cuyo ID se indica como el parámetro "cityId" del estado (o URL). Dichos parámetros se pueden obtener mediante el servicio "$stateParams".
  • Líneas 27-29: Dado que la función "WeatherService.checkCity()" retorna una promesa, se usan los métodos "then", "catch" y "finally" para indicar las funciones que se ejecutarán cuando la consulta del clima finalice con éxito, error y una función que se ejecutará siempre.
  • Línea 33: Al finalizar exitosamente la consulta se asigna "vm.cityWeather" al resultado obtenido.
  • Líneas 36-41: En caso de error se asigna un valor por defecto y se muestra una alerta indicando que se presentó un error.
  • Línea 44: Sin importar si el resultado de la consulta del clima fue exitoso o no, se reemplaza el atributo "$rootScope.pageTitle" el cual contiene el título que se mostrará en la pestaña del navegador, bien sea con el nombre de la ciudad o el valor por defecto "Unknown".

Notas:

  • Es importante recalcar que al usar promesas la función "activate" no se bloqueará mientras se hace la consulta del clima, sino que continuará su procesamiento y posiblemente se termine mostrando la página HTML antes de tener los datos listos. Es por ello que algunas aplicaciones muestra un diálogo de espera y lo ocultan en la función indicada en "finally", para que el usuario sepa que la página inicialmente vacía está esperando por la llegada de los datos.
  • La carpeta "city" se encuentra dentro de "home" sólo para ilustrar cómo se ven secciones anidadas en esta estructura. Bien podría tenerse un módulo "city" por su propia cuenta.


app/home/city/city.html
<wa-section-header wa-title="vm.cityWeather.name"
                   wa-subtitle="vm.cityWeather.country">
</wa-section-header>

<div id="currentWeather" ng-show="vm.cityWeather.image">
  <img id="weatherImage" ng-src="{{vm.cityWeather.image}}" />
  <br/>
  <strong>Weather</strong>: {{vm.cityWeather.description}}
  <br/>
  <strong>Temperature</strong>: {{vm.cityWeather.temperature}} &deg;C
</div>

<br/>
Weather data from: <a href="http://openweathermap.org" target="_blank">OpenWeatherMap</a>

  • Líneas 1-3: Se indica que nombre de la ciudad y el país serán el título y subtítulo de la sección usando la directiva "waSectionHeader".
  • Línea 5: La información del clima se muestra sólo si la propiedad "vm.cityWeather.image" existe (dado que esta propiedad no se implementa en la función "CityController.checkCityError()" usando la directiva "ngShow".
  • Líneas 6-10: Se muestra la información obtenida del clima.
  • Línea 14: Aunque el uso del API de OpenWeatherMap es gratuito, sí se requiere que se indique el origen de los datos mostrados: http://openweathermap.org/price (ver "What about licensing?").


app/home/home.route.js
(function() {
  'use strict';

  angular
    .module('weatherApp.home')
    .run(appRun);

  /* @ngInject */
  function appRun(routerHelper) {
    routerHelper.configureStates(getStates(), '/home');
  }

  function getStates() {
    return [
      {
        state: 'home',
        config: {
          url: '/home',
          templateUrl: 'home/home.html',
          controller: 'HomeController',
          controllerAs: 'vm',
          data: {
            pageTitle: 'Home'
          }
        }
      },
      {
        state: 'city',
        config: {
          url: '/home/city/:cityId',
          templateUrl: 'home/city/city.html',
          controller: 'CityController',
          controllerAs: 'vm'
        }
      }
    ];
  }
})();

Con respecto al archivo original se adicionó el bloque que va entre las líneas 26-35, en el cual se adicionó el nuevo estado "city" a las rutas del módulo "weatherApp.home".

En el valor de la propiedad "url" se indica que se espera recibir un parámetro en la URL de este estado y se le asignará el nombre "cityId". Así la URL "http://localhost:8080/#/home/city/5128581" indicará que "$stateParams.cityId" tendrá el valor "5128581".

Nota: En este caso "city" no es un estado anidado dentro de "home" dado que se trata de vistas diferentes (sin compartir elementos). Para más información sobre estados y vistas anidadas se puede consultar: https://github.com/angular-ui/ui-router/wiki/Nested-States-and-Nested-Views

Ejecutando la Aplicación

Al ejecutar la aplicación (comando gulp) podrá verse que al hacer clic en alguna de las ciudades que aparece en "Home" se muestra su clima actual (actualizando el título en la pestaña del navegador) o en caso de ingresar una URL manualmente con un ID inválido se muestra la alerta con el error.

Figura 3 - Clima actual de una ciudad

Figura 4 - Alerta de error al consultar el clima

Figura 5 - Texto y título de la página en caso de error

Conclusiones

En este punto ya se tiene una aplicación web de consulta del clima completamente funcional (al menos en ambientes de desarrollo) aplicando las recomendaciones sobre desarrollo con AngularJS 1.x.

Cabe anotar que aunque se han adicionado y modificado muchos archivos JavaScript no se ha tenido que modificar "index.html" ya que la tarea de Gulp "inject" se ha encargado de incluir las nuevas referencias en el orden correcto (mediante los plugins "gulp-inject" y "gulp-angular-filesort").

En un próximo post se cubrirán algunas de las recomendaciones para manejar errores de manera consistente y para generar la documentación, por lo pronto el código del proyecto aquí desarrollado se puede descargar desde: https://github.com/guillermo-varela/angularjs-demo/tree/part-II

Referencias

https://docs.angularjs.org/api
https://github.com/johnpapa/angular-styleguide/blob/master/a1/README.md
https://github.com/angular-ui/ui-router/wiki