Introducción a Angular y sus componentes

Este curso es una introducción a Angular. Actualmente en el momento de escribir el curso, la versión de Angular es la 16.

El curso está pensado para realizarse en 10 días de 2 horas cada uno. El entorno que se utilizará para su realización será VSCode. El sistema operativo es indiferente aunque el curso ha sido escrito y probado únicamente en Ubuntu Linux.

Para poder realizar el curso hará falta tener un entorno NodeJS instalado en la computadora y conexión a internet.

Outline

  1. Instalación del entorno
    1. NodeJS
    2. Instalación de Visual Studio Code
    3. Dependencias Angular
    4. Exploración del proyecto
  2. Creación del primer componente
    1. Mostrar las propiedades de una candidata
    2. Data binding
      1. Interpolación
      2. Binding
    3. Estilos en un componente
      1. Clases dinámicas
      2. Estilos dinámicas
    4. Test de un componente
  3. Componentes Angular
    1. Encapsulación de un componente Angular
    2. Introducción al ciclo de vida de un componente
    3. Proyección de contenido
      1. Single-slot projection
      2. Multi-slot projection
    4. Interacción entre componentes
      1. Pasar datos desde el padre al hijo con input binding
      2. Interceptar una input property con un setter
      3. Interceptar un input property con ngOnChanges
      4. Escuchar eventos de un hijo en el padre
      5. El padre accede al hijo con una variable local en el template
      6. El padre usa un @ViewChild
  4. Ejercicios Finales
  5. Referencias

Instalación del entorno

NodeJS

Primero hace falta tener NodeJS. Una buena opción es utilizar un gestor de versiones de node que nos permitirá instalar y controlar diferentes versiones al mismo tiempo en nuestro ordenador. En caso de Linux podemos utilizar nvm y en caso de windows nvm-windows.

A continuación se describe los comandos para Ubuntu Linux:

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.4/install.sh | bash
source ~/.bashrc

Si usas otro Linux o un entorno MAC a lo mejor necesitas ajustar el path al ejecutable que se acaba de instalar.

Instalación de VSCode.

Seguir las instrucciones en función del sistema operativo.

Las extensiones recomendadas son

Extensiones angular Extensiones angular

Dependencias Angular

A continuación instalaremos todas las utilidades para desarrollar cómodamente en Angular. Para empezar debemos instalar Angular CLI. Es una herramienta de línea de comandos que permite crear, desarrollar y mantener aplicaciones Angular con facilidad.

Con Angular CLI, los desarrolladores pueden:

  1. Iniciar Proyectos: Generar un nuevo proyecto Angular con una estructura de archivos estándar y configuración básica en cuestión de segundos.
  2. Agregar Componentes, Servicios y Otras Características: Mediante simples comandos, se pueden generar componentes, servicios, módulos, directivas, pipes, etc, con todos los archivos asociados ya configurados para su uso.
  3. Servir y Testear: Angular CLI permite ejecutar el proyecto localmente en un servidor de desarrollo, ver los cambios en tiempo real y también ejecutar pruebas unitarias y de end-to-end.
  4. Optimizar y Construir: Cuando llega el momento de desplegar la aplicación, Angular CLI cuenta con herramientas para optimizar el código, eliminar el código no utilizado (tree-shaking) y empaquetar la aplicación en archivos minimizados listos para producción.
  5. Actualizar Angular: Con las frecuentes actualizaciones de Angular, Angular CLI ofrece comandos que facilitan la migración y actualización de proyectos existentes a versiones más recientes.
npm install -g @angular/cli

Desde la consola de comandos que estamos utilizando vamos a crear el primer proyecto Angular. Todos los ejercicios y ejemplos que haremos están a disposición del alumnado en los repositorios git correspondientes.

ng --help # Comprobamos que tenemos instalado Angular CLI
ng new --help # Ayuda sobre el comando de creación de nuevo proyecto

