¿Qué aprenderá?

Este tutorial lo guía, usando VSCode, en la construcción de una aplicación Angular que permite desplegar una lista de libros, junto con los detalles de cada libro usando el patrón maestro / detalle.

¿Qué construirá?

Una aplicación que incluye un componente que se ocupa de desplegar el detalle de un libro. También definirá la navegación para que cuando el usuario haga clic en una imagen del catálogo de libros, se muestre el detalle de ese libro.

El resultado final del tutorial es una aplicación que despliega la lista detallada en la Figura 1.

Figura 1. Aplicación que desarrollará en este tutorial.

¿Qué necesita?

Antes de realizar este tutorial usted ya desarrolló el tutorial de desplegar lista de libros. Este taller es una extensión.

En particular usted debe:

  1. Tener instalado el ambiente de desarrollo local
  2. Saber cómo se crean módulos, componentes, servicios utilizando angular files en VSCode.

Clonar el proyecto

Este tutorial es una extensión del tutorial de listar usted puede realizarlo sobre su versión del tutorial anterior o hacer un fork del proyecto de BookList para extender ese código.

Una vez que tenga el nuevo proyecto:

Figura 2. Listado de los libros.

Crear el componente detalle

Vaya sobre la carpeta del módulo src/app/book, clic derecho Generate Component y escriba por nombre book-detail.

Dentro de la carpeta src/app/book se debió crear una nueva carpeta para el componente book-detail, tal como se ve en la Figura 3.

Figura 3. Carpeta y archivos el nuevo componente.

Declarar el componente en el módulo

El resultado es:

src/app/book/book.module.ts

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { BookListComponent } from './book-list/book-list.component';
import { BookDetailComponent } from './book-detail/book-detail.component';

@NgModule({
 imports: [
   CommonModule
 ],
 exports: [BookListComponent],
 declarations: [BookListComponent, BookDetailComponent]
})
export class BookModule { }

Ejecute el proyecto con ng serve y verifique que no haya ningún problema.

En esta solución el componente detalle recibe de parámetro, como entrada, el libro que va a desplegar. Esto se define declarando el atributo cuyo valor se va a recibir y decorándolo con @Input().

El nombre que le hemos dado al atributo es bookDetail. Este es el que se debe utilizar en el template de la vista.

Se incluye el signo de admiración de cierre en el nombre del atributo para evitar que tenga un valor indefinido por defecto.

import { Component, Input, OnInit } from '@angular/core';
import { Book } from '../book';

@Component({
 selector: 'app-book-detail',
 templateUrl: './book-detail.component.html',
 styleUrls: ['./book-detail.component.css']
})
export class BookDetailComponent implements OnInit {

 @Input() bookDetail!: Book;

 constructor() { }

 ngOnInit() {
 }

}

La vista del componente detalle, se ocupa de desplegar los valores del objeto bookDetail. Es importante notar que esta vista "no sabe" dónde la van a desplegar:

Desplegamos el título del libro y luego en una fila con una columna la imagen y en otra los demás atributos.

<div class="container-fluid">
 <p class="h3 p-3">{{ bookDetail.name }}</p>
 <div class="row">
   <div class="col-2">
     <div class="thumb">
       <img
         class="img-fluid"
         src="{{ bookDetail.image }}"
         alt="{{ bookDetail.name }}"
       />
     </div>
   </div>
   <div class="col">
     <dl>
       <dt>Authors</dt>
       <dd>No available information</dd>
       <dt>ISBN</dt>
       <dd>{{ bookDetail.isbn }}</dd>
       <hr />
       <dt>Publishing Date</dt>
       <dd>{{ bookDetail.publishingDate }}</dd>
       <hr />
       <dt class="bold">Editorial</dt>
       <dd class="caption">{{ bookDetail.editorial.name }}</dd>
       <hr />
       <dt class="bold">Description</dt>
       <dd>{{ bookDetail.description }}</dd>
     </dl>
   </div>
 </div>
</div>

Mostrar el componente detalle solo si hay un elemento seleccionado

En la vista (Html) del componente de listar desplegamos el componente detalle utilizando su selector:

Sin embargo, queremos que aparezca el detalle solamente cuando un libro ha sido seleccionado. Para esto utilizamos la directiva de Angular *ngIf. Esta directiva nos permite hacer un condicional sobre el HTML; esto significa que si la condición se cumple, se muestra lo que está dentro de la etiqueta que contiene el *ngIf.

<div *ngIf="selected">
      <app-book-detail></app-book-detail>
