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

  1. Setup del proyecto
    1. Instalación de Angular Material
  2. Crear una lista de perfiles
    1. Servicio CandidateService
    2. Router de Angular
  3. Pintar la lista de candidatos
  4. Formulario para crear candidatos
  5. Formularios dinámicos
  6. Modales
  7. Testing de formularios
  8. Ejercicios finales
  9. 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:

Estructura del proyecto.

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:

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

Primer test del servicio

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.

Primer test del servicio correcto

Con lo hecho anteriormente hemos implementado:

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.

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.

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:

Los test del servicio pasan

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.

Lista de candidatos

En la tarjeta de cada candidata hemos utilizado varios componentes que nos provee Angular 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

Ruta a CreateCandidateComponent

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

Formulario del candidato

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

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

  1. Form submission
    1. Successful submission
    2. Do not submit the invalid form
    3. Submission failure
  2. 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

Referencias