ng new angular-101-day1 # Marcar todas las opciones por defecto excepto scss
? Would you like to add Angular routing? (y/N) # N
CSS
> SCSS   [ https://sass-lang.com/documentation/sntax#scss]
Sass   [ https://sass-lang.com/documentation/syntax#the-indented-syntax]
Less   [ http://lesscss.org]

## Y tras estas opciones debemos ver la siguiente salida
CREATE angular-101-day1/README.md (1068 bytes)
CREATE angular-101-day1/.editorconfig (274 bytes)
CREATE angular-101-day1/.gitignore (548 bytes)
CREATE angular-101-day1/angular.json (2750 bytes)
CREATE angular-101-day1/package.json (1047 bytes)
CREATE angular-101-day1/tsconfig.json (901 bytes)
CREATE angular-101-day1/tsconfig.app.json (263 bytes)
CREATE angular-101-day1/tsconfig.spec.json (273 bytes)
CREATE angular-101-day1/.vscode/extensions.json (130 bytes)
CREATE angular-101-day1/.vscode/launch.json (470 bytes)
CREATE angular-101-day1/.vscode/tasks.json (938 bytes)
CREATE angular-101-day1/src/main.ts (214 bytes)
CREATE angular-101-day1/src/favicon.ico (948 bytes)
CREATE angular-101-day1/src/index.html (300 bytes)
CREATE angular-101-day1/src/styles.css (80 bytes)
CREATE angular-101-day1/src/app/app.module.ts (314 bytes)
CREATE angular-101-day1/src/app/app.component.css (0 bytes)
CREATE angular-101-day1/src/app/app.component.html (23083 bytes)
CREATE angular-101-day1/src/app/app.component.spec.ts (922 bytes)
CREATE angular-101-day1/src/app/app.component.ts (220 bytes)
CREATE angular-101-day1/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.

Con este comando tenemos un nuevo proyecto angular que vamos a explorar con nuestro VSCode instalado.

Exploración del proyecto

Después de la creación del proyecto con el comando

ng new angular-101-day-1

obtenemos una estructura como la siguiente:

Estructura del proyecto

En el primer nivel tenemos los siguientes ficheros de configuracion:

Creación del primer componente.

Vamos a crear un componente con Angular CLI. Ejecuta

ng g component components/candidate

Comprueba que se han creado las carpetas src/app/components/candidate, con 4 ficheros debajo.

Borra todo el contenido de app.component.html y reemplázalo por

<app-candidate></app-candidate>

Suponemos que tenemos tanto el comando npm start como el comando npm run test lanzados en dos terminales diferentes. Con estas premisas, ocurren dos cosas:

Primero arreglamos los test sustituyendo el código que se dio por defecto con el scaffolding en el fichero app.component.spec.ts por

import { TestBed } from "@angular/core/testing";
import { RouterTestingModule } from "@angular/router/testing";
import { AppComponent } from "./app.component";
import { CandidateComponent } from "./components/candidate/candidate.component";

describe("AppComponent", () => {
  beforeEach(() =>
    TestBed.configureTestingModule({
      imports: [RouterTestingModule],
      declarations: [AppComponent, CandidateComponent],
    })
  );

  it("should create the app", () => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.componentInstance;
    expect(app).toBeTruthy();
  });

  it("should render candidate component", () => {
    const fixture = TestBed.createComponent(AppComponent);
    fixture.detectChanges();
    const compiled = fixture.nativeElement as HTMLElement;
    expect(compiled.querySelector("app-candidate")?.textContent).toContain(
      "candidate works!"
    );
  });
});

Para que la aplicación como los test funcionen ha sido necesario declarar el nuevo componente en un módulo Angular. En el caso de la aplicación, Angular CLI ha insertado el componente automáticamente en el módulo app.module.ts.

Abrir el fichero app.module.ts y mirar cómo ha sido modificado. El componente CandidateComponent fue añadido.

En el caso del test, cada vez que creamos un test para un componente en Angular, lo que se suele hacer es crear un módulo con las mínimas dependencias posibles para que el componente funcione. En este caso, el componente AppComponent necesita del componente CandidateComponent para poder mostrar todo su contenido. Es por ello que al igual que en el módulo principal de la aplicación, hace falta insertar el componente CandidateComponent.

Mostrar las propiedades de una candidata.

Comenzamos por definir la estructura de datos que tienen las personas candidatas. Para ello creamos un fichero en src/app/models/candidate.model.ts y lo rellenamos con el siguiente código:

export interface Candidate {
  id: number;
  name: string;
  age: number;
  position: string;
  experience: number; // years
  skills: string[];
}

Vamos a sustituir el código de candidate.component.html por

<div class="candidate-card">
  <h2>José Pérez</h2>
  <p><strong>Age:</strong>25</p>
  <p><strong>Position:</strong>Desarrollador Junior</p>
  <p><strong>Experience:</strong> 1 year</p>
  <p><strong>Skills:</strong> Java, SQL</p>
</div>

Abrimos la página http://localhost:4200 y vemos como el contenido de la página ha cambiado.

Como puedes ver, el div principal del template del componente contiene la clase candidate-card. Vamos a hacer uso del fichero candidate.component.scss para definir esa clase css que se usará en el componente. Rellenar el fichero candidate.component.scss por:

.candidate-card {
  border: 1px solid #ccc;
  padding: 10px;
  margin: 10px 0;
  border-radius: 5px;
  box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.1);
}

Al final obtendremos un componente como el siguiente:

Primer componente

De esta manera, podemos reutilizar el template, ya que lo hemos componetizado. Copia varias veces las lineas <app-candidate></app-candidate> en app.component.html.

Aunque es interesante para no tener que repetir código, necesitamos otro mecanismo para que los componentes sean más flexibles y reutilizables.

Data binding

Interpolación

En primer lugar, para evitar introducir los textos en crudos dentro del html, usaremos una variable dentro del componente CandidateComponent que luego referenciaremos en el template candidate.component.html.

Primero modificamos el código de candidate.component.ts por

import { Component } from "@angular/core";

@Component({
  selector: "app-candidate",
  templateUrl: "./candidate.component.html",
  styleUrls: ["./candidate.component.scss"],
})
export class CandidateComponent {
  candidate = {
    name: "José Pérez",
    age: 25,
    position: "Desarrollador Junior",
    experience: 1,
    skills: ["Java", "SQL"],
  };

  doEdit = () => {
    alert("You want to edit this candidate");
  };
}

y el fichero candidate.component.html por

<div class="candidate-card">
  <h2>{{ candidate.name }}</h2>
  <p><strong>Age:</strong>{{ candidate.age }}</p>
  <p><strong>Position:</strong>{{ candidate.position }}</p>
  <p><strong>Experience:</strong> {{ candidate.experience }}</p>
  <p><strong>Skills:</strong> {{ candidate.skills.join(', ') }}</p>
  <p><button (click)="doEdit()">Edit</button></p>
</div>

A la técnica usada se le llama interpolación en Angular.

Además en los templates también se puede usar expresiones como la utilizada para mostrar la lista de habilidades:

<p><strong>Skills:</strong> {{ candidate.skills.join(', ') }}</p>

De esta manera, podemos utilizar cualquier variable o expresión JS dentro de nuestros templates.

También podemos añadir template statements, es decir funciones que se ejecuten como respuesta a un evento. El ejemplo es la ejecución de la función doEdit como controlador del evento click. La función está definida en la parte Typescript o controlador de angular y usada en el template. Para más información se recomienda leer la documentación de Angular

Binding

Pero de la forma anterior, las variables sólo viven dentro de los componentes. Si queremos pasar información desde el componente padre al hijo (haciendo el componente sea más reutilizable) haremos uso del binding y de las props de Angular.

Para ello modificaremos nuestro componente CandidateComponent y el componente padre AppComponent para aceptar un candidato por props.

Primero el componente AppComponent, la parte del controlador en TypeScript (fichero app.component.ts):

import { Component } from "@angular/core";
import { Candidate } from "./models/candidate.model";

@Component({
  selector: "app-root",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.scss"],
})
export class AppComponent {
  title = "candidates-frontend";

  candidates: Candidate[] = [
    {
      id: 1,
      name: "José Pérez",
      age: 25,
      position: "Desarrollador Junior",
      experience: 1,
      skills: ["Java", "SQL"],
    },
    {
      id: 2,
      name: "Paco López",
      age: 40,
      position: "Desarrollador Senior",
      experience: 15,
      skills: ["Java", "SQL", "Oracle", "PL/SQL", "Cobol", "C++"],
    },
  ];
}

Siguiente, modificamos el componente CandidateComponent para aceptar un candidato por props:

import { Component, Input } from "@angular/core";
import { Candidate } from "src/app/models/candidate.model";

@Component({
  selector: "app-candidate",
  templateUrl: "./candidate.component.html",
  styleUrls: ["./candidate.component.scss"],
})
export class CandidateComponent {
  @Input() candidate!: Candidate;

  doEdit = () => {
    alert("You want to edit this candidate");
  };
}

El template quedaría igual, ya que la prop tiene la misma forma. En typescript podemos hacer usos de interfaces para asegurar este punto. Como vemos hemos usado la misma interface tanto en AppComponent como en CandidateComponent.

Finalmente tenemos que modificar el template de AppComponent, el fichero app.component.html para hacer uso de nuestra nueva prop:

<app-candidate [candidate]="candidates[0]"></app-candidate>

En este punto, podemos ver que la prop permite al componente CandidateComponent ser dinámico. Si cambiamos el 0 por un 1, veremos al candidato Paco en lugar de a José.

Como hemos visto en los ejemplos anteriores, hay varios tipos de bindings en el template que nos permite Angular:

En resumen , si usamos la notación (...)="..." estamos ligando (binding) un evento del usuario con una función. Si usamos la notación [...]="..." estamos ligando una propiedad con una variable. Es decir, (..) son salidas (outputs) y [...] son entradas (inputs).

Estilos en un componente

Como vimos, Angular permite crear ficheros de estilos e incorporarlos al componente utilizando la propiedad styleUrls de la notación @Component.

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})

