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
- Instalación del entorno
- Creación del primer componente
- Componentes Angular
- Ejercicios Finales
- 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
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:
- Iniciar Proyectos: Generar un nuevo proyecto Angular con una estructura de archivos estándar y configuración básica en cuestión de segundos.
- 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.
- 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.
- 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.
- 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:
En el primer nivel tenemos los siguientes ficheros de configuracion:
-
angular.json
: Fichero principal de configuración de un proyecto angular. Contiene configuraciones para las herramientas de CLI (Command Line Interface) de Angular. A través de este archivo, puedes personalizar cómo las herramientas construyen, sirven, prueban y despliegan tu aplicación.- $schema: Define la ubicación del esquema JSON que se utiliza para validar la estructura del archivo angular.json.
- version: La versión del formato de archivo.
- newProjectRoot: La ubicación donde se generarán nuevos proyectos. Normalmente es “projects”.
- projects: Contiene configuraciones para cada proyecto en tu espacio de trabajo.
- nombre-del-proyecto: La configuración específica para un proyecto determinado.
- root: La raíz del proyecto.
- sourceRoot: El directorio donde están los archivos fuente del proyecto.
- projectType: Puede ser “application” para una aplicación o “library” para una biblioteca.
- prefix: El prefijo que se usa para los selectores de componentes generados automáticamente.
- schematics: Personalizaciones para cómo se generan ciertas piezas de código.
- architect: Define cómo se construye, sirve, prueba, etc., el proyecto.
- build: Configuración para construir la aplicación.
- builder: La herramienta utilizada para construir.
- options: Opciones específicas para la construcción.
- configurations: Variaciones de las opciones de construcción (por ejemplo, producción vs desarrollo).
- serve: Configuración para servir la aplicación localmente durante el desarrollo.
- test: Configuración para ejecutar pruebas unitarias.
- lint: Configuración para linting de código.
- e2e: Configuración para pruebas de extremo a extremo.
- defaultProject: El nombre del proyecto predeterminado para los comandos CLI cuando no se especifica un proyecto.
-
package.json
ypackage-lock.json
: Son los ficheros de configuración de dependencias de cualquier proyecto npm. -
tsconfig.json
,tsconfig.app.json
ytsconfig.spec.json
son ficheros de configuración de TS, referenciados en el ficheroangular.json
. -
.editor.config
es un fichero para la configuración del IDE, utilizado para que el uso de diferentes IDEs sea igual. -
.gitignore
fichero de configuración git. -
Carpeta
node_modules
es el lugar dónde se guardan las dependencias declaradas en el ficheropackage.json
. -
Carpeta
src
:-
Carpeta
app
, dónde viven los ficheros de nuestra aplicación. -
Carpeta
assets
, dónde almacenamos los ficheros estáticos que utilizará nuestra aplicación. -
main.ts
: Fichero principal Angular.import { platformBrowserDynamic } from "@angular/platform-browser-dynamic"; import { AppModule } from "./app/app.module"; platformBrowserDynamic() .bootstrapModule(AppModule) .catch((err) => console.error(err));
En la manera anterior estamos lanzando una aplicación Angular de una forma tradicional. Una aplicación Single Page Application haciendo uso del API
platformBrowserDynamic
. Por defecto las aplicaciones Angular actuales usan Ahead of Time como tipo de compilación. Como se observa, el fichero utiliza el móduloAppModule
declarado en el ficheroapp.module.ts
-
index.html
: Es el fichero raíz donde se inserta toda aplicación angular. Si se abre el fichero, se verá la directiva<app-root></app-root>
. Esa directiva es la utilizada en el ficheroapp.component.ts
en la propiedadselector
. -
src/app/app-component.ts
: Fichero que define el componente raíz de la aplicación. Los demás componentes de nuestra aplicación colgarán de éste en forma de árbol. -
src/app/app.module.ts
: Fichero que define el módulo Angular principal de nuestra aplicación. Este módulo importa el componenteAppComponent
que a su vez utiliza la etiqueta<app-root></app-root>
que es usada para insertar toda nuestra aplicación dentro de la página HTML. -
src/app/app-component.scss
: Fichero de estilos que aplica al componenteAppComponent
. Para que ello ocurra tiene que estar definido en la propiedadstyleUrls
dentro de la anotación@Component
del componenteAppComponent
. -
src/app/app-component.spec.ts
: Fichero de test unitarios e integración del componenteAppComponent
. Para lanzar los test se usang test
. También se puede ejecutarnpm run test
que lanzará el comando anterior.
-
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:
-
En la página http://localhost:4200 de un navegador se verá el contenido candidate works
-
Los tests del componente
AppComponent
fallan.
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:
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:
-
Notación
{{ ... }}
, o text interpolation que nos permite llevar variables del controlador al template. -
Template statements que son métodos o propiedades de la clase controlador del componente que podemos usar para responder a eventos del usuario. Como el ejemplo del click
(click)="doEdit()"
. Directamente usamos el métododoEdit
. -
Property Binding que hemos utilizado cuando pasamos un candidato desde el componente
AppComponent
aCandidateComponent
:<app-candidate [candidate]="candidates[0]"></app-candidate>
.
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).
- Ejercicio: Usar la notación
(...)="..."
para pintar unconsole.log('edit')
enAppComponent
cuando se haceclick
en el botóneditar
deCandidateComponent
.
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:
-
Notación en string: En
[class]="classExpression"
,classExpression
tiene que acabar siendo un string con las clases que queremos aplicar.Modificamos
candidate.component.ts
: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; candidateClases = () => { if (this.candidate.experience < 3) { return "candidate-card junior"; } else if (this.candidate.experience > 5) { return "candidate-card senior"; } else { return "candidate-card"; } }; doEdit = () => { alert("You want to edit this candidate"); }; }
y
candidate.component.html
:<div [class]="candidateClases()"> <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>
-
Notación en objeto: En
[class]="classExpression"
,classExpression
tiene que acabar con la forma de un objeto com el siguiente{ junior: true, senior: false }
Modificamos Modificamos
candidate.component.ts
: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, }; } }
-
Notación en array: En
[class]="classExpression"
,classExpression
tiene que acabar con la forma de un array['candidate-card', 'junior']
.
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.
- Ejercicio: Crear con la notación objeto un estilo de fuentes para los textos de la tarjeta si es intermedio. Los estilos a aplicar son
font-weight:semibold
yfont-size:14px
. - Ejercicio: Crear lo mismo con la notación array de strings.
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:
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
:
- ViewEncapsulation.ShadowDom: Nada de lo definido afecta afuera.
- ViewEncapsulation.Emulated: Angular emula el
ShadowDom
renombrando clases en el DOM. - ViewEncapsulation.None. Los estilos definidos serán aplicados globalmente, insertando un tag style.
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:
Con ViewEncapsultaion.Emulated
obtenemos:
Además de no utilizar … se ha añadido un tag style
al head
del documento con los estilos usando los sufijos __ngcontent
:
Por último se puede quitar el encapsulamiento con ViewEncapsulation.None
y Angular producirá la siguiente salida
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:
-
ngOnChanges
: lanzado cuando Angular inicia o reinicia las props input del componente. Se llama antes dengOnInit
si el componente ha declarado@Input
s 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. -
ngOnInit
: Llamado una vez después de la creación del componente cuando las props ya han sido seteadas y después del primerngOnChange
. -
ngDoCheck
: Utilizado para implementar un algoritmo de detección de cambios ad-hoc en el componente. No es aconsejable utilizarlo. -
ngAfterContentInit
: Llamado una vez después del primerngDoCheck
. Se utiliza para incluir lógica justo después del momento en el que el componente inserta contenido externo dentro de la directiva / componente. -
ngAfterContentChecked
: LLamado después de cadangDoCheck
. -
ngAfterViewInit
: Llamado cuando el componente ha inicializado todas sus vistas suyas y de sus hijos. -
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:
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:
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");
});
});
- Ejercicio: Usar el get, set que hemos creado para añadir la siguiente validación al
candidate
:- El nombre debe tener al menos dos partes separadas por un espacio.
- La edad debe ser mayor que 18 años.
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 @Input
s 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;
}
- Ejercicio: Refactorizar el ejemplo del setter con la interfaz ngOnChanges. Añadir la siguiente validación al
candidate
:- El nombre debe tener al menos dos partes separadas por un espacio.
- La edad debe ser mayor que 18 años.
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:
- Cualquier clase con el decorador
@Component
o@Directive
- Una variable de referencia de plantilla como una cadena (por ejemplo, consulta
<my-component #cmp></my-component>
con@ViewChild('cmp')
) - Cualquier proveedor definido en el árbol de componentes hijo del componente actual (por ejemplo, `@ViewChild(SomeService) someService: SomeService“)
- Cualquier proveedor definido a través de un token de cadena (por ejemplo,
@ViewChild('someToken') someTokenVal: any
) - Un TemplateRef (por ejemplo, consulta
<ng-template></ng-template> con @ViewChild(TemplateRef)
template)
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
-
Ejercicio: Crear un nuevo input que permita modificar el nombre del candidato.
-
Ejercicio: Crear un botón para borrar el candidato seleccionado.
-
Ejercicio: Crear un botón eliminar que permita borrar un candidato cuando se hace click.
-
Ejercicio: Crear un botón que añada un nuevo candidato con la experiencia aleatoria.
-
Ejercicio: Validar los campos del candidato nombre, experiencia y edad. Si alguno está mal, mostrar el mensaje y ocultar los campos
Age
,Position
,Experience
ySkills
. -
Ejercicio: Añadir el siguiente código css en
candidate.component.scss
:
.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
.
-
Ejercicio: Comprobar en un test de integración en
app.component.spec.ts
, que cada candidato emite correctamente su candidato interno, comprobando que el nombre que se muestra enapp.component.html
se corresponde con el último candidato que ha hechoclick
en el botóneditar
. Hay un ejemplo en este enlace. -
Ejercicio: Comprobar en un test de integración en
app.component.spec.ts
que el botónCambiar lista
modifica el orden de los elementos enAppComponent
. basarse en el siguiente ejemplo -
Ejercicio: Añadir un botón en
AppComponent
con nombresacudir
que permita sacudir la última card de un/una candidato/a. -
Ejercicio: Añadir un avatar a los usuarios. Una imagen o un default placeholder.