Introducción

En esta ocasión vamos a desarrollar una aplicación que permita al usuario postear un artículo (título y URL) y votar entre todos los posts.

Técnicas que aprenderemos desarrollando el ejercicio:

  • Construir componentes nuevos.
  • Procesar la entrada del usuario en un formulario.
  • Renderizar una serie de objetos en una vista.
  • Capturar los eventos de pulsación del ratón del usuario y procesarlos.

Creación del proyecto

Seguimos siempre los mismos pasos para construir la estructura de proyecto y ejecutar nuestra aplicación en el navegador:

ng new reddit-clone
cd reddit-clone
ng serve --open

Si el puerto por defecto está ocupado por otro servicio podemos especificar otro con --port 9001, por defecto arranca el servidor local de desarrollo en el puerto 4200.

Creando un componente

Una de las características más destacadas de Angular son los componentes Web.

ng generate component reddit-clone

Podemos usar la sintaxis abreviada para hacer lo mismo ng g c reddit-clone.

Ahora tenemos una nueva ruta y varios ficheros del componente ya creados en [reddit-clone/src/app/reddit-clone]:

El componente TypeScript (extensión .ts) "reddit-clone.component.ts" tiene dos secciones principales:

  • El decorador del componente.
  • Definición de la clase del componente, en este caso la clase se llamará RedditCloneComponent.
import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-reddit-clone',
  templateUrl: './reddit-clone.component.html',
  styleUrls: ['./reddit-clone.component.css']
})
export class RedditCloneComponent implements OnInit {

  constructor() { }

  ngOnInit() {
  }

}

La sentencia import define los módulos que queremos usar (Component y OnInit) de la ruta @angular/core.

Después definimos el decorador, es algo así como los metadatos del componente. Con el selector le decimos que queremos usar una etiqueta <app-reddit-clone></app-reddit-clone>.

La siguiente parte es definir que plantilla queremos usar con templateUrl, en este caso es "reddit-clone.component.html":

<p>
  reddit-clone works!
</p>

Si en vez de usar templateUrl usamos template podemos definir la plantilla dentro del propio decorador como sigue:

@Component({
  selector: "app-reddit-clone",
  template: `
  < p >
      Works inline!
  </p>
  `
})

Usamos "`" para definir una cadena formada por varias líneas.

Con el último parámetro definimos la hoja de estilos CSS del componente.

Cargando nuestro componente

Para cargar el componente editamos "app.component.html", reemplazamos todo el contenido por este:

<h1>
  {{ title }}
  <app-reddit-clone></app-reddit-clone>
</h1>

Añadiendo datos al componente

Hasta el momento nuestro componente solo renderiza una plantilla estática. Imaginemos que queremos mostrar una lista de usuarios y sus nombres.

ng generate component user-item

Para poder usarlo debemos añadir la nueva etiqueta <app-user-item></app-user-item> a "app.component.html".

Queremos usar el nuevo componente para mostrar un nombre de usuario, añadimos una nueva propiedad de tipo cadena (string) a la clase y la rellenamos en el constructor:

export class UserItemComponent implements OnInit {
  name: string; // <-- added name property

  constructor() {
    this.name = "Felipe"; // set the name
  }

  ngOnInit() {}
}

Ahora vamos a renderizar la plantilla mostrando el nombre, modificamos "user-item.component.html":

<p>Hello {{ name }}</p>

La sintaxis "{{}}" para definir una expresión se conoce como template tags o mustache tags.

Trabajando con arreglos

Queremos mostrar los nombres de una serie de usuarios, creamos un nuevo componente:

ng generate component user-list

Como en las anteriores ocasiones añadimos <app-user-list></app-user-list> a "app.component.html".

Modificamos la definición de la clase del nuevo componente, añadimos un array de cadenas como propiedad de la clase

export class UserListComponent implements OnInit {
  names: string[];

  constructor() {
    this.names = ["Ari", "Carlos", "Felipe", "Nate"];
  }

  ngOnInit() {}
}

Para recorrer el array usaremos un bucle for *ngFor en "user-list-component.html":

<ul>
  <li *ngFor="let name of names">Hello {{ name }}</li>
</ul>

Usando el UserItemComponent

En vez de declarar el array dentro de la clase UserListComponent usaremos la clase UserItemComponent como un elemento hijo.

Pasos:

  • Configurar la plantilla UserListComponent.
  • Configurar UserItemComponent para aceptar el nombre como una entrada.
  • Configurar UserItemComponent para que acepte el atributo nombre como entrada en el constructor.

Modificamos la plantilla "user-list.component.html", en cada iteración del bucle que recorre los elementos array carga el componente UserItemComponent.

<ul>
  <li *ngFor="let name of names">
    <app-user-item></app-user-item>
  </li>
</ul>

Ahora veremos esto en el navegador:

El bucle *ngFor recorre los elementos del array atributo de UserListComponent pero carga el componente <p>Hello {{ name }}</p> mostrando el atributo cadena de la clase UserItemComponent.