Pero si queremos dotar de dinamismo a los estilos tendremos que hacer uso de los bindings para clases css y estilos que provee Angular.

Como ejemplo, cambiaremos el fondo de la tarjeta de nuestro candidato en función de los años de experiencia que tiene. Si es junior (1-3 años) la pintaremos con #8cc0f7. Si es senior con #f74131. Si es intermedio la dejaremos en blanco.

Para ello modificamos el fichero candidate.component.scss, añadiendo las clases pertinentes:

.candidate-card {
  border: 1px solid #ccc;
  padding: 10px;
  margin: 10px 0;
  border-radius: 5px;
  box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.1);
  &.junior {
    background-color: #8cc0f7;
  }
  &.senior {
    background-color: #f74131;
  }
}

Y hacemos uso de las nuevas clases en candidate.component.html:

<div
  class="candidate-card"
  [class.junior]="candidate.experience < 3"
  [class.senior]="candidate.experience > 5"
>
  <h2>{{ candidate.name }}</h2>
  <p><strong>Age:</strong>{{ candidate.age }}</p>
  <p><strong>Position:</strong>{{ candidate.position }}</p>
  <p><strong>Experience:</strong> {{ candidate.experience }}</p>
  <p><strong>Skills:</strong> {{ candidate.skills.join(', ') }}</p>
  <p><button (click)="doEdit()">Edit</button></p>
</div>

Clases dinámicas

Como vemos usamos los [...] para ligar una clase en el div principal del componente CandidateComponent. Hay 4 formas de enganchar clases de forma dinámica a un elemento html. La que acabamos de ver que permite ligar una única clase y otras 3 más que permiten ligar multiples clases con una única expresión del tipo [class]="classExpression". Vamos a refactorizar el ejemplo anterior para ver cada una de ellas:

Ejercicio: Refactorizar el ejemplo anterior y dejarlo como un array de clases.

Estilos dinámicos

Angular también permite realizar el mismo dinamismo con los estilos css directamente, sin usar clases de forma intermedia. Permite aplicar estilos como color, backgroundColor, display a un elemento HTML directamente de forma dinámica.

Para mostrar ésto, vamos a aplicar la propiedad color dependiendo de la experiencia.

Primero actualizamos app.component.ts con una nueva candidata:

import { Component } from "@angular/core";
import { Candidate } from "./models/candidate.model";

@Component({
  selector: "app-root",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.scss"],
})
export class AppComponent {
  title = "candidates-frontend";

  candidates: Candidate[] = [
    {
      id: 1,
      name: "José Pérez",
      age: 25,
      position: "Desarrollador Junior",
      experience: 1,
      skills: ["Java", "SQL"],
    },
    {
      id: 2,
      name: "Paco López",
      age: 40,
      position: "Desarrollador Senior",
      experience: 15,
      skills: ["Java", "SQL", "Oracle", "PL/SQL", "Cobol", "C++"],
    },
    {
      id: 3,
      name: "Mireia García",
      age: 30,
      position: "Desarrolladora Intermedia",
      experience: 4,
      skills: ["Java", "SQL", "Oracle", "PL/SQL", "Cobol", "C++"],
    },
  ];
}

y también el fichero app.component.html:

<app-candidate [candidate]="candidates[0]"></app-candidate>
<app-candidate [candidate]="candidates[1]"></app-candidate>
<app-candidate [candidate]="candidates[2]"></app-candidate>

Por cuestiones de contraste, las candidatas junior e intermedia tienen que tener la letra en negro, pero el candidato senior la debe tener en blanco. Actualizamos el fichero candidate.component.html

<div [class]="candidateClasses()" [style.color]="getColor()">
  <h2>{{ candidate.name }}</h2>
  <p><strong>Age:</strong>{{ candidate.age }}</p>
  <p><strong>Position:</strong>{{ candidate.position }}</p>
  <p><strong>Experience:</strong>{{ candidate.experience }}</p>
  <p><strong>Skills:</strong>{{ candidate.skills.join(", ") }}</p>
  <p><button (click)="doEdit()">Edit</button></p>
</div>

y el fichero candidate.component.ts a:

import { Component, Input } from "@angular/core";
import { Candidate } from "../../models/candidate.model";

@Component({
  selector: "app-candidate",
  templateUrl: "./candidate.component.html",
  styleUrls: ["./candidate.component.scss"],
})
export class CandidateComponent {
  @Input() candidate!: Candidate;

  doEdit = () => {
    alert(`You want to edit this candidate: ${this.candidate.name}`);
  };

  candidateClasses() {
    return {
      "candidate-card": true,
      senior: this.candidate.experience < 3,
      junior: this.candidate.experience > 5,
    };
  }

  getColor() {
    if (this.candidate.experience <= 5) {
      return "black";
    }
    return "white";
  }
}

como para las clases también hay la opción de usar la notación [style]="styleExpression", siendo styleExpression un string directamente o un objeto. Mirar la documentación.

Test de un componente

Ejecutamos

npm run test

