Recipes App – Parte 4 – Refactorización usando Servicios

4ª parte de nuestro proyecto con Angular, que consiste en una sencilla app de recetas donde podrás buscar recetas y apuntar ingredientes en una "lista de la compra". Si acabas de aterrizar aquí, puedes ver las partes #1 #2 y #3 antes ?.

Hasta ahora hemos creado y ordenado nuestra app en componentes y creado una Directiva propia (en inglés, custom directive). En esta parte nos vamos a encargar de optimizar el código y reducir su innecesaria complejidad debido a la enrevesada estructura de comunicación entre componentes que hemos construido. ?

Para eso, vamos a echar mano de una herramienta de Angular: los Servicios. Antes te meternos en harina, aquí tienes una guía completa sobre el tema ?.

Esta es la primera vez en todo el proyecto que en lugar de añadir, vamos a quitar. Porque, también

en programación, a veces menos es más.

Servicios necesarios y sus funciones

Recordemos que los servicios nos ayudan a centralizar datos y tareas, así que si repasamos la estructura actual de nuestra app, podríamos concluir que, para empezar, vamos a necesitar dos servicios, uno para cada feature de la app.

root (AppComponent)

header

shopping list

shopping list

shopping list

shopping list edit

ingredient

recipe book

recipe

recipe list

recipe item

recipe detail

recipe

  •  características (en inglés, features) que necesita nuestra app 
  •  componentes
  •  modelos
  •  servicios

El ShoppingListService gestionará la parte de la shopping list, pero también podremos acceder a él desde el área de recetas, porque necesitaremos añadir ingredientes a nuestra lista de la compra desde ahí. Por otro lado, como su propio nombre indica, el RecipeService se encargará de gestionar las recetas y sus correspondientes datos.

Creación de los servicios

Uno de los enfoques habituales es colocar un servicio lo más cerca posible de los componentes con los que vaya a trabajar. Por ejemplo, colocaremos el RecipeService dentro de la carpeta recipes.

1. En la terminal, nos posicionamos dentro de la carpeta recipes y creamos el servicio con el comando ng generate service recipe --skipTests.

2. Navegamos en la terminal hasta la carpeta shopping-list y creamos el otro servicio, ShoppingListService, usando también la CLI de Angular, con el comando ng generate service shopping-list --skipTests.

Hecho esto, vamos a configurar el RecipeService en primer lugar.

Cómo configurar el RecipeService para que gestione nuestras recetas

1. Copiamos el array de recipes del recipe-list.component.ts y dejamos el array sin definir.

  recipes: Recipe[];

2. Pegamos el array en el RecipeService, creando así la primera propiedad de nuestro servicio.

3. Importamos la referencia al Recipe model

4. Convertimos la propiedad recipes en privada, usando la palabra clave private, para que no podamos acceder a ella desde fuera del componente directamente. Sin embargo, necesitamos alguna forma de acceder al array desde fuera, así que lo que hacemos es crear un método llamado getRecipes que nos devuelva el array.

import { Injectable } from '@angular/core';
import { Recipe } from './recipe.model';

@Injectable({
  providedIn: 'root'
})
export class RecipeService {

  private recipes: Recipe[] = [
    new Recipe('Paella valenciana',
      'Recipe description',
      'https://upload.wikimedia.org/wikipedia/commons/thumb/e/ed/01_Paella_Valenciana_original.jpg/800px-01_Paella_Valenciana_original.jpg'),
    new Recipe('Recipe title test',
      'Recipe description',
      'https://p1.pxfuel.com/preview/683/172/968/cake-sponge-cake-bowl-cake-small.jpg')
  ];

  getRecipes() {
    return this.recipes;
  }

}

No parece que tenga sentido bloquear el acceso a recipes desde fuera si luego creamos un acceso desde el método ?. Pero esto lo hacemos como un primer paso para crear, no un acceso al array mismo, sino a una copia

?‍? Con esta medida conseguimos proteger al array que vive en el servicio, de manera que, si por algún motivo hacemos cambios en el array en otra parte de la app, no estaremos haciendo cambios directamente sobre la propiedad recipes, sino sobre una copia de la misma. 

? Si te haces la picha un lío con esto de las copias, quizás necesites reforzar tus conocimientos sobre reference types y primitive types

5. Para obtener una copia del array, usamos el método slice sin argumentos.

    return this.recipes.slice();

