SPA en Angular

De castillowiki
Ir a la navegación Ir a la búsqueda

Aquest és un tutorial per a fer una Single Page Application en Angular. No explica en detall la teoria d'Angular.

Les SPA són pàgines web que, una vegada carregades, no es refresquen ni es carreguen completament més. Utilitza rutes en la URI del navegador, però aquestes no són enviades d'aquesta manera al servidor, ja que és el client web, en Javascript, qui demana fitxers en JSON o XML i modifica el contingut de la web. Aquestes pàgines presenten alguns avantatges i alguns inconvenients com el SEO o la necessitat de Javascript per a funcionar. Anirem veient en més detall el que significa al fer-la.

El codi resultant estarà en: https://github.com/xxjcaxx/dwec-2020-2021/tree/master/holamonAngular/tenda

Instal·lació

Anem a instal·lar l'entorn de desenvolupament per a Angular. Algunes coses cal explicar-les i altres ja les pots instal·lar com vulgues. També suposarem que tenim Ubuntu escriptori per a desenvolupar i Ubuntu server en cas d'enviar a producció. Per a moltes coses hi ha varies opcions, però per simplificar vaig a mencionar quasi sempre sols una.

  • node: Per a fer anar les ferramentes de terminal d'angular i altres coses, és necessari instal·lar node. En ubuntu:
sudo apt install nodejs
  • npm: El gestor de paquets npm també és necessari:
sudo apt install npm
  • Typescript: Angular funciona en Typescript. L'instal·larem de manera global (-g) per a que funcione en tots els projectes:
sudo npm install -g typescript
  • Angular CLI https://cli.angular.io/ Les ferramentes de terminal d'Angular són necessàries per a treballar més còmodament.
sudo npm install -g @angular/cli
  • Editor de text: Visual Studio Code. Es recomana descarregar el .deb de la web oficial.
    • Extensions:
    • Angular 2 TypeScript Emmet: Permet en zen-coding. Això vol dir que autocompleta codi amb unes instruccions determinades i adaptar en aquest cas a Angular.
    • Angular v5 Snippets: Fragments útils de codi ja predefinit que es poden reutilitzar en el projecte.
    • Angular Language Service: Serveix per a crear més fàcilment les plantilles d'Angular.
    • Material icon Theme: Modifica les icones per a trobar més fàcilment els fitxers.
    • Terminal: Encara que es pot utilitzar la terminal d'Ubuntu, aquesta està en la mateixa finestra i és còmoda.

Creació del projecte

En una terminal, naveguem fins al directori del projecte i executem

ng new tenda

El nom del l'aplicació és tenda

Això ha generat molts fitxers i directoris que anirem descrivint quan els necessitem.

Si volem veure el que ha passat, podem llançar ja el servidor amb

ng serve -o

En aquest comandament observem el que estem indicant: ng és el comandament del Angular/CLI. Com hem vist, serveix tant per a llançar una nova aplicació com per a iniciar el servidor. Amb serve li diguem que cree un servidor web per a aquest directori i en -o per a que òbriga el navegador web en el port obert pel servidor.

Instal·lar Bootstrap

Podem copiar el CDN, instal·lar amb npm o descarregar i descomprimir el directori. Anem a instal·lar-lo de forma local amb npm per a que estiga integrat en el projecte.

npm install bootstrap@next --save

Utilitzem --save per a que clave la dependència en el packaje.json

Aquesta versió serà al menys la 5 i no necessita jquery per a funcionar.

Cal modificar aquestes línies en angular.json:

 "styles": [
              "node_modules/bootstrap/scss/bootstrap.scss",
              "src/styles.css"
            ],
            "scripts": ["node_modules/bootstrap/dist/js/bootstrap.js"]

Creació del navbar

Per anar organitzant bé el projecte a l'inici, anem a crear una estructura de directoris. El navbar serà un component compartit per totes les pàgines del projecte. Per això, podem fer un directori components en app i dins un anomenat shared.

cd tenda
mkdir -p src/app/components/shared

Açò no és precís, però sempre podrem trobar millor els components si tenim el codi organitzat.

Ara sí, anem a crear el component navbar:

ng g c components/shared/navbar

Aquest comandament utilitza abreviatures per dir que volem crear un nou component i el directori on es crearà dins del directori 'app.

Dins del directori aquest, el comandament ng ha creat alguns fitxers de .ts, .css i .html. El que ens interessa ara és ficar una plantilla html amb una barra de navegació de manera que es puga afegir a la web. Sols en interessa el html i el ts, ja que no anem a fer tests ni estils propis del component. Si volem, podem inclús esborrar els fitxers que no interessen.

Una altra cosa que ha passat és que en el fitxer app.module.ts que representa al mòdul principal de l'aplicació, s'ha afegir un import del navbar. Amés, s'ha afegit a declarations del decorador de la classe. D'aquesta manera, Angular ja sap que existeix aquest component i es pot utilitzar per tota l'aplicació.

El que anem a fer és aprofitar el navbar que ens dona Bootstrap i pegar-lo en l'arxiu navbar.component.html En aquest moment es poden llevar les elements html no necessaris del navbar per defecte i modificar el nom de les coses que es queden. Una cosa interessant que ja podem fer en la part visual és guardar una icona en assets i ficar-la com a logo del .navbar-brand:

   <a class="navbar-brand" href="#"><img src="assets/images/logo.png" alt="" width="30" height="24" class="d-inline-block align-top">Tenda</a>

Si observem el fitxer navbar.component.ts veurem que els selector és app-navbar. Això és l'etiqueta que podem utilitzar en l'HTML per a cridar a aquest component. Podem anar al fitxer app.component.html que és el principal de l'aplicació i canviar tot l'HTML generat per Angular automàticament per:

  <app-navbar></app-navbar>

Creació de la pàgina home

Podem crear el component per a la pàgina inicial o home amb:

ng g c components/home

Com en el cas del navbar, sols ens interessa el fitxer html i .ts. En el html podem fer una pàgina de benvinguda. Per exemple, podem utilitzar el carousel de bootstrap.

Ara podem afegir el selector, que serà <app-home> en el fitxer app.component.html a continuació del navbar.

Creació de la pàgina catalogue

Estem fent una especie de tenda i necessitarem una llista de productes. Amés, això servirà per a practicar amb funcionalitats d'Angular:

ng g c components/catalogue