</div>

Si el valor de la variable selected es true entonces se desplegará el componente.

La variable selected debe ser declarada en el componente de listar. Veamos el fragmento de código:

...
export class BookListarComponent implements OnInit {
...
  selected: Boolean = false;
...

Inicializamos la variable selected en false y luego cuando el usuario selecciona un libro, le cambiamos el valor a true.

Responder a la selección del usuario

Cuando el usuario selecciona un libro hace clic sobre la imagen, entonces, definimos sobre la etiqueta de la imagen, la invocación a una función en respuesta a ese click:

class="img-fluid" src='{{book.image}}' alt="{{book.name}}"

(click)="onSelected(book)" />

La función debe definirse en el componente y se ocupa de:

  • Cambiar el valor al atributo selected
  • Actualizar un atributo del componente que guarda el libro seleccionado (el que recibe de argumento el detalle). El siguiente fragmento muestra el método onSelected:
 export class BookListarComponent implements OnInit {
...
  selectedBook!: Book;
  selected = false;
...

  onSelected(book: Book): void {
    this.selected = true;
    this.selectedBook = book;
  }
...
}

Enviar el parámetro input al componente detalle

Ya tenemos el libro que debemos enviar al componente detalle para que lo muestre. Ahora tenemos que asociar ese libro con el atributo @Input del componente del detalle. Para esto incluimos esto en la vista de listar:

<div *ngIf="selected">
   <app-book-detail [bookDetail]="selectedBook"></app-book-detail>
 </div>

Note que bookDetail es el nombre del atributo anotado con @Input en el componente detalle y selectedBook es es atributo en el componente listar que guarda el libro que el usuario selecciona (ver Figura 4).

Figura 4. Paso de parámetros entre componentes.

Hasta el momento este es el código del componente BookList:

<div *ngIf="selected">
 <app-book-detail [bookDetail]="selectedBook"></app-book-detail>
</div>

<hr />

<div class="container-fluid">
 <div class="row">
   <div *ngFor="let book of books" class="col mb-2">
     <div class="card p-2" style="width: 15rem; height: 33rem">
       <img
         class="card-img-top"
         src="{{ book.image }}"
         alt="{{ book.name }}"
         (click)="onSelected(book)"
       />
       <div class="card-body">
         <h5 class="card-title">{{ book.name }}</h5>
         <p class="card-text">[{{ book.editorial.name }}]</p>
       </div>
     </div>
   </div>
 </div>
</div>

Cambiar el modelo de datos

En el componente de listar libros, definimos el modelo de datos solo con la información básica de Book y su relación de cardinalidad 1 con Editorial. Si queremos desplegar el detalle de Book con los autores, debemos incluir esa información en el modelo.

En el API Rest de nuestra aplicación todos los servicios GET retornan objetos DetailDTO, es decir, los atributos básicos del objeto y las colecciones de objetos básicos que representan las asociaciones. El diagrama de la Figura 5 muestra el caso para Book.

Figura 5. Diagrama de clases.

Tenemos que BookDetail:

  • hereda de Book, los atributos básicos y la relación con Editorial que es de cardinalidad 1.
  • tiene una colección authors que corresponde a los atributos básicos de los autores de los libros
  • tiene una colección de reviews que corresponde a los atributos de las reseñas de los libros.
  • También podemos ver en el diagrama que el componente BookListComponent tiene una colección books de tipo BookDetail y que BookDetailComponent tiene una referencia a la clase BookDetail.

Vamos a completar el ejemplo con lo siguiente:

  • la definición de la clase BookDetail
  • la definición de la clase Author (en un nuevo módulo denominado author)
  • la definición de la clase Review. Esta clase la vamos a definir dentro del módulo book dado que la relación entre Book y Review es composite (Review depende de Book).

Adicionalmente, en BookListComponent cambiamos:

books: Array = [];

selectedBook!: Book;

onSelected(book: Book): void {

this.selected = true;

this.selectedBook = book;

}

por:

books: Array = [];

selectedBook!: BookDetail;

onSelected(book: BookDetail): void {

this.selected = true;

this.selectedBook = book;

}

Y en BookDetailComponent cambiamos:

@Input() bookDetail!: Book;

por:

@Input() bookDetail!: BookDetail;

La siguiente es la definición de la clase BookDetail. Esta clase se incluye en el módulo book. Note que utiliza la clase Review y la clase Author para representar las colecciones.

import { Author } from "../author/author";
import { Editorial } from "../editorial/editorial";
import { Book } from "./book";
import { Review } from "./review";

export class BookDetail extends Book {
 authors: Array<Author> = [];
 reviews: Array<Review> = [];