Dado que nuestros servicios están configurados para ser accesibles a lo largo de toda nuestra app, no necesitamos especificarle a Angular nada más. Únicamente tendremos que llamarlos desde donde los necesitemos. En este caso, vamos a llamar al RecipeService desde el RecipeListComponent. 

6. En el constructor del recipe-list.component.ts, inyectamos el servicio, lo importamos y lo convertimos en una propiedad usando la palabra clave private. 

7. Inicializamos el servicio en el ngOnInit, asignándoselo como valor a la propiedad recipes. Desde el servicio, accedemos al método getRecipes(). Esta es la manera de obtener la copia del array recipes que hemos creado en el servicio.

  constructor(private recipeService: RecipeService) { }

  ngOnInit() {
    this.recipes = this.recipeService.getRecipes();
  }

Con estos cambios, si ahora guardas, tu app debería comportarse exactamente igual que antes, pero ahora con un código más simple y optimizado. ¡Chachi! ?

Cómo usar el RecipeService para comunicar componentes entre sí

Sigamos optimizando nuestra app gracias a los servicios. En las partes anteriores de este proyecto construimos una laaarga cadena de inputs outputs para comunicar componentes entre sí, para cosas tan básicas como informar a un componente de que alguien ha seleccionado un ingrediente. Vamos a mejorar ese código. ?

1. En el recipe-item.component.ts, dejamos de emitir nuestro propio evento. No olvides limpiar los imports que se quedan sin utilizar. 

  @Input() recipe: Recipe;

  constructor() { }

  ngOnInit() {
  }

  onSelected() { }

2. En el RecipeService, añadimos una propiedad llamada, por ejemplo, recipeSelected, de tipo Recipe, que actuará como event emitter. 

export class RecipeService {
  recipeSelected = new EventEmitter<Recipe>();

3. Volvemos al recipe-item.component.ts e inyectamos el servicio en el constructor.

4. Desde el método onSelected, llamaremos al servicio para acceder a nuestro event emitter, y emitimos la propiedad recipe, porque esa es la propiedad que el usuario ha seleccionado.

  constructor(private recipeService: RecipeService) { }

  ngOnInit() {
  }

