Otro tutorial de Angular y Electron
Lectura de un buen retete
Este tutorial está basado en el que puedes encontrar aquí. Vamos, que básicamente lo he leído, hecho y traducido. Lo que vas a leer a continuación es lo mismo que puedes encontrar allí. Este tutorial lo hago para que me sirva de fácil referencia y, si ya puestos le puede servir a alguien más, pues eso que nos llevamos todos.
Primeros pasos
Instalación
Para instalar angular debes tener primer instalado npm
que normalmente viene instalado si instalas Node. Luego ejecuta el siguiente comando:
$ npm install -g @angular/cli
Crear una aplicación
Las aplicaciones también se crean a través de la consola. En la terminal ejecuta el siguiente comando:
$ ng new angular-tour-of-heroes
Este comando crea un directorio nuevo con los ficheros iniciales y con un proyecto git
local con el primer commit
ya hecho con los ficheros del proyecto.
Lanzar la aplicación
$ cd angular-tour-of-heroes
$ ng serve --open
Entramos al directorio de la aplicación y la lanzamos. El comando ng serve
construye la aplicación, inicia un servidor de desarrollo y se queda comprobando los ficheros para reconstruir el proyecto en caso de modificación. El parámetro --open
abre el navegador por defecto a http://localhost:4200
que es la dirección del proyecto construido.
Lo que has iniciado es el núcleo de la aplicación. Este núcleo es un componente. Estos componentes son bloques usados para construir aplicaciones Angular. Muestran información, recogen la información introducida por el usuario y realizan acciones dependiendo de la información introducida.
Abre el editor de código de tu preferencia y abre el directorio src/app
, allí está el núcleo AppComponente
compuesto por tres ficheros:
app.component.ts
es la clase del componente escrita en TypeScript.app.component.html
es la plantilla del componente escrita en HTML.app.component.css
es el estilo privado del componente escrito en CSS.
Abre el fichero app.component.ts
y cambia el valor de la variable title:
title = 'Tour of Heroes';
Abre la plantilla de componente app.component.html
, borra el contenido generado y reemplazalo por:
<h1>{{title}}</h1>
Angular marca con dobles llaves la variables que se van a utilizar.
El fichero styes.css
contiene el estilo para la aplicación. Añade el siguiente estilo al fichero:
/* Application-wide Styles */
h1 {
color: #369;
font-family: Arial, Helvetica, sans-serif;
font-size: 250%;
}
h2, h3 {
color: #444;
font-family: Arial, Helvetica, sans-serif;
font-weight: lighter;
}
body {
margin: 2em;
}
body, input[text], button {
color: #888;
font-family: Cambria, Georgia;
}
/* everywhere else */
* {
font-family: Arial, Helvetica, sans-serif;
}
Crear un componente
Para generar un componente nuevo se utiliza el comando ng generate comnent
, en este caso cre un componente llamado heroes:
$ ng generate component heroes
Se creará el directorio src/app/heroes
y los tres ficheros. El fichero heroes.component
debe ser como el que sigue:
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-heroes',
templateUrl: './heroes.component.html',
styleUrls: ['./heroes.component.css']
})
export class HeroesComponent implements OnInit {
constructor() { }
ngOnInit() {
}
}
Siempre se debe importar el símbolo Component
mediante @Component
. Se generan 3 propiedades de metadatos:
- selector: Es el selector CSS del componente.
- teplanteUrl: localización de la plantilla del componente.
- styleUrls: localización de los estilos CSS privados del componente.
El selector CSS del componente coincide con el nombre del identificador HTML que identifica el componente con un componente padre.
El método ngOnInit
es un hook del ciclo de vida de Angular que es llamado después de crear un componente. Es el lugar idóneo para añadir lógica de inicialización. Siempre debe exportar la case así se puede importar en cualquier otro lugar para ser usada.
Añadir una propiedad
Añade una propiedad a la clase héroe:
hero = 'Windstorm';
Mostrar la propiedad
Para mostrarlo abre la plantilla heroes.component.html
y reemplaza el contenido con la nueva propiedad:
{{hero}}
Muestra el componente
Para que el nuevo componente se muestre, debe ser añadido a la plantilla AppComponent
. Añade la siguiente línea al fichero src/app/app.component.html
:
<app-heroes></app-heroes>
Si el comando ng serve
está en funcionamiento, el navegador se actualizará automáticamente.
Crear la clase Heroe
Un héroe no es solo un nombre. Crea la clase Hero en el directorio src/app
y crea las propiedades id
y name
:
export class Hero {
id: number;
name: string;
}
Vuelve al componente HeroesComponent
y refactoriza la variable hero:
hero: Hero = {
id:1,
name:'Windstorm'
};
La página no se mostrará correctamente, ahora la variable hero es un objeto.
Mostrar el objeto hero
Abre la plantilla y muestra el id
y name
:
<h2>{{hero.name}}</h2>
<div><span>id: </span>{{hero.id}}</div>
<div><span>name: </span>{{hero.name}}</div>
Al guardar el fichero el navegador se actualizará y el héroe se mostrará correctamente.
Formateo
Si quieres que el nombre del héroe aparezca en mayúsculas modifica la etiqueta de cabecera:
<h2>{{hero.name | uppercase}}</h2>
La palabra uppercase
es una interpolación de la variable, que se utiliza a la derecha del operador |
. Este tipo de operadores se utilizan para formatear texto, divisas, fechas, etc. Angular ofrece algunos por defecto pero siempre puedes crear más.
Editar el héroe
Para editar el héroe utiliza un imput, este input deberá mostrar el nombre del héroe y modificarlo, de modo que la información fluye en dos sentidos, del objeto a la pantalla y de vuelta al objeto. ng-Model
habilita esta característica. Abre el fichero heroes.component.html
y añade el siguiente contenido:
<div>
<label>name: <input [(ngModel)]="hero.name" placeholder="name"/></label>
</div>
Ahora la página no funciona. Para ver el error abre las herramientas de desarrollador del navegador y mira en la consola de errores, debe aparecer el siguiente error:
Template parse errors:
Can't bind to 'ngModel' since it isn't a known property of 'input'.
ngModel
no está disponible por defecto. Angular necesita saber como encargan las piezas de la aplicación y que ficheros y librerías se necesitan. A esta información se le llama metadatos. Alguno ya lo hemos visto en @Component
. Otros son los @NgModule
. Los más importantes se indican en el nivel de AppModule
. Angular crea una clase AppModule
en src/app/app.module.ts
. Ábrelo y el siguiente contenido:
import {FormsModule} from '@angular/forms';
Y luego añádelo al array de metadatos imports:
imports: [
BrowserModule,
FormsModule
],
Ahora, cuando el navegador refresque, podrás editar el nombre del héroe y los cambios se verán inmediatamente cambiados en pantalla.
Todo componente debe estar declarado en el módulo NgModule
. A pesar de que esta vez ha funcionado sin que se haya añadido. Ha funcionado porque se ha generado a través de la línea de comandos. Comprueba que el componente está importado en el fichero src/app/app.modules.ts
:
import {HeroesComponent} from './heroes/heroes.component';
Y declarado en el array @NgModule.declarations
:
declarations: [
AppComponent,
HeroesComponent
],
AppModule
declara tanto el AppComponent
como el HeroesComponent
.
Mostrar un listado
Ahora que puedes editar y ver un héroe, es hora de crear un listado de héroes. Primero necesitas una colección de héroes para mostrarlos, en este caso y por el momento crearás un mock con un set de héroes definido. Para ello crea un fichero en el directorio src/app
llamado mock-heroes.ts
y define un array de 10 héroes. El fichero debe quedar como algo así:
import {Hero} from './hero';
export const HEROES: Hero[] = [
{ id: 11, name: 'Mr. Nice' },
{ id: 12, name: 'Narco' },
{ id: 13, name: 'Bombasto' },
{ id: 14, name: 'Celeritas' },
{ id: 15, name: 'Magneta' },
{ id: 16, name: 'RubberMan' },
{ id: 17, name: 'Dynama' },
{ id: 18, name: 'Dr IQ' },
{ id: 19, name: 'Magma' },
{ id: 20, name: 'Tornado' }
];
Ahora que ya dispones de información para mostrar abre el fichero heroes.component.ts
del directorio src/app/heroes
e importa el listado que acabas de crear:
import {HEROES} from '../mock-heroes';
Y añade la propiedad heroes a la clase:
heroes = HEROES;
Abre la plantilla HeroesComponent
y modificalo siguiendo los siguientes pasos:
- Añade una etiqueta
<h2>
al inicio. - Justo a continuación añade una lista desordenada
<ul>
. - Inserta un elemento
<li>
a la lista que muestre las propiedades del héroe. - Añade las clases css para el estilo.
Debería quedar algo así:
<h2>My Heroes</h2>
<ul class="heroes">
<li>
<span class="badge">{{hero.id}}</span>{{hero.name}}
</li>
</ul>
Ahora modifica el elemento <li>
:
<li *ngFor="let hero of heroes">
*ngFor
es la directiva de Angular para bucles, repite la etiqueta en la que está alojada para cada elemento en la lista. En este caso <li>
es la etiqueta donde está alojada, heroes
la lista de la clase HeroesComponent
y hero
contiene el héroe actual para cada iteración de la lista. IMPORTANTE el asterisco *
.
Al refrescar el navegador debe aparecer el listado.
Héroes con estilo
Este listado debería ser más atractivo. En el inicio del tutorial has definido en el fichero styles.css
los estilos básicos para toda la aplicación, pero no incluye estilos para la lista de héroes. Puedes añadir los estilos en este mismo fichero, pero crecería a cada componente que añades. Puede que prefieras definir estilos privados para cada componente, así el código HTML y CSS están en el mismo directorio.
Esta forma hace que sea más sencillo reusar el componente y mostrarlo de forma intencionada diferente al estilo global. Los estilos se definen indistintamente en el array @Component.styes
o en las hojas de estilo definidos en el array Component.styleUrls
. Cuando se crea el componente, en este caso HeroesComponent
, se crea un fichero heroes.component.css
vacío y se añade al array @Component.styleUrls
:
@Component({
selector: 'app-heroes',
templateUrl: './heroes.component.html',
styleUrls: ['./heroes.component.css']
})
Abre el fichero heroes.component.css
y añade el siguiente contenido:
/* HeroesComponent's private CSS styles */
.selected {
background-color: #CFD8DC !important;
color: white;
}
.heroes {
margin: 0 0 2em 0;
list-style-type: none;
padding: 0;
width: 15em;
}
.heroes li {
cursor: pointer;
position: relative;
left: 0;
background-color: #EEE;
margin: .5em;
padding: .3em 0;
height: 1.6em;
border-radius: 4px;
}
.heroes li.selected:hover {
background-color: #BBD8DC !important;
color: white;
}
.heroes li:hover {
color: #607D8B;
background-color: #DDD;
left: .1em;
}
.heroes .text {
position: relative;
top: -3px;
}
.heroes .badge {
display: inline-block;
font-size: small;
color: white;
padding: 0.8em 0.7em 0 0.7em;
background-color: #607D8B;
line-height: 1em;
position: relative;
left: -1px;
top: -4px;
height: 1.8em;
margin-right: .8em;
border-radius: 4px 0 0 4px;
}
Mostrar los detalles
Al hacer click en un héroe de la lista, se debería mostrar los detalles. Empieza por añadir el evento onclick
al elemento <li>
:
<li *ngFor="let hero of heroes" (clic)="onSelect(hero)">
Los paréntesis alrededor de click
indican a Angular qye debe escuchar el evento click
de los elementos <li>
, y cuando el usuario haga click ejecute el método onSelect
del componente HeroesComponent
, que definirás a continuación, y le envía como parámetro el mismo heroe que se ha definido en el bucle *ngFor
.
Abre el fichero heroes.component.ts
y renombra hero
a selectedHero
, pero no lo asignes. No hay heroe seleccionado cuando se lanza la aplicación. Añade el siguiente método, que asigna el héroe clicado de la plantilla al componente selectedHero
:
selectedHero: Hero;
onSelect (hero: Hero): void {
this.selectedHero = hero;
}
La plantilla todavía apunta a la propiedad hero, renombralo a selectedHero:
<h2>{{selectedHero.name | uppercase}} Details </h2>
<div><span>id: </span> {{selectedHero.id}} </div>
<div><label>name: <input [(ngModel)] = "selectedHero.name" placeholder="name"/></label></div>
Componentes maestro/detalle
Hasta ahora el componente HeroesComponent
muestra tanto la lista de héroes como el detalle del héroe seleccionado. Mantener todas las características en un componente en una aplicación que crece no es mantenible. Querrás separar componentes grandes en pequeños subcomponentes, cada uno realizando un trabajo concreto.
En este caso, primer paso es trasladar el componente de detalle a otro componente y reusarlo, así el componente HeroesComponent
mostrará el detalle del héroe seleccionado.
Usa la consola de Angular para generar un nuevo componente llamado hero-detail
:
$ ng generate component hero-detail
A continuación corta el HTML que contiene el detalle en la plantilla HeroesComponent
y pegalo en el nuevo fichero de plantilla HeroDetailComponent
. El HTML pegado hace referencia a selectedHero
, pero el nuevo componente puede mostrar cualquier héroe, así que reemplaza todos los selectedHero
por hero
en la plantilla. La plantilla debería parecerse a:
<div *ngIf="hero">
<h2>{{hero.name | uppercase}} Details </h2>
<div><span>id: </span>{{hero.id}}</div>
<div>
<label> name: <input [(ngModel)] = "hero.name" placeholder="name" /></label>
</div>
</div>
Ahora debes añadir la propiedad @Input
al héroe. La plantilla HeroDetailComponent
se vincula a la propiedad del componente hero
que es del tipo Hero
. Abre la clase HeroDetailComponent
e importa Hero
:
import {Hero} from '../hero';
La propiedad hero
debe ser una propiedad Input
, anotada con el decorador @Input()
, porque HeroesComponent
lo usa de forma externa del siguiente modo:
<app-hero-detail [hero] = "selectedHero"></app-hero-detail>
Añade @angular/core
para incluir el símbolo Input
en el fichero hero-detail-component.ts
:
import {Component,OnInit,Input} from '@angular/core';
Ahora añade una propiedad hero
precedido del decorador @Input()
:
@Input() hero: Hero;
Este es el único cambio que deberías hacer en la clase HeroDetailComponent
. No hay más propiedades ni lógica de presentación. Este componente sólo recibe un objeto de tipo hero
y lo muestra.
Mostrar el detalle del subcomponente
HeroesComponent
aún es una vista maestro detalle. Se usa para mostrar los detalles del héroe, antes de que lo cortaras de la plantilla. Ahora lo delega en HeroDetailComponent
. Ambos componentes tienen una relación padre/hijo. El padre HeroesComponent
controlará el hijo HeroDetailComponent
enviando un héroe para mostrarlo cada vez que se haga click en un héroe de la lista. No hay que cambiar la clase HeroesComponent
per sí su plantilla.
El selector HeroDetailComponent
es app-hero-detail
. Añade <app-hero-detail>
cerca del final de la plantilla HeroesComponent
, donde el detalle del héroe estaba.
<app-hero-detail [hero] = "selectedHero"></app-hero-detail>
[hero] = "selectedHero"
es la forma de vincular de Angular. Es un vínculo de una sola dirección, desde HeroesComponent
a la propiedad hero
. Cuando se haga click en un hero de la lista, selectedHero
cambia. Cuando selectedHero
cambie, la propiedad vinculada actualiza hero
y HeroDetailComponent
muestra el nuevo héroe. La plantilla heroes.component.html
debería quedar parecido a:
<h2>My Heroes</h2>
<ul class="heroes">
<li *ngFor="let hero of heroes"
[class.selected] = "hero === selectedHero"
(click)="onSelect(hero)">
<span class="badge">{{hero.id}}</span>
{{hero.name}}
</li>
</ul>
<app-hero-detail [hero]="selectedHero"></app-hero-detail>
Cuando el navegador actualice, la aplicación seguirá igual que antes.
Lo que ha cambiado es HeroesComponent
. Se ha refactorizado en dos componente, que trae consigo los siguientes beneficios:
- Se ha simplificado
HeroesComponent
, reduciendo sus responsabilidades. HeroDetailComponent
puede evolucionar sin tener que modificarHeroesComponent
.- Y viceversa,
HeroesComponent
puede evolucionar sin modificarHeroDetailComponent
. - Puedes reusar
HeroDetailComponent
en un componente futuro.
Servicios
Actualmente HeroesComponent
está obteniendo y mostrando datos de ejemplo.
¿Por qué servicios?
Los componentes no deberían obtener o guardar información directamente y tampoco mostrar información de ejemplo. Se deben centrar en mostrar información y delegar el acceso de la información a un servicio.
Para crear un servicio, en lugar de usar new
, usarás la inyección de dependencias para inyectarlo en el constructor HeroesComponent
. Los servicios se deben usar para compartir información entre clases que no se conocen. Crea MessageService
e inyectalo en dos lugares:
- En
HeroService
que usa el servicio para enviar un mensaje. - En
MessagesComponent
que muestra el mensaje.
Crear el servicio
Para crear HeroService
, generalo desde la terminal:
$ ng generate service hero
Este comando genera el esqueleto HeroService
en src/app/hero.service.ts
que debería ser:
import {Injectable} from '@angular/core';
@Injectable()
export class HeroService {
constructor() {}
}
Observa que el servicio importa Injectable
de Angular y anota la clase con el decorador @Injectable
. Este decorador le dice a Angular que el servicio puede tener dependencias inyectadas. En este momento no las tiene, pero las tendrá. Tanto si las tiene como si no, es buena práctica mantener el decorador.
Obtener la información
HeroService
puede obtener la información desde cualquier lugar, un servicio web, una base de datos local o un mock. Quitando el acceso a la información desde los componentes puedes cambiar la implementación sin cambiar ningún componente. Para este caso utilizaremos un mock. Importa Hero
y HEROES
en HeroService
.
import {Hero} from './hero';
import {HEROES} from './mock-heroes';
Añade el método getHeroes
:
getHeroes(): Hero[] {
return HEROES
}
Debes proveer el servicio HeroService
al sistema de inyección de dependencias antes de que Angular pueda inyectarlo a HeroesComponent
. Hay varias formas de hacerlo: en HeroesComponent
, en AppComponent
y en AppModule
. Cada uno tiene ventajas y desventajas. Aquí usaras AppModule
. Está elección es popular y se puede generar desde el terminal:
$ ng generate service hero --module=app
Como en el paso anterior no se ha indicado, lo debes introducir de forma manual. Abre la clase AppModule
, importa HeroService
y añadelo al array @NgModule.providers
:
providers: [
HeroService,
/* ... */
],
Providers
indica a Angular que cree una instancia única y compartida de HeroService
y que lo inyecte a cada clase que lo pida.
Ahora abre la clase HeroesComponent
, borra la importación HEROES
y sustituyela por HeroService
:
import {HeroService} from '../hero.service';
Reemplaza la definición heroes
:
heroes: Hero[];
Para inyectar el servicio añade un parámetro privado al constructor:
constructor (private heroService: HeroService) {}
El parámetro se define como una propiedad privada y se identifica como una inyección de HeroService
. Cuando Angular cree un HeroesComponent
, el sistema de inyección de dependencias establecerá el parámetro heroService
a la instancia única de HeroService
.
Ahora crea una función para recibir heroes del servicio:
getHeroes(): void{
this.heroes = this.heroService.getHeroes();
}
Esta función puede ser llamada desde el constructor, pero no es una buena práctica. Utiliza el constructor para la inicialización simple, como por ejemplo, vincular los parámetros del constructor a propiedades. El constructor no debería hacer nada. En lugar de esto llama a getHeroes()
desde ek ciclo de vida ngOnInit
y deja que Angular llame a ngOnInit
en el momento apropiado después de construir la instancia HeroesComponent
:
ngOnInit() {
this.getHeroes();
}
Cuando el navegador actualice, la aplicación debería funcionar como hasta ahora.
Observable data
El método HeroService.getHeroes()
obtiene los heroes de forma asíncrona. HeroesComponent
usa getHeroes
como si se obtuviera de forma síncrona, this.heroes = this.heroService.getHeroes();
. Esto no funcionará en una aplicación real. Ahora mismo funciona porque se obtienen heroes mock. La aplicación se modificará para obtener heroes de un servidor remoto, cuya operación es asíncrona. HeroService
debe esperar a que el servidor responde, getHeroes()
no puede devolver la información de forma inmediata, y el navegador no se debe bloquear en las esperas del servicio. HeroService.getHeroes()
debe ser de alguna forma asíncrono. Se puede hacer mediante callback, que devuelva una promesa, o un Observable. En este tutorial, HeroService.getHeroes
devolverá un Observable.
Observable es una clase de RxJS
. Más adelante verás que los métodos HttpClient
devuelven Observables de RxJS
. Ahora simularás esa respuesta con la función of()
. Abre HeroService
e importa Observable
y of
de RxJS
:
import {Observable} from 'rxjs/Observable';
import {of} from 'rxjs/observable/of';
Reemplaza el método getHeroes
:
getHeroes(): Observable<Hero[]>{
return of(HEROES);
}
of(HEROES)
devuelve Observable[Hero]>
que tiene un solo valor, el array de mock de heroes. Ahora que HeroService.getHeroes
devuelve Observable<Hero[]>
modifica HeroesComponent
, reemplaza getHeroes
:
getHeroes(): void {
this.heroService.getHeroes().subscribe(heroes => this.heroes = heroes);
}
La diferencia está en Observable.subscribe()
. En la versión anterior se asigna el array de héroes a la propiedad de forma síncrona como si el servidor devolviera héroes de forma instantánea o el navegador bloqueara la interfaz mientras espera la respuesta. En la nueva versión se espera a que Observable
emita el array de heroes, que podría suceder en cualquier momento. Luego subscribe
pasa el array al callback
, que asigna la propiedad heroes del componente. Esta aproximación funcionará cuando HeroService
haga las peticiones al servidor.
Mensajes
En la refactorización hemos dejado los mensajes olvidados. Volverás a habilitarlos utilizando los servicios. Primero has de crear el componente, abre el terminal y ejecuta el siguiente comando:
$ ng generate componente messages
Se generarán los ficheros de componente en el directorio src/app/messages
y se declarará MessagesComponente
en AppModule
. Modifica AppComponent
para que muestre MessagesComponent
:
<h1>{{title}}</h1>
<app-heroes></app-heroes>
<app-messages></app-messages>
Si actualizas el navegador verás el párrafo por defecto al final de la página. Ejecuta la siguiente instrucción en la terminal para crear MessageService
:
$ ng generate service message --module=app
La opción --module=app
indica que debe proveer este servicio en AppModule
. Abre MessageService
y reemplaza el contenido por el siguiente:
import {Injectable} from '@angular/core';
@Injectable()
export class MessageService{
messages: string [] = [];
add(message: string) {
this.messages.push(message);
}
clear() {
this.messages = [];
}
}
El servicio expone su caché de mensajes y dos métodos: uno para añadir, add()
y otro para limpiar clear()
la caché de mensajes. Vuelve a abrir HeroService
e importa el servicio MessageService
:
import {MessageService} from './message.service';
Modifica el constructor con el parámetro privado messageService
. Angular inyectará un objeto MessageService
único a la propiedad:
constructor (private messageService: MessageService) {}
Modifica el método getHeroes
para enviar un mensajes cuando se obtienen los héroes:
getHeroes(): Observable<Hero[]> {
//TODO: send the message -after- fetching the heroes
this.messageService.add('HeroService: fetched heroes');
return of(HEROES);
}
MessagesComponent
debería mostrar todos los mensajes enviados por HeroService
cuando obtiene los heroes. Abre MessagesComponent
e importa MessageService
:
import {MessageService} from '../message.service';
Modifica el constructor y añade un parámetro público messageService
. Angular inyectará cuando cree un HeroService
:
constructor(public messageService: MessageService) {}
messageService
debe ser público porque se va a usar en la plantilla. Abre messages.component.html
y modificalo con lo siguiente:
<div *ngIf="messageService.messages.length">
<h2>Messages</h2>
<button class="clear"
(click)="messageService.clear()">Clear</button>
<div *ngFor='let message of messageService.messages'>{{message}}</div>
</div>
La plantilla se vincula directamente a messageService
:
*ngIf
solo muestra los mensajes si hay mensajes que mostrar.*ngFor
hace un bucle con todos los mensajes y los muestra en elementosdiv
separados.- La vinculación de eventos de Angular, vincula el
click
del botón aMessageService.clear();
Los mensajes tendrán mejor apariencia con el siguiente css:
/* MessagesComponent's private CSS styles */
h2 {
color: red;
font-family: Arial, Helvetica, sans-serif;
font-weight: lighter;
}
body {
margin: 2em;
}
body, input[text], button {
color: crimson;
font-family: Cambria, Georgia;
}
button.clear {
font-family: Arial;
background-color: #eee;
border: none;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
cursor: hand;
}
button:hover {
background-color: #cfd8dc;
}
button:disabled {
background-color: #eee;
color: #aaa;
cursor: auto;
}
button.clear {
color: #888;
margin-bottom: 12px;
}
Al actualizar el navegador, la página muestra los heroes. Abajo podrás ver los mensajes de HeroService
. Haz click en el botón ‘Clear’ para hacer desaparecer los mensajes.
Enrutado
A la aplicación le falta un poco de navegación, a continuación la modificarás para que puedas navegar del siguiente modo:
Una buena práctica en Angular es cargar y configurar el enrutador en módulo separado. Por convenio, el nombre de la clase es AppRoutingModule
que se encuentra en app-routing.module.ts
en el directorio src/app
. Escribe el siguiente comando en un terminal:
$ ng generate module app-routing --flat --module=app
--flat
indica que se debe poner en el directorio src/app
en lugar de us propio directorio. --module=app
lo registra en el array de imports de AppModule
. El fichero generado debe ser algo así:
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
@NgModule({
imports: [
CommonModule
],
declarations: []
})
export class AppRoutingModule { }
Normalmente no se declara componentes en un componente de enrutado, así que puedes eliminar @NgModule.declarations
y las referencias CommonModule
también. Configurarás el enrutador con Routes
en RoyterModule
así que se debe importar desde a librería @angular/router
. Añade un array @NgModule.exports
en RouterModule
, hace que las directivas RouterModule
estén disponibles en los componentes AppModule
que los necesiten. AppRoutingModule
debe ser como el siguiente:
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
@NgModule({
exports: [ RouterModule ]
})
export class AppRoutingModule {}
Añadiendo rutas
Routest
indica al enrutador que vista debe mostrar cuando el usuario hace click en un enlace o escribe una URL en la barra de direcciones. Un Route
de Angular tiene las siguientes dos propiedades:
path
: un texto que coincide con la URL del navegador.component
: el componente que el enrutador debe crear cuando se navega a esta ruta.
Cuando intentas navegar a HeroesComponent
la URL es algo parecido como localhost:4200/heroes
. Importa HeroesComponent
y referencialo en Route
. Luego define un array de rutas con un único route
al componente:
import { HeroesComponent } from './heroes/heroes.component';
const routes: Routes = [
{ path: 'heroes', component: HeroesComponent }
];
Una vez has acabado, el enrutador emparejará esa URL con path: 'heroes'
y mostrará HeroesComponent
.
Antes de usarlo debes inicializarlo y comprobar si la localización del navegador cambia. Añade RouterModule
al array @NgModule.imports
y configuralo con routes
en un solo paso llamando a RouterModule.forRoot()
:
imports: [ RouterModule.forRoot(routes) ],
El método se llama forRoot()
porque configuras el enrutador a nivel de raíz.
Abre la plantilla AppComponent
y reemplaza <app-heroes>
por <router-outlet>
:
<h1>{{title}}</h1>
<router-outlet></router-outlet>
<app-messages></app-messages>
Debes borrar <app-heroes>
porque no muestras HeroesComponent
cuando el usuario navega. <router-outlet>
indica al enrutador que vista debe mostrar. Ejecuta el comando neg serve
y navega al sitio. El navegador debe mostrar el título de la aplicación pero no el listado de héroes. La URL del navegador debe acabar en /
, la ruta a HeroesComponent
es /heroes
.
Rutas de enlace
Los usuarios no deben introducir URL en la barra de navegación. Deberían poder hacer click en los enlaces para navegar. Añade un elemento <nav>
con un <a>
en su interior para que se pueda navegar a HeroesComponent
. El fichero AppComponent
debe ser como el siguiente:
<h1></h1>
<nav>
<a routerLink="/heroes">Heroes</a>
</nav>
<router-outlet></router-outlet>
<app-messages></app-messages>
Un atributo de tipo router Link
se establece a "/heroes"
, es el texto que se empareja a la ruta a HeroesComponent
. router link
es el selector para la directiva router link
que torna los clicks de usuario a navegaciones enrutadas. Cuando el navegador actualice, mostrará el titulo de la aplicación y el enlace, pero no la lista. Haz click en el enlace, la barra de navegación debe actualizarse a /heroes
y debe aparecer el listado de héroes.
Vista de tablero
El enrutado cobra sentido cuando hay varias vistas. Pero ahora mismo solo hay una. Añade DashboardComponent
usando la terminal:
$ ng generate component dashboard
Se generan los ficheros DashboardComponent
y se declara en AppModule
. Reemplaza el contenido de los tres ficheros al siguiente. src/app/dashboard/dashboard.component.html
:
<h3>Top Heroes</h3>
<div class="grid grid-pad">
<a *ngFor="let hero of heroes" class="col-1-4">
<div class="module hero">
<h4>{{hero.name}}</h4>
</div>
</a>
</div>
src/app/dashboard/dashboard.compnent.ts
import { Component, OnInit } from '@angular/core';
import { Hero } from '../hero';
import { HeroService } from '../hero.service';
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: [ './dashboard.component.css' ]
})
export class DashboardComponent implements OnInit {
heroes: Hero[] = [];
constructor(private heroService: HeroService) { }
ngOnInit() {
this.getHeroes();
}
getHeroes(): void {
this.heroService.getHeroes()
.subscribe(heroes => this.heroes = heroes.slice(1, 5));
}
}
src/app/dashboard/dashboard.component.css
/* DashboardComponent's private CSS styles */
[class*='col-'] {
float: left;
padding-right: 20px;
padding-bottom: 20px;
}
[class*='col-']:last-of-type {
padding-right: 0;
}
a {
text-decoration: none;
}
*, *:after, *:before {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
h3 {
text-align: center; margin-bottom: 0;
}
h4 {
position: relative;
}
.grid {
margin: 0;
}
.col-1-4 {
width: 25%;
}
.module {
padding: 20px;
text-align: center;
color: #eee;
max-height: 120px;
min-width: 120px;
background-color: #607D8B;
border-radius: 2px;
}
.module:hover {
background-color: #EEE;
cursor: pointer;
color: #607d8b;
}
.grid-pad {
padding: 10px 0;
}
.grid-pad > [class*='col-']:last-of-type {
padding-right: 20px;
}
@media (max-width: 600px) {
.module {
font-size: 10px;
max-height: 75px; }
}
@media (max-width: 1024px) {
.grid {
margin: 0;
}
.module {
min-width: 60px;
}
}
La plantilla presenta un tablero de nombres de héroe que son enlaces.
*ngFor
crea tantos enlaces como componentes hay en el arrayheroes
.- Los enlaces tienen estilo de bloques coloreados por el fichero
dashboard.component.css
. - Los enlaces no dirigen a ningún sitio, lo harán en breve.
La clase es parecida a HeroesComponent
.
- Define un array de
heroes
. - El constructor espera que Angular inyecte
HeroService
en una propiedad privadaheroService
. - El ciclo de vida
ngOnInit()
llama agetHeroes
.
Este getHeroes
redice el número de héroes mostrados a 4:
getHeroes(): void {
this.heroService.getHeroes()
.subscribe(heroes => this.heroes = heroes.slice(1, 5));
}
Para navegar a este tablero, el enrutador necesita la ruta apropiada. Importa DashboardComponent
en AppRoutingModule
:
import { DashboardComponent } from './dashboard/dashboard.component';
Añade la ruta al array AppRoutingModule.routes
que enlace con DashboardComponent
:
{ path: 'dashboard', component: DashboardComponent },
Ruta por defecto
Cuando la aplicación se inicia, la dirección a la que apunta el navegador es la raíz del sitio. Esto no coincide con una ruta existente así que el enrutador no navega a ningún sitio. El espacio debajo de <router-outlet>
está en blanco. Para hacer que la aplicación navegue al panel de forma automática, añade lo siguiente al array AppRoutingModule.Routes
:
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
Esta ruta redirecciona una URL que coincide con la ruta vacía cuya dirección es /dashboard
. Cuando el navegador actualice, el enrutador cargará DashboardComponent
y debe mostrar en la barra de navegación la URL /dashboard
.
Vuelta atrás
El usuario debería poder navegar en ambos sentidos entre DashboardComponent
y HeroesComponent
haciendo click en botones de navegación. Añade un enlace en AppComponent
, encima del enlace Heroes:
<h1></h1>
<nav>
<a routerLink="/dashboard">Dashboard</a>
<a routerLink="/heroes">Heroes</a>
</nav>
<router-outlet></router-outlet>
<app-messages></app-messages>
Cuando el navegador actualice podrás navegar libremente entre las dos vistas haciendo click en los enlaces.
Ahora mismo HeroDetailComponent
muestra el detalle de un héroe seleccionado y solamente es visible al final de HeroesComponent
. El usuario debería poder acceder a este detalle de tres maneras:
- Haciendo click en el héroe del tablero.
- Haciendo click al héroe de la lista.
- Introduciendo la URL en la barra de navegación.
El listado de héroes no necesita el detalle del mismo, por lo que abre el fichero heroes/heroes.component.html
y elimina <app-hero-detail>
. Con este cambio si se hace click en un elemento de héroe no hace nada. Se solucionará en breve.
Rutas detalle
URL tipo ~/detail/11
son un buen tipo de URL para navegar al detalle de héroe. Abre AppRoutingModule
e importa HeroDetailComponent
:
import { HeroDetailComponent } from './hero-detail/hero-detail.component';
Luego añade una ruta parametrizada al array AppRoutingModule.routes
:
{ path: 'detail/:id', component: HeroDetailComponent },
Los dos puntos en una ruta indica que :id
es un parámetro específico de héroes. A estas alturas, las rutas de aplicación deberían ser:
const routes: Routes = [
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: DashboardComponent },
{ path: 'detail/:id', component: HeroDetailComponent },
{ path: 'heroes', component: HeroesComponent }
];
Ahora que se puede acceder al detalle del héroe por enrutado puedes habilitar esta navegación desde el panel de héroes. Abre src/app/dashboard/dashboard.compo.html
y añade el routerLink
al enlace:
<a *ngFor="let hero of heroes" class="col-1-4"
routerLink="/detail/{{hero.id}}">
Modifica también en el listado de héroes en src/app/heroes/heroes.component.html
, ahora es un elemento <li>
en el que al hacer click llama al método onSelect()
:
<ul class="heroes">
<li *ngFor="let hero of heroes"
[class.selected]="hero === selectedHero"
(click)="onSelect(hero)">
<span class="badge">{{hero.id}}</span> {{hero.name}}
</li>
</ul>
Se debe cambiar, el elemento <li>
no debe realizar esa llamada, solamente debe contener el enlace, sustituye el código anterior por:
<ul class="heroes">
<li *ngFor="let hero of heroes">
<a routerLink="/detail/{{hero.id}}">
<span class="badge">{{hero.id}}</span> {{hero.name}}
</a>
</li>
</ul>
Al realizar estos cambios el estilo privado deja de funcionar, para solucionarlo modifica el fichero heroes.component.css
:
/* HeroDetailComponent's private CSS styles */
label {
display: inline-block;
width: 3em;
margin: .5em 0;
color: #607D8B;
font-weight: bold;
}
input {
height: 2em;
font-size: 1em;
padding-left: .4em;
}
button {
margin-top: 20px;
font-family: Arial;
background-color: #eee;
border: none;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer; cursor: hand;
}
button:hover {
background-color: #cfd8dc;
}
button:disabled {
background-color: #eee;
color: #ccc;
cursor: auto;
}
HeroesComponent
funciona perfectamente, pero el método onSelect()
y la propiedad selectedHero
ya no van a seguir en uso, elimina ese código. Siempre es bueno tener el código limpio, lo agradecerás más tarde. La clase debería quedar como lo siguiente:
export class HeroesComponent implements OnInit {
heroes: Hero[];
constructor(private heroService: HeroService) { }
ngOnInit() {
this.getHeroes();
}
getHeroes(): void {
this.heroService.getHeroes()
.subscribe(heroes => this.heroes = heroes);
}
}
HeroDetailComponent enrutable
Antes, el padre HeroesComponent
establecía el valor de la propiedad HeroDetailComponent.hero
para mostrarlo. Ya no lo hace. Ahora el enrutador crea HeroDetailComponent
en respuesta a una URL tipo ~/detail/11
. HeroDetailComponent
necesita una nueva forma de obtener hero-to-display
.
- Debe obtener la ruta que lo creó.
- Debe extraer el
id
de la ruta. - Debe obtener el héroe con ese
id
del servicio viaHeroService
.
Añade los siguientes imports
a hero-detail.component.ts
:
import { ActivatedRoute } from '@angular/router';
import { Location } from '@angular/common';
import { HeroService } from '../hero.service';
Inyecta los servicios ActivatedRoute
, HeroService
y Location
a esta instancia.
constructor(
private route: ActivatedRoute,
private heroService: HeroService,
private location: Location
) {}
Este componente está interesado en los parámetros de la ruta. El parámetro “id” es el id
del héroe a mostrar. HeroService
obtiene la información del héroe de un servidor remoto y este componente lo usará para mostrarlo. Location
es un servicio de Angular que interactúa con el navegador. Lo verás más adelante.
En el ciclo de vida ngOnInit()
llama a getHero()
y defínelo tal que así:
ngOnInit(): void {
this.getHero();
}
getHero(): void {
const id = +this.route.snapshot.paramMap.get('id');
this.heroService.getHero(id)
.subscribe(hero => this.hero = hero);
}
route.snapshot
es una imagen estática de la información de la ruta. paramMap
es un diccionario de ritas de parámetros y valores extraídos de la URL. La clave "id"
devuelve el id
del héroe que se debe obtener. Los parámetros de las rutas son siempre texto. El operador (+) de JavaScript convierte el texto a numérico, que es lo que el id
del héroe debería ser. Si actualizas en el navegador el compilador debe dar un error. HeroService
no tiene un método getHero()
. Añádelo:
getHero(id: number): Observable<Hero> {
// TODO: send the message _after_ fetching the hero
this.messageService.add(`HeroService: fetched hero id=${id}`);
return of(HEROES.find(hero => hero.id === id));
}
Igual que getHeroes()
, getHero()
es un método asíncrono. Devuelve el mock de héroes como un Observable
usando la función of()
de RxJS. Debes de ser capaz de reimplementar getHero()
como una llamada real de Http
sin tener que cambiar la llamada de HeroDetailComponent
.
Cuando actualice el navegador la aplicación ya debe volver a funcionar. Si haces click en un héroe del tablero navegarás a la información detalle del héroe. Si escribes la URL en el navegador, por ejemplo localhost:4200/detail/11
, el enrutador navegará a la información detalle del héroe con id 11.
Encontrando en camino de vuelta
Haciendo click en el botón atrás del navegador puedes volver al listado desde el detalle o al tablero, dependiendo desde dónde hayas navegado. Estaría bien que un botón en la vista HeroDetail
pudiera hacer eso. Añade un botón de vuelta atrás en la plantilla del componente y vinculalo al método goBack()
.
<button (click)="goBack()">go back</button>
El método goBack()
navega atrás un paso en el historial del navegador usando el servicio Location
que has inyectado anteriormente:
goBack(): void {
this.location.back();
}
Actualiza el navegador y empieza ha navegar por la aplicación. Como usuario debes de ser capaz de navegar por toda la aplicación sin tener que escribir URL ni hacer click en el botón atrás del navegador.
Http
Ahora mismo, lo que le falta a la aplicación es persistencia. Lo conseguirás mediante los siguientes pasos:
HeroService
obtiene la información con peticiones HTTP.- Los usuarios deben poder añadir, editar y eliminar héroes y guardar estos cambios vía HTTP.
- Los usuarios deben poder buscar héroes por su nombre.
Habilitando servicios HTTP
HttpClient
es un mecanismo de Angular para comunicarse con un servidor remoto a través de HTTP. Para que HttpClient
esté disponible en toda la aplicación sigue los siguientes pasos:
- Abre el fichero raíz
AppModule
. - Importa
HtppClientModule
desde@angular/common/http
. - Añádelo al array
@NgModule.imports
.
Simulando la información en el servidor
En este tutorial simularemos un servidor que contiene la información usando el módulo In-memory Web API
. Después de instalar el módulo, la aplicación hará peticiones y recibirá respuestas de HttpClient
sin saber que In-memory Web API
está interceptando esas peticiones. Esta funcionalidad viene muy bien para el tutorial. No tienes que configurar un servidor. También es conveniente realizar este tipo de desarrollo en las etapas tempranas, cuando el api del servidor no está totalmente definido o no está implementado. Instala el paquete In-memory Web API
desde npm
. La versión está bloqueada a v0.5
para mantener la compatibilidad actual con @angular/cli
.
$ npm install angular-in-memory-web-api@0.5 --save
Importa HttpClientInMemoryWebApiModule
y InMemoryDataService
:
import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api';
import { InMemoryDataService } from './in-memory-data.service';
Añade HttpClientInMemoryWebApiModule
al array @NgModule.imports
:
HttpClientModule,
// The HttpClientInMemoryWebApiModule module intercepts HTTP requests
// and returns simulated server responses.
// Remove it when a real server is ready to receive requests.
HttpClientInMemoryWebApiModule.forRoot(
InMemoryDataService, { dataEncapsulation: false }
)
El método de configuración forRoot()
obtiene una clase InMemoryDataService
que prima la base de datos en memoria. Crea una clase en src/app/in-memory-data.service.ts
que tenga el siguiente contenido:
import { InMemoryDbService } from 'angular-in-memory-web-api';
export class InMemoryDataService implements InMemoryDbService {
createDb() {
const heroes = [
{ id: 11, name: 'Mr. Nice' },
{ id: 12, name: 'Narco' },
{ id: 13, name: 'Bombasto' },
{ id: 14, name: 'Celeritas' },
{ id: 15, name: 'Magneta' },
{ id: 16, name: 'RubberMan' },
{ id: 17, name: 'Dynama' },
{ id: 18, name: 'Dr IQ' },
{ id: 19, name: 'Magma' },
{ id: 20, name: 'Tornado' }
];
return {heroes};
}
}
Este fichero reemplaza mock-heroes.ts
, que puedes eliminar ahora. Cuando el tengas un servidor api listo, podrás desacoplar In-memory Web API
, y las peticiones de la aplicación irán al servidor.
HTTP Heroes
Importa los símbolos HTTP que necesitas en hero.services.ts
:
import { HttpClient, HttpHeaders } from '@angular/common/http';
Inyecta HtppClient
al constructor en una propiedad privada llamada http
:
constructor(
private http: HttpClient,
private messageService: MessageService) { }
Mantén inyectado el servicio MessageService
:
/** Log a HeroService message with the MessageService */
private log(message: string) {
this.messageService.add('HeroService: ' + message);
}
Define la variable heroesUrl
con la dirección de héroes del servidor:
private heroesUrl = 'api/heroes'; // URL to web api
Actualmente HeroService.getHeroes()
usa la función of()
de RxJS para obtener el array de héroes:
getHeroes(): Observable<Hero[]> {
return of(HEROES);
}
Convierte ese método para usar HttpClient
:
/** GET heroes from the server */
getHeroes (): Observable<Hero[]> {
return this.http.get<Hero[]>(this.heroesUrl)
}
Actualiza el navegador. La información de los héroes debe cargarse correctamente desde el servidor mock. Has cambiado of
por http.get
y la aplicación sigue funcionando porque ambos devuelve un Observable<Hero[]>
.
Todos los métodos HttpClient
devuelven un Observable
RxJs de algo. HTTP es un protocolo de petición/respuesta. Haces una petición y devuelve una única respuesta. En general, Observable
puede devolver varios valores en el tiempo. Un Observable
de HttpClient
siempre emitirá un único valor y luego se completa, nunca se emite otra vez. Este HttpClient.get
en particular, devuelve un Observabble<Hero[]>
, literalmente un array de héroes observable. En la práctica es un único array de héroes.
Cuando las cosas no van bien
Cuando se realizan llamadas a un servidor remoto, las conexiones pueden fallar, por lo que HeroService.getHeroes()
debe estar preparado para esos errores y actuar en consecuencia. Para capturar los errores y los debes canalizar desde http.get()
a través de un operador catchError()
de RxJS. Importa el símbolo catchError
desde rxjs/operators
y otros que necesitarás más tarde:
import { catchError, map, tap } from 'rxjs/operators';
Ahora extiende el resultado con el método pipe()
y dale un operador catchError()
:
getHeroes (): Observable<Hero[]> {
return this.http.get<Hero[]>(this.heroesUrl)
.pipe(
catchError(this.handleError('getHeroes', []))
);
}
El operador catchError()
intercepta un Observable
que ha fallado. Pasa el error a un manejador que puede hacer lo que desee con ese error. La función handleError()
lo reporta y devuelve un resultado inocuo para que la aplicación siga funcionando.
El siguiente errorHandler()
será compartido por varios métodos HeroService
por lo que está generalizado. En lugar de manejar el error directamente, devuelve una función de manejador de error a catchError
que ha sido configurado tanto para el nombre de la operación que ha fallado como para devolver un valor seguro.
/**
* Handle Http operation that failed.
* Let the app continue.
* @param operation - name of the operation that failed
* @param result - optional value to return as the observable result
*/
private handleError<T> (operation = 'operation', result?: T) {
return (error: any): Observable<T> => {
// TODO: send the error to remote logging infrastructure
console.error(error); // log to console instead
// TODO: better job of transforming error for user consumption
this.log(`${operation} failed: ${error.message}`);
// Let the app keep running by returning an empty result.
return of(result as T);
};
}
Después de reportar el error a la consola, el manejador construye un mensaje amigable y devuelve un valor seguro a la aplicación para que siga funcionando. Como cada método del servicio devuelve distintos tipos de Observable
, errorHandler()
toma un tipo de parámetro para que pueda retornar un tipo seguro a la aplicación.
Los métodos HeroService
entrarán en el flujo de los valores observables y enviarán un mensaje, a través de log()
, al área de mensajes al final de la página. Se realizará a través del operador tap
, que mira los valores observables, hace algo con esos valores y los devuelve. El operador tap
no toca los valores en sí mismos. A continuación está la versión final del método getHeroes
con tap
que loguea la operación:
/** GET heroes from the server */
getHeroes (): Observable<Hero[]> {
return this.http.get<Hero[]>(this.heroesUrl)
.pipe(
tap(heroes => this.log(`fetched heroes`)),
catchError(this.handleError('getHeroes', []))
);
}
La gran mayoría de las APIs dan soporte a peticiones tipo get by id con la forma api/hero/:id
. Añade un método HeroService.getHero()
para realizar esa petición:
/** GET hero by id. Will 404 if id not found */
getHero(id: number): Observable<Hero> {
const url = `${this.heroesUrl}/${id}`;
return this.http.get<Hero>(url).pipe(
tap(_ => this.log(`fetched hero id=${id}`)),
catchError(this.handleError<Hero>(`getHero id=${id}`))
);
}
Hay tres diferencias significantes con getHeroes()
:
- Construye URL de petición con el id de héroe deseado.
- El servidor debería responder con un único héroe en lugar de un array de héroes.
getHero
devuelve unObservable<Hero>
en lugar de un array.
Ahora cuando editas el nombre de un héroe en la vista detalle, el nombre se actualiza en la cabecera, pero cuando vuelves atrás los cambios se pierden. Si quieres que los cambios persistan debes escribirlos en el servidor. Añade en la vista hero-detail.component.html
un botón para guardar los cambios al final de la plantilla que esté vinculado al nuevo método save()
:
<button (click)="save()">save</button>
Añade el método save()
al componente hero-detail.component.ts
que persista los cambios usando el servicio de héroes, el método updateHero()
y que luego vuelva a la vista anterior:
save(): void {
this.heroService.updateHero(this.hero)
.subscribe(() => this.goBack());
}
La estructura de updateHero()
es simular a getHeroes()
, pero usa http.put()
para persistir los cambios en el servidor:
/** PUT: update the hero on the server */
updateHero (hero: Hero): Observable<any> {
return this.http.put(this.heroesUrl, hero, httpOptions).pipe(
tap(_ => this.log(`updated hero id=${hero.id}`)),
catchError(this.handleError<any>('updateHero'))
);
}
El método HttpClient.put()
recibe tres parámetros:
- La URL
- La información a actualizar.
- Las opciones.
La URL no cambia, la API sabe que héroe actualizar a través del id
y espera una cabecera HTTP especial en las peticiones de guardado. Esa cabecera está en httpOptions
definido en HeroService
.
const httpOptions = {
headers: new HttpHeaders({ 'Content-Type': 'application/json' })
};
Actualiza el navegador, cambia el nombre de un héroe, haz click en el botón para volver atrás. El héroe que has modificado debe persistir con los cambios que hayas realizado.
Para añadir un héroe solo necesitamos el nombre. Usa un elemento input
con un botón para añadirlo. Inserta el siguiente código en la plantilla HeroesComponent
después de la cabecera:
<div>
<label>Hero name:
<input #heroName />
</label>
<!-- (click) passes input value to add() and then clears the input -->
<button (click)="add(heroName.value); heroName.value=''">
add
</button>
</div>
En respuesta al evento click, llama al manejador click del componente y limpia el campo input
para dejarlo listo para otro nombre:
add(name: string): void {
name = name.trim();
if (!name) { return; }
this.heroService.addHero({ name } as Hero)
.subscribe(hero => {
this.heroes.push(hero);
});
}
Cuando llega un nombre que no está en blanco, el manejador crea un Hero
, como un objeto pero sin el id
, y lo pasa al servicio con el método addHero()
. Cuando addHero
lo guarda, la función subscribe
recibe el nuevo héroe y lo añade al listado de heroes
a mostrar. Añade el método addHero()
a la clase HeroService
:
/** POST: add a new hero to the server */
addHero (hero: Hero): Observable<Hero> {
return this.http.post<Hero>(this.heroesUrl, hero, httpOptions).pipe(
tap((hero: Hero) => this.log(`added hero w/ id=${hero.id}`)),
catchError(this.handleError<Hero>('addHero'))
);
}
HeroService.addHero()
difiere de updateHero
en dos formas:
- Llama a
HttpClient.post()
en lugar deput
. - Espera que el servidor genere el id del nuevo héroe, que es devuelto en
Observable<Hero>
.
Actualiza el navegador y añade algunos héroes.
Cada héroe en la lista debe tener un botón de borrado. Añade el siguiente elemento a la plantilla HeroesComponent
, después del nombre del héroes repetido en el elemento <li>
:
<button class="delete" title="delete hero"
(click)="delete(hero)">x</button>
El código HTML del listado de héroes debería parecerse al siguiente:
<ul class="heroes">
<li *ngFor="let hero of heroes">
<a routerLink="/detail/">
<span class="badge"></span>
</a>
<button class="delete" title="delete hero"
(click)="delete(hero)">x</button>
</li>
</ul>
La posición del botón de borrado se puede definir mediante css, añade lo siguiente a heroes.component.css
:
/* HeroesComponent's private CSS styles */
.heroes {
margin: 0 0 2em 0;
list-style-type: none;
padding: 0;
width: 15em;
}
.heroes li {
position: relative;
cursor: pointer;
background-color: #EEE;
margin: .5em;
padding: .3em 0;
height: 1.6em;
border-radius: 4px;
}
.heroes li:hover {
color: #607D8B;
background-color: #DDD;
left: .1em;
}
.heroes a {
color: #888;
text-decoration: none;
position: relative;
display: block;
width: 250px;
}
.heroes a:hover {
color:#607D8B;
}
.heroes .badge {
display: inline-block;
font-size: small;
color: white;
padding: 0.8em 0.7em 0 0.7em;
background-color: #607D8B;
line-height: 1em;
position: relative;
left: -1px;
top: -4px;
height: 1.8em;
min-width: 16px;
text-align: right;
margin-right: .8em;
border-radius: 4px 0 0 4px;
}
button {
background-color: #eee;
border: none;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
cursor: hand;
font-family: Arial;
}
button:hover {
background-color: #cfd8dc;
}
button.delete {
position: relative;
left: 194px;
top: -32px;
background-color: gray !important;
color: white;
}
Y el manejador delete()
del componente:
delete(hero: Hero): void {
this.heroes = this.heroes.filter(h => h !== hero);
this.heroService.deleteHero(hero).subscribe();
}
Aunque el componente delegue el borrado del héroe en HeroService
, sigue actualizando la lista de héroes. El método delete()
elimina el héroe del listado anticipando que HeroService
funcionará correctamente.
No hay que hacer nada con el Observable
devuelto por heroService.delete()
, pero se debe realizar de todos modos. En caso contrario no se realizaría la llamada al servidor.
Añade la función deleteHero()
a HeroService
:
/** DELETE: delete the hero from the server */
deleteHero (hero: Hero | number): Observable<Hero> {
const id = typeof hero === 'number' ? hero : hero.id;
const url = `${this.heroesUrl}/${id}`;
return this.http.delete<Hero>(url, httpOptions).pipe(
tap(_ => this.log(`deleted hero id=${id}`)),
catchError(this.handleError<Hero>('deleteHero'))
);
}
En este método:
- Se llama a
HttpClient.delete
. - La URL es el recurso de héroe más el
id
del héroe a eliminar. - No necesitas enviar información como hiciste con
put
ypost
. - Envías también
httpOptions
Actualiza el navegador y ya tienes la funcionalidad de borrado.
Búsqueda por nombre
En esta sección añadirás la funcionalidad de búsqueda al tablero de héroes. Cuando el usuario escriba teto en una caja de texto realizarás peticiones HTTP para obtener héroes filtrados por ese nombre. El objetivo es realizar las llamadas que sean necesarias.
Empieza añadiendo el método searchHeroes
a HeroService
:
/* GET heroes whose name contains search term */
searchHeroes(term: string): Observable<Hero[]> {
if (!term.trim()) {
// if not search term, return empty hero array.
return of([]);
}
return this.http.get<Hero[]>(`api/heroes/?name=${term}`).pipe(
tap(_ => this.log(`found heroes matching "${term}"`)),
catchError(this.handleError<Hero[]>('searchHeroes', []))
);
}
El método devuelve inmediatamente un array vacío si no hay término que buscar. El resto es muy similar a getHeroes()
. La única diferencia es la URL, que incluye una query string en el término de búsqueda.
Abre la plantilla DashBoardComponent
y añade el elemento de búsqueda, <app-hero-search>
al final.
<h3>Top Heroes</h3>
<div class="grid grid-pad">
<a *ngFor="let hero of heroes" class="col-1-4"
routerLink="/detail/{{hero.id}}">
<div class="module hero">
<h4>{{hero.name}}</h4>
</div>
</a>
</div>
<app-hero-search></app-hero-search>
Esta plantilla se parece mucho a la plantilla HeroesComponent
.
Desafortunadamente, añadiendo este elemento, se rompe la aplicación. Angular no puede encontrar el componente que corresponde con <app-hero-search>
. HeroSearchComponent
no existe aún. Arréglalo introduciendo el siguiente comando:
$ ng generate component hero-search
Se crean los tres HeroSeachComponent
y se añade a las declaraciones AppModule
. Reemplaza el contenido generado de la plantilla HeroSearchComponent
y una caja de texto y una lista con los resultados:
<div id="search-component">
<h4>Hero Search</h4>
<input #searchBox id="search-box" (keyup)="search(searchBox.value)" />
<ul class="search-result">
<li *ngFor="let hero of heroes$ | async" >
<a routerLink="/detail/{{hero.id}}">
{{hero.name}}
</a>
</li>
</ul>
</div>
Añade el siguiente estilo css a hero-search.component.css
:
/* HeroSearch private styles */
.search-result li {
border-bottom: 1px solid gray;
border-left: 1px solid gray;
border-right: 1px solid gray;
width:195px;
height: 16px;
padding: 5px;
background-color: white;
cursor: pointer;
list-style-type: none;
}
.search-result li:hover {
background-color: #607D8B;
}
.search-result li a {
color: #888;
display: block;
text-decoration: none;
}
.search-result li a:hover {
color: white;
}
.search-result li a:active {
color: white;
}
#search-box {
width: 200px;
height: 20px;
}
ul.search-result {
margin-top: 0;
padding-left: 0;
}
Cuando el usuario escribe en la caja de texto, el evento keyup llama al método search()
con el nuevo valor de la caja de texto.
Como esperamos *ngFor
repite los objetos de héroe. Si te fijas en el *ngFor
, itera sobre una lista llamada heroes$
, no heroe
:
<li *ngFor="let hero of heroes$ | async" >
$
indica que heroe$
es un Observable
, no un array. *nFor
no puede hacer nada con un Observable
. Pero también está el carácter |
seguido de async
, que define AsyncPipe
. Este AsyncPipe
suscribe Observable
automáticamente por lo que lo tienes en la clase del componente.
Reemplaza el código generado de HeroSearchComponent
por esto:
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
import { of } from 'rxjs/observable/of';
import {
debounceTime, distinctUntilChanged, switchMap
} from 'rxjs/operators';
import { Hero } from '../hero';
import { HeroService } from '../hero.service';
@Component({
selector: 'app-hero-search',
templateUrl: './hero-search.component.html',
styleUrls: [ './hero-search.component.css' ]
})
export class HeroSearchComponent implements OnInit {
heroes$: Observable<Hero[]>;
private searchTerms = new Subject<string>();
constructor(private heroService: HeroService) {}
// Push a search term into the observable stream.
search(term: string): void {
this.searchTerms.next(term);
}
ngOnInit(): void {
this.heroes$ = this.searchTerms.pipe(
// wait 300ms after each keystroke before considering the term
debounceTime(300),
// ignore new term if same as previous term
distinctUntilChanged(),
// switch to new search observable each time the term changes
switchMap((term: string) => this.heroService.searchHeroes(term)),
);
}
}
Fíjate que la declaración de heroe$
es un Observable
.
heroes$: Observable<Hero[]>;
Lo podrás encontrar en ngOnInit()
.
La propiedad searchTerms
está declarada como Subject
de RxJS.
private searchTerms = new Subject<string>();
// Push a search term into the observable stream.
search(term: string): void {
this.searchTerms.next(term);
}
Subject
es una fuente de valores observable y un Observable
en sí mismo. Te puedes suscribir a Subject
tal y como haces con Observable
. También puedes añadir valores a ese Observable
llamado a su método next(value)
como el hace el método search()
. El método search()
se le llama a través del evento vinculado keystore
:
<input #searchBox id="search-box" (keyup)="search(searchBox.value)" />
Siempre que se escriba en la caja de texto, se llama al método search()
con el valor de la caja de texto con un “search term”. Los searchTerms
se transforman en Observable
emitiendo un flujo de términos de búsqueda.
Pasando un nuevo término de búsqueda a searchHeroes()
cada vez que el usuario pulsa una tecla puede crear un exceso de llamadas HTTP, consumiendo mucho ancho de banda.
En lugar de ello, el método ngOnInit()
canaliza los observables searchTerms
a través de una secuencia de operadores RxJS que reduce el número de llamadas a searchHeroes()
, y finalmente devolviendo un observable como resultados de búsqueda:
this.heroes$ = this.searchTerms.pipe(
// wait 300ms after each keystroke before considering the term
debounceTime(300),
// ignore new term if same as previous term
distinctUntilChanged(),
// switch to new search observable each time the term changes
switchMap((term: string) => this.heroService.searchHeroes(term)),
);
debounceTime(300)
espera hasta que los eventos de flujo de nuevo texto paran 300 milisegundos antes de pasar el último texto introducido. Nunca hagas peticiones más frecuentemente de 300 milisegundos.distinctUntilChanged
se asegura que una petición solo se envía si el texto ha cambiado.switchMap()
llama al servicio de búsqueda por cada término de búsqueda, devolviendo solo el último resultado.
Recuerda que el componente de class
no se suscribe al observable heroe$
. Esto es trabajo de AsyncPipe
de la plantilla.
Vuelve a ejecutar la aplicación. En el tablero de héroes, introduce texto en la caja de texto para realizar la búsqueda. Si lo introducido coincide con nombres de héroe existentes debería aparecer algo como esto:
Electron
Bueno, toda esto está muy bien, pero si lo que quieres es aprovechar todo este conocimiento y aplicarlo a una aplicación de escritorio multiplataforma. Puedes. Si no conoces Electron te dejo aquí un enlace a su web. Básicamente te permite aplicar HTML y javascript entre otras a realizar aplicaciones de escritorio. En este cas lo tenemos facilito.
Abre el fichero src/index.html
y cambia la dirección base generada por Angular. Esta dirección base está apuntando a /
, esto causa problemas a Electron:
<base href="./">
Si no tienes Electron instalado es el momento:
$ npm install electron --save-dev
Ahora cambia el siguiente contenido en package.json
:
{
"name": "angular-electron",
"version": "0.0.0",
"license": "MIT",
"main": "main.js", // <-- aquí
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e",
"electron": "electron .", // <-- lanzar electron
"electron-build": "ng build --prod && electron ." // <-- construir la aplicación y luego lanza electron
},
// ...omitido
}
Lo que queda es lanzarlo con el siguiente comando:
$ npm run electron-build
Al poco tiempo se debe lanzar la misma aplicación que antes pero en una ventana de escritorio.
Esta última parte lo he encontrado en la web de firebase.
Para acabar
Como de costumbre, dejo en Github los proyectos. Aquí el de Angular, aquí con Electron
Me ha salido un post kilométrico. Lo siento.
Si tienes dudas o comentarios no dudes en comentar.
Nos vemos en las próximas líneas.