De moment la deixarem buida o amb algun html estàtic d'exemple.

Gestió de les rutes

Com ja sabem, estem fent una SPA i les rutes seran gestionades pel client. Les rutes ens permeten anar als diferents components on 'pàgines'.

Si al crear el mòdul hem dit que sí que volem instal·lar les rutes Angular, tindrem un fitxer anomenat app-routing.module.ts. El contingut d'aquest fitxer serà paregut a:

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

const routes: Routes = [];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

Amb aquesta estructura inicial podem anar treballant.

Les rutes de l'array routes són objectes amb atributs path i component. Per començar, poden crear la ruta al home i la ruta per defecte en cas de no trobar la que diu la URI:

const routes: Routes = [
 { path: 'home', component: HomeComponent},
 { path: '**', pathMatch: 'full', redirectTo: 'home'}
];

Les rutes funcionen com passa en els components perquè la classe de la ruta està exportada i importada en app.module.ts:

...
import { AppRoutingModule } from './app-routing.module';
...
imports: [
    BrowserModule,
    AppRoutingModule
  ],
...

Com es veu, va en la part de imports del decorador. Va ací perquè és un mòdul i els components d'aquest mòdul seran accessibles per el mòdul actual. El fet de fer un mòdul, exportar e importar el mòdul en el mòdul principal és una manera de separar el codi, però pot estar tot en el fitxer del mòdul principal.

Per a que les rutes funcionen, cal un lloc on renderitzar-les. El que cal fer és crear l'element <router-outlet> a la plantilla app.component.html:

<app-navbar></app-navbar>
<div class="container-fluid">
  <router-outlet></router-outlet>
</div>

En aquest moment no funcionen encara les rutes perquè els menús encara no fan res i perquè sols tinguem definida la ruta de home. Podem afegir una altra ruta i provar-la modificant manualment la URI:

const routes: Routes = [
 { path: 'home', component: HomeComponent},
 { path: 'cataloge', component: CatalogueComponent},
 { path: '**', pathMatch: 'full', redirectTo: 'home'}
];

Menús a routes

Anem a modificar el navbar per afegir les distintes pàgines al menú. Per a que funcionen, cal utilitzar [routerLink]:

        <li class="nav-item">
          <a class="nav-link active" aria-current="page" [routerLink]="['home']">Home</a>
        </li>
        <li class="nav-item">
          <a class="nav-link" [routerLink]="['catalogue']">Cataloge</a>
        </li>

El routerLink accepta un array amb les parts de la ruta. En aquest cas sols és un nivell.

Una altra modificació que podem fer és que la classe active estiga en la que realment està activa. Això ho farem en routerLinkActive:

 <li class="nav-item">
          <a class="nav-link" aria-current="page" 
          [routerLink]="['home']"
          [routerLinkActive]="['active']"
          >Home</a>
        </li>
        <li class="nav-item">
          <a class="nav-link" [routerLink]="['catalogue']" 
          [routerLinkActive]="['active']">catalogue</a>
        </li>

Omplir el catàleg

En aquest projecte hem creat l'estructura de pàgines i rutes abans de clavar contingut en la web. No obstant, es pot fer en distint ordre.

Per a mostrar els productes de la tenda, anem a utilitzar una plantilla de Boostrap molt típica: les cards. En concret, anem a organitzar les cards en un grid. Busquem en la web de Bootstrap en que ens interessa i creem un principi de plantilla en catalogue.component.html:

<h1>Catalogue</h1>
<div class="row row-cols-1 row-cols-md-2 g-4">
  <div class="col">
    <div class="card">
      <img src="..." class="card-img-top" alt="...">
      <div class="card-body">
        <h5 class="card-title">Card title</h5>
        <p class="card-text">This is a longer card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.</p>
      </div>
    </div>
  </div>
</div>

Està clar que açò cal omplir-ho en contingut obtingut d'un servidor. Com que no ens interessa de moment en backend, el que anem a fer és crear uns JSON amb dades.

Servicis

Per poder mostrar alguna cosa en el catàleg, necessitem mantindre les dades i donar-les al component per a que les renderitze.

Anem a crear un directori anomenant, per exemple, services dins del directori app.

ng g service services/products

Aquest comandament crea un fitxer products.service.ts amb aquest contingut inicial:

import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class ProductsService {

  constructor() { }
}

Com es veu, necessitem importar Injectable i utilitzar-lo com a decorador de la classe. Això fa que Angular puga proveir un objecte d'aquesta classe quan qualsevol component el necessite.

A continuació, cal anar al app.module.ts i afegir l'importació i clavar el ProductsService com a providers

...
import { AppRoutingModule } from './app-routing.module';
...
 providers: [ProductsService],
...

Anem a fer açò pas a pas, per tant, primer anem a provar si funciona el servici fent que retorne unes dades dirèctament i més endavant farem que les arreplegue del servidor. Per a fer això, podem fer una funció que retorne un array d'objectes (en la classe ProductsService dins de products.service.ts):

getProducts(): any[]{
    return [
      {id: 1, name: 'PC', price: 200}, {id: 2, name: 'mac', price: 300}
    ]
  }

Com es veu, Typescript necessita saber de quin tipus són les dades retornades, però com no hem creat interfaces ni res, podem dir que és un array de any[], açò no està bé i serà corregit més endavant, però ara ens interessa que funcione el prototip.

Aquest servici serà consultat per el component catalogue. Cal, per tant, en catalogue.component.ts, importar el servici i afegir aquest atribut al constructor de la classe CatalogueComponent:

  constructor( private productsService: ProductsService) {}

Observem el codi anterior, al constructor de la classe li passem un argument privat de tipus ProductService. Necessita eixe servici, però en compte de cridar al constructor del servici dins del constructor del component, està com a dependència. Angular ja s'encarregarà de crear-lo al ser un Injectable segons aquest patró de disseny. Amés, al ser declarat en la part de paràmetres, Typescript ja el crea sense la necessitat de fer-ho dins de la funció constructora.

Ara falta que el component cride al servici quan el necessite. El necessitarà cada vegada que siga iniciat. Per això cal utilitzar ngOnInit:

  products: any[] = [];

  ngOnInit(): void {
    this.products =  this.productsService.getProducts();
  }

El que tenim que fer a continuació és modificar la plantilla per a que puga mostrar aquestes dades:

<div class="card" *ngFor="let p of products">
      <img src="..." class="card-img-top" alt="...">
      <div class="card-body">
        <h5 class="card-title">{{p.name}}</h5>
        <p class="card-text">{{p.price}}</p>
      </div>

Evidentment ací falten moltes coses, però ja deuria funcionar. Ara el que falta és agafar aquestes dades del servidor i amb alguna imatge.

Interfaces

Les interfaces són més cosa de Typescript que d'Angular, però és important centralitzar-les per que siguen més fàcilment gestionables. Amés, l'aplicació va fent-se cada vegada més gran, així que cal organitzar-se un poc. De moment sols anem a fer un directori per gestionar els productes en el que clavar les interfaces que utilitzarem en l'aplicació.

ng g interface product/product

Crea l'interficie que ja es pot utilitzar tant el servici com en el component del catàleg.

Detalls dels productes

Anem a fer que al fer clic en un botó del producte es puga entrar a veure el seu detall. Per a fer-ho necessitem modificar coses en les rutes, servicis i components.

El primer és ficar un botó dins de cada card:

<a [routerLink]="['/product',p.id]" class="btn btn-outline-primary">Detalls</a>

Aquest botó fa referència a una ruta que no existeix que apuntarà a un component que encara no està definit. Per això cal fer primer el component. Com hem dit abans, l'aplicació està cada vegada més gran i cal organitzar-la d'alguna manera. El component del detall de producte el crearem en el directori components però de dins del directori product, on està ja el fitxer de l'interficie:

ng g component product/productDetail

També tenim que afegir la ruta en app-routing.module.ts:

 { path: 'product/:id', component: ProductDetailComponent}

Modificarem el html del component per a mostrar els detalls del producte, ja que de moment funciona però no mostra res:

<h1>{{product.name}}</h1>
<div class="row ">
  <div class="col-md-4">

      <img src="..." class="card-img-top" alt="...">
      <p>{{product.price}}</p>

       <a href="cataloge" class="btn btn-outline-primary">Tornar</a>
      </div>
    </div>

Amés, necessitem obtindre la informació que mostrar, ho farem en un ActivatedRoute. Així quedarà una primera versió del product-detail.component.ts:

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';

@Component({
  selector: 'app-product-detail',
  templateUrl: './product-detail.component.html',
})
export class ProductDetailComponent implements OnInit {

  constructor( private activatedRoute: ActivatedRoute) { }

  ngOnInit(): void {
    this.activatedRoute.params.subscribe( params => { console.log(params)})
  }
}

El problema és que el servici sols està fet per a mostrar tots els productes, ara cal modificar-lo per a mostrar un sols del productes:

import { Injectable } from '@angular/core';
import { Product } from '../product/product';

@Injectable({
  providedIn: 'root'
})
export class ProductsService {
  productList: Product[] = [{id: 1, name: 'PC', price: 200}, {id: 2, name: 'mac', price: 300}]

  constructor() { }
  getProducts(): Product[]{
    return this.productList;
  }
  getProduct(id: number){
   // return this.productList[id]; // no funcionarà bé, ja que id no és index de l'array.
    return this.productList.find(p => p.id == id);
  }
}

Com es pot veure, hi ha una línia que diu que no funcionarà bé. En aquest cas hem optat per buscar amb la funció find() en l'array en un criteri determinat. Si volem utilitzar l'index de l'array es pot afegir un codi al catàleg per a poder utilitzar aquest índex com a variable i poder passar-la al botó:

<h1>Catalogue</h1>
<div class="row row-cols-1 row-cols-md-2 g-4">
  <div class="col">
    <div class="card" *ngFor="let p of products; let i = index ">
      <img src="..." class="card-img-top" alt="...">
      <div class="card-body">
        <h5 class="card-title">{{p.name}}</h5>
        <p class="card-text">{{p.price}}</p>
        <button class="btn btn-outline-primary" (click)="detailsProduct(i)">Detalls</button>
      </div>
    </div>
  </div>
</div>

Però no és necessari i es pot considerar més consistent utilitzar el 'id' en compte de l'index en l'array, que pot canviar. Així que el deixarem com està.

Separar els productes en components niuats

De moment res és massa complicat, però deguem anar separant el codi. El catàleg de productes de moment s'encarrega del propi catàleg i de cadascun dels productes. És millor fer un component per a cada producte, de manera que es puga reutilitzar aquest component per a altres catàlegs, carrets de la compra o similars. Anem a fer un component per a cada producte i el farem ja al directori product que hem fet per als detalls:

ng g component product/product-item

En ell copiarem el fragment d'HTML que necessitem que estava en el catàleg:

<div class="card">
  <img src="..." class="card-img-top" alt="...">
  <div class="card-body">
    <h5 class="card-title">{{p.name}}</h5>
    <p class="card-text">{{p.price}}</p>
    <button class="btn btn-outline-primary" (click)="detailsProduct(p.id)">Detalls</button>
   <!--<a [routerLink]="['/product',p.id]" class="btn btn-outline-primary">Detalls</a> -->
  </div>
</div>

Ara en el catàleg sols tenim que deixar el selector del product-item:

<h1>Catalogue</h1>
<div class="row row-cols-1 row-cols-md-2 g-4">
  <div class="col">
    <app-product-item *ngFor="let product of products"></app-product-item>
  </div>
</div>

Encara falla perquè el product-item no té res en codi del component per a mostrar les dades necessàries. Anem a fer-ho amb unes dades fixes que més endavant obtindrem del pare:

import { Component, OnInit } from '@angular/core';
import { Product } from '../product';
import { Router } from '@angular/router';

@Component({
  selector: 'app-product-item',
  templateUrl: './product-item.component.html',
})
export class ProductItemComponent implements OnInit {
  p: Product = { id: 1, name: 'PC', price: 300 }
  constructor( private router: Router) { }
  ngOnInit(): void {
  }
  detailsProduct(id: number): void{
    this.router.navigate(['/product', id]);
  }
}

Com es pot veure, hem aprofitat la funció per a anars als detalls del producte i hemtingut que importar el Router i injectar-lo en el constructor del component.

Ara el que falta és comunicar el component pare (catàleg) i el fill (product-item) per a que el fill tinga la informació del pare. Per a que funcione, cal modificar l'atribut p del product-item per a que en compte de tindre una informació fixa, tinga el decorador @input():

...
 @Input() p: Product; // = { id: 1, name: 'PC', price: 300 }
