¿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

Para crear el componente book-detail, utilizaremos la línea de comandos de Angular CLI, lo cual garantiza que se integre correctamente al módulo del proyecto.

  1. Desde la carpeta raíz del proyecto, ejecute el siguiente comando para generar el componente nuevo:
    En la terminal de Visual Studio Code, navega hasta la carpeta del módulo correspondiente:
ng generate component book/book-detail --type=component

Esto creará una nueva carpeta llamada book-detail dentro de src/app/book, donde encontrarás los archivos del componente generados automáticamente (ver 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({
  declarations: [BookListComponent, BookDetailComponent],
  imports: [CommonModule],
  exports: [BookListComponent],
})
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',
  standalone: false,
  templateUrl: './book-detail.component.html',
  styleUrl: './book-detail.component.css',
})
export class BookDetailComponent implements OnInit {
  @Input() bookDetail!: Book;

  constructor() {}
  ngOnInit(): void {}
}

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 (book-list.html) desplegamos el componente detalle utilizando su selector:

<app-book-detail></app-book-detail>

Si está ejecutando el servidor mientras hace la corrección, la terminal mostrará un error como se muestra a continuación.

Momentáneamente ignoraremos este error.

Sin embargo, queremos que aparezca el detalle solamente cuando un libro ha sido seleccionado. Para esto utilizamos la directiva de Angular @if. 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 del condicional que contiene el @if.

@if (selected) {
<app-book-detail></app-book-detail>
}

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 BookListComponent 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:

<img class="img-fluid" src='{{book.image}}' alt="{{book.name}}"
 (click)="onSelected(book)" />

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

 export class BookListComponent 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:

@if (selected) {
<app-book-detail [bookDetail]="selectedBook"></app-book-detail>
}

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 de la vista componente book-list:

@if (selected) {
<app-book-detail [bookDetail]="selectedBook"></app-book-detail>
}
<div class="container-fluid">
  <div class="row">
    @for (book of books; track book.id) {
    <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>

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:

Vamos a completar el ejemplo creando los siguientes elementos en su proyecto:

Adicionalmente, en BookList cambiamos el tipo de dato de la variable selectedBook de Book a BookDetail como se ve a continuación:

 books: Array<Book> = [];
 selectedBook!: Book;
 
 onSelected(book: Book): void {
   this.selected = true;
   this.selectedBook = book;
 }

por:

books: Array<BookDetail> = [];
 selectedBook!: BookDetail;
 
 onSelected(book: BookDetail): void {
   this.selected = true;
   this.selectedBook = book;
 }

Y en BookDetailComponent tambien cambiamos el tipo de la variable bookDetail como se muestra a continuación:

@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 { Book } from './book';
import { Editorial } from '../editorial/editorial';
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 { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { environment } from '../../environments/environment.development';
import { Observable } from 'rxjs';
import { BookDetail } from './book-detail';

@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>
@for(author of bookDetail.authors; track author.id) {
<dd class="caption">{{ author.name }}</dd>
}

Figura 7. Resultado final de la aplicación.

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.