Para que los test funcionen tenemos que modificar el fichero app.component.spec.ts:

import { TestBed } from "@angular/core/testing";
import { RouterTestingModule } from "@angular/router/testing";
import { AppComponent } from "./app.component";
import { CandidateComponent } from "./components/candidate/candidate.component";

describe("AppComponent", () => {
  beforeEach(() =>
    TestBed.configureTestingModule({
      imports: [RouterTestingModule],
      declarations: [AppComponent, CandidateComponent],
    })
  );

  it("should create the app", () => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.componentInstance;
    expect(app).toBeTruthy();
  });

  it("should render candidate component", () => {
    const fixture = TestBed.createComponent(AppComponent);
    fixture.detectChanges();
    const compiled = fixture.nativeElement as HTMLElement;
    expect(compiled.querySelector("app-candidate")?.textContent).toBeDefined();
  });
});

En el caso de comprobar si se pintan candidatos, se comprueba la existencia de un tag app-candidate que además tenga contenido.

Además hay que modificar el fichero candidate.component.spec.ts con:

import { ComponentFixture, TestBed } from "@angular/core/testing";

import { CandidateComponent } from "./candidate.component";
import { Candidate } from "src/app/models/candidate.model";

describe("CandidateComponent", () => {
  let component: CandidateComponent;
  let fixture: ComponentFixture<CandidateComponent>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [CandidateComponent],
    });
    fixture = TestBed.createComponent(CandidateComponent);
    component = fixture.componentInstance;
    const candidate: Candidate = {
      age: 25,
      experience: 6,
      id: 0,
      name: "Alex",
      position: "developer",
      skills: ["JS"],
    };
    component.candidate = candidate;
    fixture.detectChanges();
  });

  it("should create", () => {
    expect(component).toBeTruthy();
  });

  it("should render candidate name", () => {
    const compiled = fixture.nativeElement as HTMLElement;
    expect(compiled.querySelector("h2")?.textContent).toContain("Alex");
  });

  it("should render white color for senior", () => {
    const compiled = fixture.nativeElement as HTMLElement;
    expect(compiled.querySelector("div")?.style.color).toContain("white");
  });
});

Se han añadido un test para comprobar el que el nombre del candidato se pinta correctamente. Y otro para comprobar que el estilo color se modifica por white ya que el candidato es senior y tiene más de 5 años de experiencia.

La ejecución produce el siguiente resultado:

AppComponent y CandidateCardComponent

Componentes Angular

Encapsulación de un componente Angular

Los estilos de un componente angular pueden ser encapsulados dentro del elemento host del componente para que no afecten a otras partes de la aplicación. Es decir, los estilos definidos en los ficheros de estilos declarados en la directiva @Component, solo aplicarán al template definido por el componente en la misma directiva. Aún así, Angular permite modificar la política de encapsulamiento modificando @Component:

Como ejemplo vamos a modificar la encapsulación CandidateComponent modificando el fichero candidate.component.ts:

import { Component, Input, ViewEncapsulation } from "@angular/core";
import { Candidate } from "../../models/candidate.model";

@Component({
  selector: "app-candidate",
  templateUrl: "./candidate.component.html",
  styleUrls: ["./candidate.component.scss"],
  encapsulation: ViewEncapsulation.ShadowDom,
})
export class CandidateComponent {
  @Input() candidate!: Candidate;

  ...
}

Con ViewEncapsulation.ShadowDom obtenemos la siguiente estructura del DOM:

ShadowDOM

Con ViewEncapsultaion.Emulated obtenemos:

Emulated

Además de no utilizar … se ha añadido un tag style al head del documento con los estilos usando los sufijos __ngcontent:

Emulated Completed

Por último se puede quitar el encapsulamiento con ViewEncapsulation.None y Angular producirá la siguiente salida

None

Los estilos será insertados en el head sin modificación de scope para el componente.

Introducción al ciclo de vida de un componente

Las instancias de componentes en Angular tienen un ciclo de vida que va desde su instanciación y renderizado hasta su destrucción y eliminación del DOM. Durante este ciclo, Angular gestiona la detección de cambios y actualiza las propiedades vinculadas a datos. De manera similar, las directivas siguen un ciclo de vida. Las aplicaciones pueden intervenir en estos ciclos mediante hooks de ciclo de vida para manejar eventos específicos, como inicialización, detección de cambios y limpieza previa a la eliminación.

El orden de ejecución de los hooks es el siguiente:

  1. ngOnChanges: lanzado cuando Angular inicia o reinicia las props input del componente. Se llama antes de ngOnInit si el componente ha declarado @Inputs y siempre que una prop cambie. Este hook es muy utilizado, ya que se usa siempre que desees realizar alguna acción cuando los @Input de un componente cambia.

  2. ngOnInit: Llamado una vez después de la creación del componente cuando las props ya han sido seteadas y después del primer ngOnChange.

  3. ngDoCheck: Utilizado para implementar un algoritmo de detección de cambios ad-hoc en el componente. No es aconsejable utilizarlo.

  4. ngAfterContentInit: Llamado una vez después del primer ngDoCheck. Se utiliza para incluir lógica justo después del momento en el que el componente inserta contenido externo dentro de la directiva / componente.

  5. ngAfterContentChecked: LLamado después de cada ngDoCheck.

  6. ngAfterViewInit: Llamado cuando el componente ha inicializado todas sus vistas suyas y de sus hijos.

  7. ngOnDestroy: Llamado cuando el componente se destruye. Usado para insertar lógica de limpiado y evitar las fugas de memoria principalmente.

Para hacer uso de ésto ciclos de vida hay que implementar la interfaz de cada uno, modificando el fichero candidate.component.ts:

import {
  AfterContentChecked,
  AfterContentInit,
  AfterViewChecked,
  Component,
  DoCheck,
  Input,
  OnChanges,
  OnDestroy,
  OnInit,
  SimpleChanges,
  ViewEncapsulation,
} from "@angular/core";
import { Candidate } from "../../models/candidate.model";