 constructor(
   id: number,
   name: string,
   isbn: string,
   description: string,
   image: string,
   publishingDate: any,
   editorial: Editorial,
   authors: Array<Author>,
   reviews: Array<Review>
 ) {
   super(id, name, isbn, description, image, publishingDate, editorial);
   this.authors = authors;
   this.reviews = reviews;
 }
}

La siguiente es la definición de la clase Review la cual estará dentro del módulo book:

export class Review {
 id: number;
 source: string;
 name: string;
 description: string;

 constructor(
   id: number,
   source: string,
   name: string,
   description: string
 ) {
   this.id = id;
   this.source = source
   this.name = name;
   this.description = description;
 }
}

La clase Author debe estar dentro de un nuevo módulo denominado author. Cree el módulo, remueva el componente que se crea por defecto, elimine la mención al componente en el módulo y cree la clase author. La siguiente es la definición de la clase Author.

export class Author {
 id: number;
 name: string;
 birthDate: any;
 image: string;
 description: string;

 constructor(id: number,
   name: string,
   birthDate: any,
   image: string,
   description: string){
     this.id = id;
     this.name = name;
     this.birthDate = birthDate;
     this.image = image;
     this.description = description;
   }
}

Cambiar el servicio para recuperar el objeto BookDetail

En el servicio BookService cambiamos el método para traer los libros teniendo en cuenta el nuevo tipo de dato Detail:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

import { environment } from '../../environments/environment.development';
import { Observable } from 'rxjs';
import { BookDetail } from './bookDetail';

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

 private apiUrl: string = environment.baseUrl + 'books';

 constructor(private http: HttpClient) { }

 getBooks(): Observable<BookDetail[]> {
   return this.http.get<BookDetail[]>(this.apiUrl);
 }

}

Paso 3: Cambiar la vista del componente detalle

Para agregar la información de los autores, realizamos un ciclo en la vista HTML del componente detalle. El ciclo itera sobre la colección de autores que se encuentra en el objeto bookDetail.

<dt>Authors</dt>
<dd class="caption" *ngFor="let author of bookDetail.authors">
 {{ author.name }}
</dd>

Figura 7. Resultado final de la aplicación.

Si en este punto del tutorial ejecutamos las pruebas vamos a obtener el siguiente error:

Esto ocurre porque cambiamos el tipo de dato al atributo books del componente BookListComponent (pasamos de Book a BookDetail). Por tanto debemos actualizar los datos que pasamos al constructor cuando se crea un arreglo de books en la prueba del componente BookListComponent. La actualización quedará de la siguiente forma:

for(let i = 0; i < 10; i++) {
     const book = new BookDetail(
       faker.number.int(),
       faker.lorem.sentence(),
       faker.lorem.sentence(),
       faker.lorem.sentence(),
       faker.image.url(),
       faker.date.past(),
       editorial,
       [],
       []
     );
     component.books.push(book);
   }

/*archivo src/app/book/book-list/book-list.component.spec.ts*/

Ahora al ejecutar las pruebas obtendremos un error en el test de BookDetailComponent.

Esto ocurre porque el componente está renderizando los datos del libro que se pasa como parámetro pero ese parámetro es nulo (dado que no se ha seleccionado ningún libro). Para corregir el error vamos a crear un libro y lo vamos a setear en el atributo bookDetail del componente de detalle. La prueba ajustada quedará así:

/* tslint:disable:no-unused-variable */
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { DebugElement } from '@angular/core';

import { BookDetailComponent } from './book-detail.component';
import { Editorial } from '../../editorial/editorial';
import { faker } from '@faker-js/faker';
import { Author } from '../../author/author';
import { BookDetail } from '../bookDetail';

describe('BookDetailComponent', () => {
  let component: BookDetailComponent;
  let fixture: ComponentFixture<BookDetailComponent>;
  let debug: DebugElement;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [],
      declarations: [ BookDetailComponent ],
    })
    .compileComponents();
  }));

  beforeEach(() => {
   fixture = TestBed.createComponent(BookDetailComponent);
   component = fixture.componentInstance;


   const editorial = new Editorial(
    faker.number.int(),
     faker.lorem.sentence()
   );


   const authors: Author[] = [];
   for (let i = 0; i < 3; i++) {
    const author = new Author (
      faker.number.int(),
      faker.lorem.sentence(),
      faker.number.int(),
      faker.lorem.sentence(),
      faker.lorem.sentence(),
    );
    authors.push(author);
  }


   component.bookDetail= new BookDetail(
       faker.number.int(),
       faker.lorem.sentence(),
       faker.lorem.sentence(),
       faker.lorem.sentence(),
       faker.image.url(),
       faker.date.past(),
       editorial,
       authors,
       []
     );


   fixture.detectChanges();
   debug = fixture.debugElement;
 });


});