Debemos pasarle de alguna manera los datos de entrada al componente UserItemComponent, en Angular usamos el decorador @Input.

Aceptando entradas

Hasta el momento el constructor de la clase UserItemComponent fijaba el atributo con el nombre (this.name = "Felipe";), vamos a modificarlo para que acepte el valor de esta propiedad.

import {
  Component,
  OnInit,
  Input // <--- added this
} from "@angular/core";

@Component({
  selector: "app-user-item",
  templateUrl: "./user-item.component.html",
  styleUrls: ["./user-item.component.css"]
})
export class UserItemComponent implements OnInit {
  @Input() name: string; // <-- added Input annotation

  constructor() {
    // removed setting name
  }

  ngOnInit() {}
}

Pasando un valor de entrada

Para pasar valores a un componente usamos "[]" en la plantilla:

<ul>
  <li *ngFor="let individualUserName of names">
    <app-user-item [name]="individualUserName"></app-user-item>
  </li>
</ul>

Ejecución de la aplicación

Cuando ejecutamos ng serve sucede lo siguiente:

ng busca en "angular.json" el punto de entrada a la aplicación, en este caso es "main": "src/main.ts", este a su vez usa AppModule definido en [src/app/app.module.ts]:

El módulo app también se conoce como módulo raíz porque de él surgen las demás ramas que conforman una aplicación. La asignación de los nodos hijos se realiza en la propiedad imports:[], que es un array de punteros a otros módulos.

import { BrowserModule } from "@angular/platform-browser";
import { NgModule } from "@angular/core";

import { AppComponent } from "./app.component";
import { RedditCloneComponent } from "./reddit-clone/reddit-clone.component";
import { UserItemComponent } from "./user-item/user-item.component";
import { UserListComponent } from "./user-list/user-list.component";