@Component({
  selector: "app-candidate",
  templateUrl: "./candidate.component.html",
  styleUrls: ["./candidate.component.scss"],
  encapsulation: ViewEncapsulation.None,
})
export class CandidateComponent
  implements
    OnChanges,
    OnInit,
    DoCheck,
    AfterContentInit,
    AfterContentChecked,
    AfterViewChecked,
    OnDestroy
{
  ngOnChanges(changes: SimpleChanges): void {
    console.log("OnChanges");
  }
  ngOnInit(): void {
    console.log("OnInit");
  }
  ngDoCheck(): void {
    console.log("DoCheck");
  }
  ngAfterContentInit(): void {
    console.log("AfterContentInit");
  }
  ngAfterContentChecked(): void {
    console.log("AfterContentChecked");
  }
  ngAfterViewChecked(): void {
    console.log("AfterViewChecked");
  }
  ngOnDestroy(): void {
    console.log("OnDestroy");
  }

  @Input() candidate!: Candidate;

  doEdit = () => {
    alert(`You want to edit this candidate: ${this.candidate.name}`);
  };

  candidateClasses() {
    return {
      "candidate-card": true,
      senior: this.candidate.experience < 3,
      junior: this.candidate.experience > 5,
    };
  }

  getColor() {
    if (this.candidate.experience <= 5) {
      return "black";
    }
    return "white";
  }
}

Si abrimos la consola podremos ver los siguientes mensajes:

Lifecycle

Como ejemplo, vamos a añadir un output a cada candidato, para poder seleccionarlo. Y luego con un input en AppComponent vamos a editar la experiencia de la candidata o candidato seleccionada.

Modifica app.component.html:

<div class="main">
  <div>{{ candidateName }}</div>
  <div>
    <label for="candidate-skill">Candidate Skill</label>
    <input
      type="number"
      id="candidate-skill"
      (input)="changeInput($event)"
      [value]="candidateName"
    />
  </div>
  <div>
    <app-candidate
      [candidate]="candidates[0]"
      (select)="selectCandidate($event)"
    ></app-candidate>
    <app-candidate
      [candidate]="candidates[1]"
      (select)="selectCandidate($event)"
    ></app-candidate>
    <app-candidate
      [candidate]="candidates[2]"
      (select)="selectCandidate($event)"
    ></app-candidate>
  </div>
</div>

Como se puede ver, cada elemento app-candidate tiene un binding de salida (select)="" que permite pasar una función que se ejecutará cada vez que se pulse en el botón editar de cada componente CandidateComponent. Modifica el fichero candidate.component.ts:

import {
  AfterContentChecked,
  AfterContentInit,
  AfterViewChecked,
  Component,
  DoCheck,
  EventEmitter,
  Input,
  OnChanges,
  OnDestroy,
  OnInit,
  Output,
  SimpleChanges,
  ViewEncapsulation,
} from "@angular/core";
import { Candidate } from "../../models/candidate.model";

@Component({
  selector: "app-candidate",
  templateUrl: "./candidate.component.html",
  styleUrls: ["./candidate.component.scss"],
  encapsulation: ViewEncapsulation.None,
})
export class CandidateComponent
  implements
    OnChanges,
    OnInit,
    DoCheck,
    AfterContentInit,
    AfterContentChecked,
    AfterViewChecked,
    OnDestroy
{
  @Input() candidate!: Candidate;

  @Output() select = new EventEmitter<Candidate>();

  ngOnChanges(changes: SimpleChanges): void {
    if (changes && "candidate" in changes) {
      console.log("changes", changes);
    }
  }
  ngOnInit(): void {
    console.log("OnInit", { candidate: this.candidate });
  }
  ngDoCheck(): void {
    console.log("DoCheck");
  }
  ngAfterContentInit(): void {
    console.log("AfterContentInit");
  }
  ngAfterContentChecked(): void {
    console.log("AfterContentChecked");
  }
  ngAfterViewChecked(): void {
    console.log("AfterViewChecked");
  }
  ngOnDestroy(): void {
    console.log("OnDestroy");
  }

  doEdit = () => {
    this.select.emit(this.candidate);
  };

  candidateClasses() {
    return {
      "candidate-card": true,
      senior: this.candidate.experience < 3,
      junior: this.candidate.experience > 5,
    };
  }

  getColor() {
    if (this.candidate.experience <= 5) {
      return "black";
    }
    return "white";
  }
}

En este punto, nuestro componente CandidateComponent es capaz de emitir un evento cuando se hace click sobre el botón Edit. Ahora falta que nuestro componente principal AppComponent sea capaz de escucharlo. Modifica app.component.html:

<div class="main">
  <div>{{ candidateName }}</div>
  <div>
    <label for="candidate-skill">Candidate Skill</label>
    <input
      type="number"
      id="candidate-skill"
      (input)="changeInput($event)"
      [value]="candidateName"
    />
  </div>
  <div>
    <app-candidate
      [candidate]="candidates[0]"
      (select)="selectCandidate($event)"
    ></app-candidate>
    <app-candidate
      [candidate]="candidates[1]"
      (select)="selectCandidate($event)"
    ></app-candidate>
    <app-candidate
      [candidate]="candidates[2]"
      (select)="selectCandidate($event)"
    ></app-candidate>
  </div>
</div>

Como se puede ver, cada elemento app-candidate tiene un binding de salida (select)="" que permite pasar una función que se ejecutará cada vez que se pulse en el botón editar de cada componente CandidateComponent.

Falta actualizar app.component.ts:

import { Component } from "@angular/core";
import { Candidate } from "./models/candidate.model";

@Component({
  selector: "app-root",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.scss"],
})
export class AppComponent {
  title = "candidates-frontend";

  candidates: Candidate[] = [
    {
      id: 1,
      name: "José Pérez",
      age: 25,
      position: "Desarrollador Junior",
      experience: 1,
      skills: ["Java", "SQL"],
    },
    {
      id: 2,
      name: "Paco López",
      age: 40,
      position: "Desarrollador Senior",
      experience: 15,
      skills: ["Java", "SQL", "Oracle", "PL/SQL", "Cobol", "C++"],
    },
    {
      id: 3,
      name: "Mireia García",
      age: 30,
      position: "Desarrolladora Intermedia",
      experience: 4,
      skills: ["Java", "SQL", "Oracle", "PL/SQL", "Cobol", "C++"],
    },
  ];

  selectedCandidate: Candidate | null = null;

  get candidateName(): string {
    return this.selectedCandidate ? this.selectedCandidate.name : "";
  }

  get candidateExperience(): number {
    return this.selectedCandidate ? this.selectedCandidate.experience : 0;
  }

