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.
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. |
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:
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:
node_modules
en el nivel del root borrela.code .
npm install
desde la carpeta principal del proyectong serve
y verifique que puede ver la lista de los libros, tal como se observa en la Figura 2. Figura 2. Listado de los libros. |
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. |
BookModule
(book.module.ts
) y agregue en el decorador, en el atributo declarations, el nombre del nuevo componente BookDetailComponent
. El resultado es:
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>
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
.
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:
selected
onSelected
: export class BookListarComponent implements OnInit {
...
selectedBook!: Book;
selected = false;
...
onSelected(book: Book): void {
this.selected = true;
this.selectedBook = book;
}
...
}
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>
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
:
Book
, los atributos básicos y la relación con Editorial
que es de cardinalidad 1
. authors
que corresponde a los atributos básicos de los autores de los librosreviews
que corresponde a los atributos de las reseñas de los libros.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:
BookDetail
Author
(en un nuevo módulo denominado author
)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;
}
}
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);
}
}
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.