Ahora definimos los spec. Retomando la vista del detalle de un libro tenemos que el nombre del libro se muestra en un párrafo con las clases h3 y p-3.

<p class="h3 p-3">{{ bookDetail.name }}</p>

Por tanto en el spec consultamos el párrafo con esas dos y comprobamos que el contenido corresponde al nombre del libro.

it('should have a p.h3.p-3 element with bookDetail.name', () => {
   const element: HTMLElement = debug.query(By.css('p.h3.p-3')).nativeElement;
   expect(element.textContent).toContain(component.bookDetail.name);
 });

Luego en la vista tenemos la imagen del libro. El atributo src tendrá como valor la imagen del libro mientras que el atributo alt tendrá el nombre del libro.

<img
 class="img-fluid"
 src="{{ bookDetail.image }}"
 alt="{{ bookDetail.name }}"
/>

Esta será entonces la prueba. En la primera se comprueba el valor del atributo src de la imagen con la imagen del libro y en la segunda se comprueba el valor del atributo alt de la imagen con el nombre del libro.

it('should have an img element with src= bookDetail.image', () => {
   expect(debug.query(By.css('img')).attributes['src']).toEqual(
     component.bookDetail.image
   );
 });

 it('should have an img element with alt= bookDetail.name', () => {
   expect(debug.query(By.css('img')).attributes['alt']).toEqual(
     component.bookDetail.name
   );
 });

Luego tenemos una etiqueta de tipo definition description con la clase caption por cada autor del libro:

 <dd class="caption" *ngFor="let author of bookDetail.authors">
         {{ author.name }}
 </dd>

En la prueba comprobaremos que existan tres etiquetas de ese tipo con esa clase específica ya que ese es el número de autores que hemos seteado al libro:

it('should have 4 <dd> elements', () => {
  expect(debug.queryAll(By.css('dd.caption'))).toHaveSize(4)
});

Finalmente tenemos en la vista información para el ISBN, la fecha de publicación y el nombre de la editorial. Todos esos atributos se muestran como contenido de una etiqueta dd.

<dt>ISBN</dt>
<dd>{{ bookDetail.isbn }}</dd>
<hr />
<dt>Publishing Date</dt>
<dd>{{ bookDetail.publishingDate }}</dd>
<hr />
<dt class="bold">Editorial</dt>
<dd>{{ bookDetail.editorial.name }}</dd>
<hr />
<dt class="bold">Description</dt>
<dd>{{ bookDetail.description }}</dd>

Las pruebas serán entonces las siguientes:

it('should have one dd tag for component.bookDetail.isbn', () => {
   const allDt : DebugElement[] = debug.queryAll(By.css('dt'));
   const node = allDt.find((value) => {
     return value.nativeElement.textContent == 'ISBN';
   });
   expect(node?.nativeElement.nextSibling.textContent).toContain(component.bookDetail.isbn);
 });

 it('should have one dd tag for component.bookDetail.publishingDate', () => {
   const allDt : DebugElement[]= debug.queryAll(By.css('dt'));
   const node = allDt.find((value) => {
     return value.nativeElement.textContent == 'Publishing Date';
   });
   expect(node?.nativeElement.nextSibling.textContent).toContain(component.bookDetail.publishingDate);
 });

 it('should have one dd tag for component.bookDetail.editorial.name', () => {
   const allDt : DebugElement[]= debug.queryAll(By.css('dt'));
   const node = allDt.find((value) => {
     return value.nativeElement.textContent == 'Editorial';
   });
   expect(node?.nativeElement.nextSibling.textContent).toContain(component.bookDetail.editorial.name);
 });

En resumen, se buscan todas las etiquetas dt. Por cada etiqueta se ubica la que contenga como texto un atributo específico (ISBN por ejemplo). Luego se verifica que el elemento siguiente a ese node (nextSibling) tenga como valor el atributo del libro.

Como ejercicio se propone modificar la vista del componente de detalle para mostrar las reviews de un libro. En la prueba del componente incluya un nuevo spec para verificar que las reviews se muestran correctamente.