  set candidateExperience(experience: number) {
    if (this.selectedCandidate) {
      const candidateIndex = this.candidates.findIndex(
        (c) => c.name === this.selectedCandidate?.name
      );
      if (candidateIndex > -1) {
        this.candidates[candidateIndex] = Object.assign(
          {},
          this.candidates[candidateIndex],
          { experience }
        );
      }
    }
  }

  changeInput(event: Event) {
    this.candidateExperience = parseInt((event.target as any).value);
  }

  selectCandidate(candidate: Candidate) {
    this.selectedCandidate = candidate;
  }
}

Hemos creado una mini app que es capaz de seleccionar un candidato y una vez seleccionado modificar su experiencia con un input en app.component.html.

Nada más arrancar la ejecución, obtenemos la siguiente traza en la consola:

OnChange y OnInit

En los tres componentes, primero ocurre OnChanges, con la propiedad firstChange: true, y luego viene el OnInit. Si hacemos click en el primero y luego modificamos el input poniendo un 9, veremos como en la consola solo aparece el evento change pero con la propiedad firstChange: false.

Si lo que necesitamos es una inicialización del componente mejor usar ngOnInit, si necesitamos actuar siempre que se modifica un @Input, usar ngOnChange.

Proyección de contenido

Angular permite insertar componentes en el template de otro componente. La técnica de cómo se pinta el hijo en el componente padre es Content Projection.

Single-slot content projection

Escenario, dónde un componente sólo puede proyectar otro componente. Tomemos el caso de que queremos pintar las cards en una lista estilada. Podemos crear un componente que se utilice en AppComponent para estilar cómo se disponen las cards.

Ejecutar

ng g component components/CandidateList

y abrir el fichero candidate-list.component.ts. Modifcarlo por:

import { Component } from "@angular/core";

@Component({
  selector: "app-candidate-list",
  template: ` <div class="candidate-list"><ng-content></ng-content></div> `,
  styleUrls: ["./candidate-list.component.scss"],
})
export class CandidateListComponent {}

Ahora abrir el fichero app.component.html, y modificarlo:

<div class="main">
  <div>{{ candidateName }}</div>
  <div>
    <label for="candidate-skill">Candidate Skill</label>
    <input
      type="number"
      id="candidate-skill"
      (input)="changeInput($event)"
      [value]="candidateName"
    />
  </div>
  <div>
    <app-candidate-list
      ><app-candidate
        [candidate]="candidates[0]"
        (select)="selectCandidate($event)"
      ></app-candidate>
      <app-candidate
        [candidate]="candidates[1]"
        (select)="selectCandidate($event)"
      ></app-candidate>
      <app-candidate
        [candidate]="candidates[2]"
        (select)="selectCandidate($event)"
      ></app-candidate
    ></app-candidate-list>
  </div>
</div>

Por último modificamos los estilos de CandidateListComponent en el fichero candidate-list.component.scss,

.candidate-list {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
}

y los estilos de cada CandidateComponent en candidate.component.scss:

// Añadimos
:host {
  flex: grow;
  width: 300px;
}

.candidate-card {
 ...
}

El selector especial :host se utiliza para seleccionar el host del componente, el tag en el dom de dónde cuelga todo el contenido del componente. Tener en cuenta que si se utiliza encapsulation: ViewEncapsulation.None, no funciona. En el ejemplo, nuestros CandidateCardComponent se pintarán correctamente dentro de un CandidateListComponent siguiendo sus reglas flex.

Multi-slot content projection

En Angular se consigue proyectar múltiples componentes o contenido en otro componente nombrando los slots hechos con ng-content. Modificamos el fichero candidate-list.component.ts:

import { Component } from "@angular/core";

@Component({
  selector: "app-candidate-list",
  templateUrl: "candidate-list.component.html",
  styleUrls: ["./candidate-list.component.scss"],
})
export class CandidateListComponent {}

y sustituimos el siguiente código en el fichero candidate-list.component.html:

<ng-content select="[title]"></ng-content>
<div class="candidate-list"><ng-content></ng-content></div>

En el caso de arriba, se utiliza el slot por defecto usando sólo <ng-content></ng-content> y un slot nombrado con title mediante select=[title] que luego en el fichero app.component.html utilizamos:

<div class="main">
  <div>{{ candidateName }}</div>
  <div>
    <label for="candidate-skill">Candidate Skill</label>
    <input
      type="number"
      id="candidate-skill"
      (input)="changeInput($event)"
      [value]="candidateName"
    />
  </div>
  <div>
    <app-candidate-list>
      <h2 title>Lista de candidatos</h2>
      <app-candidate
        [candidate]="candidates[0]"
        (select)="selectCandidate($event)"
      ></app-candidate>
      <app-candidate
        [candidate]="candidates[1]"
        (select)="selectCandidate($event)"
      ></app-candidate>
      <app-candidate
        [candidate]="candidates[2]"
        (select)="selectCandidate($event)"
      ></app-candidate
    ></app-candidate-list>
  </div>
</div>

Ahora nuestra lista de candidatos acepta un tag o un componente mediante el atributo title como título de la lista. Lo va a emplazar primero en la parte superior.

Interacción entre componentes

Pasar datos desde el padre al hijo con input binding

Es lo que hemos realizado al utilizar la anotación @Input en CandidateCardComponent y luego pasar un candidato desde AppComponent.

Test
describe("CandidateComponent", () => {
  let component: CandidateComponent;
  let fixture: ComponentFixture<CandidateComponent>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [CandidateComponent],
    });
    fixture = TestBed.createComponent(CandidateComponent);
    component = fixture.componentInstance;
    const candidate: Candidate = {
      age: 25,
      experience: 6,
      id: 0,
      name: "Alex",
      position: "developer",
      skills: ["JS"],
    };
    component.candidate = candidate;
    fixture.detectChanges();
  });

  it("should create", () => {
    expect(component).toBeTruthy();
  });

  // Probamos una vez que la test suite de angular ha montado el componente, que
  // el nombre y el color del texto se muestran correctamente
  it("should render candidate name", () => {
    const compiled = fixture.nativeElement as HTMLElement;
    expect(compiled.querySelector("h2")?.textContent).toContain("Alex");
  });

  it("should render white color for senior", () => {
    const compiled = fixture.nativeElement as HTMLElement;
    expect(compiled.querySelector("div")?.style.color).toContain("white");
  });
});