@NgModule({
  declarations: [
    AppComponent,
    RedditCloneComponent,
    UserItemComponent,
    UserListComponent
  ],
  imports: [BrowserModule],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule {}

El decorador @NgModule añade los metatados de la clase AppModule que va después y está compuesto por cuatro claves:

  • declarations: Declara los componentes de nuestra aplicación. Cuando añadimos nuevos componentes con ng generate estos se añaden de forma automática a esta información.
  • imports: Describe las dependencias del módulo. Como estamos creando una aplicación Web usamos BrowserModule.
  • providers: Se usa para inyección de dependencias.
  • bootstrap: Le dice a Angular que componente usa para arrancar la aplicación, AppComponent es el componente de nivel superior.

Expandiendo nuestra aplicación

Ahora vamos a crear la aplicación final, esta compuesta por dos componentes lógicos.

  1. La aplicación completa que contiene el formulario para dar de alta nuevos artículos (en una versión más avanzada lo lógico sería que el formulario tuviese su propio componente).
  2. Cada uno de los artículos en una lista.

Creamos una nueva aplicación:

ng new angular-reddit

Empezamos como siempre por [angular-reddit/src/app/app.component.ts]:

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

@Component({
  selector: "app-root",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"]
})
export class AppComponent {
  //title = 'angular-reddit'; // <--- Comentado
}

Comentamos el título.

Editamos [angular-reddit/src/app/app.component.html]:

<form>
  <h3>Add a Link</h3>

  <div>
    <label for="title">Title:</label>
    <input name="title" id="title" />
  </div>

  <div>
    <label for="link">Link:</label>
    <input name="link" id="link" />
  </div>
</form>

Hemos creado el formulario para introducir el título y el enlace de un artículo.

Añadiendo acción al formulario

Añadimos un botón para procesar el formulario de arriba:

<button (click)="addArticle(newtitle, newlink)">Submit link</button>

Ahora cuando se pulse el botón ejecuta una función addArticle que debemos definir en el componente, entonces añadimos a la clase AppComponent [angular-reddit/src/app/app.component.ts]:

  addArticle(title: HTMLInputElement, link: HTMLInputElement): boolean {
    console.log(`Adding article title: ${title.value} and link: ${link.value}`);
    return false;
  }

Las funciones en TypeScript tienen algunas cosas peculiares si venimos de otros lenguajes de programación, el tipo de dato retornado (en este caso boolean) se define después de la cabecera de la función. Ahora mismo si lo probamos no le estamos pasando nada, necesitamos asociar en la plantilla los campos de edición con la llamada a la función.

<form>
  <h3>Add a Link</h3>

  <div>
    <label for="title">Title:</label>
    <!-- Cambio #newtitle -->
    <input name="title" id="title" #newtitle />
  </div>

  <div>
    <label for="link">Link:</label>
    <!-- Cambio #newlink -->
    <input name="link" id="link" #newlink />
  </div>

  <button (click)="addArticle(newtitle, newlink)">Submit link</button>
</form>

Hemos introducido #newtitle con "#" (hash) por delante para crear una referencia a un elemento de la plantilla y pasarlas como parámetro a la función.

newtitle es ahora un objeto que representa el elemento DOM de la etiqueta input, de tipo HTMLInputElement concretamente, como es un objeto podemos acceder a su valor usando newtitle.value.

Ejemplo en acción:

Añadiendo el componente con el artículo

Vamos a crear un componte para mostrar cada uno de los artículos dados de alta.

ng generate component article

Editamos [angular-reddit/src/app/article/article.component.html]:

<div>
  <div>
    <div>{{ votes }}</div>
    <div>Points</div>
  </div>
</div>

<div>
  <a href="{{ link }}">{{ title }}</a>
  <ul>
    <li>
      <a href (click)="voteUp()"> <i class="arrow up icon"></i>upvote </a>
    </li>

    <li>
      <a href (click)="voteDown()"> <i class="arrow down icon"></i>downvote</a>
    </li>
  </ul>
</div>

Ahora editamos el componente [angular-reddit/src/app/article/article.component.ts]:

  constructor() {
    this.title = "Angular";
    this.link = "http://angular.io";
    this.votes = 10;
  }

  voteUp() {
    this.votes += 1;
  }

  voteDown() {
    this.votes -= 1;
  }

Hemos definido tres atributos para la clase ArticleComponent, el título, el enlace y el número total de votos. También hemos definido dos funciones miembro para sumar o restar votos. Hemos inicializado los atributos de la clase con algunos valores por defecto. Ahora ya podemos ver el formulario el acción y como funciona el data-binding, si sumamos votos o los restamos inmediatamente se visualiza en pantalla.

Ahora cuando pinchamos sobre los enlaces provocan que la página se vuelva a cargar, JS por defecto propaga el evento click a los componentes padre, eso provoca que nuestro navegador trate de seguir un enlace vacío (<a href (click)="voteUp()">) que le dice al navegador que recargue la página.

Para evitar este efecto tan poco estético necesitamos decirle al manejador del evento que retorn false.

  voteUp(): boolean {
    this.votes += 1;
    console.log("voteUp, total:" + this.votes);
    return false;
  }

Renderizando una serie de artículos

Hasta el momento sólo tenemos un artículo y no hay forma de mostrar más excepto que dupliquemos la etiqueta <app-article>.

Creando una clase para los artículos

Una buena práctica de programación en Angular es tratar de aislar las estructuras de datos de los componentes. Para ello vamos a crear una clase Article que represente un artículo. Añadimos un nuevo archivo [angular-reddit/src/app/article/article.model.ts]

export class Article {
  title: string;
  link: string;
  votes: number;

  constructor(title: string, link: string, votes?: number) {
    this.title = title;
    this.link = link;
    this.votes = votes || 0;
  }
}

Acabamos de crear una clase que representa un artículo, es una clase "plana" y no un componente (el equivalente el modelo en MVC).

El carácter "?" indica que el parámetro votes es opcional (sería 0 en caso de no pasarlo).

Ahora debemos actualizar el componente ArticleComponent para usar nuestra nueva clase Article, en [angular-reddit/src/app/article/article.component.ts] importamos primero la clase.

import { Article } from "./article.model";

Ahora ya podemos usarla:

export class ArticleComponent implements OnInit {
  article: Article;

  constructor() {
    this.article = new Article("Angular", "http://angular.io", 10);
  }

  voteUp(): boolean {
    this.article.votes += 1;
    return false;
  }

  voteDown(): boolean {
    this.article.votes -= 1;
    return false;
  }

  ngOnInit() {}
}

Ahora debemos modificar la plantilla para reflejar los cambios:

<div>
  <div>
    <div>{{ article.votes }}</div>
    <div>Points</div>
  </div>
</div>

<div>
  <a href="{{ article.link }}">{{ article.title }}</a>
  <ul>
    <li>
      <a href (click)="voteUp()"><i class="arrow up icon"></i>upvote</a>
    </li>

    <li>
      <a href (click)="voteDown()"><i class="arrow down icon"></i>downvote</a>
    </li>
  </ul>
</div>

Las funciones voteUp y voteDown contravienen las normas de la Ley de Demeter que (o principio de menor conocimiento), en programación orientada a objetos dice que un objeto debería asumir lo menor posible sobre las propiedades de otro objeto o su estructura. Definimos estas mismas funciones en la clase Article:

export class Article {
  title: string;
  link: string;
  votes: number;

  constructor(title: string, link: string, votes?: number) {
    this.title = title;
    this.link = link;
    this.votes = votes || 0;
  }

  voteUp(): void {
    this.votes += 1;
  }

  voteDown(): void {
    this.votes -= 1;
  }
}

Ahora desde las funciones voteUp y voteDown de ArticleComponent llamamos a las funciones correspondientes de la clase Article, por ejemplo this.article.voteUp();.

Almacenando más de un artículo

Código fuente

  • angular / src / 01-intro / reddit-clone. Primera parte del artículo.

Enlaces externos