Librería de componentes, formularios, rutas
En el dia de hoy implementaremos una aplicación más compleja. Vamos a hacer un master - detail con una lista que muestra los candidatos. Una pantalla de edición. Una pantalla de creación y la posibilidad de borrar candidatos y candidatas. Para hacer que la aplicación sea más accesible, estética y funcional vamos a basarnos en Angular Material. Es una librería de componentes para Angular muy completa que nos dará las bases necesarias para implementar nuestra mini aplicación.
Los formularios serán realizados con las librerías por defecto que nos provee Angular.
Outline
- Setup del proyecto
- Crear una lista de perfiles
- Pintar la lista de candidatos
- Formulario para crear candidatos
- Formularios dinámicos
- Modales
- Testing de formularios
- Ejercicios finales
- Referencias
Setup del proyecto
Empezaremos con un proyecto nuevo, donde de forma breve iremos repasando muchos puntos ya vistos. Creamos un nuevo proyecto:
ng new candidates-day3
? Would you like to add Angular routing? (y/N) y
? Which stylesheet format would you like to use?
CSSS
❯ SCSS [ https://sass-lang.com/documentation/syntax#scss ]
Sass [ https://sass-lang.com/documentation/syntax#the-indented-syntax ]
Less [ http://lesscss.org
CREATE candidates-day3/README.md (1068 bytes)
CREATE candidates-day3/.editorconfig (274 bytes)
CREATE candidates-day3/.gitignore (548 bytes)
CREATE candidates-day3/angular.json (2919 bytes)
CREATE candidates-day3/package.json (1046 bytes)
CREATE candidates-day3/tsconfig.json (901 bytes)
CREATE candidates-day3/tsconfig.app.json (263 bytes)
CREATE candidates-day3/tsconfig.spec.json (273 bytes)
CREATE candidates-day3/.vscode/extensions.json (130 bytes)
CREATE candidates-day3/.vscode/launch.json (470 bytes)
CREATE candidates-day3/.vscode/tasks.json (938 bytes)
CREATE candidates-day3/src/main.ts (214 bytes)
CREATE candidates-day3/src/favicon.ico (948 bytes)
CREATE candidates-day3/src/index.html (300 bytes)
CREATE candidates-day3/src/styles.scss (80 bytes)
CREATE candidates-day3/src/app/app-routing.module.ts (245 bytes)
CREATE candidates-day3/src/app/app.module.ts (393 bytes)
CREATE candidates-day3/src/app/app.component.scss (0 bytes)
CREATE candidates-day3/src/app/app.component.html (23115 bytes)
CREATE candidates-day3/src/app/app.component.spec.ts (1018 bytes)
CREATE candidates-day3/src/app/app.component.ts (220 bytes)
CREATE candidates-day3/src/assets/.gitkeep (0 bytes)
✔ Packages installed successfully.
hint: Using 'master' as the name for the initial branch. This default branch name
hint: is subject to change. To configure the initial branch name to use in all
hint: of your new repositories, which will suppress this warning, call:
hint:
hint: git config --global init.defaultBranch <name>
hint:
hint: Names commonly chosen instead of 'master' are 'main', 'trunk' and
hint: 'development'. The just-created branch can be renamed via this command:
hint:
hint: git branch -m <name>
Successfully initialized git.
Tras la ejecución del comando, tendremos un nuevo proyecto en la carpeta candidates-day3
con la siguiente estructura de carpetas:
.
Con lo que podemos ejecutar
npm start
y visitando http://localhost:4200
veremos nuestro scaffolding de proyecto.
Instalación de Angular Material
ng add @angular/material
ℹ Using package manager: npm
✔ Found compatible package version: @angular/material@16.2.4.
✔ Package information loaded.
The package @angular/material@16.2.4 will be installed and executed.
Would you like to proceed? (Y/n) Y
✔ Packages successfully installed.
? Choose a prebuilt theme name, or "custom" for a custom theme: (Use arrow keys)
❯ Indigo/Pink [ Preview: https://material.angular.io?theme=indigo-pink ]
Deep Purple/Amber [ Preview: https://material.angular.io?theme=deeppurple-amber ]
Pink/Blue Grey [ Preview: https://material.angular.io?theme=pink-bluegrey ]
Purple/Green [ Preview: https://material.angular.io?theme=purple-green ]
Custom
Choose a prebuilt theme name, or "custom" for a custom theme: Indigo/Pink [ Preview: https://material.angular.io?theme=indigo-pink ]
? Set up global Angular Material typography styles? (y/N) y
? Include the Angular animations module? (Use arrow keys)
❯ Include and enable animations
Include, but disable animations
Do not include
UPDATE package.json (1112 bytes)
✔ Packages installed successfully.
UPDATE src/app/app.module.ts (502 bytes)
UPDATE angular.json (3053 bytes)
UPDATE src/index.html (582 bytes)
UPDATE src/styles.scss (181 bytes)
Tras estas operaciones habremos modificado una serie de ficheros.
Primero el package.json
ha incluido las librerías de Angular Material:
{
"name": "candidates-day3",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/animations": "^16.2.0",
"@angular/cdk": "^16.2.4",
"@angular/common": "^16.2.0",
"@angular/compiler": "^16.2.0",
"@angular/core": "^16.2.0",
"@angular/forms": "^16.2.0",
"@angular/material": "^16.2.4",
"@angular/platform-browser": "^16.2.0",
"@angular/platform-browser-dynamic": "^16.2.0",
"@angular/router": "^16.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.13.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^16.2.0",
"@angular/cli": "~16.2.0",
"@angular/compiler-cli": "^16.2.0",
"@types/jasmine": "~4.3.0",
"jasmine-core": "~4.6.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.1.3"
}
}
Después el fichero de configuración del workspace Angular, angular.json
ha incluido los ficheros de estilos de Angular Material:
{
...
"projects": {
"candidates-day3": {
...
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
...
"styles": [
"@angular/material/prebuilt-themes/indigo-pink.css",
"src/styles.scss"
],
"scripts": []
},
"configurations": {
...
},
"defaultConfiguration": "production"
},
"serve": {
...
},
"extract-i18n": {
...
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
...
"styles": [
"@angular/material/prebuilt-themes/indigo-pink.css",
"src/styles.scss"
],
"scripts": []
}
}
}
}
}
}
Además se ha modificado el fichero src/styles.scss
/* You can add global styles to this file, and also import other style files */
html,
body {
height: 100%;
}
body {
margin: 0;
font-family: Roboto, "Helvetica Neue", sans-serif;
}
y el fichero src/index.html
para incluir las fuentes por defecto de Angular Material:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>CandidatesDay3</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet"
/>
</head>
<body class="mat-typography">
<app-root></app-root>
</body>
</html>
Con esto ya podemos hacer un commit de nuestro proyecto para guardar los cambios:
git add .
git commit -m "feat: angular material installed"
Crear una lista de perfiles
Para empezar con nuestro mini proyecto, mantendremos una lista de perfiles candidatos dentro de nuestra aplicación. Nuestros candidatos van a ser un poco más complejo con ánimo de poder hacer ejemplos más reales. Empezamos definiendo las interfaces y tipos que definirán nuestro modelo:
ng g i models/candidate
ng g e models/experience
ng g i models/project
y rellenamos los ficheros creados
// experience.ts
export enum Experience {
Junior = "Junior",
Midlevel = "Midlevel",
Senior = "Senior",
}
// project.ts
export interface Project {
name: string;
technology: string[];
description: string;
experience: number;
}
// candidate.ts
import { Experience } from "./experience";
import { Project } from "./project";
export interface Candidate {
id?: number | string;
name: string;
surname: string;
email: string;
age?: number;
phone?: string;
linkedIn?: string;
experience: Experience;
previousProjects: Project[];
}
Servicio CandidateService
La lista la vamos a mantener dentro de un servicio como vimos en partes anteriores. Creamos un servicio:
ng g service services/candidates
CREATE src/app/services/candidates.service.spec.ts (377 bytes)
CREATE src/app/services/candidates.service.ts (139 bytes)
Este nuevo servicio va a tener las siguientes funcionalidades:
- Se puede inicializar con un valor.
- Permite recuperar la lista de candidatos.
- Permite crear una nueva candidata a partir de de un objeto de entrada.
- Permite modificar una candidata con un objeto de entrada.
- Permite eliminar una candidata existente
Además del servicio, vamos a definir una constante o valor inyectable, que en caso de estar definida, pueda ser usada por CandidatesService
para ser inicializado. Creamos el fichero config/app.config.ts
y lo rellenamos con:
import { InjectionToken } from "@angular/core";
import { Candidate } from "../models/candidate";
import { Experience } from "../models/experience";
export interface AppConfig {
candidates?: Candidate[];
}
export const APP_CONFIG = new InjectionToken<AppConfig>("app.config");
export const Config: AppConfig = {
candidates: [
{
id: 0,
email: "candidate@email.com",
phone: "+34634434312",
experience: Experience.Junior,
name: "Carlos",
previousProjects: [
{
name: "BBVA",
technology: ["ReactJS"],
description: "Programador Junior",
experience: 1,
},
],
surname: "Ruiz Marco",
},
{
id: 1,
email: "candidate1@email.com",
phone: "+34634434312",
experience: Experience.Midlevel,
name: "Juan",
previousProjects: [
{
name: "BBVA",
technology: ["ReactJS", "JQuery"],
description:
"Programador encargado de correcciones en la página web de venta privada",
experience: 1,
},
{
name: "Indra",
technology: ["Angular", "Express"],
description: "Desarrollador fullstack JS",
experience: 3,
},
],
surname: "Martínez",
},
{
id: 2,
email: "candidate1@email.com",
phone: "+34634434312",
experience: Experience.Senior,
name: "Paco",
previousProjects: [
{
name: "BBVA",
technology: ["ReactJS", "JQuery"],
description:
"Programador encargado de correcciones en la página web de venta privada",
experience: 1,
},
{
name: "Indra",
technology: ["Angular", "Express"],
description: "Desarrollador fullstack JS",
experience: 3,
},
{
name: "Compañía del Cantabrico",
technology: ["Java", "AngularJS"],
description: "Desarrollador fullstack java y JS",
experience: 3,
},
],
surname: "García Olano",
},
],
};
Hemos creado un Injection Token, que hará referencia a una constante de configuración, donde podemos tener una lista de candidates
.
Modificamos el fichero candidates.service.ts
:
import { Inject, Injectable } from "@angular/core";
import { BehaviorSubject, Observable, of } from "rxjs";
import { Candidate } from "../models/candidate";
import { APP_CONFIG, AppConfig } from "../config/app.config";
@Injectable({
providedIn: "root",
})
export class CandidatesService {
private candidates: Candidate[] = [];
private subject: BehaviorSubject<Candidate[]>;
constructor(@Inject(APP_CONFIG) config: AppConfig) {
this.candidates = Array.isArray(config.candidates) ? config.candidates : [];
this.subject = new BehaviorSubject(this.candidates);
}
getCandidates(): Observable<Candidate[]> {
return this.subject.asObservable();
}
private notify() {
this.subject.next(this.candidates);
}
}
Haciendo uso de la nueva configuración.
Con idea de tener un código mantenible empezaremos a usar los tests. Para ejecutar los tests del servicios que se han creado en candidates.service.spec.ts
, ejecutamos
npm test
## o si solo se ejecutan los test del servicio...
npm test -- --include src/app/services
El parámetro --include
permite solo ejecutar la parte de los tests que nos interesa.
Rellenamos el fichero candidates.service.spec.ts
con
import { TestBed } from "@angular/core/testing";
import { CandidatesService } from "./candidates.service";
describe("CandidatesService", () => {
let service: CandidatesService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CandidatesService);
});
it("should be created", () => {
expect(service).toBeTruthy();
});
it("allows retrieve the candidates", () => {});
it("allows create a new candidate", () => {});
it("allows delete a candidate", () => {});
it("allows update a candidate", () => {});
});
y veremos una pantalla parecida a
Modificamos candidates.service.spec.ts
import { TestBed } from "@angular/core/testing";
import { CandidatesService } from "./candidates.service";
import { Candidate } from "../models/candidate";
import { Experience } from "../models/experience";
import { APP_CONFIG } from "../config/app.config";
describe("CandidatesService", () => {
let service: CandidatesService;
const candidates: Candidate[] = [
{
id: 1,
name: "Nombre 1",
surname: "Apellido 1",
email: "email@email.com",
experience: Experience.Junior,
previousProjects: [],
age: 25,
},
];
const appConfig = { candidates };
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{
provide: APP_CONFIG,
useValue: appConfig,
},
],
});
service = TestBed.inject(CandidatesService);
});
it("should be created", () => {
expect(service).toBeTruthy();
});
it("should be initialized with empty array by default", () => {
service = new CandidatesService({});
service.getCandidates().subscribe((retrieved) => {
expect(retrieved).toEqual([]);
});
});
it("allows retrieve the candidates", () => {
service.getCandidates().subscribe((retrieved) => {
expect(retrieved).toEqual(candidates);
});
});
it("allows create a new candidate", () => {});
it("allows delete a candidate", () => {});
it("allows update a candidate", () => {});
});
En el test se aprovecha la inyección de dependencias de Angular configurando el módulo de test con una lista prefijada de candidatos.
Con lo hecho anteriormente hemos implementado:
- Se puede inicializar con un valor.
- Permite recuperar la lista de candidatos.
La implementación del servicio es más complicada que anteriormente. Mantenemos la lista de candidatos dentro de una variable interna. Cada vez que hacemos una operación modificamos la variable interna y la pasamos a un Subject para que la exponga en forma de Observable.
La idea es hacer el servicio parecido a la realidad. Más tarde, cuando el servicio llame a un endpoint http real, será un Observable lo que devuelva, ya que HttpClient y la mayoría de librerías de Angular hacen uso intensivo de RXJS y los Observables.
En el test se comprueba que una instancia de CandidateService
nueva devuelva una array vacío de candidatas.
Vamos a implementar el resto de funcionalidades.
- Permite crear una nueva candidata a partir de de un objeto de entrada.
Creamos una función save
.
Actualizamos el servicio candidates.service.ts
para que cumpla su cometido y además pase el test:
import { Inject, Injectable } from "@angular/core";
import { BehaviorSubject, Observable, of } from "rxjs";
import { Candidate } from "../models/candidate";
import { APP_CONFIG, AppConfig } from "../config/app.config";
@Injectable({
providedIn: "root",
})
export class CandidatesService {
private candidates: Candidate[] = [];
private subject: BehaviorSubject<Candidate[]>;
constructor(@Inject(APP_CONFIG) config: AppConfig) {
this.candidates = Array.isArray(config.candidates) ? config.candidates : [];
this.subject = new BehaviorSubject(this.candidates);
}
getCandidates(): Observable<Candidate[]> {
return this.subject.asObservable();
}
save(candidate: Candidate): Observable<Candidate> {
const savedCandidate = Object.assign({}, candidate, {
id: this.candidates.length,
});
this.candidates.push(savedCandidate);
this.notify();
return of(savedCandidate);
}
private notify() {
this.subject.next(this.candidates);
}
}
Especificamos su test:
import { TestBed } from "@angular/core/testing";
import { CandidatesService } from "./candidates.service";
import { Candidate } from "../models/candidate";
import { Experience } from "../models/experience";
import { APP_CONFIG } from "../config/app.config";
import { mergeAll, switchMap } from "rxjs";
describe("CandidatesService", () => {
let service: CandidatesService;
const candidates: Candidate[] = [
{
id: 1,
name: "Nombre 1",
surname: "Apellido 1",
email: "email@email.com",
experience: Experience.Junior,
previousProjects: [],
age: 25,
},
];
const appConfig = { candidates };
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{
provide: APP_CONFIG,
useValue: appConfig,
},
],
});
service = TestBed.inject(CandidatesService);
});
it("should be created", () => {
expect(service).toBeTruthy();
});
it("should be initialized with empty array by default", () => {
service = new CandidatesService({});
service.getCandidates().subscribe((retrieved) => {
expect(retrieved).toEqual([]);
});
});
it("allows retrieve the candidates", () => {
service.getCandidates().subscribe((retrieved) => {
expect(retrieved).toEqual(candidates);
});
});
describe("create", () => {
const newCandidate: Candidate = {
name: "Nombre 2",
surname: "Apellido 2",
email: "email@email.com",
experience: Experience.Senior,
previousProjects: [],
age: 52,
};
it("allows add a new candidate", () => {
service.save(newCandidate).subscribe((candidate) => {
expect(candidate).toEqual(
Object.assign({}, newCandidate, { id: candidate.id })
);
});
});
it("should emit a new list", (done) => {
service
.save(newCandidate)
.pipe(switchMap(() => service.getCandidates()))
.subscribe((candidates) => {
expect(candidates.length).toBe(2);
done();
});
});
});
it("allows delete a candidate", () => {});
it("allows update a candidate", () => {});
});
Como vemos en el test, podemos decirle a jasmine cuando la ejecución de un test asíncrono ha terminado llamando a la función done
.
- Permite modificar una candidata con un objeto de entrada.
Implementamos el método update
:
update(candidate: Candidate): Observable<Candidate> {
let index = -1;
if (typeof candidate.id === 'number' || typeof candidate.id === 'string') {
this.candidates = this.candidates.map((c, i) => {
if (c.id === candidate.id) {
index = i;
return candidate;
}
return c;
});
if (index > -1) {
this.notify();
return of(this.candidates[index]);
}
}
const errorResponse = {
status: 404,
error: new Error('Recurso no encontrado'),
message: 'Recurso no encontrado',
};
return throwError(() => errorResponse);
}
Creamos el test:
import { TestBed } from "@angular/core/testing";
import { CandidatesService } from "./candidates.service";
import { Candidate } from "../models/candidate";
import { Experience } from "../models/experience";
import { APP_CONFIG } from "../config/app.config";
import { mergeAll, mergeMap, switchMap } from "rxjs";
describe("CandidatesService", () => {
let service: CandidatesService;
...
describe("update", () => {
const updatedCandidate = Object.assign({}, candidates[0], {
name: "Enrique",
});
it("should fail if user does not exist", (done) => {
updatedCandidate.id = 999;
service.update(updatedCandidate).subscribe({
complete: () => {
done.fail(); // el test debe fallar asi que si devuelve algo, fallo
},
error: (error) => {
console.log(error);
expect(error).toBeDefined();
expect(error.status).toBe(404);
done();
},
});
});
it("should update an existing candidate", (done) => {
updatedCandidate.id = 1;
service
.update(updatedCandidate)
.pipe(mergeMap(() => service.getCandidates()))
.subscribe((candidates) => {
expect(candidates[0]).toEqual(updatedCandidate);
done();
});
});
});
...
});
En este caso hemos creado dos test. El primero comprueba que el servicio devuelve el candidato pasado actualizado. El segundo que si se intenta actualizar un candidato que no existe (el servicio busca el candidato a actualizar por id), se devuelve un error.
En el test hemos utilizado el operador mergeMap
para poder concatenar dos subscripciones consecutivas. Haber hecho algo como
service.update(updatedCandidate).subscribe(() => {
service.getCandidates().subscribe((candidates) => {
expect(candidates[0]).toEqual(updatedCandidate);
done();
});
});
es un antipatrón que nos llevará a cometer errores y fallos de memoria. mergeMap
se utiliza para manejar efectos secundarios o acciones derivadas de un flujo de datos principal, y luego fusionar esos efectos secundarios o resultados derivados de nuevo en un flujo único. En nuestro caso, después usar update
queremos comprobar que la lista se ha actualizado con getCandidates
(otro observable).
El servicio también va a poder devolver un único candidato que usaremos más tarde en las vistas de editar candidato. Creamos su test:
describe("getCandidate", () => {
beforeEach(() => {
testBed.resetTestingModule();
});
it("should return the candidate if it exists", (done) => {
service.getCandidate(1).subscribe({
next: (candidate: Candidate) => {
expect(candidates[0]).toEqual(candidate);
done();
},
error: () => {
done.fail(); // test fail if does not found
},
});
});
it("should throw error if the candidate does not exist", (done) => {
service.getCandidate(99).subscribe({
complete: () => {
done.fail();
},
error: (error) => {
expect(error).toBeDefined();
expect(error.status).toBe(404);
done();
},
});
});
});
e implementamos el método getCandidates
:
getCandidate(id: number | string) {
const candidate = this.candidates.find((c) => c.id === id);
if (candidate) {
return of(candidate);
}
const errorResponse = {
status: 404,
error: new Error('Recurso no encontrado'),
message: 'Recurso no encontrado',
};
return throwError(() => errorResponse);
}
Para finalizar nuestro servicios, le dotamos de la posibilidad de eliminar candidatos. Como hemos hecho antes, empezamos por el test:
describe("remove", () => {
beforeEach(() => {
testBed.resetTestingModule();
});
it("should remove a candidate if it exists", (done) => {
service
.remove(1)
.pipe(mergeMap(() => service.getCandidates()))
.subscribe({
next: (candidates) => {
expect(candidates.length).toEqual(0);
done();
},
error: (error) => {
done.fail(error);
},
});
});
it("should throws error if it does not exist", (done) => {
service.remove(99).subscribe({
complete: () => {
done.fail();
},
error: (error) => {
expect(error).toBeDefined();
expect(error.status).toBe(404);
done();
},
});
});
});
e implementamos la función remove
:
remove(id: string | number) {
const candidateIndex = this.candidates.findIndex((c) => c.id === id);
if (candidateIndex > -1) {
this.candidates.splice(candidateIndex, 1);
this.notify();
return of({
status: 204,
message: 'Recurso borrado',
});
}
const errorResponse = {
status: 404,
error: new Error('Recurso no encontrado'),
message: 'Recurso no encontrado',
};
return throwError(() => errorResponse);
}
Finalmente el fichero candidates.service.ts
queda:
import { Inject, Injectable } from "@angular/core";
import { BehaviorSubject, Observable, of, throwError } from "rxjs";
import { Candidate } from "../models/candidate";
import { APP_CONFIG, AppConfig } from "../config/app.config";
@Injectable({
providedIn: "root",
})
export class CandidatesService {
private candidates: Candidate[] = [];
private subject: BehaviorSubject<Candidate[]>;
constructor(@Inject(APP_CONFIG) config: AppConfig) {
this.candidates = Array.isArray(config.candidates) ? config.candidates : [];
this.subject = new BehaviorSubject(this.candidates);
}
getCandidates(): Observable<Candidate[]> {
return this.subject.asObservable();
}
getCandidate(id: number | string) {
const candidate = this.candidates.find((c) => c.id === id);
if (candidate) {
return of(candidate);
}
const errorResponse = {
status: 404,
error: new Error("Recurso no encontrado"),
message: "Recurso no encontrado",
};
return throwError(() => errorResponse);
}
save(candidate: Candidate): Observable<Candidate> {
const savedCandidate = Object.assign({}, candidate, {
id: this.candidates.length,
});
this.candidates.push(savedCandidate);
this.notify();
return of(savedCandidate);
}
update(candidate: Candidate): Observable<Candidate> {
let index = -1;
if (typeof candidate.id === "number" || typeof candidate === "string") {
this.candidates = this.candidates.map((c, i) => {
if (c.id === candidate.id) {
index = i;
return candidate;
}
return c;
});
if (index > -1) {
this.notify();
return of(this.candidates[index]);
}
}
const errorResponse = {
status: 404,
error: new Error("Recurso no encontrado"),
message: "Recurso no encontrado",
};
return throwError(() => errorResponse);
}
remove(id: string | number) {
const candidateIndex = this.candidates.findIndex((c) => c.id === id);
if (candidateIndex > -1) {
this.candidates.splice(candidateIndex, 1);
this.notify();
return of({
status: 204,
message: "Recurso borrado",
});
}
const errorResponse = {
status: 404,
error: new Error("Recurso no encontrado"),
message: "Recurso no encontrado",
};
return throwError(() => errorResponse);
}
private notify() {
this.subject.next(this.candidates);
}
}
y el fichero de test candidates.service.spec.ts
:
import { TestBed } from "@angular/core/testing";
import { CandidatesService } from "./candidates.service";
import { Candidate } from "../models/candidate";
import { Experience } from "../models/experience";
import { APP_CONFIG } from "../config/app.config";
import { mergeAll, mergeMap, switchMap } from "rxjs";
describe("CandidatesService", () => {
let service: CandidatesService;
let testBed: TestBed;
const candidates: Candidate[] = [
{
id: 1,
name: "Nombre 1",
surname: "Apellido 1",
email: "email@email.com",
experience: Experience.Junior,
previousProjects: [],
age: 25,
},
];
const appConfig = { candidates };
beforeEach(() => {
testBed = TestBed.configureTestingModule({
providers: [
{
provide: APP_CONFIG,
useValue: appConfig,
},
],
});
service = TestBed.inject(CandidatesService);
});
it("should be created", () => {
expect(service).toBeTruthy();
});
it("should be initialized with empty array by default", (done) => {
service = new CandidatesService({});
service.getCandidates().subscribe((retrieved) => {
expect(retrieved).toEqual([]);
done();
});
});
it("allows retrieve the candidates", (done) => {
service.getCandidates().subscribe((retrieved) => {
expect(retrieved).toEqual(candidates);
done();
});
});
describe("create", () => {
const newCandidate: Candidate = {
name: "Nombre 2",
surname: "Apellido 2",
email: "email@email.com",
experience: Experience.Senior,
previousProjects: [],
age: 52,
};
it("allows add a new candidate", (done) => {
service.save(newCandidate).subscribe((candidate) => {
expect(candidate).toEqual(
Object.assign({}, newCandidate, { id: candidate.id })
);
done();
});
});
it("should emit a new list", (done) => {
service = new CandidatesService({});
service
.save(newCandidate)
.pipe(switchMap(() => service.getCandidates()))
.subscribe((candidates) => {
expect(candidates.length).toBe(1);
done();
});
});
});
describe("update", () => {
const updatedCandidate = Object.assign({}, candidates[0], {
name: "Enrique",
});
it("should fail if user does not exist", (done) => {
updatedCandidate.id = 999;
service.update(updatedCandidate).subscribe({
complete: () => {
done.fail(); // el test debe fallar asi que si devuelve algo, fallo
},
error: (error) => {
expect(error).toBeDefined();
expect(error.status).toBe(404);
done();
},
});
});
it("should update an existing candidate", (done) => {
updatedCandidate.id = 1;
service
.update(updatedCandidate)
.pipe(mergeMap(() => service.getCandidates()))
.subscribe((candidates) => {
expect(candidates[0]).toEqual(updatedCandidate);
done();
});
});
});
describe("getCandidate", () => {
beforeEach(() => {
testBed.resetTestingModule();
});
it("should return the candidate if it exists", (done) => {
service.getCandidate(1).subscribe({
next: (candidate: Candidate) => {
expect(candidates[0]).toEqual(candidate);
done();
},
error: () => {
done.fail(); // test fail if does not found
},
});
});
it("should throw error if the candidate does not exist", (done) => {
service.getCandidate(99).subscribe({
complete: () => {
done.fail();
},
error: (error) => {
expect(error).toBeDefined();
expect(error.status).toBe(404);
done();
},
});
});
});
describe("remove", () => {
beforeEach(() => {
testBed.resetTestingModule();
});
it("should remove a candidate if it exists", (done) => {
service
.remove(1)
.pipe(mergeMap(() => service.getCandidates()))
.subscribe({
next: (candidates) => {
expect(candidates.length).toEqual(0);
done();
},
error: (error) => {
done.fail(error);
},
});
});
it("should throws error if it does not exist", (done) => {
service.remove(99).subscribe({
complete: () => {
done.fail();
},
error: (error) => {
expect(error).toBeDefined();
expect(error.status).toBe(404);
done();
},
});
});
});
});
Comprobamos que los tests pasan correctamente:
Tras esto hacemos un commit para guardar nuestros cambios:
git add .
git commit -m "feat: candidate service works"
Router de Angular
Ahora podemos pasar a crear nuestro componente que actuará de home, la lista de candidatos.
Para ello haremos uso de la librería @angular/router
que la instalamos al indicar una opción con Angular CLI. Podemos comprobarlo si miramos el fichero package.json
.
El router de Angular permite que los componentes se muestren u oculten en función de la URL del navegador.
Actualmente nuestra aplicación define un módulo para las rutas que se llama app-routing.module.ts
. Es utilizado en el fichero app.module.ts
al ser importado como módulo (AppRoutingModule
).
Para hacer uso del router, hay que definir un placeholder donde el router de Angular insertará los componentes según la ruta. Ese placeholder es la etiqueta <router-outlet></router-outlet>
. Vamos a modificar el componente principal de la aplicación AppComponent
:
<style>
:host {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
font-size: 14px;
color: #333;
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 8px 0;
}
p {
margin: 0;
}
.spacer {
flex: 1;
}
.toolbar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 60px;
display: flex;
align-items: center;
background-color: #1976d2;
color: white;
font-weight: 600;
}
.toolbar img {
margin: 0 16px;
}
.toolbar #twitter-logo {
height: 40px;
margin: 0 8px;
}
.toolbar #youtube-logo {
height: 40px;
margin: 0 16px;
}
.toolbar #twitter-logo:hover,
.toolbar #youtube-logo:hover {
opacity: 0.8;
}
.main {
margin-top: 60px;
display: block;
}
</style>
<!-- Toolbar -->
<div class="toolbar" role="banner">
<img
width="40"
alt="Angular Logo"
src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTAgMjUwIj4KICAgIDxwYXRoIGZpbGw9IiNERDAwMzEiIGQ9Ik0xMjUgMzBMMzEuOSA2My4ybDE0LjIgMTIzLjFMMTI1IDIzMGw3OC45LTQzLjcgMTQuMi0xMjMuMXoiIC8+CiAgICA8cGF0aCBmaWxsPSIjQzMwMDJGIiBkPSJNMTI1IDMwdjIyLjItLjFWMjMwbDc4LjktNDMuNyAxNC4yLTEyMy4xTDEyNSAzMHoiIC8+CiAgICA8cGF0aCAgZmlsbD0iI0ZGRkZGRiIgZD0iTTEyNSA1Mi4xTDY2LjggMTgyLjZoMjEuN2wxMS43LTI5LjJoNDkuNGwxMS43IDI5LjJIMTgzTDEyNSA1Mi4xem0xNyA4My4zaC0zNGwxNy00MC45IDE3IDQwLjl6IiAvPgogIDwvc3ZnPg=="
/>
<span>Welcome</span>
<div class="spacer"></div>
<a
aria-label="Angular on twitter"
target="_blank"
rel="noopener"
href="https://twitter.com/angular"
title="Twitter"
>
<svg
id="twitter-logo"
height="24"
data-name="Logo"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 400 400"
>
<rect width="400" height="400" fill="none" />
<path
d="M153.62,301.59c94.34,0,145.94-78.16,145.94-145.94,0-2.22,0-4.43-.15-6.63A104.36,104.36,0,0,0,325,122.47a102.38,102.38,0,0,1-29.46,8.07,51.47,51.47,0,0,0,22.55-28.37,102.79,102.79,0,0,1-32.57,12.45,51.34,51.34,0,0,0-87.41,46.78A145.62,145.62,0,0,1,92.4,107.81a51.33,51.33,0,0,0,15.88,68.47A50.91,50.91,0,0,1,85,169.86c0,.21,0,.43,0,.65a51.31,51.31,0,0,0,41.15,50.28,51.21,51.21,0,0,1-23.16.88,51.35,51.35,0,0,0,47.92,35.62,102.92,102.92,0,0,1-63.7,22A104.41,104.41,0,0,1,75,278.55a145.21,145.21,0,0,0,78.62,23"
fill="#fff"
/>
</svg>
</a>
<a
aria-label="Angular on YouTube"
target="_blank"
rel="noopener"
href="https://youtube.com/angular"
title="YouTube"
>
<svg
id="youtube-logo"
height="24"
width="24"
data-name="Logo"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="#fff"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path
d="M21.58 7.19c-.23-.86-.91-1.54-1.77-1.77C18.25 5 12 5 12 5s-6.25 0-7.81.42c-.86.23-1.54.91-1.77 1.77C2 8.75 2 12 2 12s0 3.25.42 4.81c.23.86.91 1.54 1.77 1.77C5.75 19 12 19 12 19s6.25 0 7.81-.42c.86-.23 1.54-.91 1.77-1.77C22 15.25 22 12 22 12s0-3.25-.42-4.81zM10 15V9l5.2 3-5.2 3z"
/>
</svg>
</a>
</div>
<div class="main">
<router-outlet></router-outlet>
</div>
Luego vamos a crear un componente nuevo CandidateList
. Ejecutamos
ng g component components/candidate-list
CREATE src/app/components/candidate-list/candidate-list.component.scss (0 bytes)
CREATE src/app/components/candidate-list/candidate-list.component.html (29 bytes)
CREATE src/app/components/candidate-list/candidate-list.component.spec.ts (609 bytes)
CREATE src/app/components/candidate-list/candidate-list.component.ts (234 bytes)
UPDATE src/app/app.module.ts (625 bytes)
y ahora vamos a modificar nuestro fichero de rutas para que la ruta por defecto apunte a éste componente. Modifica app-routing.module.ts
:
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { CandidateListComponent } from "./components/candidate-list/candidate-list.component";
const routes: Routes = [
{
path: "",
component: CandidateListComponent,
},
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
})
export class AppRoutingModule {}
Si vamos a nuestro navegador en http://localhost:4200
veremos el toolbar con la palabra candidate-list works. Nuestro CandidateListComponent
se ha cargado en la ruta /
.
Notar que esto funciona porque al ejecutar el comando de Angular CLI, se modificó automáticamente el fichero app.module.ts
incluyendo CandidateListComponent
en la sección declarations
.
El CandidateListComponent
será el encargado de inyectar el servicio CandidatesService
y pasar los candidatos a otro componente que vamos a crear con nombre CandidateCardComponent
.
Pintar la lista de candidatos
Ejecutar
ng g component components/candidate-card
CREATE src/app/components/candidate-card/candidate-card.component.scss (0 bytes)
CREATE src/app/components/candidate-card/candidate-card.component.html (29 bytes)
CREATE src/app/components/candidate-card/candidate-card.component.spec.ts (609 bytes)
CREATE src/app/components/candidate-card/candidate-card.component.ts (234 bytes)
UPDATE src/app/app.module.ts (857 bytes)
Estos componentes van a hacer uso de la librería de @angular/material
. Cuando usamos un elemento de dicha librería, lo tenemos que importar como módulo en la aplicación, es decir en el fichero app.module.ts
. Para mantener dicho fichero simple, vamos a crear otro modulo (como hicimos con app-routing.module.ts
) en un fichero llamado app-material.module.ts
con el contenido:
import { NgModule } from "@angular/core";
import { A11yModule } from "@angular/cdk/a11y";
import { ClipboardModule } from "@angular/cdk/clipboard";
import { DragDropModule } from "@angular/cdk/drag-drop";
import { PortalModule } from "@angular/cdk/portal";
import { ScrollingModule } from "@angular/cdk/scrolling";
import { CdkStepperModule } from "@angular/cdk/stepper";
import { CdkTableModule } from "@angular/cdk/table";
import { CdkTreeModule } from "@angular/cdk/tree";
import { MatAutocompleteModule } from "@angular/material/autocomplete";
import { MatBadgeModule } from "@angular/material/badge";
import { MatBottomSheetModule } from "@angular/material/bottom-sheet";
import { MatButtonModule } from "@angular/material/button";
import { MatButtonToggleModule } from "@angular/material/button-toggle";
import { MatCardModule } from "@angular/material/card";
import { MatCheckboxModule } from "@angular/material/checkbox";
import { MatChipsModule } from "@angular/material/chips";
import { MatStepperModule } from "@angular/material/stepper";
import { MatDatepickerModule } from "@angular/material/datepicker";
import { MatDialogModule } from "@angular/material/dialog";
import { MatDividerModule } from "@angular/material/divider";
import { MatExpansionModule } from "@angular/material/expansion";
import { MatGridListModule } from "@angular/material/grid-list";
import { MatIconModule } from "@angular/material/icon";
import { MatInputModule } from "@angular/material/input";
import { MatListModule } from "@angular/material/list";
import { MatMenuModule } from "@angular/material/menu";
import { MatNativeDateModule, MatRippleModule } from "@angular/material/core";
import { MatPaginatorModule } from "@angular/material/paginator";
import { MatProgressBarModule } from "@angular/material/progress-bar";
import { MatProgressSpinnerModule } from "@angular/material/progress-spinner";
import { MatRadioModule } from "@angular/material/radio";
import { MatSelectModule } from "@angular/material/select";
import { MatSidenavModule } from "@angular/material/sidenav";
import { MatSliderModule } from "@angular/material/slider";
import { MatSlideToggleModule } from "@angular/material/slide-toggle";
import { MatSnackBarModule } from "@angular/material/snack-bar";
import { MatSortModule } from "@angular/material/sort";
import { MatTableModule } from "@angular/material/table";
import { MatTabsModule } from "@angular/material/tabs";
import { MatToolbarModule } from "@angular/material/toolbar";
import { MatTooltipModule } from "@angular/material/tooltip";
import { MatTreeModule } from "@angular/material/tree";
import { OverlayModule } from "@angular/cdk/overlay";
@NgModule({
exports: [
A11yModule,
ClipboardModule,
CdkStepperModule,
CdkTableModule,
CdkTreeModule,
DragDropModule,
MatAutocompleteModule,
MatBadgeModule,
MatBottomSheetModule,
MatButtonModule,
MatButtonToggleModule,
MatCardModule,
MatCheckboxModule,
MatChipsModule,
MatStepperModule,
MatDatepickerModule,
MatDialogModule,
MatDividerModule,
MatExpansionModule,
MatGridListModule,
MatIconModule,
MatInputModule,
MatListModule,
MatMenuModule,
MatNativeDateModule,
MatPaginatorModule,
MatProgressBarModule,
MatProgressSpinnerModule,
MatRadioModule,
MatRippleModule,
MatSelectModule,
MatSidenavModule,
MatSliderModule,
MatSlideToggleModule,
MatSnackBarModule,
MatSortModule,
MatTableModule,
MatTabsModule,
MatToolbarModule,
MatTooltipModule,
MatTreeModule,
OverlayModule,
PortalModule,
ScrollingModule,
],
})
export class AppMaterialModule {}
y lo importamos en app.module.ts
:
import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { AppRoutingModule } from "./app-routing.module";
import { AppComponent } from "./app.component";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { CandidateListComponent } from "./components/candidate-list/candidate-list.component";
import { CandidateCardComponent } from "./components/candidate-card/candidate-card.component";
import { AppMaterialModule } from "./app-material.module";
@NgModule({
declarations: [AppComponent, CandidateListComponent, CandidateCardComponent],
imports: [
BrowserModule,
AppRoutingModule,
BrowserAnimationsModule,
AppMaterialModule,
],
providers: [],
bootstrap: [AppComponent],
})
export class AppModule {}
Ahora ya podemos empezar a trabajar sobre los componentes para pintar nuestra lista de candidatas.
Inicialmente tenemos que tener una MatCard
para pintar cada candidato. Modificar el componente CandidateCardComponent
que hemos creado anteriormente, que es lo mismo que modificar los ficheros candidate-card.component.ts
, candidate-card.component.html
y candidate-card.component.scss
:
import { Component, Input, OnInit } from "@angular/core";
import { Candidate } from "src/app/models/candidate";
@Component({
selector: "app-candidate-card",
templateUrl: "./candidate-card.component.html",
styleUrls: ["./candidate-card.component.scss"],
})
export class CandidateCardComponent implements OnInit {
@Input() candidate!: Candidate;
skills: { technology: string; experience: number }[] = [];
seniority = {
junior: false,
mid: false,
senior: false,
};
ngOnInit(): void {
this.seniority.junior = this.candidate?.experience === "Junior";
this.seniority.mid = this.candidate?.experience === "Midlevel";
this.seniority.senior = this.candidate?.experience === "Senior";
this.buildUniqueSkills();
}
/* Build an unique array of techs with years where only appears the
technology with biggest experience number */
private buildUniqueSkills() {
if (this.candidate && Array.isArray(this.candidate.previousProjects)) {
function onlyUnique(
value: { technology: string; experience: number },
index: number,
self: { technology: string; experience: number }[]
) {
const found = self.findIndex(
(v: { technology: string; experience: number }) =>
v.technology === value.technology && v.experience > value.experience
);
return found === -1 || found === index;
}
this.skills = this.candidate.previousProjects
.map((project) => {
return project.technology.map((t) => ({
technology: t,
experience: project.experience,
}));
})
.flat()
.filter(onlyUnique);
}
}
}
El fichero ts
contiene la lógica del componente como vimos anteriormente. En primer lugar se añade un @Input
de entrada que permite que el componente sea dinámico (pueda mostrar diferentes candidatos). Será el componente padre, en este caso el futuro CandidateListComponent
el que se encargará de pasar cada candidato a la MatCard
que se implementa en CandidateCardComponent
.
Además hemos hecho uso del ciclo de vida ngOnInit
. Cuando el componente se inicializa, se actualiza una variable seniority
que ahora usaremos para pintar de diferentes colores el candidato en función de su experiencia. También se actualiza la variable skills
con la lista de tecnologías que usó y los años de experiencia. Sólo se pinta la tecnología una vez, con el número más grande de experiencia.
La notación
@Input() candidate!: Candidate;
con el signo de exclamación indica que la propiedad candidate
puede estar sin inicializar. Es una utilidad para evitar que el compilador de un error por no haber inicializado esa variable y haberla usado después. Como this.candidate
lo usamos en el ciclo ngOnInit
, sabemos que el @Input
del componente ya está inicializado, por lo que no debe dar un error.
<mat-card class="candidate-card">
<mat-card-header>
<mat-card-title class="candidate-title">
<span class="candidate-title__name"
>{{ candidate.name }} {{ candidate.surname }}</span
>
<span class="candidate-title__seniority" [class]="seniority"
>{{ candidate.experience }}</span
>
</mat-card-title>
<mat-card-subtitle class="candidate-subtitle">
<div class="candidate-detail">
<span class="candidate-detail__label">Email</span>
<span class="candidate-detail__value">{{ candidate.email }}</span>
</div>
<div class="candidate-detail" *ngIf="candidate.phone">
<span class="candidate-detail__label">Phone</span>
<span class="candidate-detail__value">{{ candidate.phone }}</span>
</div>
</mat-card-subtitle>
</mat-card-header>
<mat-card-content class="candidate-card__content">
<div class="candidate-skills">
<div *ngFor="let skill of skills" class="skill">
<span class="skill__technology">{{ skill.technology }}</span>
<span class="skill__experience">{{ skill.experience }}</span>
</div>
</div>
<h3 class="other-skills-title">Otras skills</h3>
<div class="candidate-skills">
<div *ngFor="let otherSkill of candidate.skills" class="skill">
<span class="skill__name">{{ otherSkill }}</span>
</div>
</div>
<div class="candidate-positions">
<h3>Proyectos</h3>
<mat-expansion-panel
hideToggle
*ngFor="let position of candidate.previousProjects"
>
<mat-expansion-panel-header>
<mat-panel-title>
<div class="candidate-positions__title">
<span>{{ position.name }}</span>
<span class="year">{{ position.experience }} años</span>
</div></mat-panel-title
>
</mat-expansion-panel-header>
<div>
<p class="candidate-positions__description">
{{ position.description }}
</p>
<div
*ngFor="let tech of position.technology"
class="candidate-positions__tech"
>
<div>{{ tech }}</div>
</div>
</div>
</mat-expansion-panel>
</div>
</mat-card-content>
<mat-card-actions [align]="'end'">
<button mat-button color="primary" [routerLink]="['/edit', candidate.id]">
Editar
</button>
<button mat-button color="warn">Borrar</button>
</mat-card-actions>
</mat-card>
.candidate-card {
min-width: 300px;
max-width: 350px;
&__content {
display: flex;
flex-direction: column;
gap: 1rem;
}
mat-card-header {
font-size: 25px;
margin-bottom: 1rem;
border-bottom: 1px solid lightgrey;
}
}
::ng-deep {
mat-card-header {
.mat-mdc-card-header-text {
width: 100%;
}
}
}
.candidate-title {
width: 100%;
padding: 0.5rem 0;
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
&__name {
font-size: x-large;
}
&__seniority {
border-radius: 20px;
padding: 2px 10px;
color: white;
font-size: medium;
font-weight: 500;
&.junior {
background-color: cornflowerblue;
}
&.mid {
background-color: darkcyan;
}
&.senior {
background-color: darkblue;
}
}
}
.candidate-detail {
display: flex;
justify-content: space-between;
gap: 1rem;
&__label {
font-weight: bold;
}
&__value {
color: grey;
}
}
.candidate-skills {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.skill {
background-color: lightgrey;
display: flex;
align-items: center;
gap: 0.5rem;
border-radius: 20px;
padding: 2px 2px 2px 10px;
&__name {
color: darkslategrey;
font-weight: 300;
}
&__experience {
text-align: center;
border-radius: 50px;
padding: 5px;
min-width: 20px;
background-color: darkslategray;
color: white;
}
}
::ng-deep {
.candidate-positions__title {
width: 100%;
display: flex;
justify-content: space-between;
}
}
Primero tenemos que inyectar el servicio de candidatos en la lista de candidatos y ésta deberá pintar tantas card de candidatos como candidatos devuelva el servicio. Modificamos candidate-list.component.ts
:
import { Component, OnDestroy, OnInit } from "@angular/core";
import { Subscription } from "rxjs";
import { Candidate } from "src/app/models/candidate";
import { CandidatesService } from "src/app/services/candidates.service";
@Component({
selector: "app-candidate-list",
templateUrl: "./candidate-list.component.html",
styleUrls: ["./candidate-list.component.scss"],
})
export class CandidateListComponent implements OnInit, OnDestroy {
candidates: Candidate[] = [];
subscriptions: Subscription[] = [];
constructor(public service: CandidatesService) {}
ngOnInit(): void {
const subscription = this.service
.getCandidates()
.subscribe((candidates) => {
this.candidates = candidates;
});
this.subscriptions.push(subscription);
}
ngOnDestroy(): void {
this.subscriptions.forEach((s) => s.unsubscribe());
}
}
Actualizamos candidate-list.component.html
:
<div class="add-button">
<button mat-fab color="primary" aria-label="Añadir un candidato">
<mat-icon>add</mat-icon>
</button>
</div>
<div class="candidate-list">
<div *ngFor="let candidate of candidates">
<app-candidate-card [candidate]="candidate"></app-candidate-card>
</div>
</div>
y candidate-list.component.scss
.add-button {
position: fixed;
bottom: 1rem;
right: 1rem;
}
.candidate-list {
flex-wrap: wrap;
padding: 1rem;
display: flex;
gap: 1rem;
}
Lo que está ocurriendo en el método ngOnInit
de CandidateListComponent
es que nos estamos subscribiendo al observable público que tiene el servicio CandidateService
. Cuando llegan los datos, actualizamos una variable candidates
interna que tiene el componente CandidateListComponent
.
Para evitar dejar basura cuando el componente CandidateListComponent
es desmontado del DOM, ya que la suscripción realizada al CandidatesService
se quedaría viva después de que CandidateListComponent
muera, debemos utilizar el hook ngOnDestroy
que vimos anteriormente.
El código se puede mejorar con el uso del Pipe async
que veremos luego.
La lista de candidatos pinta tarjetas (MatCard) de candidatos.
En la tarjeta de cada candidata hemos utilizado varios componentes que nos provee Angular Material:
-
Card: Es un contenedor para texto, fotos y acciones en el contexto de un único sujeto. Nuestro candidato.
-
Expansion Panel: También llamados acordeones en otras librerías.
-
Button: Botones con estilo material.
Además hemos hecho uso de las directivas ya vistas *ngIf, *ngFor y ngClass.
Formulario para crear candidatos
Vamos a mostrar los formularios de crear / editar en diferentes URL de nuestra aplicación Para ello haremos uso del Router de Angular.
Cuando se pulse en el botón editar de un candidato o en el fav button que aparece con el símbolo +
en la parte inferior de la página lista se va a navegar a diferentes rutas.
Empezamos con la implementación del formulario vacío para permitir crear candidatos y candidatas en nuestra aplicación.
Creamos un componente para la creación:
ng g component components/create-candidate -m app.module.ts
CREATE src/app/components/create-candidate/create-candidate.component.scss (0 bytes)
CREATE src/app/components/create-candidate/create-candidate.component.html (31 bytes)
CREATE src/app/components/create-candidate/create-candidate.component.spec.ts (623 bytes)
CREATE src/app/components/create-candidate/create-candidate.component.ts (242 bytes)
UPDATE src/app/app.module.ts (932 bytes)
Podemos añadir la opción -m app.module.ts
para evitar el conflicto que se da al tener varios módulos.
Este nuevo componente va a actuar de vista, por lo que tenemos que cablearlo en el router. Actualizamos el fichero app-routing.module.ts
:
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { CandidateListComponent } from "./components/candidate-list/candidate-list.component";
import { CreateCandidateComponent } from "./components/create-candidate/create-candidate.component";
const routes: Routes = [
{
path: "",
component: CandidateListComponent,
},
{
path: "create",
component: CreateCandidateComponent,
},
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
})
export class AppRoutingModule {}
Ahora si navegamos a la ruta http://localhost:4200/create
, veremos que se muestra
Además vamos a crear un nuevo componente para mantener el formulario propiamente dicho:
ng g component components/candidate-form -m app.module.ts
CREATE src/app/components/candidate-form/candidate-form.component.scss (0 bytes)
CREATE src/app/components/candidate-form/candidate-form.component.html (29 bytes)
CREATE src/app/components/candidate-form/candidate-form.component.spec.ts (609 bytes)
CREATE src/app/components/candidate-form/candidate-form.component.ts (234 bytes)
UPDATE src/app/app.module.ts (1051 bytes)
Antes de empezar con el formulario vamos a rutear los componentes. Modificamos el fichero candidate-list.component.html
:
<div class="add-button">
<button
mat-fab
color="primary"
aria-label="Añadir un candidato"
routerLink="/create"
>
<mat-icon>add</mat-icon>
</button>
</div>
<div class="candidate-list">
<div *ngFor="let candidate of candidates">
<app-candidate-card [candidate]="candidate"></app-candidate-card>
</div>
</div>
Hemos añadido la directiva routerLink
que es parte del paquete @angular/router
. Esta directiva permite definir una url que será visitada cuando se hace click en el botón.
Por último falta modificar el template de create-candidate.component.html
, para que haga uso de CandidateFormComponent
:
<app-candidate-form></app-candidate-form>
Los formularios Angular son un potente herramienta que nos va a permitir gestionar todo tipo de casuísticas asociadas a la introducción de datos del usuario.
Angular provee de formularios dirigidos por el template y de formularios reactivos.
Los formularios dirigidos por template hacen uso de la directiva ngModel. En la documentación de Angular se explica claramente la diferencia entre ambos modelos.
En esta guía vamos a hacer uso de los formularios reactivos. Para hacer uso de ellos hace falta importar el módulo ReactiveFormsModule
dentro del fichero app.module.ts
:
import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { AppRoutingModule } from "./app-routing.module";
import { AppComponent } from "./app.component";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { CandidateListComponent } from "./components/candidate-list/candidate-list.component";
import { CandidateCardComponent } from "./components/candidate-card/candidate-card.component";
import { AppMaterialModule } from "./app-material.module";
import { APP_CONFIG, Config } from "./config/app.config";
import { CreateCandidateComponent } from "./components/create-candidate/create-candidate.component";
import { CandidateFormComponent } from "./components/candidate-form/candidate-form.component";
import { ReactiveFormsModule } from "@angular/forms";
@NgModule({
declarations: [
AppComponent,
CandidateListComponent,
CandidateCardComponent,
CreateCandidateComponent,
CandidateFormComponent,
],
imports: [
BrowserModule,
AppRoutingModule,
BrowserAnimationsModule,
AppMaterialModule,
ReactiveFormsModule, // <-- insertado
],
providers: [
{
provide: APP_CONFIG,
useValue: Config,
},
],
bootstrap: [AppComponent],
})
export class AppModule {}
Para empezar, vamos a implementar los campos sencillos que no son dinámicos ni tienen tipos de datos complejos.
Los formularios reactivos son un paradigma de formularios que permiten realizar toda la gestión del formulario programáticamente. Los cambios siempre son controlados y dirigidos por la parte TypeScript del componente.
La pieza básica son los FormControl
, que permiten seguir el valor y el estado de validación de un control de formulario. Por ejemplo para crear un campo para introducir el nombre de un candidato haríamos:
export class CandidateFormComponent implements OnInit {
...
name = new FormControl('')
...
}
En el template, hace falta registrar el control, es decir, asociar el FormControl
creado con un control de formulario en el HTML utilizando la directiva formControl
:
<label for="name">Name: </label>
<input id="name" type="text" [formControl]="name" />
Llegados a este punto, ya podemos mostrar el valor del formulario o usarlo dónde queramos:
<p>Value: {{ name.value }}</p>
El valor será actualizado cada vez que actualicemos el valor del input
.
Además de modificar el valor en el template, podemos modificar el valor en código directamente:
updateName() {
this.name.setValue("Alejandro");
}
Podemos actualizar nuestro template candidate-form.component.html
para hacer una prueba:
<button type="button" (click)="updateName()">Update Name</button>
En este ejemplo estamos usando un único control, pero lo más normal es tener un grupo de controles asociados a un formulario. Podemos crear los controles uno a uno, pero Angular provee una forma de crear los controles de una vez dentro de un FormGroup
:
export class CandidateFormComponent implements OnInit {
...
candidateForm = new FormGroup({
name: new FormControl(''),
surname: new FormControl(''),
email: new FormControl(''),
...
});
...
}
Con esta estructura, podemos usar el formulario dentro del template de la siguiente forma:
<form [formGroup]="candidateForm">
<label for="name">Nombre: </label>
<input id="name" type="text" formControlName="name" />
<label for="surname">Apellidos: </label>
<input id="surname" type="text" formControlName="surname" />
<label for="email">Email: </label>
<input id="email" type="text" formControlName="email" />
...
</form>
La propiedad candidateForm
es la utilizada en la directiva formGroup
para dotar de funcionalidad a un formulario normal html. Angular provee de estado tanto para mantener el valor de los controles como el estado de validación de los mismos. Para enganchar cada control se hace uso de la directiva formControlName
.
Si se desea reaccionar al evento submit del formulario, Angular provee de la directiva ngSubmit
:
<form [formGroup]="candidateForm" (ngSubmit)="onSubmit()"></form>
y en el script ts:
onSubmit() {
// TODO: usar EventEmitter con el valor del formulario
console.warn(this.candidateForm.value);
}
El evento submit
es emitido de forma nativa por el elemento form
de html. El evento se lanza cuando se hace click en un botón del formulario con el type="submit"
:
<p>Complete the form to enable button.</p>
<button type="submit" [disabled]="!candidateForm.valid">Submit</button>
En el ejemplo anterior, asociamos un botón al submit
pero solo lo dejamos clickar cuando el formulario tiene el estado valid
.
Las validaciones en los formularios dirigidos por template se realizan en el template. En el caso de los formularios reactivos se realiza en la parte TS del componente. Vemos el ejemplo para los controles definidos anteriormente:
export class CandidateFormComponent implements OnInit {
...
candidateForm = new FormGroup({
name: new FormControl('', [
Validators.required,
Validators.minLength(4)
]),
surname: new FormControl('', , [
Validators.required,
Validators.minLength(4)
]),
email: new FormControl('', , [
Validators.required,
Validators.email
]),
...
});
...
}
En el ejemplo de arriba estamos obligando que los campos sean rellenados y para el caso de name
y surname
usamos Validators.minLength(4)
indicando que los campos tienen que tener al menos 4 caracteres. Para el caso de email
usamos el validador que provee angular email
para obligar que el control tenga la forma de un email cuando sea rellenado.
Con los ejemplos vistos anteriormente, vamos a hacer uso del servicio FormBuilder
que nos permite crear el FormGroup
y los FormControl
con una fluent api.
Primero vamos a crear un fichero extra src/app/utils/validators.ts
, que va a contener todos los validadores y patrones que usemos para validar nuestros formularios:
export const emailPattern: RegExp = /^\w+([.-]?\w+)@\w+([.-]?\w+)(.\w{2,3})+$/;
export const phonePattern: RegExp =
/^(\+\d{1,3}(\s|-)?)?\d{1,14}(\s|-)?\d{1,14}$/;
export const linkedinPattern: RegExp =
/^(?:https?:\/\/)?(?:www\.)?linkedin\.com\/in\/[^\/]+\/?$/;
Actualizamos nuestro candidate-form.component.ts
:
import { Component, EventEmitter, Output } from "@angular/core";
import {
FormBuilder,
FormControl,
FormGroup,
Validators,
} from "@angular/forms";
import { Candidate } from "src/app/models/candidate";
import { linkedinPattern, phonePattern } from "src/app/utils/Validators";
@Component({
selector: "app-candidate-form",
templateUrl: "./candidate-form.component.html",
styleUrls: ["./candidate-form.component.scss"],
})
export class CandidateFormComponent {
candidateForm: FormGroup;
@Output() submit = new EventEmitter<Candidate>();
@Output() back = new EventEmitter();
constructor(private fb: FormBuilder) {
this.candidateForm = this.fb.group({
name: ["", [Validators.required, Validators.minLength(2)]],
surname: ["", [Validators.required, Validators.minLength(2)]],
email: ["", [Validators.required, Validators.email]],
phone: ["", [Validators.pattern(phonePattern)]],
linkedIn: ["", [Validators.pattern(linkedinPattern)]],
experience: ["", [Validators.required]],
});
}
get name() {
return this.candidateForm.get("name");
}
getNameErrors() {
if (this.name?.hasError("required")) {
return "Debe introducir un nombre.";
}
return this.name?.hasError("minlength")
? "El nombre debe tener al menos 2 carácteres"
: "";
}
get surname() {
return this.candidateForm.get("surname");
}
getSurnameErrors() {
if (this.name?.hasError("required")) {
return "Debe introducir los apellidos.";
}
return this.name?.hasError("minlength")
? "Los apellidos debe tener al menos 2 carácteres"
: "";
}
get email() {
return this.candidateForm.get("email");
}
getEmailErrors() {
if (this.email?.hasError("required")) {
return "Debe introducir un email.";
}
return this.email?.hasError("email")
? "Debe introducir un email correcto"
: "";
}
get phone() {
return this.candidateForm.get("phone");
}
getPhoneErrors() {
return this.phone?.hasError("pattern")
? "Debe introducir un teléfono correcto, ej (+34555667788)"
: "";
}
get linkedin() {
return this.candidateForm.get("linkedIn");
}
getLinkedinErrors() {
return this.linkedIn?.hasError("pattern")
? "Debe introducir un perfil de linkedin correcto"
: "";
}
get experience() {
return this.candidateForm.get("experience");
}
getExperienceErrors() {
return this.experience?.hasError("required")
? "Debe seleccionar la experiencia"
: "";
}
onSubmit() {
const savedCandidate: Candidate = Object.assign(
{},
this.candidateForm.value
);
this.submit.emit(savedCandidate);
}
goBack() {
this.back.emit();
}
reset() {
this.candidateForm.reset();
}
}
y hacemos lo mismo con le fichero candidate-form.component.html
:
<form
[formGroup]="candidateForm"
class="candidate-form"
(ngSubmit)="onSubmit()"
>
<mat-form-field class="half">
<mat-label>Nombre</mat-label>
<input matInput type="text" formControlName="name" />
<mat-error *ngIf="!candidateForm.pristine && name?.invalid"
>{{ getNameErrors() }}</mat-error
>
</mat-form-field>
<mat-form-field class="half">
<mat-label>Apellidos</mat-label>
<input matInput type="text" formControlName="surname" />
<mat-error *ngIf="candidateForm.dirty && surname?.invalid"
>{{ getSurnameErrors() }}</mat-error
>
</mat-form-field>
<mat-form-field class="half">
<mat-label>Email</mat-label>
<input matInput type="email" formControlName="email" />
<mat-error *ngIf="candidateForm.dirty && email?.invalid"
>{{ getEmailErrors() }}</mat-error
>
</mat-form-field>
<mat-form-field class="quarter">
<mat-label>Teléfono</mat-label>
<input matInput type="tel" formControlName="phone" />
<mat-error *ngIf="candidateForm.dirty && email?.invalid"
>{{ getPhoneErrors() }}</mat-error
>
</mat-form-field>
<mat-form-field class="full">
<mat-label>Linkedin</mat-label>
<input matInput type="url" formControlName="linkedIn" />
<mat-error *ngIf="candidateForm.dirty && linkedIn?.invalid"
>{{ getLinkedinErrors() }}</mat-error
>
</mat-form-field>
<mat-form-field class="quarter">
<mat-label>Nivel</mat-label>
<mat-select formControlName="level">
<mat-option value="Junior">Junior</mat-option>
<mat-option value="Midlevel">Intermedio</mat-option>
<mat-option value="Senior">Senior</mat-option>
</mat-select>
<mat-error *ngIf="candidateForm.dirty && experience?.invalid"
>{{ getExperienceErrors() }}</mat-error
>
</mat-form-field>
<div class="full actions">
<button
mat-raised-button
color="primary"
type="submit"
[disabled]="candidateForm.invalid"
>
Guardar
</button>
<button type="button" mat-raised-button color="accent" (click)="reset()">
Reset
</button>
<button type="button" mat-button (click)="goBack()">Volver</button>
</div>
</form>
finalmente dotar de algo de estilo a nuestro formulario, modificando candidate-form.component.scss
:
.candidate-form {
padding: 1rem;
display: flex;
flex-wrap: wrap;
gap: 1rem;
max-width: 960px;
}
.full {
width: 100%;
}
.half {
width: calc(50% - 0.5rem);
}
.quarter {
width: calc(25% - 0.25rem);
}
.actions {
display: flex;
justify-content: end;
gap: 1rem;
}
En el ejemplo anterior vemos varios patrones que utilizamos.
Por un lado hemos hecho uso de getters para facilitar el acceso al estado de validación de los diferentes controles desde el template.
Por otro lado, en lugar de crear los FormControl
y el FormGroup
a mano, hemos hecho uso de FormBuilder
. En el mismo sitio hemos definido las validaciones de los distintos campos. Los campos phone
y linkedin
hacen uso del validador pattern
que permite introducir una expresión regular para validar el campo. Los demás campos de momento hacen uso de validadores síncronos y dados por Angular.
En el template hemos hecho uso intensivo de Angular Material para dotar de un aspecto bonito y funcional (siguiendo las reglas de google Material) a nuestro formulario con el mínimo esfuerzo.
El aspecto actual de nuestro formulario sería algo como
El ejemplo anterior usa CandidateFormComponente
como dummy component y CreateCandidateComponent
como smart component.
Cuando hablamos de dummy component y smart component hablamos de una categorización que se hace para separar las responsabilidades en diferentes tipos de componentes. El CandidateFormComponent
es un componente que está definido en términos de sus inputs
y outputs
. Se le llama también componente puro en analogía a las funciones puras, que solo dependen de los parámetros de entrada para devolver la misma respuesta.
En algunos casos, éste incluido, podemos tomar ventaja de una característica de Angular que permite evitar ciclos de pintados extras, haciendo nuestra aplicación más ligera (Se ejecutan menos ciclos de JS para mantener la vista en sincronía). Podemos incluir en la anotación @Component
la propiedad
changeDetection: ChangeDetectionStrategy.OnPush;
Modificar el componente CandidateFormComponent
:
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Output,
} from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Candidate } from 'src/app/models/candidate';
import { linkedinPattern, phonePattern } from 'src/app/utils/Validators';
@Component({
selector: 'app-candidate-form',
templateUrl: './candidate-form.component.html',
styleUrls: ['./candidate-form.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CandidateFormComponent implements OnInit {
...
Terminamos de cablear correctamente el componente con CreateCandidateComponent
, modificando el fichero create-candidate.component.ts
:
import { Component } from "@angular/core";
import { Location } from "@angular/common";
import { Candidate } from "src/app/models/candidate";
import { CandidatesService } from "src/app/services/candidates.service";
import { Router } from "@angular/router";
@Component({
selector: "app-create-candidate",
templateUrl: "./create-candidate.component.html",
styleUrls: ["./create-candidate.component.scss"],
})
export class CreateCandidateComponent {
constructor(
private location: Location,
private service: CandidatesService,
private router: Router
) {}
onSubmit(candidate: Candidate) {
this.service.save(candidate);
this.router.navigate(["/"]);
}
back() {
this.location.back();
}
}
y su template create-candidate.component.html
:
<app-candidate-form (submit)="onSubmit($event)" (back)="back()">
</app-candidate-form>
Cuando el evento submit
del componente CandidateFormComponent
es emitido, el componente CreateCandidateComponent
ejecuta el método onSubmit
. En ese método por un lado se llama al método save
del servicio CandidateService
y luego se navega al inicio de la aplicación para mostrar la lista de candidatas actualizada.
Si en cambio, el evento emitido es back
, se llama al método back
del servicio location
. location
es una clase provista por Angular que puede ser inyectada por DI.
Como vemos, el componente CreateCandidateComponent
hace uso de servicios externos inyectados por el sistema de inyección de dependencias de Angular. Un patrón de diseño a respetar es el Separation of concerns, que se consigue separando la lógica mediante servicios y utilizando la dependency injection.
Según hemos planteado el ejemplo, el formulario solo es responsable de recopilar los datos del usuario. El servicio CandidateService
tiene la lógica de crear un candidato mientras que el componente CreateCandidateComponent
tiene la lógica de negocio para unir la recopilación de los datos con la creación.
Formulario para editar candidatos
La edición de candidatos se realiza sobre el mismo formulario que ya hemos implementado en el crear, solo que éste debe ser inicializado con el candidato que va a ser editado.
La ruta será edit/:id
, siendo :id
el id del candidato que se va a editar. Primero creamos un nuevo componente
ng g component components/edit-candidate --module "app.module"
Este componente será el componente smart que contendrá nuestro ya creado CandidateForm
inicializado con el candidato a editar. Editamos edit-candidate.component.ts
:
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { Location } from "@angular/common";
import { Observable, switchMap } from "rxjs";
import { Candidate } from "src/app/models/candidate";
import { CandidatesService } from "src/app/services/candidates.service";
@Component({
selector: "app-edit-candidate",
templateUrl: "./edit-candidate.component.html",
styleUrls: ["./edit-candidate.component.scss"],
})
export class EditCandidateComponent implements OnInit {
candidate$!: Observable<Candidate>;
constructor(
private activatedRoute: ActivatedRoute,
private candidatesService: CandidatesService,
private location: Location,
private router: Router
) {}
ngOnInit(): void {
this.candidate$ = this.activatedRoute.paramMap.pipe(
switchMap((params) => {
const selectedId = parseInt(params.get("id")!, 10);
return this.candidatesService.getCandidate(selectedId);
})
);
}
onSubmit(candidate: Candidate) {
this.candidatesService.update(candidate);
this.router.navigate(["/"]);
}
back() {
this.location.back();
}
}
En el ciclo OnInit
configuramos un Observable
que parte del servicio ActivatedRouter
para a partir de la URL sacar el parámetro :id
que nos dará el identificador del candidato:
ngOnInit(): void {
this.candidate$ = this.activatedRoute.paramMap.pipe(
switchMap((params) => {
const selectedId = parseInt(params.get("id")!, 10);
return this.candidatesService.getCandidate(selectedId);
})
);
}
Hemos hecho uso del operador switchMap
que recordemos que sirve para concatenar los resultados de activatedRoute.paramMap
y candidatesService.getCandidate
.
En lugar de devolver un candidato, dejamos un observable que emitirá candidatos. Esto lo usaremos en el template edit-candidate.component.html
:
<app-candidate-form
*ngIf="candidate$ | async as candidate"
[candidate]="candidate"
(submit)="onSubmit($event)"
(back)="back()"
>
</app-candidate-form>
Hacemos uso del pipe async
. async
subscribe a un observable y devuelve el valor emitido. Además cuando el componente se destruye, se encarga de llamar a unsubscribe
de la subscription
evitando fugas de memoria.
Ahora hace falta actualizar CandidateForm
para que acepte un candidato si es en caso de edición. Modificamos candidate-form.component.ts
:
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnInit,
Output,
} from "@angular/core";
import { FormBuilder, FormGroup, Validators } from "@angular/forms";
import { Candidate } from "src/app/models/candidate";
import { linkedinPattern, phonePattern } from "src/app/utils/Validators";
@Component({
selector: "app-candidate-form",
templateUrl: "./candidate-form.component.html",
styleUrls: ["./candidate-form.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CandidateFormComponent implements OnInit {
candidateForm: FormGroup;
@Input() candidate: Candidate | null = null;
@Output() submit = new EventEmitter<Candidate>();
@Output() back = new EventEmitter();
constructor(private fb: FormBuilder) {
this.candidateForm = this.fb.group({
name: ["", [Validators.required, Validators.minLength(2)]],
surname: ["", [Validators.required, Validators.minLength(2)]],
email: ["", [Validators.required, Validators.email]],
phone: ["", [Validators.pattern(phonePattern)]],
linkedIn: ["", [Validators.pattern(linkedinPattern)]],
experience: ["", [Validators.required]],
});
}
ngOnInit(): void {
this.reset();
}
get name() {
return this.candidateForm.get("name");
}
getNameErrors() {
if (this.name?.hasError("required")) {
return "Debe introducir un nombre.";
}
return this.name?.hasError("minlength")
? "El nombre debe tener al menos 2 carácteres"
: "";
}
get surname() {
return this.candidateForm.get("surname");
}
getSurnameErrors() {
if (this.name?.hasError("required")) {
return "Debe introducir los apellidos.";
}
return this.name?.hasError("minlength")
? "Los apellidos debe tener al menos 2 carácteres"
: "";
}
get email() {
return this.candidateForm.get("email");
}
getEmailErrors() {
if (this.email?.hasError("required")) {
return "Debe introducir un email.";
}
return this.email?.hasError("email")
? "Debe introducir un email correcto"
: "";
}
get phone() {
return this.candidateForm.get("phone");
}
getPhoneErrors() {
return this.phone?.hasError("pattern")
? "Debe introducir un teléfono correcto, ej (+34555667788)"
: "";
}
get linkedIn() {
return this.candidateForm.get("linkedIn");
}
getLinkedinErrors() {
return this.linkedIn?.hasError("pattern")
? "Debe introducir un perfil de linkedin correcto"
: "";
}
get experience() {
return this.candidateForm.get("experience");
}
getExperienceErrors() {
return this.experience?.hasError("required")
? "Debe seleccionar la experiencia"
: "";
}
onSubmit() {
const savedCandidate: Candidate = Object.assign(
{},
this.candidate,
this.candidateForm.value
);
this.submit.emit(savedCandidate);
}
goBack() {
this.back.emit();
}
reset() {
this.candidateForm.reset();
if (this.candidate) {
this.candidateForm.setValue({
name: this.candidate.name,
surname: this.candidate.surname,
email: this.candidate.email,
phone: this.candidate.phone ? this.candidate.phone : null,
linkedIn: this.candidate.linkedIn ? this.candidate.linkedIn : null,
experience: this.candidate.experience,
});
}
}
}
Por un lado hemos incluido una propiedad candidate
:
@Input() candidate: Candidate | null = null;
y luego hacemos uso de esa propiedad para que en caso de que esté el formulario se inicie con los datos del candidato. Para ello, implementamos el método reset
:
reset() {
this.candidateForm.reset();
if (this.candidate) {
this.candidateForm.setValue({
name: this.candidate.name,
surname: this.candidate.surname,
email: this.candidate.email,
phone: this.candidate.phone ? this.candidate.phone : null,
linkedIn: this.candidate.linkedIn ? this.candidate.linkedIn : null,
experience: this.candidate.experience,
});
}
}
que además de reiniciar el formulario (dejar todos los controles como pristine
), incluye los datos originales del candidato.
Este método reset
es llamado en el ciclo de vida OnInit
, ya que implementamos su interfaz:
ngOnInit(): void {
this.reset();
}
Para finalizar, debemos hacer que el botón Editar
de cada MatCard
que muestra un candidato, nos lleve a la ruta edit/:id
. La primera parte sería configurar ese botón para que haga justo eso, vemos que nuestra tarjeta de candidato ya tiene su html actualizado:
<mat-card-actions [align]="'end'">
<button mat-button color="primary" [routerLink]="['/edit', candidate.id]">
Editar
</button>
<button mat-button color="warn">Borrar</button>
</mat-card-actions>
Hacemos uso de la directiva routerLink
que provee @angular/router
para configurar el botón y que cuando se haga click, se navegue a edit/:id
.
La segunda parte es actualizar nuestro módulo de rutas definido en app-routing.module.ts
:
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { CandidateListComponent } from "./components/candidate-list/candidate-list.component";
import { CreateCandidateComponent } from "./components/create-candidate/create-candidate.component";
import { EditCandidateComponent } from "./components/edit-candidate/edit-candidate.component";
const routes: Routes = [
{
path: "",
component: CandidateListComponent,
},
{
path: "create",
component: CreateCandidateComponent,
},
{
path: "edit/:id",
component: EditCandidateComponent,
},
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
})
export class AppRoutingModule {}
Vemos que hemos añadido una nueva ruta a "edit:id"
. La notación :param
sirve para definir partes dinámicas en la url que hagan matchear una ruta. En este caso cualquier cosa como
edit/1
-> 1edit/afdfasdf
-> afdasdf
Coincidiría con la tercera ruta y Angular mostraría EditCandidateComponent
. Cuando la ruta activa el componente EditCandidateComponente
, se ejecuta su ngOnInit
:
ngOnInit(): void {
this.candidate$ = this.activatedRoute.paramMap.pipe(
switchMap((params) => {
const selectedId = parseInt(params.get('id')!, 10);
return this.candidatesService.getCandidate(selectedId);
})
);
}
Que extrae de la URL el parámetro id.
Para finalizar, hemos tenido que actualizar el método onSubmit
del componente:
onSubmit() {
const savedCandidate: Candidate = Object.assign(
{},
this.candidate,
this.candidateForm.value
);
this.submit.emit(savedCandidate);
}
Para que además de devolver los datos actualizados, devuelva también el id
que necesita EditCandidateComponent
en su método onSubmit
:
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { Location } from "@angular/common";
import { Observable, switchMap } from "rxjs";
import { Candidate } from "src/app/models/candidate";
import { CandidatesService } from "src/app/services/candidates.service";
@Component({
selector: "app-edit-candidate",
templateUrl: "./edit-candidate.component.html",
styleUrls: ["./edit-candidate.component.scss"],
})
export class EditCandidateComponent implements OnInit {
candidate$!: Observable<Candidate>;
constructor(
private activatedRoute: ActivatedRoute,
private candidatesService: CandidatesService,
private location: Location,
private router: Router
) {}
ngOnInit(): void {
this.candidate$ = this.activatedRoute.paramMap.pipe(
switchMap((params) => {
const selectedId = parseInt(params.get("id")!, 10);
return this.candidatesService.getCandidate(selectedId);
})
);
}
onSubmit(candidate: Candidate) {
// Se llama a update, que necesita un candidato con id
this.candidatesService.update(candidate);
this.router.navigate(["/"]);
}
back() {
this.location.back();
}
}
Ese onSubmit
esta cableado en su html como vimos anteriormente.
Ejercicio: Comprobar qué pasa si se le pasa un parámetro id
que no corresponde a ningún candidato. ¿Cómo podríamos solucionar ésto?
Formularios dinámicos - múltiples controles en array
Angular provee una forma de crear formularios dinámicos basados en Array
usando FormArray
.
Vamos a actualizar el formulario del candidato para que pueda introducir skills.
Modificamos el tipo de candidate.ts
, para que incluya skills
:
import { Experience } from "./experience";
import { Project } from "./project";
export interface Candidate {
id?: number | string;
name: string;
surname: string;
email: string;
age?: number;
phone?: string;
linkedIn?: string;
experience: Experience;
skills: string[];
previousProjects: Project[];
}
El fichero app.config.ts
que hemos utilizado para mantener los datos de nuestras candidatas da error, porque a las entidades le falta la propiedad skills
. Actualizamos app.config.ts
:
import { InjectionToken } from "@angular/core";
import { Candidate } from "../models/candidate";
import { Experience } from "../models/experience";
export interface AppConfig {
candidates?: Candidate[];
}
export const APP_CONFIG = new InjectionToken<AppConfig>("app.config");
export const Config: AppConfig = {
candidates: [
{
id: 0,
email: "candidate@email.com",
phone: "+34634434312",
experience: Experience.Junior,
name: "Carlos",
previousProjects: [
{
name: "BBVA",
technology: ["ReactJS"],
description: "Programador Junior",
experience: 1,
},
],
surname: "Ruiz Marco",
skills: [],
},
{
id: 1,
email: "candidate1@email.com",
phone: "+34634434312",
experience: Experience.Midlevel,
name: "Juan",
previousProjects: [
{
name: "BBVA",
technology: ["ReactJS", "JQuery"],
description:
"Programador encargado de correcciones en la página web de venta privada",
experience: 1,
},
{
name: "Indra",
technology: ["Angular", "Express"],
description: "Desarrollador fullstack JS",
experience: 3,
},
],
surname: "Martínez",
skills: [],
},
{
id: 2,
email: "candidate1@email.com",
phone: "+34634434312",
experience: Experience.Senior,
name: "Paco",
previousProjects: [
{
name: "BBVA",
technology: ["ReactJS", "JQuery"],
description:
"Programador encargado de correcciones en la página web de venta privada",
experience: 1,
},
{
name: "Indra",
technology: ["Angular", "Express"],
description: "Desarrollador fullstack JS",
experience: 3,
},
{
name: "Compañía del Cantabrico",
technology: ["Java", "AngularJS", "ReactJS"],
description: "Desarrollador fullstack java y JS",
experience: 3,
},
],
surname: "García Olano",
skills: ["JS", "Proactivo"],
},
],
};
Ahora ya podemos modificar el formulario de un candidato para que maneje un array de skill
. Primero en el fichero candidate-form.component.ts
, añadimos el array:
..
export class CandidateFormComponent implements OnInit {
...
constructor(private fb: FormBuilder) {
this.candidateForm = this.fb.group({
name: ['', [Validators.required, Validators.minLength(2)]],
surname: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]],
phone: ['', [Validators.pattern(phonePattern)]],
linkedIn: ['', [Validators.pattern(linkedinPattern)]],
experience: ['', [Validators.required]],
skills: this.fb.array([]),
});
}
...
El servicio FormBuilder
también permite crear FormArray
. Hemos iniciado skills
con un control vacío.
Después creamos un getter
para manejar fácilmente el array de skills.
get skills() {
return this.candidateForm.get('skills') as FormArray;
}
Además tenemos que cambiar el método reset
, de forma que nuestro candidato vea reflejado los skills en el los controles que añadimos. Al principio del método construimos un array de skills (array de strings
) y generamos tantos controles como haga falta.
Hay que notar que el formulario por defecto se construye para que contenga un control de tipo text
en el array de skills
.
...
reset() {
this.candidateForm.reset();
this.skills.clear();
if (this.candidate) {
const skills = [];
if (Array.isArray(this.candidate.skills)) {
this.candidate.skills.forEach((skill, index) => {
skills.push(skill);
this.addSkill();
});
} else {
this.addSkill();
skills.push('');
}
this.candidateForm.setValue({
name: this.candidate.name,
surname: this.candidate.surname,
email: this.candidate.email,
phone: this.candidate.phone ? this.candidate.phone : null,
linkedIn: this.candidate.linkedIn ? this.candidate.linkedIn : null,
experience: this.candidate.experience,
skills,
});
}
}
addSkill() {
this.skills.push(this.fb.control(''));
}
}
Para continuar actualizamos el template candidate-form.component.html
:
<div formArrayName="skills">
<h2>Skills</h2>
<button type="button" (click)="addSkill()">+ Add another skill</button>
<mat-form-field *ngFor="let skill of skills.controls; let i = index">
<!-- The repeated alias template -->
<mat-label>Skill</mat-label>
<input matInput type="text" [formControlName]="i" />
</mat-form-field>
</div>
Insertamos un nuevo div
en candidate-form.component.html
que mantenga un array mediante *ngFor
de mat-form-field
.
Destacar que hace falta anotar el div
contenedor con formArrayName
y luego cada input con formControlName
al que se le pasa un number
que es el index
de *ngFor
.
Ejercicio: Estilar el grupo para que aparezca vertical, con cada control uno encima de otro y ocupe el 100% del espacio.
Además también queremos poder borrar skills
, por lo que a cada mat-form-field
vamos a introducirle un button
borrar. Usaremos mat-button
y lo introduciremos como sufijo del control. Primero insertamos el método removeSkill
en el fichero candidate-form.component.ts
:
removeSkill(index: number) {
this.skills.removeAt(index);
}
y luego ajustamos el template para que contenga dicho botón, actualizamos candidate-form.component.html
:
<div class="full" formArrayName="skills">
<h2>Skills</h2>
<button type="button" (click)="addSkill()">+ Add another skill</button>
<div class="skills">
<mat-form-field *ngFor="let skill of skills.controls; let i = index">
<!-- The repeated alias template -->
<mat-label>Skill</mat-label>
<input matInput type="text" [formControlName]="i" />
<button matSuffix type="button" mat-icon-button (click)="removeSkill(i)">
<mat-icon>remove</mat-icon>
</button>
</mat-form-field>
</div>
</div>
Notar el uso de matSuffix
dentro de mat-form-field
y mat-icon-button
con un mat-icon
para mostrar un menos que actúa como botón borrar.
Modales
En la lista de candidatos, cuando usamos CandidateCardComponent
, hemos pintado un botón Borrar
que de momento está sin funcionalidad. Es muy común mostrar una confirmación cuando se realiza una operación de borrado. Vamos a mostrar un modal con Angular Material
para pedir al usuario confirmación.
En Angular Material
a los modales se les llama Dialog
. Esta librearía como muchas otras mantienen elementos fuera del árbol DOM que creamos en nuestra aplicación para mostrar esos div
s de manera que se sobrepongan a otros elementos.
Primero, para contener el template del modal, creamos un componente:
ng g component confirmation-modal --module "app.module"
Actualizamos el fichero confirmation-modal.component.ts
generado por:
import { Component, Inject } from "@angular/core";
import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog";
import { Candidate } from "../models/candidate";
@Component({
selector: "app-confirmation-modal",
templateUrl: "./confirmation-modal.component.html",
styleUrls: ["./confirmation-modal.component.scss"],
})
export class ConfirmationModalComponent {
constructor(
public dialogRef: MatDialogRef<ConfirmationModalComponent>,
@Inject(MAT_DIALOG_DATA) public data: Candidate
) {}
cancel(): void {
this.dialogRef.close();
}
}
Para poder pasar a los modales datos desde los componentes que lo abren, inyectamos el siguiente token en su constructor:
constructor(
public dialogRef: MatDialogRef<ConfirmationModalComponent>,
@Inject(MAT_DIALOG_DATA) public data: Candidate
) {}
Ahora el modal puede acceder a una variable data
que será provista en tiempo de creación del modal como veremos a continuación. Pero antes definimos el template del modal en confirmation-modal.component.html
:
<h1 mat-dialog-title>Borrar</h1>
<div mat-dialog-content>
<p>¿Desea borrar el candidato?</p>
<h2>{{ data.name }} {{ data.surname }}</h2>
</div>
<div mat-dialog-actions [align]="'end'">
<button mat-button cdkFocusInitial (click)="cancel()">No, gracias</button>
<button mat-button [mat-dialog-close]="data" color="warn">Ok</button>
</div>
En primer lugar, notamos que hemos hecho uso de las directivas para Dialog
que nos provee Angular Material
. Tanto mat-dialog-title
como mat-dialog-content
y mat-dialog-actions
son utilidades que nos facilitan la colocación de elementos dentro del Dialog
.
Los datos del candidato se mantienen en una variable data
que será pasada al crear el modal.
El botón No, gracias
quita el modal sin pasar datos al componente que lo lanzó. Además se usa junto con la directiva cdkFocusInitial
que pone el foco en el botón según se abre el modal.
El botón Ok
devuelve al componente que creo el modal el propio candidato, dando la información al componente de que el modal se quitó con ok, por lo que tiene que borrar el candidato.
Finalmente, actualizamos CandidateCardComponent
para permitir el borrado de candidatos. Modificamos su template (candidate-card.component.html
) para hacer uso de un nuevo método remove
:
...
<button type="button" mat-button color="warn" (click)="remove()">Borrar</button>
...
e implementamos remove
en candidate-card.component.ts
:
import { Component, Input, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ConfirmationModalComponent } from 'src/app/confirmation-modal/confirmation-modal.component';
import { Candidate } from 'src/app/models/candidate';
import { CandidatesService } from 'src/app/services/candidates.service';
@Component({
selector: 'app-candidate-card',
templateUrl: './candidate-card.component.html',
styleUrls: ['./candidate-card.component.scss'],
})
export class CandidateCardComponent implements OnInit {
@Input() candidate!: Candidate;
skills: { technology: string; experience: number }[] = [];
seniority = {
...
};
constructor(
public dialog: MatDialog,
private candidateService: CandidatesService
) {}
ngOnInit(): void {
...
}
private buildUniqueSkills() {
...
}
remove(): void {
const dialogRef = this.dialog.open(ConfirmationModalComponent, {
data: this.candidate,
});
dialogRef.afterClosed().subscribe((result) => {
console.log('The dialog was closed');
if (result) {
this.candidateService.remove(result.id);
}
});
}
}
Para ello hemos necesitado inyectar tanto MatDialog
como CandidatesService
. MatDialog
permite que trabajemos con modales.
remove(): void {
const dialogRef = this.dialog.open(ConfirmationModalComponent, {
data: this.candidate,
});
dialogRef.afterClosed().subscribe((result) => {
console.log('The dialog was closed');
if (result) {
this.candidateService.remove(result.id);
}
});
}
La primera parte del método remove
es abrir el diálogo, dónde le pasamos el data
que usamos luego en el propio modal (el candidato a borrar).
La segunda parte es una suscripción a un observable que emitirá cuando el dialogo es cerrado. El método
dialogRef.afterClosed();
devuelve un observable. Las emisiones del observable vienen con un result
, dependiendo de cómo se llamó al
this.dialogRef.close();
en el controlador del modal. Observar que para el botón Ok
se usó el siguiente template
<button mat-button [mat-dialog-close]="data" color="warn">Ok</button>
La directiva [mat-dialog-close]="data"
es como si hubiéramos hecho
this.dialogRef.close(this.data);
Testing de formularios
Vamos verificar que las siguientes partes de nuestro formulario funcionan bien:
- Form submission
- Successful submission
- Do not submit the invalid form
- Submission failure
- Campos requeridos son marcados mostrando los mensajes de error
Primero tenemos que importar las utilidades de testing. Crear el fichero src/app/utils/testing.ts
:
import { DebugElement } from "@angular/core";
import { ComponentFixture } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
export function findEl<T>(
fixture: ComponentFixture<T>,
testId: string
): DebugElement {
return fixture.debugElement.query(By.css(`[data-testid="${testId}"]`));
}
Luego podemos hacer el test en candidate-form.component.spec.ts
import { ComponentFixture, TestBed, fakeAsync } from "@angular/core/testing";
import { HarnessLoader } from "@angular/cdk/testing";
import { MatButtonHarness } from "@angular/material/button/testing";
import { MatInputHarness } from "@angular/material/input/testing";
import { TestbedHarnessEnvironment } from "@angular/cdk/testing/testbed";
import { CandidateFormComponent } from "./candidate-form.component";
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { ReactiveFormsModule } from "@angular/forms";
import { Experience } from "src/app/models/experience";
import { findEl } from "src/app/utils/testing";
let loader: HarnessLoader;
describe("CandidateFormComponent", () => {
let component: CandidateFormComponent;
let fixture: ComponentFixture<CandidateFormComponent>;
let testBed: TestBed;
beforeEach(() => {
testBed = TestBed.configureTestingModule({
imports: [ReactiveFormsModule],
declarations: [CandidateFormComponent],
schemas: [NO_ERRORS_SCHEMA],
});
fixture = TestBed.createComponent(CandidateFormComponent);
loader = TestbedHarnessEnvironment.loader(fixture);
component = fixture.componentInstance;
});
it("should create", async () => {
fixture.detectChanges();
const buttons = await loader.getAllHarnesses(MatButtonHarness);
expect(buttons.length).toBe(4);
});
describe("submit", () => {
const candidate = {
name: "Julio",
surname: "Ródenas",
email: "julio@email.com",
phone: "786565434",
linkedIn: "",
experience: Experience.Senior,
previousProjects: [],
skills: [],
};
let submitButton: MatButtonHarness;
beforeEach(async () => {
component.candidate = candidate;
component.reset();
submitButton = await loader.getHarness(
MatButtonHarness.with({ text: "Guardar" })
);
});
it("successful submission", async () => {
fixture.detectChanges();
component.submit.asObservable().subscribe((value) => {
expect(value).toEqual(candidate);
});
expect(await submitButton.isDisabled()).toBe(false);
await submitButton.click();
});
it("do not submit the invalid form", fakeAsync(async () => {
const emailInput = await loader.getHarness(
MatInputHarness.with({ selector: '[type="email"]' })
);
await emailInput.setValue("asdfasdf");
fixture.detectChanges();
expect(await submitButton.isDisabled()).toBe(true);
}));
});
describe("required fields", () => {
it("display error messages", async () => {
const emailInput = await loader.getHarness(
MatInputHarness.with({ selector: '[type="email"]' })
);
await emailInput.setValue("asdfasdf");
const error = findEl(fixture, "candidate-email-field-error");
expect(error).toBeDefined();
});
});
});
El test de required fields falla. Hace falta actualizar el mensaje de error con el data-testid="candidate-email-field-error"
para poder buscarlo cómodamente.
Ejercicios Finales
Ejercicio: Hacer editable la sección de proyectos de un candidato utilizando modales para bien editar un proyecto o para crear uno nuevo. También permitir el borrado de un proyecto desde el propio modal