Interceptar una input property con un setter

Typescript y JS permiten definir propiedades de un objeto mediante funciones getters y setters. Vamos a modificar nuestra clase CandidateComponent para hacer que el candidate sea accedido mediante un get y guardado en una variable interna mediante un set:

import {
  ...
} from '@angular/core';
import { Candidate } from '../../models/candidate.model';

@Component({
  selector: 'app-candidate',
  templateUrl: './candidate.component.html',
  styleUrls: ['./candidate.component.scss'],
  encapsulation: ViewEncapsulation.None,
})
export class CandidateComponent
  implements ...
{
  @Input()
  set candidate(candidate: Candidate) {
    this._candidate = candidate;
    const index = candidate.name.indexOf(' ');
    this.name = candidate.name.slice(0, index);
    this.surname = candidate.name.slice(index);
    this.cssClasses = {
      'candidate-card': true,
      senior: candidate.experience < 3,
      junior: candidate.experience > 5,
    };
    this.colorStyle = candidate.experience <= 5 ? 'black' : 'white';
  }
  get candidate() {
    return this._candidate;
  }

  @Output() select = new EventEmitter<Candidate>();

  public name: string = '';
  public surname: string = '';
  public cssClasses: any = {};
  public colorStyle: string = '';
  private _candidate!: Candidate;

  ...

  doEdit = () => {
    this.select.emit(this.candidate);
  };
}

y el template candidate.component.html:

<div [class]="cssClasses" [style.color]="colorStyle">
  <h2>{{ name }}</h2>
  <h3>{{ surname }}</h3>
  <p><strong>Age:</strong>{{ candidate.age }}</p>
  <p><strong>Position:</strong>{{ candidate.position }}</p>
  <p><strong>Experience:</strong>{{ candidate.experience }}</p>
  <p><strong>Skills:</strong>{{ candidate.skills.join(", ") }}</p>
  <p><button (click)="doEdit()">Edit</button></p>
</div>

A la vez que guardamos nuestro candidate en una variable privada, extraemos información y la guardamos en variables que se acceden desde el template.

Test

Actualizamos el test candidate.spec.ts:

import { ComponentFixture, TestBed } from "@angular/core/testing";

import { CandidateComponent } from "./candidate.component";
import { Candidate } from "src/app/models/candidate.model";

describe("CandidateComponent", () => {
  let component: CandidateComponent;
  let fixture: ComponentFixture<CandidateComponent>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [CandidateComponent],
    });
    fixture = TestBed.createComponent(CandidateComponent);
    component = fixture.componentInstance;
    const candidate: Candidate = {
      age: 25,
      experience: 6,
      id: 0,
      name: "Alex Garrido",
      position: "developer",
      skills: ["JS"],
    };
    component.candidate = candidate;
    fixture.detectChanges();
  });

  it("should create", () => {
    expect(component).toBeTruthy();
  });

  it("should render candidate name", () => {
    const compiled = fixture.nativeElement as HTMLElement;
    expect(compiled.querySelector("h2")?.textContent).toContain("Alex");
  });

  it("should render candidate surname", () => {
    const compiled = fixture.nativeElement as HTMLElement;
    expect(compiled.querySelector("h3")?.textContent).toContain("Garrido");
  });

  it("should render white color for senior", () => {
    const compiled = fixture.nativeElement as HTMLElement;
    expect(compiled.querySelector("div")?.style.color).toContain("white");
  });
});

Interceptar un input property con ngOnChanges

Al igual que la intercepción del @Input con un setter, podemos actuar en la variable según cambia en el padre. Pero la interfaz ngOnChanges proporciona más versatilidad, además del cambio actual devuelve el cambio anterior e indica si es primera vez o no que cambia el valor. Lo hace para todos los @Inputs declarados en el componente.

Ya teníamos la interfaz de ngOnChanges implementada en nuestro CandidateComponent:

...
ngOnChanges(changes: SimpleChanges): void {
  console.log('changes', changes);
}
...

El parámetros changes: SimpleChanges es una hash table que se puede acceder así:

ngOnChanges(changes: SimpleChanges) {
  let log: string = '';
  for (const propName in changes) {
    const changedProp = changes[propName];
    const to = JSON.stringify(changedProp.currentValue);
    if (changedProp.isFirstChange()) {
      log = `Initial value of ${propName} set to ${to}`;
    } else {
      const from = JSON.stringify(changedProp.previousValue);
      log = `${propName} changed from ${from} to ${to}`;
    }
  }
  console.log('OnChanges', log);
}

Cada valor de la tabla va a ser de la forma

{
  currentValue: any;
  firstChange: boolean;
  previousValue: any;
}

Escuchar eventos de un hijo en el padre

Como vimos en binding para escuchar eventos, Angular permite el uso de la notación (...)={...}. Si además usamos el decorador @Output:

@Output() select = new EventEmitter<Candidate>();

podemos emitir un evento de tipo EventEmitter hacia el padre. Y el padre podrá recogerlo con:

<app-candidate
  [candidate]="candidates[2]"
  (select)="selectCandidate($event)"
></app-candidate>
Test

Para testear unitariamente un @Output vamos a simular un click en el botón edit y vamos a escuchar el evento aprovechando que EventEmitter es una clase que hereda de Rxjs.Subject.

Necesitamos crear un fichero de utilidades para tests que nos permitan hacer click y seleccionar elementos mediante tags data-testid="...". 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";

function findEl<T>(fixture: ComponentFixture<T>, testId: string): DebugElement {
  return fixture.debugElement.query(By.css(`[data-testid="${testId}"]`));
}

export function click<T>(fixture: ComponentFixture<T>, testId: string): void {
  const element = findEl(fixture, testId);
  const event = makeClickEvent(element.nativeElement);
  element.triggerEventHandler("click", event);
}

export function makeClickEvent(target: EventTarget): Partial<MouseEvent> {
  return {
    preventDefault(): void {},
    stopPropagation(): void {},
    stopImmediatePropagation(): void {},
    type: "click",
    target,
    currentTarget: target,
    bubbles: true,
    cancelable: true,
    button: 0,
  };
}

export function expectText<T>(
  fixture: ComponentFixture<T>,
  testId: string,
  text: string
): void {
  const element = findEl(fixture, testId);
  const actualText = element.nativeElement.textContent;
  expect(actualText).toBe(text);
}