...

I també modificarem el catàleg per a que li passe la informació en el selector:

<h1>Catalogue</h1>
<div class="row row-cols-1 row-cols-md-2 g-4">
  <div class="col">
    <app-product-item
    [p]="product"
    *ngFor="let product of products">
    </app-product-item>
  </div>
</div>

Obtindre les dades del servidor

De moment hem mostrat dos productes en un array literal dins del servici que ha de oferir les dades. El més normal és que les dades estiguen en un servidor. Com que estem fent un SPA, no carregarem al servidor de la feina de construir l'HTML. El servidor sols consultarà la base de dades i retornarà un JSON amb la informació mínima per a que el client (Angular) la transforme en HTML.

Com que no ens interessa de moment el que fa el servidor, sols anem a utilitzar un JSON estàtic que Angular demanarà per AJAX.

El primer que tenim que fer és importar el HttpClientModule. Com que és un mòdul, anirà en imports:

...
import { HttpClientModule } from '@angular/common/http';
...
...
  imports: [
    BrowserModule,
    AppRoutingModule,
    HttpClientModule,
  ],
...
export class AppModule { }

A continuació, cal modificar la manera en la que el servici demana les dades:

import { Injectable } from '@angular/core';
import { Product } from '../product/product';
import { HttpClient} from '@angular/common/http';
import { Observable } from 'rxjs';
import {map,filter} from 'rxjs/operators'


@Injectable({
  providedIn: 'root'
})
export class ProductsService {
  productList: Product[] = [{id: 1, name: 'PC', price: 200}, {id: 2, name: 'mac', price: 300}]
  private productURL = './assets/productes.json';

  constructor(private http: HttpClient) { }
  getProducts(): Observable<Product[]>{   // retorna un observable al que cal subscriure's
    return this.http.get<{products: Product[]}>(this.productURL).pipe( // get retorna un observable i pipe accepta funcions de manipulació de les dades
      map(response => response.products) // de la resposta traguem l'array que ens interessa
      );
  }
  getProduct(id: number){
    return this.http.get<{products: Product[]}>(this.productURL).pipe(
      map(response => response.products.filter(p => p.id == id)[0]), // de la resposta sols traguem el producte amb el mateix id
      );
  }
}

Tant en el catàleg com en els detalls dels productes cal modificar la cridada al servici amb una subscripció a l'observable que retorna ara.

  ngOnInit(): void {
    this.productsService.getProducts().subscribe(
      prods => this.products = prods, // Success function
      error => console.error(error), // Error function (optional)
      () => console.log('Products loaded') // Finally function (optional)
      );
  }
 ngOnInit(): void {
    this.activatedRoute.params.subscribe( params => {
    this.productsService.getProduct(params.id).subscribe(p=> this.product = p);
    });
  }

Millora en la visualització dels productes

Ja que estem, anem a ficar ja les dades més completes. Per això, hem de modificar l'interfície dels productes per a acceptar descriptions. També crearem un JSON amb varis productes, imatges i descripcions llargues. Les imatges les guardarem en el directori assets, on també està el JSON. Com que pot ser molt llarg, ací tenim un exemple de cóm pot quedar en un producte:

 {"id": 1, "name": "PC", "price": 300, "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum"},

En aquest moment ja comencem a gaudir dels avantatges de treballar en un framework i amb un projecte ben estructurat. Tan sols tenim que afegir Plantilla:Product.description a la plantilla HTML i ja es pot veure.

En el component product-detail no hi ha cap problema en posar una descripció llarga. El problema està en el catàleg, ja que cada producte queda massa llarg. Per això anem a aprofitar una ferramenta del framework, les Pipes que transformen les dades abans de ser mostrades. Angular ja té una per a reduir la quantitat de caràcters a mostrar. Utilitzarem el Pipe slice:

<p class="card-text">{{p.description | slice:0:50}}</p>
En manuals antics trobarem limitTo com a Pipe que fa el que hem fet, però ha quedat obsolet. Ara s'utilitza slice

Quan tenim molts productes tenim que trobar la manera de poder buscar per ells. Anem a crear un buscador. En concret, anem a experimentar dos maneres de fer el buscardor. La primera serà en una Pipe personalitzada que modificarà la llista de productes per filtrar el que s'han de mostrar. La segona serà més permanent, ja que modificarem el servici per a que ens retorne sols els productes que passen el filtre.

Filtrar en Pipes:

El que cal fer és una Pipe personalitzada, un input en el mateix component del catàleg i un [(ngModel)] en el input per filtrar en temps real.

ng g pipe pipes/product-filter

Dins de product-filter.pipe.ts:

import { Pipe, PipeTransform } from '@angular/core';
import { Product } from '../product/product';
@Pipe({
  name: 'productFilter'
})
export class ProductFilterPipe implements PipeTransform { //al implementar PipeTransform cal fer la funció Transform

  transform(products: Product[], filterBy: string): Product[] { // el primer argument és el que cal filtrar i després una llista d'arguments
  // en aquest cas sols és un, el criteri de búsqueda
    const filter = filterBy ? filterBy.toLocaleLowerCase() : null; // passem el filtre a minúscules o a null si no està
    return filter ?  // Si no és null filtra
    products.filter(p => p.name.toLocaleLowerCase().includes(filter))
    : products; // si és null trau l'array sense filtre
  }
}

Ara en la plantilla HTML cal afegir aquest input per poder buscar. En ngModel associem l'input bidireccionalment amb una variable:

<input type="text" name="searchProduct" id="searchProduct" [(ngModel)]="searchProduct">

Creem eixa variable en el component, però per a que funcione ngModel cal afegir a app.module.ts FormsModule en imports:

...
imports: [
    BrowserModule,
    AppRoutingModule,
    HttpClientModule,
    FormsModule,  // Per a que funcione ngModel
  ],
...

Filtrar en el Servici:

Per a que tot funcione necessitem modificar el navbar que conté el formulari de cerca:

   <form class="d-flex">
        <input class="form-control me-2" type="search" placeholder="Buscar Producte" aria-label="Search" (keyup.enter)="buscarProducte(buscarTexto.value)" #buscarTexto>
        <button class="btn btn-outline-success" type="button" (click)="buscarProducte(buscarTexto.value)">Buscar</button>
      </form>

