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

No hay comentarios.:

Publicar un comentario