Añadimos el siguiente test al fichero candidate.component.spec.ts usando la función click definida arriba:

import { ComponentFixture, TestBed } from '@angular/core/testing';

import { CandidateComponent } from './candidate.component';
import { Candidate } from 'src/app/models/candidate.model';
import { click } from 'src/app/utils/testing';

describe('CandidateComponent', () => {
  let component: CandidateComponent;
  let fixture: ComponentFixture<CandidateComponent>;

  beforeEach(() => {
    ...
  )
  ...

  it('emits select event on click edit', () => {
    let candidate: Candidate | undefined;
    // Arrange
    const subscription = component.select.subscribe((event) => {
      candidate = event;
    });

    // Act
    click(fixture, 'candidate-edit');

    // Assert
    expect(candidate).toBeDefined();

    // Cleanup
    subscription.unsubscribe();
  });
});

El test se vuelve simple ahora. Primero definimos el test en la parte Arrange. Aprovechamos que @Output es un observable(es un Subject tb) internamente y nos subscribimos a él (ejecutará el callback de subscribe cada vez que select.emit se ejecute).

Más tarde en Act hacemos click en el botón editar.

Por último comprobamos que candidate ha sido inicializado (se ha ejecutado el callback de component.select.subscribe).

El padre accede al hijo con una variable local en el template

Angular permite acceder a un componente hijo si se define una variable en el template del padre sobre la etiqueta (tag) del componente hijo. Modificamos nuestra candidate-list.component.ts para que permita la funcionalidad de poner al revés los candidatos. Modificar la propiedad flex-direction: row-reverse:

import { Component } from "@angular/core";

@Component({
  selector: "app-candidate-list",
  templateUrl: "./candidate-list.component.html",
  styleUrls: ["./candidate-list.component.scss"],
})
export class CandidateListComponent {
  reverse = false;
  toggleDirection() {
    this.reverse = !this.reverse;
  }
}

modificar candidate-list.component.html:

<ng-content select="[title]"></ng-content>
<div class="candidate-list" [ngClass]="{ reverse }">
  <ng-content></ng-content>
</div>

y actualizar candidate-list.component.scss:

.candidate-list {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  &.reverse {
    flex-direction: row-reverse;
  }
}

haciendo que la clase reverse se añada en el div.candidate-list si la variable interna reverse=true.

En el template padre, fichero app.component.html, marcamos al hijo con una variable local y usamos el método toggleDirection para actuar sobre CandidateListComponent:

<div class="main">
  <div>{{ candidateName }}</div>
  <div>
    <label for="candidate-skill">Candidate Skill</label>
    <input
      type="number"
      id="candidate-skill"
      (input)="changeInput($event)"
      [value]="candidateName"
    />
  </div>
  <button (click)="list.toggleDirection()">Cambiar lista</button>
  <div></div>
  <app-candidate-list #list>
    <h2 title>Lista de candidatos</h2>
    <app-candidate
      [candidate]="candidates[0]"
      (select)="selectCandidate($event)"
    ></app-candidate>
    <app-candidate
      [candidate]="candidates[1]"
      (select)="selectCandidate($event)"
    ></app-candidate>
    <app-candidate
      [candidate]="candidates[2]"
      (select)="selectCandidate($event)"
    ></app-candidate
  ></app-candidate-list>
</div>

Al hacer click en el Cambiar lista, el orden de los elementos se modifica.

El padre usa un @ViewChild

El enfoque de la variable local es directo. Sin embargo, es limitado porque la conexión entre el componente padre e hijo debe hacerse completamente dentro de la plantilla del padre. El componente padre en sí mismo no tiene acceso al hijo.

Si se quiere hacer uso de la clase hija dentro de la clase padre, se definen [@ViewChild](https://angular.io/api/core/ViewChild) los siguientes selectores:

Como ejemplo, vamos a recuperar todos los CandidateComponent y mostraremos el total en AppComponent:

Modificar app.component.ts:

import { Component, ViewChild, ViewChildren } from '@angular/core';
import { Candidate } from './models/candidate.model';
import { CandidateComponent } from './components/candidate/candidate.component';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
})
export class AppComponent {
  title = 'candidates-frontend';

  @ViewChildren(CandidateComponent)
  private candidateComps!: CandidateComponent[];

  ...

  getCandidatesLength() {
    // this.candidateComps es de tipo QueryList
    return !!this.candidateComps && 'length' in this.candidateComps
      ? this.candidateComps.length
      : 0;
  }
}

y el template para mostrar el resultado de getCandidatesLength:

<div class="main">
  ...
  <div>
    <h3>Número de candidatos <span>{{ getCandidatesLength() }}</span></h3>
  </div>
  <app-candidate-list #list>
    <h2 title>Lista de candidatos</h2>
    <app-candidate
      [candidate]="candidates[0]"
      (select)="selectCandidate($event)"
    ></app-candidate>
    <app-candidate
      [candidate]="candidates[1]"
      (select)="selectCandidate($event)"
    ></app-candidate>
    <app-candidate
      [candidate]="candidates[2]"
      (select)="selectCandidate($event)"
    ></app-candidate
  ></app-candidate-list>
</div>

Ejercicios Finales

.animate:hover {
  /* Start the shake animation and make the animation last for 0.5 seconds */
  animation: shake 0.5s;

  /* When the animation is finished, start again */
  animation-iteration-count: infinite;
}

@keyframes shake {
  0% {
    transform: translate(1px, 1px) rotate(0deg);
  }
  10% {
    transform: translate(-1px, -2px) rotate(-1deg);
  }
  20% {
    transform: translate(-3px, 0px) rotate(1deg);
  }
  30% {
    transform: translate(3px, 2px) rotate(0deg);
  }
  40% {
    transform: translate(1px, -1px) rotate(1deg);
  }
  50% {
    transform: translate(-1px, 2px) rotate(-1deg);
  }
  60% {
    transform: translate(-3px, 1px) rotate(0deg);
  }
  70% {
    transform: translate(3px, 1px) rotate(-1deg);
  }
  80% {
    transform: translate(-1px, -1px) rotate(1deg);
  }
  90% {
    transform: translate(1px, 2px) rotate(0deg);
  }
  100% {
    transform: translate(1px, -2px) rotate(-1deg);
  }
}

y hacer que la tarjeta tiemble por 500ms cuando se haga click en editar.

Referencias