Mirem que hem afegit l'esdeveniment keyup.enter (polsar Enter al teclat). El manejador de l'esdeveniment és la funció buscarTexto a la que li passem l'identificador de l'input que hem ficar a continuació: #buscarTexto. Falta crear la funció en el component:

import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';

@Component({
  selector: 'app-navbar',
  templateUrl: './navbar.component.html',
})
export class NavbarComponent implements OnInit {
  constructor( private router: Router) { }
  ngOnInit(): void {
  }
  buscarProducte( criteri: string): void{
    //console.log('navbar',{criteri});
    this.router.navigate(['/cataloge', criteri]);
  }
}

Això farà que es cree una ruta per al catàleg on estarà el criteri de cerca. Per tant, cal atendre aquesta ruta. Per això cal modificar el mòdul de rutes afegint una línia:

...
const routes: Routes = [
 { path: 'home', component: HomeComponent},
 { path: 'cataloge', component: CatalogeComponent},
 { path: 'cataloge/:criterio', component: CatalogeComponent}, // <------- Aquesta línia
 { path: 'product/:id', component: ProductDetailComponent},
 { path: '**', pathMatch: 'full', redirectTo: 'home'}
];
...

I sobretot, el més important, el .ts del catàleg per a acceptar eixa ruta o la de tots els productes, quedant de la següent manera:

import { Component, OnInit } from '@angular/core';
import { ProductsService } from '../../services/products.service';
import { Product } from '../../product/product';
import { ActivatedRoute, Router } from '@angular/router';

@Component({
  selector: 'app-cataloge',
  templateUrl: './cataloge.component.html',
})
export class CatalogeComponent implements OnInit {

  constructor(private activatedRoute: ActivatedRoute,  // <---- Ara és una ruta activa
              private productsService: ProductsService,
    // private router: Router
    // el router ja no fa falta perquè està en el component product-item
  ) { }
  products: Product[] = [];
  searchProduct: string;

  ngOnInit(): void {
    this.activatedRoute.params.subscribe(params => {    // <--- Ens subscrivim a la ruta
      if (params.criterio) {                            // <--- Si hi ha criteri de búsqueda cridem a aquesta funció del servici
        this.productsService.searchProducts(params.criterio).subscribe(
          prods => this.products = prods, // Success function
          error => console.error(error), // Error function (optional)
          () => console.log('Products loaded', this.products) // Finally function (optional)
        );
      }
      else {                                           // <--- Si no hi ha criteri, cridem a la que trau tots.
        this.productsService.getProducts().subscribe(
          prods => this.products = prods, // Success function
          error => console.error(error), // Error function (optional)
          () => console.log('Products loaded', this.products) // Finally function (optional)
        );
      }



    });

    /* this.productsService.getProducts().subscribe(
       prods => this.products = prods, // Success function
       error => console.error(error), // Error function (optional)
       () => console.log('Products loaded') // Finally function (optional)
       );*/
  }


}

Per últim cal implementar la búsqueda en el servici, que serà pareguda a la que teniem en la Pipe:

  searchProducts(criteri: string): Observable<Product[]>{
    criteri = criteri.toLowerCase();
    console.log({criteri});
    return this.http.get<{products: Product[]}>(this.productURL).pipe(
      map(response => response.products.filter(p => p.name.toLocaleLowerCase().includes(criteri))),
      );
  }

Puntuació dels productes

Anem a fer que el product-item, que rep les dades del catàleg, tinga un métode de puntuació. El primer que podem fer és afegir aquesta puntuació al json de dades estàtiques per a que mostre alguna cosa sempre. Es dirà ratting i cal afegir-ho a la interface també.

Al no tindre un servidor real, aquesta puntuació no serà guardada.

Com que ratting és un número del 0 al 5, anem a mostrar-ho amb estreles. Podem utilitzar el símbol unicode corresponent: ★☆ El que farem serà un *ngFor per mostrar 5 estreles i depen de la puntuació seran blanques o negres:

<p class="card-text">
     <span class="star" *ngFor="let i of [1,2,3,4,5]" (click)="puntuar(i)">
       {{ i <= p.ratting ? '★' : '☆' }}
     </span>
</p>

Mirem també que hi ha un esdeveniment al fer click que crida a puntuar amb la i corresponent.

Anem a fer que quan passem el ratolí per damunt es veja cóm canvien les estreles, però això no canvia res, el que val són les que té guardades. Per a fer-ho necessitarem una variable auxiliar i mètodes per a restablir les estreles correctes quan no estiga el ratolí damunt:

<p class="card-text" (mouseleave)="auxRatting=p.ratting">
     <span class="star" *ngFor="let i of [1,2,3,4,5]"
     (click)="puntuar(i)"
     (mouseenter)="auxRatting = i"
     >
       {{ i <= auxRatting ? '★' : '☆' }}
     </span>
    </p>

Ara cal puntuar, però com que les dades les te el component pare, cal modificar-lo. Per a fer això, tenim que emetre un esdeveniment personalitzat i amb @Output, dir quina informació volem passar al pare.

...
 auxRatting: number;
...
  @Output() rattingChanged = new EventEmitter<number>();
...
  ngOnInit(): void {
    this.auxRatting = this.p.ratting;
  }
...
  puntuar(i: number): void {
    this.rattingChanged.emit(this.auxRatting);
  }
...

I ara el pare arreplegarà aquesta informació i actualitzarà el producte:

  <app-product-item
    [p]="product"
    *ngFor="let product of products | productFilter:searchProduct"
    (rattingChanged)="changeRatting($event, product)">
    </app-product-item>
  changeRatting(stars: number, p: Product): void {
  p.ratting = stars;
  }


Autenticació i validació de rutes

Com que no estem treballant en un servidor és complicat que ens valide l'usuari, per tant, anem a simular això. Per a l'autenticació és necessari utilitzar guards. Anem a fer primer una prova simple de les guards que va a validar que es demana un id de producte correcte. Per a que funcione, tenim que crear un guard:

ng g guard product/guards/product-detail

Este guard mirarà si el que es demana a la ruta és numéric i major que 0:

import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree, Router } from '@angular/router';
import { Observable } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class ProductDetailGuard implements CanActivate {

  constructor(private router: Router){}

  canActivate(
    route: ActivatedRouteSnapshot,  //rep una activatedroute d'un moment determinat per ser avaluada
    state: RouterStateSnapshot): // representa l'estar del router en un determinat moment
    Observable<boolean | UrlTree>
    | Promise<boolean| UrlTree>
    | boolean
    | UrlTree { // Pot retornar qualsevol d'aquestes coses.
      // Esta és la part que valida
      const id = route.params.id; // Trau de la ruta activa el id
      if(isNaN(id) || id < 1){
        console.log('La id no funciona')
        return this.router.parseUrl('/cataloge'); // retorna un URLTree amb el catàleg per tornar
      }
      return true; // true deixa eixir
  }

}

Ara cal afegir-lo a les rutes:

const routes: Routes = [
...
 { path: 'product/:id',
 canActivate: [ProductDetailGuard],
 component: ProductDetailComponent},
...

També es pot impedir que l'usuari isca d'una ruta amb canDeactivate. Imaginem que podem modificar les dades del productes. No anem a implementar aquesta opció de moment, però sí a fer els components i les rutes necessàries:

 ng g component product/product-edit
De moment no cal implementar el formulari d'edició.

En el component del product-detail afegirem un botó per anar al component d'edició:

<button type="button" class="btn btn-success ml-4"(click)="edit()">Editar</button>
  edit() {this.router.navigate(['/product/edit', this.product.id]);  }

Ara tindrem que afegir aquesta ruta al router:

 { path: 'product/:id',
 canActivate: [ProductDetailGuard],
 component: ProductDetailComponent},
 { path: 'products/edit/:id', 
 canActivate: [ProductDetailGuard], 
 component: ProductEditComponent},

Com es veu, podem aprofitar el guard de validar els id numèrics.

A continuació fem el guard seleccionant CanDeactivate i l'omplim així:

export class LeavePageGuard implements CanDeactivate<ProductEditComponent> { // cal dir el component d'on eixir
  canDeactivate(
    component: ProductEditComponent,
    currentRoute: ActivatedRouteSnapshot,
    currentState: RouterStateSnapshot,
    nextState?: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
    return confirm('Segur que vols abandonar?');
  }

I la ruta quedarà així:

 { path: 'product/edit/:id',
 canActivate: [ProductDetailGuard],
 canDeactivate: [LeavePageGuard],
 component: ProductEditComponent},

Formulari de Login

El que hem fet és practicar amb Guards, i ara hi ha que juntar-ho amb el login. Anem a fer el component de login abans de tot:

$ ng g component components/login

Fem una ruta al component i un routerLink en el navbar a eixa ruta per a que funcione. A continuació anem a fer el formulari, que tindrà aquest HTML:

 <div class="row row-cols-1 row-cols-md-2 g-4">
  <div class="col">
<form class="form-signin">
  <img class="mb-4" src="/assets/images/store-logo.png" alt="" width="72" height="72">
  <h1 class="h3 mb-3 font-weight-normal">Please sign in</h1>
  <label for="inputEmail" class="sr-only">Email address</label>
  <input type="email" id="inputEmail" class="form-control"
  [(ngModel)] = "usuari.login"
  name="login"
  placeholder="Email address" required autofocus>
  <label for="inputPassword" class="sr-only">Password</label>
  <input type="password" id="inputPassword" class="form-control"
  [(ngModel)] = "usuari.passwd"
  name="passwd"
  placeholder="Password" required>
  <div class="checkbox mb-3">
    <label>
      <input type="checkbox" value="remember-me"> Remember me
    </label>
  </div>
  <button class="btn btn-lg btn-primary btn-block" (click)="login($event);">Sign in</button>
</form>
</div></div>

Com es veu, necessitem utilitzat [(ngModel)] per vincular bidireccionalment les variables del formulari. Anem a fer una interficie per als usuaris i la variable usuari serà d'aquest tipus:

$ ng g interface user/user
export interface User {
  login: string;
  passwd: string;
  name?: string;
  token?: string;
}
En una aplicació real el que en realitat autentica als usuaris és sempre el servidor. Nosaltres anem a simular que el servidor ens ha validat i ens envia un json amb les dades de validació. El client utilitza eixa validació per mostrar algunes coses o no, però és el servidor el que en última instància ens donarà les dades o permetrà modificar-les. En una aplicació que no siga SPA en la que el contingut està generat pel servidor, el client web pràcticament no s'ha de preocupar per l'autenticació.

Per simular que el servidor ens ha validat, anem a fer un json estàtic en un arxiu anomenat usuari.json en assets:

{"login": "antonio", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" }

El token és inventat, però en una aplicació real podria ser JWT o similar.

A continuació anem a fer el servici que simular que es connecta al servidor per demanar eixe json:

$ ng g service services/auth

I dins del auth.service.ts:

...
export class AuthService {

  constructor(private http: HttpClient) { }
  url = "/assets/usuari.json"

  authUser(usuario: User): Observable<User>{
    /*   ///////////// Mètode si fora un servidor real
    const authData = { ...usuario};
    return this.http.post(this.url,authData);
    */
   return this.http.get<User>(this.url);
  }
}

Si fora un servidor real enviariem l'usuari (login i passwd) per POST al servidor dins de l'objecte authData. això és el body del POST i cada servidor necessitarà eixes dades d'una manera distinta. En el nostre cas tenim un get que símplement trau les dades del json estàtic.

En el fitxer login.component.ts ens subscribim a aquest servici:

login($event) :void {
    $event.preventDefault();
  this.authService.authUser(this.usuari).subscribe(
    u => {
      console.log(u);
    },
    error => console.log(error),
    () => console.log('login')
  );
  }

Ja es veu que no fa res, sols trau per consola l'usuari. Ara cal guardar el token, que és el que realment indica que el server ha autenticat. Com que el token es guardarà en localstorage és millor gestionar-ho desde el servici:

authUser(usuario: User): Observable<User>{
    /*   ///////////// Mètode si fora un servidor real
    const authData = { ...usuario};
    return this.http.post(this.url,authData);
    */
   return this.http.get<User>(this.url).pipe(map( u => {
     this.guardarToken(u.token);
     return u;
  }));
  }
  private guardarToken(token:string): void{
    localStorage.setItem('token', token);
  }
  leerToken(){
    if (localStorage.getItem('token')){
      return localStorage.getItem('token');
    }
    else {
      return "";
    }
  }

El que fem és interceptar en map l'usuari abans de ser enviat al subcriptor del servici i guardar el token en localStorage. La funció de llegir del localstorage no l'utilitzem pel moment, però ja es queda ahí per a després.

Protegir rutes en Guard

Anem a fer un guard que no deixe entrar a determinades rutes si no estem autenticats. Per a començar, afegim aquesta simple funció al servici d'autenticació:

estaAutenticado(): boolean{
    if (this.leerToken.length > 2) return true;
    else return false
  }

Ara creem en guard:

$ ng g guard guards/auth

I dins del guard:

  constructor(private authService: AuthService){} 

  canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
    return this.authService.estaAutenticado();
  }

Ara cal utilitzar el guard quan es necessita. En aquest cas anem a protegit el catàleg:

 { path: 'cataloge',
 canActivate: [AuthGuard],
 component: CatalogeComponent},

Recordar a l'usuari

Els formularis de Login solen tindre l'opció de recordar. En realitat l'únic que fan és guardar en localStorage el login de l'usuari. Anem a modificar el component i el servici per a que ho guarde.

    <input type="checkbox" name="recordar" value="remember-me" [(ngModel)]="recordar"> Remember me
...
recordar: Boolean = false;
ngOnInit(): void {
    if( localStorage.getItem('login')) {
      this.usuari.login =  localStorage.getItem('login');
      this.recordar = true;
    }
  }
...
this.authService.authUser(this.usuari, this.recordar).subscribe(...
...
 authUser(usuario: User, recordar: Boolean): Observable<User>{
   recordar ? localStorage.setItem('login',usuario.login): localStorage.removeItem('login');
   return this.http.get<User>(this.url).pipe(map( u => {
     this.guardarToken(u.token);
     return u;
  }));
  }

Logout

Per a eixir de sessió cal cridar també al servici d'autenticació per a que esborre el token del localStorage. Necessitarem una funció per a això:

  logout(): void{
    localStorage.removeItem('token');
  }

I la cridarem en un enllaç en el propi navbar:

  <li class="nav-item" *ngIf="logueado">
          <a class="nav-link" (click)="logout();">Logout</a>
        </li>
logueado: boolean = false;

  ngOnInit(): void {
    this.logueado = this.authservice.estaAutenticado();
  }
....
 logout(){
    this.authservice.logout();
  }


Directives

Les directives afecten a les plantilles HTML. Anem a fer algunes directives que ajudaran a la interacció amb l'usuari.

Mostrar més (directiva d'atribut)

Anem a fer una directiva que mostre més informació sobre les plaques quan passem el ratolí per damunt.

ng g directive directives/mostrarMes

En el codi que ja ens dona la plantilla, necessitem saber quin és l'element i per tant, necessitem un ElementRef en el constructor.

import { Directive, ElementRef, HostListener, Input, Output, Renderer2 } from '@angular/core';
import * as EventEmitter from 'events';

@Directive({
  selector: '[appMostrarMes]'
})
export class MostrarMesDirective {

  constructor(private e: ElementRef, // Element sobre el que es fa la directiva
private r: Renderer2) { }            //ferramenta per a fer millor la manipulaciño  dels estils

  @Input('appMostrarMes') elementMostrar!: any[];

@HostListener('mouseenter')
@HostListener('mouseleave')
 entrarMouse(){
   for( let e of this.elementMostrar){
     console.log(e.style.display);
     if(e.style.display == '') this.r.setStyle(e,'display','none');
     else  this.r.setStyle(e,'display','');
   }
 }
}

La directiva rep per @Input un array d'elements sobre els que canviar la seua visibilitat.

La manera de cridar a aquesta directiva serà en:

<div class="card" [appMostrarMes]="[mostrar1,mostrar2]">
 ...
    <p class="card-text" #mostrar1>{{p.description | slice:0:50}}</p>
    <p class="card-text" #mostrar2 style="display: none;">{{p.description}}</p>
  ...
</div>

El que hem utilitzat són variables de referència de template amb #

Formulari d'edició dels productes

Abans de fer el formulari anem a implementar una forma diferent d'obtindre les dades del servidor que és amb un resolver. Farem un servici de la manera tradicional que després implementarà de resolve:

import { Injectable } from '@angular/core';
import { Product } from './product';
import { ProductsService } from '../services/products.service';
import { Router, ActivatedRoute, ActivatedRouteSnapshot, RouterStateSnapshot, Resolve } from '@angular/router';
import { Observable, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
@Injectable({
  providedIn: 'root'
})
export class ProductResolver implements Resolve<Product> {
  constructor(private productsService: ProductsService, private router: Router) { }
  resolve(route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Product | Observable<Product> | Promise<Product>
  {
  return this.productsService.getProduct(route.params.id).pipe(
    catchError(error => {this.router.navigate(['/products']);
    return of(null);
  })
   );
  }
}

La funció resolve consulta al servici per a que li done el producte i passar-ho al component quan carregue. Per a que funcione especificarem en la ruta que volem un resolver:

 { path: 'product/edit/:id',
 canActivate: [ProductDetailGuard],
 canDeactivate: [LeavePageGuard],
 resolve: { product: ProductResolver},
 component: ProductEditComponent},

I en el component consultarem el snapshot que ens dona la ruta:

  product: Product;
  constructor(private activatedRoute: ActivatedRoute ) { }
  ngOnInit(): void {
   this.product = this.activatedRoute.snapshot.data['product'];
  }

Com es veu, no necessita accedir al servici. Mirem cóm s'ha resolt la part del product-detail amb servici per contrastar en aquesta amb resolve.

Ara que ja tenim les dades, anem a fer un formulari en plantilla HTML per començar:

Formulari de plantilla

Anem a fer un formulari per editar el nom, preu i descripció dels productes. En el cas de la descripció i del formulari sencer es mostra tota la informació que es pot treue de ngModel i ngForm:

<div class="card">
  <div class="card-header bg-success text-white">Editar producto</div>
  <div class="card-body">
    <div>
      <form #productForm="ngForm" novalidate>
        <input type="text" name="name" class="form-control" [(ngModel)]="product.name"
        minlength="1" maxlength="60" required  #nameModel="ngModel"
        [ngClass]="{
          'is-valid': nameModel.touched && nameModel.valid,
          'is-invalid': nameModel.touched && !nameModel.valid
        }"/>
        <div *ngIf="nameModel.touched && nameModel.invalid"
        class="alert alert-danger">
        Nombre requerido (entre 1 y 60 caracteres)</div>
        <input type="number" name="price" class="form-control" [(ngModel)]="product.price"
        required  #priceModel="ngModel"
        [ngClass]="{
          'is-valid': priceModel.touched && priceModel.valid,
          'is-invalid': priceModel.touched && !priceModel.valid
        }"/>
        <div *ngIf="priceModel.touched && priceModel.invalid"
        class="alert alert-danger">
        Precio requerido (> 0)</div>
        <input
          type="text"
          name="description"
          class="form-control"
          [(ngModel)]="product.description"
          minlength="5"
          maxlength="600"
          required
          #descriptionModel="ngModel"
          [ngClass]="{
            'is-valid': descriptionModel.touched && descriptionModel.valid,
            'is-invalid': descriptionModel.touched && !descriptionModel.valid
          }"
        />
        <div *ngIf="descriptionModel.touched && descriptionModel.invalid"
        class="alert alert-danger">
        Descripción requerida (entre 5 y 600 caracteres)</div>
      </form>
    </div>
    <div>{{ product | json }}</div>
    <div>Dirty: {{ descriptionModel.dirty }}</div>
    <div>Valid: {{ descriptionModel.valid }}</div>
    <div>Value: {{ descriptionModel.value }}</div>
    <div>Errors: {{ descriptionModel.errors | json }}</div>
    <div>Touched: {{ productForm.touched }}</div>
    <div>Valid: {{ productForm.valid }}</div>
    <div>Value: {{ productForm.value | json }}</div>
    <div>Descripción: {{productForm.control.get('description').value | json}}</div>
  </div>
</div>

Cada input és validat sols per HTML5 de moment. En funció de si és vàlid s'aplica la classe is-valid i es mostra o no un div amb el missatge informant de l'error.

Validator personalitzat

De moment no validem que el preu siga major que 0, però ho farem en un validator personalitzat, que és una directiva que implementa l'interfície Validator:

ng g directive directives/validators/min-price
import { Directive, Input } from '@angular/core';
import { AbstractControl, NG_VALIDATORS, Validator } from '@angular/forms';

@Directive({
  selector: '[appMinPrice]',
  providers: [{provide: NG_VALIDATORS, useExisting: MinPriceDirective,
     multi: true}]
})
export class MinPriceDirective implements Validator {
  @Input('appMinPrice') minPrice;

  constructor() { }

  validate( c: AbstractControl): { [key: string]: any}{
    console.log(c.value, this.minPrice);
    if(this.minPrice && c.value) { // si rebem algun valor
      console.log(c.value, this.minPrice);
      if(this.minPrice > c.value) {
        return { minPrice: true }  //devolvemos el error
      }
    }
    return null; // sense error
  }
}

I afegim la directiva al input:

<input type="number" name="price" class="form-control" [(ngModel)]="product.price"
        required appMinPrice="0"  #priceModel="ngModel"
        [ngClass]="{
          'is-valid': priceModel.touched && priceModel.valid,
          'is-invalid': priceModel.touched && !priceModel.valid
        }"/>

Enviar el formulari

Afegim el botó submit al formulari:

<button type="submit" class="btn btn-primary"
        [disabled]="productForm.invalid"> Submit </button>

Si el formulari no està bé no es podrà polsar.

Ara afegim l'esdeveniment ngSubmit al formulari:

      <form #productForm="ngForm" (ngSubmit)="editar()" novalidate>

I la funció per validar algo i enviar al service

 editar(){
    // Les validacions que calguen
    this.productService.editProduct(this.product).subscribe(
      ok => this.router.navigate(['/product/',this.product.id])
    )
  }

Formulari Reactiu

Per a explorar les possibilitats dels formularis reactius anem a fer un altre component per a crear nous productes. Deixarem el formulari d'edició com a plantilla i així tenim un exemple de codi de cada.

ng g c product/product-new

Afegirem el mòdul ReactiveFormsModule a imports en app.module.ts

Afegim un botó al catáleg que enllaçe amb /products/new:

<a [routerLink]="['/products','new']" class="btn btn-primary"> Create </a>

I cal configurar la ruta en app-routing.module.ts:

{ path: 'products/new',
 canActivate: [AuthGuard],
 canDeactivate: [LeavePageGuard],
 component: ProductNewComponent},

Podem aprofitar les guards.

Ara anem a fer un formulari HTML amb la menor quantitat d'opcions possible. Així quedarà una formulari molt paregut a la plantilla Bootstrap:

<div class="container">
<h1>New Product</h1>
<form [formGroup]="formulario" (ngSubmit)="crear()">
  <div class="row mb-3">
    <label for="inputName" class="col-sm-2 col-form-label">Name</label>
    <div class="col-sm-10">
      <input type="text" class="form-control" id="inputName" formControlName="name">
    </div>
  </div>
  <div class="row mb-3">
    <label for="inputPrice" class="col-sm-2 col-form-label">Price</label>
    <div class="col-sm-10">
      <input type="number" class="form-control" id="inputPrice" formControlName="price">
    </div>
  </div>
  <div class="row mb-3">
    <label for="inputDescription" class="col-sm-2 col-form-label">Description</label>
    <div class="col-sm-10">
      <textarea class="form-control" id="inputDescription" rows="3" formControlName="description"></textarea>
    </div>
  </div>
  <button type="submit" class="btn btn-primary">Create</button>
</form>
</div>

Les úniques diferències en un formulari normal són el [formGroup], el (ngSubmit) i els formControlName que hi ha als inputs. En això és suficient per a comunicar-se amb el codi del component i fer la resta per codi.

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-product-new',
  templateUrl: './product-new.component.html',
})
export class ProductNewComponent implements OnInit {

  formulario: FormGroup; // Representa al formulari i els seus inputs

  crearFormulario(){
    this.formulario = this.formBuilder.group({ // Indicar els inputs esperables
      //han de coincidir en els formControlName del formulari
      name: ['', [Validators.required, // un array de validadors
        Validators.minLength(5),
        Validators.pattern('.*[a-zA-Z].*')]],
      price: [0, Validators.min(0.01)],
      description: [''],
    });
  }
  constructor(private formBuilder: FormBuilder) {
    this.crearFormulario();
   }
  ngOnInit(): void {
  }

  crear(){
    console.log(this.formulario);
  }
}

El que més destaca és la necessitat de tindre un FormGroup i un formBuilder per relacionar la plantilla amb el formulari reactiu.