  onSelected() {
    this.recipeService.recipeSelected.emit(this.recipe);

5. En el recipe-list.component.html, borramos nuestro custom event, porque ya no lo vamos a necesitar.

<app-recipe-item *ngFor="let recipeEle of recipes" [recipe]="recipeEle">

6. Nos deshacemos del método onRecipeSelected del recipe-list.component.ts y del event emitter. 

export class RecipeListComponent implements OnInit {

  recipes: Recipe[];

  constructor(private recipeService: RecipeService) { }

  ngOnInit() {
    this.recipes = this.recipeService.getRecipes();
  }

}

7. En el recipes.component.html, nos deshacemos del evento recipeWasSelected, porque ya no estamos usando esa comunicación de datos en cadena. 

    <app-recipe-list></app-recipe-list>

8. Desde el recipes.component.ts, inyectamos el servicio y configuramos un código en el ngOnInit para que esté pendiente de cuando ocurra un cambio. Es decir, configuramos lo que en inglés se llama un listenerPara eso, accedemos a nuestro event emitter (recipeSelected) y le decimos a Angular que nos informe cuando haya cualquier cambio en los datos que le pasemos por parámetro (espera una receta). Hacemos esto usando el método subscribe.

9. A la propiedad selectedRecipe le damos el valor del parámetro del método subscribe. Recuerda que ese parámetro es una receta, emitida desde nuestro event emitter. 

  selectedRecipe: Recipe;

  constructor(private recipeService: RecipeService) { }

  ngOnInit() {
    this.recipeService.recipeSelected
      .subscribe(
        (recipe: Recipe) => {
          this.selectedRecipe = recipe;
        }
      );
  }

Con este cambio, nuestra app debería funcionar exactamente igual que antes, pero con un código mucho más limpio y simplificado. ?

Configuración del servicio para gestionar la sección de la shopping list

Vamos a darle vida a nuestro ShoppingListService, que se encargará, entre otras cosas, de gestionar nuestra lista de ingredientes, con tareas como eliminar / añadir ingredientes. De momento, es el shopping-list.component.ts el responsable de añadir ingredientes, así que vamos a cambiar eso, centralizando esa tarea. 

1. Copiamos la propiedad ingredients del shopping-list.component.ts y la pegamos en el servicio. Convertimos la propiedad en privada, igual que hemos hecho en el RecipeService, y obtenemos una copia de ella a través de un método, para evitar tener acceso al array de ingredients original.

import { Injectable } from '@angular/core';
import { Ingredient } from '../shared/ingredient.model';

@Injectable({
  providedIn: 'root'
})
export class ShoppingListService {

  private ingredients: Ingredient[] = [
    new Ingredient('bananas', 3),
    new Ingredient('strawberries', 10)
  ];

  getIngredients() {
    return this.ingredients.slice();
  }

}

2. En el shopping-list.component.ts, borramos el valor de ingredients y lo dejamos sin inicializar. Borramos también el método onIngredientAdded, porque será el servicio el que se encargue se eso.

3. Inyectamos el servicio, lo convertimos en una propiedad para acceder a él y lo inicializamos en el ngOnInit, porque es una buena práctica inicializarlo ahí, recuerda. ?

4. Desde el ngOnInit, llamamos al método getIngredients del servicio y lo asignamos como valor a la propiedad ingredients.

export class ShoppingListComponent implements OnInit {

  ingredients: Ingredient[];

  constructor(private shoppingListService: ShoppingListService) { }

  ngOnInit() {
    this.ingredients = this.shoppingListService.getIngredients();
  }

}

Lo siguiente que vamos a hacer es crear un método en el servicio para añadir ingredientes, llamado, por ejemplo, addIngredients, que espera un ingredienteDentro del método, accedemos a la propiedad ingredients y añadimos el nuevo ingrediente al array.

  addIngredient(ingredient: Ingredient) {
    this.ingredients.push(ingredient);
  }

5. En el shopping-list-edit.component.ts, eliminamos nuestro event emitter e inyectamos el ShoppingListService en el constructor.

6. En el método onAddItem, accedemos al método addIngredient del servicio y le pasamos el newIngredient.

  @ViewChild('amountInput', { static: false }) amountInputRef: ElementRef;

  constructor(private shoppingListService: ShoppingListService) { }

  ngOnInit() {
  }

  onAddItem() {
    const ingName = this.nameInputRef.nativeElement.value;
    const ingAmount = this.amountInputRef.nativeElement.value;
    const newIngredient = new Ingredient(ingName, ingAmount);
    this.shoppingListService.addIngredient(newIngredient);
  }

7. En el shopping-list.component.html, eliminamos nuestro custom event (ingredientAdded).

    <app-shopping-list-edit></app-shopping-list-edit>

Con estos cambios, verás que si vas a tu navegador y pruebas a añadir ingredientes en la pestaña de la shopping list, no funciona, es decir, no nos añade ningún ingrediente. Pero la consola no nos lanza ningún error. ¿Qué está pasando? ?

El problema de generar copias de arrays y cómo solucionarlo

Lo que ocurre es que, al llamar al método getIngredients, del servicio, obtenemos una copia del array "ingredients". Hasta aquí, nada que no sepamos ya. El problema es que, al añadir un nuevo ingrediente, en realidad lo estamos añadiendo al array original, no a su copia. Por tanto, como el array que mostramos en nuestra UI es la copia, y no el original, estamos añadiendo el ingrediente al array equivocado. ?

Vamos a solucionar eso. Existen dos maneras de arreglarlo.

La 1ª, no haciendo una copia del array, solución más rápida pero más arriesgada. Sólo tendríamos que eliminar el método slice, haciendo así un return del array original.

    return this.ingredients;

Pero no vamos a tomar ese camino, sino una 2ª opción, que consiste en informar al componente correspondiente cuando hayan nuevos datos disponibles para añadir a la copia del array

1. Creamos un event emitter al que llamamos, por ejemplo, ingredientsChangedque emitirá un array de nuestro modelo Ingredient.

2. En el método addIngredient, que, al añadir un ingrediente al array, lo modifica, llamamos al event emitter y emitimos nuestro evento, que será una copia del array ingredients.

ingredientsChanged = new EventEmitter<Ingredient[]>();
private ingredients: Ingredient[] = [
  new Ingredient('bananas', 3),
  new Ingredient('strawberries', 10)
];
getIngredients() {
  return this.ingredients.slice();
}
addIngredient(ingredient: Ingredient) {
  this.ingredients.push(ingredient);
  this.ingredientsChanged.emit(this.ingredients.slice());
}

3. Nos vamos al shopping-list.component.ts, donde estamos obteniendo los ingredientes en el momento en el que la app se carga:

  ngOnInit() {
    this.ingredients = this.shoppingListService.getIngredients();
  }

4. En el ngOnInit, llamamos al ShoppingListService y nos suscribimos al evento ingredientsChanged. Con esto, lo que estamos haciendo es crear una sistema para detectar cada vez que el array de ingredientes cambie (porque añadamos algún ingrediente), dándole ese nuevo valor a la propiedad ingredients.

  ngOnInit() {
    this.ingredients = this.shoppingListService.getIngredients();
    this.shoppingListService.ingredientsChanged.subscribe(
      (ingredients: Ingredient[]) => {
        this.ingredients = ingredients;
      }
    );
  }

¡Y listo! En tu navegador podrás ver cómo la sección de shopping list vuelve a funcionar, pudiendo añadir ingredientes y quedando éstos registrados en la lista. ?

Cómo añadir ingredientes a una receta

Recordemos que nuestra sección de recetas tiene esta pinta:

seccion recipes

Es el momento de sustituir la palabra "Ingredients" por los ingredientes reales de cada receta. Para empezar, vamos a editar el Recipe model, porque ahora deberá tener también ingredientes, así que introduciremos un modelo dentro de otro.

import { Ingredient } from '../shared/ingredient.model';

export class Recipe {
  public name: string;
  public description: string;
  public imagePath: string;
  public ingredients: Ingredient[];

  constructor(name: string, desc: string, imagePath: string, ingredients: Ingredient[]) {
      this.name = name;
      this.description = desc;
      this.imagePath = imagePath;
      this.ingredients = ingredients;
  }
}

1. En el RecipeService, añadimos un par de ingredientes a cada receta, utilizando el constructor del Ingredient. Ya que estamos, cambiamos la "Recipe description" y el "Recipe title" por algo más real.

  private recipes: Recipe[] = [
    new Recipe('Paella valenciana',
      'Traditional Spanish dish, with hundreds of variations',
      'https://upload.wikimedia.org/wikipedia/commons/thumb/e/ed/01_Paella_Valenciana_original.jpg/800px-01_Paella_Valenciana_original.jpg',
      [
        new Ingredient('rice', 300),
        new Ingredient('rabbit', 1)
      ]),

    new Recipe('Orange cake',
      'Fluffy and delicious treat',
      'https://p1.pxfuel.com/preview/683/172/968/cake-sponge-cake-bowl-cake-small.jpg',
      [
        new Ingredient('flour', 400),
        new Ingredient('sugar', 80),
        new Ingredient('orange', 1)
      ])
  ];

2. En el recipe-detail.component.html, sustituimos el <div> de los Ingredients:

  <div class="col">
    Ingredients
  </div>

por una lista de ingredientes. Construimos la lista con un ngForiterando a lo largo de los ingredientes que acabamos de añadir.

3. Usamos string interpolation para añadir el nombre del ingrediente y la cantidad.

  <div class="col">
    <li class="list-group-item list-group-item-action"
        *ngFor="let ingredient of recipe.ingredients">
        {{ingredient.name}} -> {{ingredient.amount}}
    </li>
  </div>

¡Y listo! Ahora cuando seleccionas una receta, se puede ver una lista con sus ingredientes debajo.

lista de ingredientes dentro de receta

El siguiente paso será añadir esos ingredientes a la lista de la compra al hacer clic sobre el botón "To shopping list", accesible desde el dropdown "Manage recipe".

Cómo pasar los ingredientes de una receta a nuestra lista de la compra

Para hacer aparecer los ingredientes de una receta en la pestaña de Shopping list, vamos a hacer uso, cómo no, de nuestros servicios. Para eso, tendremos que informar al componente de que algo ha cambiado, así como averiguar la manera de añadir más de un ingrediente a la vez a la lista de la compra. ¡Vamos a ello! ?‍?

1. En el recipe-detail.component.html, le añadimos funcionalidad al botón "To shopping list", por medio de un click event vinculado a un método al que llamamos, por ejemplo, onAddToShoppingList.

2. Le eliminamos el href y añadimos un inline style para que siga apareciendo la manita cuando hagamos hover.

        <a class="dropdown-item" style="cursor: pointer;"
           (click)="onAddToShoppingList()">
           To shopping list
        </a>

3. Añadimos el método al archivo TS de ese componente e inyectamos el RecipeService.

  constructor(private recipeService: RecipeService) { }

  ngOnInit() {
  }

  onAddToShoppingList() {
    
  }

4. Creamos un método en el RecipeService encargado de añadir ingredientes a la lista de la compra, que espera un array de ingredientes.

  addIngredientsToShopList(ingredient: Ingredient[]) {
    
  }

5. Llamamos al método addIngredientsToShopList en el recipe-detail.component, dentro del onAddToShoppingList, y le pasamos los ingredientes de la receta.

  onAddToShoppingList() {
    this.recipeService.addIngredientsToShopList(this.recipe.ingredients);
  }

6. En el ShoppingListServicecreamos un método para añadir ingredientes, que espera un array de ingredientes. Para añadirlos, podríamos iterar sobre ellos, tipo:

  addIngredients(ingredients: Ingredient[]) {
    for (let ingredient of ingredients) {
      this.addIngredient(ingredient);
    }
  }

Pero esto tiene un problemilla, ya que estaríamos emitiendo muchos eventos innecesariamente. Así que en su lugar, vamos a configurar una lógica para añadir todos los ingredientes a la vez, y emitir un único evento con todos ellos, como "en pack". Para ello, accedemos a la propiedad ingredients y utilizamos el spread operatorque convierte un array en una lista de elementos. Al spread operator le pasamos el parámetro ingredients, porque así añadirá todos nuestros ingredientes como una lista de elementos que pasarán a formar parte del array.

7. Usamos nuestro event emitter para avisar a Angular de que nuestros ingredientes han cambiado y le pasamos una copia de nuestro array "ingredients".

  addIngredients(ingredients: Ingredient[]) {
    this.ingredients.push(...ingredients);
    this.ingredientsChanged.emit(this.ingredients.slice());
  }

8. En el RecipeService, inyectamos el ShoppingListService y accedemos a él en el método addIngredientsToShopList. Desde este último método llamamos al método que acabamos de crear en el ShoppingListService.

  constructor(private shoppingListService: ShoppingListService) { }

  getRecipes() {
    return this.recipes.slice();
  }

  addIngredientsToShopList(ingredients: Ingredient[]) {
    this.shoppingListService.addIngredients(ingredients);
  }

Y con estas adiciones, si ahora vamos a una de nuestras recetas y pulsamos el botón para añadir los ingredientes a la lista de la compra, ¡verás cómo se añaden! Ve a la pestaña de shopping list para comprobarlo. ¡Genial! ?

No es perfecto, desde luego, pero con los conocimientos que ya tienes después de seguir esta serie, puedes ir perfilándolo poco a poco, a tu manera. 

THE END!

¡Y hasta aquí esta 4ª parte (y de momento, última) del proyecto Recipes App! Espero que hayas aprendido algo nuevo ?.

Sobre la autora de este post

Soy Rocío, una abogada reconvertida en programadora. Soy una apasionada de aprender cosas nuevas y ferviente defensora de que la única manera de ser feliz es alcanzando un equilibrio entre lo que te encanta hacer y lo que te saque de pobre. Mi historia completa, aquí. 

Más recursos de aprendizaje

En mi experiencia, la manera más eficaz para aprender Angular es combinando varias vías de aprendizaje. Uno de mis métodos favoritos son los vídeo-cursos y mi plataforma predilecta para eso es Udemy. He hecho varios cursos pero sólo recomiendo aquellos que verdaderamente me han sido útiles. Aquí van:

angular the complete guide - curso Max S.

  Max Schwarzmüller

curso angular fernando herrera

   Fernando Herrera

Si necesitas apoyo en forma de libro, puede que éstos te sirvan de ayuda:

libro 1 angular
libro 2 angular

La programación es un mundo que evoluciona a una velocidad de vértigo. Los autores de estos libros lo saben, por eso suelen encargarse de actualizar su contenido regularmente. Asegúrate de que así sea antes de adquirirlos ?.

Participo en el programa de afiliados de Udemy y Amazon, lo que significa que, si compras alguno de estos cursos y/o libros, yo me llevaré una pequeña comisión y a ti no costará nada extra. Vamos, lo que se dice un win-win ?.

Si crees que este post puede serle útil a alguien, ¡compártelo!:

Deja un comentario

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.

Esta web utiliza cookies para asegurar que se da la mejor experiencia al usuario. Si continúas utilizando este sitio se asume que estás de acuerdo. más información

Los ajustes de cookies en esta web están configurados para «permitir las cookies» y ofrecerte la mejor experiencia de navegación posible. Si sigues usando esta web sin cambiar tus ajustes de cookies o haces clic en «Aceptar», estarás dando tu consentimiento a esto.

Cerrar