Le langage Angular :

Introduction :

Angular est un framework open-source de développement front-end maintenu par Google. Il est conçu pour créer des applications web dynamiques, interactives et performantes. Angular utilise TypeScript, un superset de JavaScript, pour offrir des fonctionnalités avancées comme le typage statique, les décorateurs et les interfaces.

Principales caractéristiques d'Angular :

  • Basé sur des composants : Chaque partie de l'interface utilisateur est décomposée en composants réutilisables.

  • Two-way Data Binding : Synchronisation automatique des données entre le modèle et la vue.

  • Modularité : Les applications Angular sont organisées en modules pour une meilleure organisation et maintenabilité.

  • Support de RxJS : Gère les flux de données asynchrones avec Observables.

  • Routage puissant : Pour créer des applications à plusieurs pages et gérer les chemins d'accès facilement.

Différences entre AngularJS et Angular :

Il existent différentes versions d'Angular. La première version, Angular 1, est appelée AngularJS.

AngularJS Angular
Basé sur JavaScript Basé sur TypeScript
MVC (Model-View-Controller) Achitecture composant
Moins performant Optimisé pour des applications modernes
Créé en 2010 Introduit en 2016.

En résumé, Angular est un framework JavaScript permettant la création de "Single Page Applications" (SPA).

Installation et configuration :

Angular nécessite Node.js pour gérer les dépendances et exécuter des scripts. Pour ce faire, assurez-vous de télécharger et d'installer une version LTS (Long Term Support) de Node.js.

Pour rappel, Node.js et npm sont installés en même temps.

Sous Linux, on peut le faire en lignes de commandes comme ceci :

sudo apt-get update
sudo apt-get install nodejs npm

Pour vérifier l'installation, vous pouvez taper les commandes suivantes dans un terminal :

node -v
npm -v

Angular CLI (Command Line Interface) facilite la création, le développement et le déploiement des applications Angular.

npm install -g @angular/cli

Pour vérifier l'installation du CLI, on tape ceci :

ng version

Premier projet :

Pour créer un projet Angular de base, on utilise la commande ci-dessous :

ng new mon-premier-projet

Les options demandées sont :

  • Utiliser TypeScript strict : Oui

  • Ajouter Angular Routing : Oui

  • Sélectionner le style (CSS, SCSS, etc.) : CSS

On peut utiliser les attributs suivants dans la commande ci-dessus pour configurer ces options en ligne de commande : --minimal et --style=css.

Ensuite, on va dans le répertoire du projet :

cd mon-premier-projet

Pour ouvrir ce dossier dans notre éditeur de code qu'est Visual Studio Code, on peut utiliser la commande suivante :

code .

On démarre le serveur de développement avec la commande suivante :

ng serve

On peut donc ouvrir notre navigateur et accéder à http://localhost:4200.

Structure d'un projet Angular :

Après la création du projet, la structure suivante est générée :

mon-premier-projet/
├── src/
│   ├── app/                  # Contient les composants et les modules.
│   ├── assets/               # Fichiers statiques comme les images.
│   ├── environments/         # Configurations pour différents environnements.
│   ├── index.html            # Point d'entrée de l'application.
│   └── main.ts               # Fichier principal de l'application.
├── angular.json              # Configuration Angular CLI.
├── package.json              # Dépendances du projet.
└── README.md                 # Documentation du projet.

Premier composant :

Lorsque vous créez un projet Angular, un composant de base appelée App est généré automatiquement. Ce composant sert de point d'entrée principal pour l'affichage de votre application. Dans le dossier `src/app se trouvent les fichiers suivants :

  • app.component.html : Ce fichier contient le template HTML du composant. Voici un exemple de code que devrait contenir ce fichier :

    <h1>{{ title }}</h1>
    <app-header></app-header>
    <app-footer></app-footer>
  • app.component.ts : Ce fichier contient la classe TypeScript du composant. Il sert à gérer la logique métier et à définir les données (propriétés) liées au template. Voici un exemple de code que devrait contenir ce fichier :

    import { Component } from '@angular/core';
    
    @Component({
        selector: 'app-root',
        templateUrl: './app.component.html',
        styleUrls: []
    })
    
    export class AppComponent {
        title = 'Bienvenue sur notre application Angular !'
    }
  • app.component.spec.ts : Ce fichier contient les tests unitaires pour le composant. Voici un exemple de code que devrait contenir ce fichier :

    import { ComponentFixture, TestBed } from '@angular/core/testing';
    import { AppComponent } from './app.component';
    
    describe('AppComponent', () => {
        let component: AppComponent;
        let fixture: ComponentFixture<AppComponent>;
    
        beforeEach(async () => {
            await TestBed.configureTestingModule({
                declarations: [AppComponent]
            }).compileComponents();
        });
    
        it('devrait créer le composant', () => {
            fixture = TestBed.createComponent(AppComponent);
            component = fixture.componentInstance;
            expect(component).toBeTruthy();
        });
    });
  • app.module.ts : Ce fichier contient la définition du module principal de l'application. Voici un exemple de code que devrait contenir ce fichier :

    import { NgModule } from '@angular/core';
    import { BrowserModule } from '@angular/platform-browser';
    import { AppComponent } from './app.component';
    
    @NgModule({
        declarations: [
            AppComponent // Déclaration du composant App
        ],
        imports: [
            BrowserModule // Module requis pour les applications Angular
        ],
        providers: [],
        bootstrap: [AppComponent] // Point d'entrée principal de l'application
    })
    
    export class AppModule {}
  • app-routing.module.ts : Ce fichier sert à configurer les routes de l'application. Les routes permettent de naviguer entre les fifférents composants et d'afficher le contenu correspondant dans la vue. Ce fichier n'est pas toujours généré automatiquement lorsqu'on crée un projet Angular. Cependant, si vous utilisez l'option --routing lors de la création du projet avec Angular CLI, ce fichier sera inclus dans votre projet. Voici un exemple de code que devrait contenir ce fichier :

    import { NgModule } from '@angular/core';
    import { RouterModule, Routes } from '@angular/router';
    import { AppComponent } from './app.component'; // Exemple de composant
    import { PageNotFoundComponent } from './page-not-found/page-not-found.component'; // Composant de gestion des erreurs
    
    // Déclaration des routes
    const routes: Routes = [
        { path: '', component: AppComponent }, // Route par défaut
        { path: 'page-not-found', component: PageNotFoundComponent }, // Route pour les erreurs 404
        { path: '**', redirectTo: 'page-not-found' } // Redirection pour les chemins non trouvés
    ];
    
    @NgModule({
        imports: [RouterModule.forRoot(routes)], // Configuration des routes pour l'application
        exports: [RouterModule] // Exportation pour permettre l'utilisation dans AppModule
    })
    
    export class AppRoutingModule {}

Ces fichiers, lorsqu'ils travaillent ensemble, forment un composant Angular fonctionnel. Le fichier HTML fournit la structure, le fichier TS gère la logique, le fichier de styles (non mentionné ici, mais généralement nommé app.component.css) définit l'apparence, et le fichier spec.ts vérifie que tout fonctionne correctement.

Dans notre fichier tsconfig.json à la racine de notre projet, on doit vérifier qu'on a la propriété strictPropertyInitialization à false afin d'éviter que le compilateur TypeScript nous embête lorsqu'on veut typer nos variables.

Voici également les différentes méthodes du cycle de vie d'un composant ainsi que leurs descriptions :

Méthode Description

ngOnChanges

C'est la méthode appelée en premier lors de la création d'un composant, avant même ngOnInit, et à chaque fois que Angular détecte que les valeurs d'une propriété du composant sont modifiées.

La méthode reçoit en paramètre un objet représentant les valeurs actuelles et les valeurs précédentes disponibles pour ce composant.

ngOnInit

Cette méthode est appelée juste après le premier appel à ngOnChanges, et elle initialise le composant après que Angular ait initialisé les propriétés du composant.

ngDoCheck

On peut implémenter cette interface pour étendre le comportement par défaut de la méthode ngOnChanges, afin de pouvoir détecter et agir sur des changements qu'Angular ne peut pas détecter par lui-même.

ngAfterViewInit

Cette méthode est appelée juste après la mise en place de la vue d'un composant (et des vues de ses composants fils s'il en a).

ngOnDestroy

Appelée en dernier, cette méthode est appelée avant qu'Angular ne détruise et ne retire du DOM le composant.

Cela peut se produire lorsqu'un utilisateur navigue d'un composant à un autre par exemple.

Afin d'éviter les fuites de mémoire, c'est cans cette méthode que nous effectuerons un certain nombre d'opérations afin de laisser de l'application "propre" (nous détacherons les gestionnaires d'événements par exemple).

Même si elles ne sont pas redéclarées, ces méthodes existent dans le noyau d'Angular.

Voici un exemple de code utilisant le cycle de vie :

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

@Component({
    selector: 'app-root',
    template: '<h1>Welcome to {{ pokemonList[0] }}</h1>'
})

export class AppComponent implements OnInit {
    pokemonList = ['Bulbizarre', 'Salamèche', 'Carapuce'];

    ngOnInit() {
        console.table(this.pokemonList);
        this.selectPokemon('Bulbizarre');
    }

    selectPokemon(pokemonName: string) {
        console.log(`Vous avez cliqué sur le pokémon ${pokemonName}`);
    }
}

Premier exercice :

On va créer plusieurs fichiers différents afin de séparer la logique métier de notre application de pokémon.

Le premier fichier à ajouter dans notre dossier src/app est le pokemon.ts dont voici le contenu :

export class Pokemon {
    id: number;
    hp: number;
    cp: number;
    name: string;
    picture : string;
    types: Array<string>;
    created: Date;
}

On va ensuite créer le mock avec la liste des pokémons dans le fichier mock-pokemon-list.ts :

import { Pokemon } from './pokemon';

export const POKEMONS: Pokemon[] = [
    {
        id: 1,
        name: "Bulbizarre",
        hp: 25,
        cp: 5,
        picture: "https://assets.pokemon.com/assets/cms2/img/pokedex/detail/001.png",
        types: ["Plante", "Poison"],
        created: new Date()
    },
    {
        id: 2,
        name: "Salamèche",
        hp: 28,
        cp: 6,
        picture: "https://assets.pokemon.com/assets/cms2/img/pokedex/detail/004.png",
        types: ["Feu"],
        created: new Date()
    },
    {
        id: 3,
        name: "Carapuce",
        hp: 21,
        cp: 4,
        picture: "https://assets.pokemon.com/assets/cms2/img/pokedex/detail/007.png",
        types: ["Eau"],
        created: new Date()
    },
    {
        id: 4,
        name: "Aspicot",
        hp: 16,
        cp: 2,
        picture: "https://assets.pokemon.com/assets/cms2/img/pokedex/detail/013.png",
        types: ["Insecte", "Poison"],
        created: new Date()
    },
    {
        id: 5,
        name: "Roucool",
        hp: 30,
        cp: 7,
        picture: "https://assets.pokemon.com/assets/cms2/img/pokedex/detail/016.png",
        types: ["Normal", "Vol"],
        created: new Date()
    },
    {
        id: 6,
        name: "Rattata",
        hp: 18,
        cp: 6,
        picture: "https://assets.pokemon.com/assets/cms2/img/pokedex/detail/019.png",
        types: ["Normal"],
        created: new Date()
    },
    {
        id: 7,
        name: "Piafabec",
        hp: 14,
        cp: 5,
        picture: "https://assets.pokemon.com/assets/cms2/img/pokedex/detail/021.png",
        types: ["Normal", "Vol"],
        created: new Date()
    },
    {
        id: 8,
        name: "Abo",
        hp: 16,
        cp: 4,
        picture: "https://assets.pokemon.com/assets/cms2/img/pokedex/detail/023.png",
        types: ["Poison"],
        created: new Date()
    },
    {
        id: 9,
        name: "Pikachu",
        hp: 21,
        cp: 7,
        picture: "https://assets.pokemon.com/assets/cms2/img/pokedex/detail/025.png",
        types: ["Electrik"],
        created: new Date()
    },
    {
        id: 10,
        name: "Sabelette",
        hp: 19,
        cp: 3,
        picture: "https://assets.pokemon.com/assets/cms2/img/pokedex/detail/027.png",
        types: ["Normal"],
        created: new Date()
    },
    {
        id: 11,
        name: "Mélofée",
        hp: 25,
        cp: 5,
        picture: "https://assets.pokemon.com/assets/cms2/img/pokedex/detail/035.png",
        types: ["Fée"],
        created: new Date()
    },
    {
        id: 12,
        name: "Groupix",
        hp: 17,
        cp: 8,
        picture: "https://assets.pokemon.com/assets/cms2/img/pokedex/detail/037.png",
        types: ["Feu"],
        created: new Date()
    }
];

On va modifier notre template ainsi que charger dans pokemonList la liste des pokémons contenus dans le mock :

import { Component, OnInit } from '@angular/core';
import { POKEMONS } from './mock-pokemon-list';
import { Pokemon } from './pokemon';

@Component({
    selector: 'app-root',
    template: `<h1>Liste de Pokémons</h1>`
})

export class AppComponent implements OnInit {
    pokemonList: Pokemon[] = POKEMONS;

    ngOnInit() {
        console.table(this.pokemonList);
        this.selectPokemon(this.pokemonList[0]);
    }

    selectPokemon(pokemon: Pokemon) {
        console.log(`Vous avez cliqué sur le pokémon ${pokemon.name}`);
    }
}

Les templates :

Pour rappel, les templates sont les vues de nos composants et ils contiennent le code de notre interface utilisateur.

On ne mélange pas la vue et la logique et c'est pour ça qu'on crée un fichier HTML, app.component.html, et on remplace la propriété template du composant par la propriété templateUrl comme ceci :

@Component({
    selector: 'app-root',
    templateUrl: 'app.component.html'
})

On va utiliser l'interpolation afin d'afficher les valeurs des variables de notre composant en utilisant la syntaxe des doubles accolades. Par exemple, on peut afficher les pokémons dans divers paragraphes :

<h1>Liste de Pokémons</h1>
<p>{{ pokemonList[0].name }}</p>
<p>{{ pokemonList[1].name }}</p>
<p>{{ pokemonList[2].name }}</p>
<p>{{ pokemonList[3].name }}</p>
<p>{{ pokemonList[4].name }}</p>
<p>{{ pokemonList[5].name }}</p>
<p>{{ pokemonList[6].name }}</p>
<p>{{ pokemonList[7].name }}</p>
<p>{{ pokemonList[8].name }}</p>
<p>{{ pokemonList[9].name }}</p>
<p>{{ pokemonList[10].name }}</p>
<p>{{ pokemonList[11].name }}</p>

Comme on l'a vu, l'interpolation est très pratique pour lier notre template et la classe de notre composant. Cependant, pour notre culture générale, pour monter en compétence, on va prendre connaissance d'autres manières de créer des liaisons entre le template et la classe d'un composant avec Angular. Voici un tableau récapitulatif des différentes façons de faire cela :

Propriétés Code Explications
Propriété d'éléments
<img [src]='someImageUrl">

Ce code se traduit comme ceci avec l'interpolation :

<img src="{{ someImageUrl }}">

On utilise les crochets pour lier directement la source de l'image à la propriété du composant someImageUrl.

Propriété d'attribut
<label [attr.for]="someLabelId">...</label>

On lie l'attribut for de l'élément label avec la propriété de notre composant someLabelId.

Propriété de la classe
<div [class.special]="isSpecial">Special</div>

Fonctionnement similaire, pour attribuer ou non la classe special à l'élément div.

Propriété de style
<button [style.color]="isSpecial?'red':'green'">
    Special
</button>

On peut également définir un style pour nos éléments de manière dynamique : ici on définit la couleur de notre bouton en fonction de la propriété isSpecial, soit rouge, soit vert (c'est un opérateur ternaire que l'on utilise comme expression).

Maintenant, on va gérer les interactions avec l'utilisateur comme les clics par exemple. Voici un exemple simple de l'utilisation d'un click sur le premier pokémon :

<h1>Liste de Pokémons</h1>
<p (click)="selectPokemon(pokemonList[0])">{{ pokemonList[0].name }}</p>
<p>{{ pokemonList[1].name }}</p>
<p>{{ pokemonList[2].name }}</p>
<p>{{ pokemonList[3].name }}</p>
<p>{{ pokemonList[4].name }}</p>
<p>{{ pokemonList[5].name }}</p>
<p>{{ pokemonList[6].name }}</p>
<p>{{ pokemonList[7].name }}</p>
<p>{{ pokemonList[8].name }}</p>
<p>{{ pokemonList[9].name }}</p>
<p>{{ pokemonList[10].name }}</p>
<p>{{ pokemonList[11].name }}</p>

Voyons à présent comment intercepter tous les événements du DOM et pas que le click. On va ajouter un input dans notre vue et on va modifier le paramètre de notre méthode selectPokemon :

<h1>Liste de Pokémons</h1>
<input type="number" (click)="selectPokemon($event)">

<p>{{ pokemonList[0].name }}</p>
<p>{{ pokemonList[1].name }}</p>
<p>{{ pokemonList[2].name }}</p>
<p>{{ pokemonList[3].name }}</p>
<p>{{ pokemonList[4].name }}</p>
<p>{{ pokemonList[5].name }}</p>
<p>{{ pokemonList[6].name }}</p>
<p>{{ pokemonList[7].name }}</p>
<p>{{ pokemonList[8].name }}</p>
<p>{{ pokemonList[9].name }}</p>
<p>{{ pokemonList[10].name }}</p>
<p>{{ pokemonList[11].name }}</p>
selectPokemon(event: MouseEvent) {
    const index: number = +(event.target as HTMLInputElement).value;
    console.log(`Vous avez cliqué sur le pokémon ${this.pokemonList[index].name}`);
}

Attention, n'oubliez pas que null casté en number est égal à 0.

Ensuite, on va parler des variables référencées dans le template car c'est super cool de travailler avec le $event. On utilise le # dans le template pour référencer une nouvelle variable.

<h1>Liste de Pokémons</h1>
<input #input (keyup)="0" type="number">
<p>Nombre saisi : {{ input.value }}</p>

<p>{{ pokemonList[0].name }}</p>
<p>{{ pokemonList[1].name }}</p>
<p>{{ pokemonList[2].name }}</p>
<p>{{ pokemonList[3].name }}</p>
<p>{{ pokemonList[4].name }}</p>
<p>{{ pokemonList[5].name }}</p>
<p>{{ pokemonList[6].name }}</p>
<p>{{ pokemonList[7].name }}</p>
<p>{{ pokemonList[8].name }}</p>
<p>{{ pokemonList[9].name }}</p>
<p>{{ pokemonList[10].name }}</p>
<p>{{ pokemonList[11].name }}</p>

On va faire un petit exercice pour créer un flux de données bidirectionnel. On va avoir une nouvelle variable nommée pokemonSelected.

import { Component, OnInit } from '@angular/core';
import { POKEMONS } from './mock-pokemon-list';
import { Pokemon } from './pokemon';

@Component({
    selector: 'app-root',
    templateUrl: 'app.component.html'
})

export class AppComponent implements OnInit {
    pokemonList: Pokemon[] = POKEMONS;
    pokemonSelected: Pokemon|undefined;

    ngOnInit() {
        console.table(this.pokemonList);
    }

    selectPokemon(pokemonId: string) {
        const pokemon: Pokemon|undefined = this.pokemonList.find(pokemon => pokemon.id == +pokemonId);

        if (pokemon) {
            console.log(`Vous avez demandé le pokémon ${pokemon.name}`);
            this.pokemonSelected = pokemon;
        } else {
            console.log(`Vous avez demandé un pokémon qui n'existe pas.`);
            this.pokemonSelected = pokemon;
        }
    }
}
<h1>Liste de Pokémons</h1>
<input #input (keyup)="selectPokemon(input.value)" type="number">
<p>Vous avez sélectionné le pokémon : {{ pokemonSelected?.name }}</p>

<p>{{ pokemonList[0].name }}</p>
<p>{{ pokemonList[1].name }}</p>
<p>{{ pokemonList[2].name }}</p>
<p>{{ pokemonList[3].name }}</p>
<p>{{ pokemonList[4].name }}</p>
<p>{{ pokemonList[5].name }}</p>
<p>{{ pokemonList[6].name }}</p>
<p>{{ pokemonList[7].name }}</p>
<p>{{ pokemonList[8].name }}</p>
<p>{{ pokemonList[9].name }}</p>
<p>{{ pokemonList[10].name }}</p>
<p>{{ pokemonList[11].name }}</p>

Peut importe quelles touches on appuie, ça fonctionne. Enfin, on voudrait qu'une fois la touche "Enter" ou "Entrée" est appuyée ça fonctionne. Pour cela, on rajoute le mot enter au keyup dans notre vue :

<h1>Liste de Pokémons</h1>
<input #input (keyup.enter)="selectPokemon(input.value)" type="number">
<p>Vous avez sélectionné le pokémon : {{ pokemonSelected?.name }}</p>

<p>{{ pokemonList[0].name }}</p>
<p>{{ pokemonList[1].name }}</p>
<p>{{ pokemonList[2].name }}</p>
<p>{{ pokemonList[3].name }}</p>
<p>{{ pokemonList[4].name }}</p>
<p>{{ pokemonList[5].name }}</p>
<p>{{ pokemonList[6].name }}</p>
<p>{{ pokemonList[7].name }}</p>
<p>{{ pokemonList[8].name }}</p>
<p>{{ pokemonList[9].name }}</p>
<p>{{ pokemonList[10].name }}</p>
<p>{{ pokemonList[11].name }}</p>

Ce sont des pseudo-évenments d'Angular. Enfin, je vais vous monter comment conditionner un affichage avec ngIf et le boucler avec ngFor :

<h1>Liste de Pokémons</h1>
<input #input (keyup.enter)="selectPokemon(input.value)" type="number">
<p *ngIf="pokemonSelected">
    Vous avez sélectionné le pokémon : {{ pokemonSelected?.name }}
</p>
<p *ngIf="!pokemonSelected">
    Aucun pokémon n'a été trouvé.
</p>

<p>{{ pokemonList[0].name }}</p>
<p>{{ pokemonList[1].name }}</p>
<p>{{ pokemonList[2].name }}</p>
<p>{{ pokemonList[3].name }}</p>
<p>{{ pokemonList[4].name }}</p>
<p>{{ pokemonList[5].name }}</p>
<p>{{ pokemonList[6].name }}</p>
<p>{{ pokemonList[7].name }}</p>
<p>{{ pokemonList[8].name }}</p>
<p>{{ pokemonList[9].name }}</p>
<p>{{ pokemonList[10].name }}</p>
<p>{{ pokemonList[11].name }}</p>

Attention, l'astéristique * est très importante. Il faut pas oublier la mettre devant ngIf et ngFor ou même devant n'importe quelle autre directive structurelle.

<h1>Liste de Pokémons</h1>
<input #input (keyup.enter)="selectPokemon(input.value)" type="number">
<p *ngIf="pokemonSelected">
    Vous avez sélectionné le pokémon : {{ pokemonSelected?.name }}
</p>
<p *ngIf="!pokemonSelected">
    Aucun pokémon n'a été trouvé.
</p>

<p *ngFor="let pokemon of pokemonList">
    {{ pokemon.name }}
</p>

Pour la partie CSS de notre application, on peut intégrer dans notre fichier index.html Materialize dans une balise link :

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
<h1 class="center">>Liste de Pokémons</h1>

<div class="container">
    <div class="row">
        <div *ngFor="let pokemon of pokemonList" class="col m4 s6">
            <div class="card horizontal">
                <div class="card-image">
                    <img [src]="pokemon.picture">
                </div>
                <div class="card-stacked">
                    <div class="card-content">
                        <p>{{ pokemon.name }}</p>
                        <p><small>{{ pokemon.created }}</small></p>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>

Les directives :

La première question à se poser, c'est qu'est-ce qu'une directive ? Une directive est une classe Angular qui ressemble beaucoup à un composant sauf qu'elle n'a pas de template. D'ailleurs, au sein du framework, la classe Component hérite de la classe Directive. Voici les trois types de directives qui existent :

  1. Les composants

  2. Les directives d'attributs

  3. Les directives structurelles

On va créer une directive d'attribut pour notre application. Ce type de directive va nous permettre de changer l'apparence ou le comportement d'un élément.

Par exemple, on va créer la directive BorderCardDirective qui, comme son nom l'indique, permettra d'ajouter une bordure de couleur sur les pokémons de notre liste lorsque l'utilisateur les survolera avec son curseur.

On va utiliser Angular CLI pour générer la directive avec la commande suivante :

ng generate directive border-card

Cette commande permet de créer le fichier border-card.directive.ts et à modifier le fichier app.module.ts pour déclarer la directive. Voici le code généré :

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

@Directive({
    selector: '[appBorderCard]'
})

export class BorderCardDirective {

    constructor() { }

}

On va récupérer la card du pokémon à laquelle on rajoute la bordure dans le constructeur de notre directive :

import { Directive, ElementRef } from '@angular/core';

@Directive({
    selector: '[pokemonBorderCard]'
})

export class BorderCardDirective {

    constructor(private el: ElementRef) {
        this.setBorder('#F5F5F5');
        this.setHeight(180);
    }

    private setHeight(height: number) {
        this.el.nativeElement.style.height = `${height}px`;
    }

    private setBorder(color: string) {
        this.el.nativeElement.style.border = `solid 4px ${color}`;
    }

}

Pour détecter un événement tel que le survol d'un élément, on va utiliser une nouvelle annotation nommée @HostListener qui permet de lier notre directive à un élément donné :

import { Directive, ElementRef, HostListener } from '@angular/core';

@Directive({
    selector: '[pokemonBorderCard]'
})

export class BorderCardDirective {

    constructor(private el: ElementRef) {
        this.setBorder('#F5F5F5');
        this.setHeight(180);
    }

    @HostListener('mouseenter') onMouseEnter() {
        this.setBorder('#009688');
    }

    @HostListener('mouseleave') onMouseLeave() {
        this.setBorder('#F5F5F5');
    }

    private setBorder(color: string) {
        this.el.nativeElement.style.border = `solid 4px ${color}`;
    }

    private setHeight(height: number) {
        this.el.nativeElement.style.height = `${height}px`;
    }

}

Dans le template, on rajoute l'attribut pokemonBorderCard dans notre div contenant la card :

<div class="card horizontal" ... pokemonBorderCard>

Pour le moment, notre directive pokemonBorderCard n'est pas personnalisable. À chaque utilisation, cette directive impose une couleur unique aux bordures. On va préciser une propriété d'entrée dans notre directive via l'annotation @Input :

import { Directive, ElementRef, HostListener, Input } from '@angular/core';

@Directive({
    selector: '[pokemonBorderCard]'
})

export class BorderCardDirective {

    constructor(private el: ElementRef) {
        this.setBorder('#F5F5F5');
        this.setHeight(180);
    }

    @Input('pokemonBorderCard') borderColor: string;

    @HostListener('mouseenter') onMouseEnter() {
        this.setBorder(this.borderColor || '#009688');
    }

    @HostListener('mouseleave') onMouseLeave() {
        this.setBorder('#F5F5F5');
    }

    private setBorder(color: string) {
        this.el.nativeElement.style.border = `solid 4px ${color}`;
    }

    private setHeight(height: number) {
        this.el.nativeElement.style.height = `${height}px`;
    }

}

Dans notre template, on remodifie la div contenant notre card comme ceci afin que cette fois-ci la bordure soit red au survol :

<div class="card horizontal" ... pokemonBorderCard="red">

Il y a deux façons de déclarer une propriété d'entrée : avec ou sans alias. Voici la différence :

@Input('pokemonBorderCard') borderColor: string; // avec alias
@Input() pokemonBorderCard: string; // sans alias

Enfin, pour terminer le développement de notre directive, on va remplacer les valeurs entrées en dur par des propriétés telles que initialColor, defaultColor et defaultHeight.

import { Directive, ElementRef, HostListener, Input } from '@angular/core';

@Directive({
    selector: '[pokemonBorderCard]'
})

export class BorderCardDirective {

    private initialColor: string = '#F5F5F5';
    private defaultColor: string = '#009688';
    private defaultHeight: number = 180;

    constructor(private el: ElementRef) {
        this.setBorder(this.initialColor);
        this.setHeight(this.defaultColor);
    }

    @Input('pokemonBorderCard') borderColor: string;

    @HostListener('mouseenter') onMouseEnter() {
        this.setBorder(this.borderColor || this.defaultColor);
    }

    @HostListener('mouseleave') onMouseLeave() {
        this.setBorder(this.initialColor);
    }

    private setBorder(color: string) {
        this.el.nativeElement.style.border = `solid 4px ${color}`;
    }

    private setHeight(height: number) {
        this.el.nativeElement.style.height = `${height}px`;
    }

}

Les pipes :

Maintenant, on va modifier la date affichée dans notre card pour qu'elle ait un bon format via le pipe (ou |).

<p><small>{{ pokemon.created | date }}</small></p>

Angular possède d'autres pipes disponibles dans tous nos templates tels que le DatePipe qu'on vient d'utiliser, le UpperCasePipe, le LowerCasePipe, le CurrencyPipe pour les devises, et le PercentPipe.

On peut même combiner plusieurs pipes ensemble comme ceci par exemple :

<p><small>{{ pokemon.created | date | uppercase }}</small></p>

Attention, l'ordre des pipes est important. Cela se lit de la gauche vers la droite.

On peut également paramétrer notre pipe comme le format de la date par exemple :

<p><small>{{ pokemon.created | date:"dd/MM/yyyy" }}</small></p>

Angular permet de créer notre pipe personnalisé pour des besoins spécifiques à notre application. Par exemple, on va créer le pipe PokemonTypeColorPipe dans notre application avec Angular CLI avec la commande suivante :

ng generate pipe pokemon-type-color

Cette commande crée un fichier pokemon-type-color.pipe.ts et modifier le fichier app.module.ts pour déclarer le pipe. Voici le contenu généré :

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
    name: 'pokemonTypeColor'
})

export class PokemonTypeColorPipe implements PipeTransform {
    
    transform(value: unknown, ...args: unknown[]): unknown {
        return null;
    }

}

On va modifier le code généré afin d'implémenter la logique pour afficher la couleur correspondant au type du pokémon :

import { Pipe, PipeTransform } from '@angular/core';

/*
* Affiche la couleur correspondant au type du pokémon.
* Prend en argument le type du pokémon.
* Exemple d'utilisation:
*   {{ pokemon.type | pokemonTypeColor }}
*/
@Pipe({
    name: 'pokemonTypeColor'
})

export class PokemonTypeColorPipe implements PipeTransform {

    transform(type: string): string {
    
        let color: string;
        
        switch (type) {
            case 'Feu':
                color = 'red lighten-1';
                break;
            case 'Eau':
                color = 'blue lighten-1';
                break;
            case 'Plante':
                color = 'green lighten-1';
                break;
            case 'Insecte':
                color = 'brown lighten-1';
                break;
            case 'Normal':
                color = 'grey lighten-3';
                break;
            case 'Vol':
                color = 'blue lighten-3';
                break;
            case 'Poison':
                color = 'deep-purple accent-1';
                break;
            case 'Fée':
                color = 'pink lighten-4';
                break;
            case 'Psy':
                color = 'deep-purple darken-2';
                break;
            case 'Electrik':
                color = 'lime accent-1';
                break;
            case 'Combat':
                color = 'deep-orange';
                break;
            default:
                color = 'grey';
                break;
        }
        
        return 'chip ' + color;
    }
}

On va utiliser ce nouveau pipe dans notre template en rajoutant dans le contenu de la card la liste des types de chaque pokémon :

<span *ngFor="let type of pokemon.types" class="{{ type | pokemonTypeColor }}">
    {{ type }}
</span>

Les routes :

Pour l'instant, notre application est assez limitée car elle est constituée d'un seul composant accessible par défaut au démarrage de l'application. Nous ne pouvons donc pas développer une application plus complexe avec plusieurs composants et une navigation avec des URLs différentes. Je vous propose de remédier à ce problème en dotant notre application d'un système de navigation digne de ce nom. La question Comment mettre en place plusieurs pages dans mon application Angular ? n'aura plus de secret pour vous.

Tout d'abord, vous devez savoir que le système de naviagtion fourni par Angular simule parfaitement la navigation auprès de votre navigateur.

Pour pouvoir mettre en place un système de navigation dans notre application, on va donc avoir besoin d'au moins deux composants. On va créer deux nouveaux composants via Angular CLI :

ng generate component list-pokemon --inline-template=false

Le paramètre --inline-template=false de notre commande permet de séparer la vue de la logique de notre composant, c'est-dire que cette commande créera deux fichiers, list-pokemon.component.html et list-pokemon.component.ts, dans le dossier src/app/list-pokemon. Il a déclaré ce composant dans notre app.module.ts. On va créer le deuxième composant, detail-pokemon, comme ceci :

ng generate component detail-pokemon --inline-template=false

On va déclarer les nouvelles routes dans le fichier app-routing.module.ts :

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ListPokemonComponent } from './list-pokemon/list-pokemon.component';
import { DetailPokemonComponent } from './detail-pokemon/detail-pokemon.component';

const routes: Routes = [
    { path: 'pokemons', component: ListPokemonComponent },
    { path: 'pokemon/:id', component: DetailPokemonComponent },
    { path: '', redirectTo: 'pokemons', pathMatch: 'full' }
];

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

export class AppRoutingModule { }

Il nous manque un petit quelque chose pour mettre en place un système de routes avec nos deux composants. Pour commencer, ce qu'il faut comprendre, c'est que côté template, dans notre composant racine, on va devoir ajouter un élément qui s'appelle router-outlet et qui va venir en fait permettre de relier les routes qu'on a définies avec notre template.

Donc, dans notre fichier app.component.html, on va modifier son contenu comme ceci :

<router-outlet></router-outlet>

On va également modifier la logique du composant par défaut, AppComponent :

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

@Component({
    selector: 'app-root',
    templateUrl: 'app.component.html'
})

export class AppComponent {}

On va modifier le composant et le template de notre liste de pokémons dans le dossier src/app/list-pokemon :

<h1 class="center">Liste de Pokémons</h1>

<div class="container">
    <div class="row">
        <div *ngFor="let pokemon of pokemonList" class="col m4 s6">
            <div class="card horizontal">
                <div class="card-image">
                    <img [src]="pokemon.picture">
                </div>
                <div class="card-stacked">
                    <div class="card-content">
                        <p>{{ pokemon.name }}</p>
                        <p><small>{{ pokemon.created }}</small></p>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>
import { Component } from '@angular/core';
import { POKEMONS } from './mock-pokemon-list';
import { Pokemon } from './pokemon';

@Component({
    selector: 'app-list-pokemon',
    templateUrl: './list-pokemon.component.html'
})

export class ListPokemonComponent {

    pokemonList: Pokemon[] = POKEMONS;

}

Enfin, on va modifier le template et le composant detail-pokemon dans le dossier src/app/detail-pokemon afin de le dynamiser :

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { POKEMONS } from '../mock-pokemon-list';
import { Pokemon } from '../pokemon';

@Component({
    selector: 'app-detail-pokemon',
    templateUrl: './detail-pokemon.component.html'
})

export class DetailPokemonComponent implements OnInit {

    pokemonList: Pokemon[];
    pokemon: Pokemon|undefined;

    constructor(private route: ActivatedRoute) { }

    ngOnInit() {
        this.pokemonList = POKEMONS;
        const pokemonId: string|null = this.route.snapshot.paramMap.get('id');
        
        if (pokemonId) {
            this.pokemon = this.pokemonList.find(pokemon => pokemon.id == +pokemonId);
        }
    }

}
<div *ngIf="pokemon" class="row">
    <div class="col s12 m8 offset-m2">
        <h2 class="header center">{{ pokemon.name }}</h2>
        <div class="card horizontal hoverable">
            <div class="card-image">
                <img [src]="pokemon.picture">
            </div>
            <div class="card-stacked">
                <div class="card-content">
                    <table class="bordered striped">
                        <tbody>
                            <tr>
                                <td>Nom</td>
                                <td><strong>{{ pokemon.name }}</strong></td>
                            </tr>
                            <tr>
                                <td>Points de vie</td>
                                <td><strong>{{ pokemon.hp }}</strong></td>
                            </tr>
                            <tr>
                                <td>Dégâts</td>
                                <td><strong>{{ pokemon.cp }}</strong></td>
                            </tr>
                            <tr>
                                <td>Types</td>
                                <td>
                                    <span *ngFor="let type of pokemon.types" class="{{ type | pokemonTypeColor }}">{{ type }}</span>
                                </td>
                            </tr>
                            <tr>
                                <td>Date de création</td>
                                <td><em>{{ pokemon.created | date:"dd/MM/yyyy" }}</em></td>
                            </tr>
                        </tbody>
                    </table>
                </div>
                <div class="card-action">
                    <a>Retour</a>
                </div>
            </div>
        </div>
    </div>
</div>
<h2 *ngIf='!pokemon' class="center">Aucun pokémon à afficher !</h2>

On va maintenant voir comment relier les deux composants entre eux et la page d'erreur 404 lorsqu'on arrive sur une URL inconnue. Pour commencer, on va ajouter une barre de navigation dans le template de notre composant par défaut :

<nav>
    <div class="now-wrapper teal">
        <a href="#" class="brand-logo center">
            Pokédex
        </a>
    </div>
</nav>

<router-outlet></router-outlet>

Deuxièmement, on va rajouter un click sur le bouton "Retour" du composant detail-pokemon :

<a (click)="goToPokemonList()">Retour</a>

Comme la méthode goToPokemonList n'existe pas, il faut la rajouter dans la logique du composant :

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { POKEMONS } from '../mock-pokemon-list';
import { Pokemon } from '../pokemon';

@Component({
    selector: 'app-detail-pokemon',
    templateUrl: './detail-pokemon.component.html'
})

export class DetailPokemonComponent implements OnInit {

    pokemonList: Pokemon[];
    pokemon: Pokemon|undefined;

    constructor(private route: ActivatedRoute, private router : Router) { }

    ngOnInit() {
        this.pokemonList = POKEMONS;
        const pokemonId: string|null = this.route.snapshot.paramMap.get('id');
        
        if (pokemonId) {
            this.pokemon = this.pokemonList.find(pokemon => pokemon.id == +pokemonId);
        }
    }

    goToPokemonList() {
        this.router.navigate(['/pokemons']);
    }

}

Troisièmement, on va ajouter un click sur la card d'un pokémon pour accéder à son détail. Cette-fois-ci, on commence par la logique du composant :

import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { POKEMONS } from './mock-pokemon-list';
import { Pokemon } from './pokemon';

@Component({
    selector: 'app-list-pokemon',
    templateUrl: './list-pokemon.component.html'
})

export class ListPokemonComponent {

    pokemonList: Pokemon[] = POKEMONS;

    constructor(private router: Router) {}

    goToPokemon(pokemon: Pokemon) {
        this.router.navigate(['/pokemon', pokemon.id]);
    }

}
<div class="card horizontal" pokemonBorderCard (click)="goToPokemon(pokemon)">

La toute dernière chose à faire niveau route est de gérer les erreurs 404. On va créer un nouveau composant page-not-found via Angular CLI :

ng generate component page-not-found

Cette commande nous a créé le fichier page-not-found.component.ts avec le contenu qu'on va modifier :

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

@Component({
    selector: 'app-page-not-found',
    template: `
        <div class="center">
            <img src="http://assets.pokemons.com/cms2/img/pokemon/full/035.png">
            <h1>Hey, cette page n'existe pas !</h1>
            <a routerLink="/pokemons" class="waves-effect waves-teal btn-flat">
                Retourner à l'accueil
            </a>
        </div>
    `,
    styles: []
})

export class PageNotFoundComponent { }

Dans notre fichier de routage, app-routing.module.ts, on rajoute cette route par défaut :

{ path '**', component: PageNotFoundComponent }

Les modules :

Les applications Angular sont modulaires et possèdent leurs propres systèmes de modules.

Chaque application possède au minimum un module : c'est le module racine qui est nommé app.module par convention.

La plupart des projets ont besoin de plusieurs modules. On parle souvent de modules de fonctionnalités car, pour chaque fonctionnalité dans le projet, on va rajouter un nouveau module.

Un module de fonctionnalités est un ensemble de classes et d'éléments lié à un domaine spécifique de votre application.

Quelque soit la nature du module, un module est toujours une classe avec le décorateur @NgModule. Par exemple, voici le code de app.module.ts :

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppRoutingModule } from './app-routing.module';

import { AppComponent } from './app.component';
import { ListPokemonComponent } from './list-pokemon.component';
import { DetailPokemonComponent } from './detail-pokemon.component';
import { PageNotFoundComponent } from './page-not-found.component';

import { BorderCardDirective } from './border-card.directive';
import { PokemonTypeColorPipe } from './pokemon-type-color.pipe';

@NgModule({
    imports: [BrowserModule, AppRoutingModule],
    declarations: [
        AppComponent,
        BorderCardDirective,
        PokemonTypeColorPipe,
        ListPokemonComponent,
        DetailPokemonComponent,
        PageNotFoundComponent
    ],
    bootstrap: [AppComponent]
})

export class AppModule { }

Dans la propriété declarations de @NgModule, on met dans un tableau les classes de vues qui appartiennent à ce module. Angular a trois types de classes de vues : les composants, les directives et les pipes.

Il existe également la propriété exports de @NgModule qui contient un sous-ensemble de classes de vues à exporter dans des templates de composant d'autres modules.

La propriété imports de @NgModule contient toutes les classes exportées depuis d'autres modules qu'on a besoin dans ce module-ci.

La propriété providers de @NgModule concerne les services et l'injection de dépendances qu'on traitera un peu plus tard dans ce cours.

La propriété bootstrap de @NgModule concerne que le module racine pour y renseigner le composant racine, c'est-à-dire le composant qui sera affiché au lancement de l'application.

JavaScript a son propre système de modules qui complètement différent et sans rapport avec le système de modules d'Angular.

En JavaScript, chaque fichier est un module et tous les objets définis dans ce fichier appartiennent au module. Le module JavaScript déclare certains objes public en les déclarant avec le mot-clé export. Ensuite, d'autres modules JavaScript utilise le mot-clé import pour accéder à ces objets.

C'est ce mécanisme que l'on utilise lorsqu'on fait par exemple export class AppComponent et qu'ensuite, ailleurs dans notre application, nous faisons import AppComponent from ....

Les systèmes de modules de JavaScript et d'Angular sont différents mais complémentaires et nous utilisons bien les deux pour écrire notre application.

Passons maintenant à la pratique ! On va créer un nouveau module Angular permettant de centraliser tous les éléments qui concernent la gestion des Pokémons dans notre application.

Pour créer ce module pokemon, on va utiliser Angular CLI avec la commande :

ng generate module pokemon

Cette commande a permis de créer un fichier src/app/pokemon/pokemon.module.ts. On va déplacer tous les éléments que nous avons déjà développés dans le dossier du module et de venir brancher ces éléments dans le @NgModule du nouveau module.

Donc, on a le composant qui permet d'afficher la liste des Pokémons. On a celui qui qui affiche le détail de la liste des Pokémons. On a aussi développé un pipe pour la coloration des types. On a également une directive : la BorderCardDirective. Puis, on a le modèle pokemon.ts et le fichier de mock, mock-pokemon-list.ts.

On va mettre à jour les importations dans chaque fichier pour que les chemins relatifs de nos fichiers sont bien corrects vu qu'on a déplacer tous nos éléments liés à nos pokémons.

Normalement, on doit couper ng serve avant les modifications et le relancer juste après.

Dans notre nouveau module, on voit qu'Angular a par défaut importer le CommonModule de @angular/common qui est, en fait, une base que vous allez avoir besoin dans n'importe quel module et qui comprend par exemple les directives structurelles ngIf et ngFor, c'est-à-dire que, dans tous nos composants rattachés à ce module, vous pourrez utiliser ces directives sans avoir à les importer.

Dans les declarations de notre module, on va rajouter nos deux composants, notre directive et notre pipe pour pouvoir les utiliser dans le PokemonModule. L'intérêt de les déclarer dans ce module et pas à la racine est que ce sont des éléments que l'on a besoin qu'au niveau de la fonctionnalité des Pokémons.

Voici donc le contenu de notre fichier pokemon.module.ts une fois que tous les éléments ont été déclarés :

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ListPokemonComponent } from './list-pokemon.component';
import { DetailPokemonComponent } from './detail-pokemon.component';
import { BorderCardDirective } from './border-card.directive';
import { PokemonTypeColorPipe } from './pokemon-type-color.pipe';

@NgModule({
    declarations: [
        BorderCardDirective,
        PokemonTypeColorPipe,
        ListPokemonComponent,
        DetailPokemonComponent
    ],
    imports: [
        CommonModule
    ]
})

export class PokemonModule { }

Puis, on va venir déclarer les routes propres à la fonctionnalité des Pokémons et bien directement au niveau de ce module plutôt que de passer par les routes racines.

Donc, dans notre app-routing.module.ts, on va supprimer les deux routes liées aux Pokémons. Dans notre module, pokemon.module.ts, on va déclarer ces routes supprimées :

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ListPokemonComponent } from './list-pokemon.component';
import { DetailPokemonComponent } from './detail-pokemon.component';
import { BorderCardDirective } from './border-card.directive';
import { PokemonTypeColorPipe } from './pokemon-type-color.pipe';
import { RouterModule, Routes } from '@angular/router';

const pokemonRoutes: Routes = [
    { path: 'pokemons', component: ListPokemonComponent },
    { path: 'pokemon/:id', component: DetailPokemonComponent }
];

@NgModule({
    declarations: [
        BorderCardDirective,
        PokemonTypeColorPipe,
        ListPokemonComponent,
        DetailPokemonComponent
    ],
    imports: [
        CommonModule,
        RouterModule.forChild(pokemonRoutes)
    ]
})

export class PokemonModule { }

On va importer notre PokemonModule dans notre module racine comme ceci :

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

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { PageNotFoundComponent } from './page-not-found.component';
import { PokemonModule } from './pokemon/pokemon.module';

@NgModule({
    declarations: [
        AppComponent,
        PageNotFoundComponent
    ],
    imports: [
        BrowserModule,
        PokemonModule,
        AppRoutingModule
    ],
    providers: [],
    bootstrap: [AppComponent]
})

export class AppModule { }

Voilà toutes les modifications sont finies. On va relancer notre ng serve. Attention à l'ordre des routes car, si on met le AppRoutingModule avant le PokemonModule, on arrivera tout le temps sur la page d'erreurs 404.

Je vous propose maintenant de réfléchir à l'architecture finale de notre application. Nous voulons créer un simple gestionnaire de Pokémon qui permettra à l'utilsiateur de se connecter et de modifier les Pokémons à sa guise. On va devoir créer un LoginComponent à la racine de notre application pour permettre à l'utilisateur de se connecter.

Ce que nous aurions pu faire depuis le début, c'est anticiper la conception de notre application en déterminant quelles fonctionnalités allaient dans quel module.

Les services :

On va enrichir notre application avec des services. Nos deux composants vont avoir besoin d'accéder au Pokémon et d'effectuer des opérations dessus. Nous allons donc centraliser ces données et ces opérations dans un service. Ce service sera utilisavle pour tous les composants du PokemonModule afin de leur fournir un accès et des méthodes prêtes à l'emploi pour gérer les Pokémons.

Nous allons créer un service qui s'occupe de fournir des données et des méthodes pour gérer nos Pokémons à tous les composants de notre PokemonModule. L'objectif est de masquer à nos composants la façon dont nous récupérons les données et le fonctionnement interne de certaines méthodes. Puis, ça va nous permettre aussi de factoriser des comportements communs entre plusieurs composants.

On va utiliser Angular CLI pour générer notre service avec la commande suivante :

ng generate service pokemon/pokemon

Cette commande va créer le fichier src/app/pokemon.pokemon.service.ts avec le contenu suivant :

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

@Injectable({
    providedIn: 'root'
})

export class PokemonService {

    constructor() { }

}

Dans ce code, on voit pas de décorateur nommé @Service comme on aurait pu penser comme les pipes ou les directives. On a la place le décoration @Injectable qui permet d'indiquer à Angular que notre service PokemonService peut lui-même avoir d'autres dépendances.

Même si pour l'instant notre service n'a pas de dépendances, on doit quand même ajouter ce décorateur pour venir en fait brancher ce service avec le mécanisme d'injection de dépendances d'Angular.

Cela va nous permettre à la fois d'utiliser ce service ailleurs dans notre application et de venir importer dans ce service d'autres services. Pour rappel, on pouvait injecter des services dans les constructeurs de nos composants grâce à @Injectable.

La propriété providedIn du décorateur avec la valeur root va permettre d'indiquer à Angular qu'on veut utiliser la même instance du service à travers toute l'application. C'est-à-dire qu'on va jamais créer une instance du PokemonService en faisant un new PokemonService() nous-même.

Nos composants ont aussi des dépendances et pourtant on écrit @Component et non @Injectable car, en fait, le @Injectable est déjà caché dans le @Component.

On va créer un service avec des vraies méthodes qui va nous service ailleurs dans notre application. On va soulager nos composants en utilisant les méthodes que l'on va écrire dans notre service. L'avantage de ces méthodes vont pouvoir être utilisées à la fois par le ListPokemonComponent et également par le DetailPokemonComponent.

Dans notre PokemonService, on va créer trois méthodes : getPokemonList() qui, comme son nom l'indique, renverra la liste des Pokémons, getPokemonById(id) qui renverra, comme son nom l'indique, le Pokémon selon l'id et getPokemonTypeList() qui renverra en fait tous les types de Pokémon qui sont autorisés dans l'application :

import { Injectable } form '@angular/core';
import { POKEMONS } from './mock-pokemon-list';
import { Pokemon } from './pokemon';

@Injectable({
    providedIn: 'root'
})

export class PokemonService {

    getPokemonList(): Pokemon[] {
        return POKEMONS;
    }

    getPokemonById(pokemonId: number): Pokemon|undefined {
        return POKEMONS.find(pokemon => pokemon.id == pokemonId);
    }

    getPokemonTypeList(): string[] {
        return [
            'Plante',
            'Feu',
            'Eau',
            'Insecte',
            'Normal',
            'Electrik',
            'Poison',
            'Fée',
            'Vol',
            'Combat',
            'Psy'
        ];
    }

}

On va voir maintenant comment injecter ce service dans nos composants pour utiliser qu'on vient de créer. Voici la modification du contenu de list-pokemon.component.ts :

import { Component, OnInit } from '@angular/core;
import { Router } from '@angular/router';
import { Pokemon } from '../pokemon';
import { PokemonService } from '../pokemon.service';

@Component({
    selector: 'app-list-pokemon',
    templateUrl: './list-pokemon.component.html'
})

export class ListPokemonComponent implements OnInit {
    
    pokemonList: Pokemon[];

    constructor(
        private router: Router,
        private pokemonService: PokemonService
    ) { }

    ngOnInit() {
        this.pokemonList = this.pokemonService.getPokemonList();
    }

    getPokemonTypeList(pokemon: Pokemon) {
        this.router.navigate(['/pokemon', pokemon.id]);
    }
}

On va aussi modifier le contenu de detail-pokemon.component.ts :

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Pokemon } from '../pokemon';
import { PokemonService } from '../pokemon.service';

@Component({
    selector: 'app-detail-pokemon',
    templateUrl: './detail-pokemon.component.html'
})

export class DetailPokemonComponent implements OnInit {
    
    pokemon: Pokemon|undefined;

    constructor(
        private route: ActivatedRoute,
        private router: Router,
        private pokemonService: PokemonService
    ) { }

    ngOnInit() {
        const pokemonId: string|null = this.route.snapshot.paramMap.get('id');
        
        if (pokemonId) {
            this.pokemon = this.pokemonService.getPokemonById(+pokemonId);
        }
    }

    goToPokemonList() {
        this.router.navigate(['/pokemons']);
    }
}

Enfin, on va voir comment délimiter un espace dans notre application dans lequel le service sera disponible. Angular dispose de son propre framework d'injections. On ne peut pas développer cette application sans cet outil.

L'injection de dépendances est un modèle de développement ou design pattern en anglais dans lequel chaque classe reçoit ses dépendances d'une source externe ou en les créant elle-même.

Si on veut que notre PokemonService soit uniquement lié à notre PokemonModule et non à toute notre application, on va supprimer la propriété providedIn du @Injectable() de notre service et rajouter notre service dans le tableau de la propriété providers du @NgModule de notre module.

On peut égalemnt fournir notre service au niveau de nos composants directement. Cela a peu d'intérêt car, si on fait cela, chaque composant aura une instance différente de notre service donc on perd un peu le pattern Singleton. On peut utiliser la propriété providers dans @Component. On utilise rarement ce code mais il faut juste savoir qu'il existe.

Les formulaires :

Les formulaires sont omnis présents dans les applications. Cependant, la gestion des formulaires a souvent été complexe. Entre le traitement des données de l'utilisateur, la validation, l'affichae de messages d'erreur ou de succès, il faut souvent beaucoup de travail aux développeurs pour offrir une expérience complète et agréable aux utilisateurs.

Heureusement, Angular peut nous permettre de créer des formulaires et nous faciliter le travail.

On va créer un formulaire qui va permettre à un utilisateur de modifier un pokémon avec un bouton "éditer" sur la fiche du pokémon en question.

Lorsque vous souhaitez créer un formulaire avec Angular, vous avez la possibilité d'utiliser deux modules différents : Form et ReactiveForm. Ils répondent au même besoin mais avec une approche différente.

Le premier, le FormsModule, développe une partie importante du formulaire dans le template. On parle de template-drive form.

Le second, le ReactiveFormsModule, est plus centré sur le développeement du formulaire côté composant.

Il n'y a pas une méthode mieux qu'une autre dans l'absolu. Cependant, le FormsModule est plus adapté pour les petits formulaires et pour les débutants. Pour information, ces deux modules proviennent de la même librairie : @angular/forms.

Avant d'aborder le vif du sujet, il y a deux directives que je dois vous présenter : ngForm et ngModel. Ces deux directives proviennent du FormsModule.

Pour chaque formulaire où la directive ngForm est appliquée, elle va créer une instance d'un objet nommé FormGroup au niveau global du formulaire. En tout cas, sachez qu'une référence à cette directive nous permet de savoir si le formulaire que remplit l'utilisateur est valide ou non au fur et à mesure que l'utilisateur complète ce formulaire. De plus, on peut être notifié lorsque l'utilisateur déclenchera la soumission du formulaire.

La directive ngModel doit s'appliquer sur chacun des champs du formulaire et ce pour plusieurs raisons. Premièrement, cette directive créera une instance d'un objet nommé FormControl pour chaque champ de votre formulaire comme par exemple input ou select. Ensuite, chaque instance de FormControl constituera une brique élémentaire du formulaire qui encapsulera l'état donné d'un champ. Il a pour rôle de traquer la valeur du champ, les interactions avec l'utilisateur, la validité des données saisies et de garder la vue synchronisée avec les données. Troisièmement, chaque FormControl doit être définie avec un nom et, pour cela, il suffit d'ajouter l'attribut name à la balise HTML associée. Quatrièmement, lorsque cette directive est utilisée au sein d'une balise <form>, la directive s'occuper pour nous de l'enregistrer auprès du formulaire comme un élément fils de ce formulaire.

En combinant ces deux directives, on peut donc savour en temps réel si le formulaire est valide ou non. Enfin, dernier point, on peut aussi utiliser la directive ngModelGroup pour créer des sous-groupes de champs à l'intérieur du formulaire.

En plus, la directive ngModel s'occupe de mettre en place une liaison de données bidirectionnelle pour chacun des champs du formulaire. On aura souvent de cette liaison pour gérer les interactions de l'utilisateur côté template et traiter les données saisies côté composant. Ce n'est pas tout car cette directive s'occupe également d'ajouter ou de retirer des classes spécifiques sur chaque champ. Nous pouvons ainsi savoir si l'utilisateur a cliqué sur un champ, si la valeur du champ a changé ou s'il est devenu invalide. En fonction de ces informations, nous pourrons changer l'apparence d'un champ et faire apparaître un message d'erreur ou de succès à l'utilisateur.

On va devoir injecter le FormsModule dans notre module racine pour rendre utilisable ces deux directives dans nos composants. Attention à l'ordre des importations même si ça change presque rien mais on préfère nos propres modules en tout dernier et ceux de Angular en premier, c'est-à-dire que le FormsModule doit être entre le BrowserModule et le PokemonModule. On va également l'importer dans notre PokemonModule malgré qu'il est importé à la racine.

On doit savoir ce qu'on avoir besoin dans ce formulaire avant de créer celui-ci. Le formulaire que l'on va créer en premier va permettre d'éditer certaines propriétés d'un Pokémon, c'est-à-dire que l'id et la date de création d'un pokémon ne sont appelés à être modifier.

Un truc à prendre en compte est que notre formulaire sera un composant à part entière chargé de gérer les données saisies par l'utilisateur et qui permettra d'éditer un Pokémon.

Nous allons découper le template et le composant du formulaire dans deux fichiers séparés dans le dossier src/app/pokemon/pokemon-form : pokemon-form.component.ts et pokemon-form.component.html. Pour créer ces fichiers, on utilise Angular CLI avec la commande :

ng generate component pokemon/pokemon-form --inline-template=false

Suite à cette commande, ce composant nouvellement créé est déclaré dans le PokemonModule. Voici le contenu du template de notre formulaire :

<form *ngIf="pokemon" (ngSubmit)="onSubmit()" #pokemonForm="ngForm">
    <div class="row">
        <div class="col s8 offset-s2">
            <div class="card-panel">
        
                <!-- Pokemon name -->
                <div class="form-group">
                    <label for="name">Nom</label>
                    <input type="text" class="form-control" id="name" required pattern="^[a-zA-Z0-9àéèç]{1,25}$" [(ngModel)]="pokemon.name" name="name" #name="ngModel">
                    <div [hidden]="name.valid || name.pristine" class="card-panel red accent-1">
                        Le nom du pokémon est requis (1-25).
                    </div>
                </div>
            
                <!-- Pokemon hp -->
                <div class="form-group">
                    <label for="hp">Point de vie</label>
                    <input type="number" class="form-control" id="hp" required pattern="^[0-9]{1,3}$" [(ngModel)]="pokemon.hp" name="hp" #hp="ngModel">
                    <div [hidden]="hp.valid || hp.pristine" class="card-panel red accent-1">
                        Les points de vie du pokémon sont compris entre 0 et 999.
                    </div>
                </div>
            
                <!-- Pokemon cp -->
                <div class="form-group">
                    <label for="cp">Dégâts</label>
                    <input type="number" class="form-control" id="cp" required pattern="^[0-9]{1,2}$" [(ngModel)]="pokemon.cp" name="cp" #cp="ngModel">
                    <div [hidden]="cp.valid || cp.pristine" class="card-panel red accent-1">
                        Les dégâts du pokémon sont compris entre 0 et 99.
                    </div>
                </div>
            
                <!-- Pokemon types -->
                <form class="form-group">
                    <label for="types">Types</label>
                    <p *ngFor="let type of types">
                    <label>
                        <input type="checkbox" class="filled-in" id="{{ type }}" [value]="type" [checked]="hasType(type)" [disabled]="!isTypesValid(type)" (change)="selectType($event, type)">
                        <span [attr.for]="type">
                            <div class="{{ type | pokemonTypeColor }}">
                                {{ type }}
                            </div>
                        </span>
                    </label>
                    </p>
                </form>
            
                <!-- Submit button -->
                <div class="divider"></div>
                <div class="section center">
                    <button type="submit" class="waves-effect waves-light btn" [disabled]="!pokemonForm.form.valid">
                        Valider
                    </button>
                </div>

            </div>
        </div>
    </div>
</form>
<h2 *ngIf="!pokemon" class="center">Aucun pokémon à éditer...</h2>

On va maintenant implémenter la logique dans le fichier pokemon-form.component.ts comme ceci :

import { Component, OnInit, Input } from '@angular/core';
import { Router } from '@angular/router';
import { Pokemon } from '../pokemon';
import { PokemonService } from '../pokemon.service';

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

export class PokemonFormComponent implements OnInit {

    @Input() pokemon: Pokemon;
    pokemonTypeList: string[];

    constructor(
        private pokemonService: PokemonService,
        private router: Router
    ) { }

    ngOnInit() {
        this.pokemonTypeList = this.pokemonService.getPokemonTypeList();
    }

    hasType(type: string): boolean {
        return this.pokemon.types.includes(type);
    }

    selectType($event: Event, type: string) {
        const isChecked: boolean = ($event.target as HTMLInputElement).checked;

        if (isChecked) {
            this.pokemon.types.push(type);
        } else {
            const index = this.pokemon.types.indexOf(type);
            this.pokemon.types.splice(index, 1);
        }
    }

    onSubmit() {
        console.log('Submit Form !');
        this.router.navigate(['/pokemon', this.pokemon.id]);
    }

}

L'évenement ngSubmit dans notre formulaire est un évenement généré par Angular qui est construit par dessus l'évenement submit natif du DOM dans le navigateur, mais qui fait exactement la même chose, c'est-à-dire ça déclenche cette méthode lorsque l'utilisateur va soumettre notre formulaire.

Le #pokemonForm="ngForm" est une variable référencée par le template vers mon élément du DOM (ici, c'est form) à laquelle j'attribue le résultat de la directive ngForm. Cette variable va devenir un objet géré par Angular avec juste beaucoup d'informations que juste la balise HTML 5. Par exemple, ça va nous permettre d'avoir accès à l'état de validité du formulaire en temps réel.

On va lier nos attributs HTML 5 de validation de nos champs à Angular pour qu'il sache en temps réel que le champ est valide ou non en se basant sur ces règles de validation HTML 5.

La partie [(ngModel)]="pokemon.name" contient à la fois des crochets, c'est-à-dire du property binding qui permet de pousser des données de la classe du composant vers le template, et, en même temps, on a aussi des parenthèses qui est la syntaxe de liaison d'évenements, c'est-à-dire pour remonter des évenements du template du composant vers sa classe. Donc, en combinaisant les deux, c'est-à-dire avec cette syntaxe crochet et à l'intérieur des parenthèses, ça nous permet de mettre en place une liaison de données bidirectionnelle. Quand l'utilisateur va modifier le nom d'un Pokémon, on va être informé de ça côté composant et, du côté composant, si on pilote le Pokémon et son nom, la vue va aussi se mettre à jour automatiquement.

Le .pristine veut dire que le champ n'a pas été touché ou n'a pas encore été bougé par l'utilisateur. Par exemple, au chargement du formulaire, on pourrait avoir un champ vide où on attend que l'utilisateur rentre quelque chose et même si le champ vide n'est pas une donnée valide puisque c'est required.

Dans notre template, le isTypesValid(type) de nos checkbox est en rouge dans Visual Studio Code car cette méthode n'a pas encore été implémentée dans notre composant car on a pas encore regardé la validation du formulaire.

Avant de comment ajouter les règles de validation, je vous propose de définir quelle restruction nous souhaitons implémenter sur chaque champ. On a les attributs HTML 5 required et pattern qui permet de rendre les champs obligatoires et utilisant une certaine expression régulière.

Comme on n'aura pas recours aux attributs HTML 5 pour la validation des types, on va devoir définir une méthode de validation côté composant :

isTypesValid(type: string): boolean {
    
    if (this.pokemon.types.length == 1 && thiS.hasType(type)) {
        return false;
    }

    if (this.pokemon.types.length > 2 && !this.hasType(type)) {
        return false;
    }

    return true;
}

Pour les messages d'erreur ou de succès, il faut rajouter à notre composant au minimum une feuille de style, c'est-à-dire un fichier CSS. On va devoir créer un fichier et puis, on devra rajoute la propriété styleUrls à notre @Component comme ceci :

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

Voici le contenu de notre fichier CSS en interceptant les classes fournies par Angular :

.ng-valid[required] {
    border-left: 5px solid #42A948;
}

.ng-invalid:not(form) {
    border-left: 5px solid #A94442;
}

Cela permet de mettre sur nos champs une bordure à gauche en vert ou en rouge selon la validation de chaque champs.

On va intégrer notre formulaire dans les routes de notre module afin d'y accéder et aussi créer un bouton "Éditer" dans le détail d'un pokémon pour y accéder. Pour faire cela, on va créer un nouveau composant EditPokemonComponent qui va matcher à la route qu'on va créer avec Angular CLI avec la commande suivante :

ng generate component pokemon/edit-pokemon

Cette commande créera le fichier src/app/pokemon/edit-pokemon/edit-pokemon.component.ts dont on va modifier le contenu comme ci-dessous :

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Pokemon } from '../pokemon';
import { PokemonService } from '../pokemon.service';

@Component({
    selector: 'app-edit-pokemon',
    template: `
        <h1 class="center">Éditer {{ pokemon?.name }}</h1>
        <p *ngIf="pokemon" class="center">
            <img [src]=pokemon.picture">
        </p>
        <app-pokemon-form *ngIf="pokemon" [pokemon]="pokemon"></app-pokemon-form>
    `,
    styles: [
    ]
})

export class EditPokemonComponent implements OnInit {
    
    pokemon: Pokemon|undefined;

    constructor(
        private route: ActivatedRoute,
        private pokemonService: PokemonService
    ) { }

    ngOnInit() {
        const pokemonId: string|null = this.route.snapshot.paramMap.get('id');

        if (pokemonId) {
            this.pokemon = this.pokemonService.getPokemonById(+pokemonId);
        }
    }

}

Vous vous posez sûrement la question pourquoi on n'a pas développé directement notre formulaire dans le EditPokemonComponent. Il est possible de le faire mais il y a deux choses qui nous font savoir que c'est mieux ainsi : premièrement, par la suite, on peut réutiliser le formulaire qu'on a créé pour gérer le cas d'ajout d'un nouveau pokémon dans l'application et, deuxièmement, l'avantage est la séparation des objectifs, c'est-à-dire le découpage. Le formulaire en soit est déjà une tâche suffisament conséquente pour mériter d'avoir son propre composant.

On va rajouter la route dans le fichier pokemon.module.ts :

{ path : 'edit/pokemon/:id', component: EditPokemonComponent }

L'ordre des routes est important : la route la plus spécifique en haut et les routes globales en dessous.

On va rajouter le fameux bouton "Éditer" avec une méthode goToEditPokemon(pokemon) dans notre DetailPokemonComponent :

goToEditPokemon(pokemon: Pokemon) {
    this.router.navigate(['/edit/pokemon', pokemon.id]);
}
<a (click)="goToEditPokemon(pokemon)">Éditer</a>

La programmation réactive :

Il y a un élément indispensable à la plupart des applications qu'on a pas encore aborder: Comment communiquer avec un serveur distant ?. Comme pour les formulaires, il y a plusieurs manières de faire. On peut faire des appels sur le réseau avec des Promise ou avec des Observable et la programmation réactive.

Nous verrons quels sont les outils existants pour intéragir avec un serveur distant et comment ils fonctionnent. Les promesses (ou Promise en anglais) sont natives en JavaScript depuis l'arrivée de ES6. Les promesses sont quand même là pour simplifier la programmation asynchrone. Cette dernière desssigne un mode de fonctionnement dans lequel les opérations sont non-bloquantes. Voici quelques exemples de Promise :

/***************************
* Exemple n°1
* Cet exemple permet de récupérer un utilisateur depuis un serveur distant
* à partir de son identifiant.
***************************/
let recupererUtilisateur = function(idUtilisateur) {
    return new Promise(function(resolve, reject) {
        // App assynchrone au serveur pour récupérer les informations d'un utilisateur.
        // À partir de la réponse du serveur, on extrait les données de l'utilisateur.
        let utilisateur = response.data.utilisateur;

        if (response.status === 200) {
            resolve(utilisateur);
        } else {
            reject('Cet utilisateur n\'existe pas !');
        }
    });
};

/***************************
* Exemple n°2
* Cet exemple est une fonction qui renvoie une promesse
* contenant les informations de l'utilisateur.
***************************/
recupererUtilisateur(idUtilisateur)
    .then(function(utilisateur) { // success
        console.log(utilisateur);
        this.user = utilisateur;
    }, function(error) { // error
        console.log(error);
    });

/***************************
* Exemple n°3
* Même exemple que le n°2, mais avec l'utilisation des 'Arrow functions'.
***************************/
recupererUtilisateur(idUtilisateur)
    .then(utilisateur => { // success
        console.log(utilisateur);
        this.user = utilisateur;
    }, error => consolelog(error));

On dit alors que la programmation réactive est égale à la programmation avec des flux de données asynchrones.

Un flux est une séquence d'évenements en cours qui sont ordonnés dans le temps. La succession de clics peut être modélisée comme un flux d'évenements. Ce qui est intéressant est qu'on peut appliquer des opérations sur ce flux d'évenements.

Par exemple, on peut détecter les double-clics de l'utilisateur et ignorer les clics simples. On peut considérer qu'il y a double-clic s'il y a moins de 250 ms d'écart entre deux clics.

On peut faire plus que s'abonner à un flux. Les flux peuvent émettre trois types de réponses différentes et pour chaque type on peut définir une fonction exécutée. Premièrement, une fonction peut traitée les différentes valeurs de la réponse : un nombre, un tableau, des objets, etc. Ensuite, une fonction pour traiter le cas d'erreur et, enfin, une fonction pour traiter le signal de fin, c'est-à-dire que le flux est terminé et n'émettra plus d'évenements.

Ce qu'il faut retenir c'est que les évenements du flux représentent soit les valeurs de la réponse en cas de succès, soit des erreurs ou alors des terminaisons. Il y a donc que trois cas possibles.

Pour faciliter l'implémentation de la programmation réactive, on utilise souvent des librairies spécifiques. La plus populaire dans l'écosystème JavaScript est RxJS et c'est celle choisie par les développeurs d'Angular.

Dans RxJS, un flux est représenté par un objet appelé Observable. Ils sont très similaires à des tableaux car ils contiennent une collection de valeurs.

Cet objet ajoute juste la notion de valeur reportée dans le temps. On peut traiter celui-ci avec des opérateurs similaires à ceux des tableaux.

Voici un exemple d'Observable :

Observable.fromArray([1, 2, 3, 4, 5])
    .filter(x => x > 2) // 3, 4, 5
    .map(x => x * 2) // 6, 8, 10
    .subscribe(x => console.log(x)); // affiche le résultat

Les Observable sont différents des promesses même s'ils sont se ressemblent sur certains aspects comme la gestion de valeurs asynchrones. Un Observable n'est pas quelque chose à usage unique. Il continuera d'émettre des évenements jusqu'à ce qu'il émette un évenement de terminaison ou que l'on se désabonne de lui. Globalement, l'utilisation des promesses est plus simple et, dans de nombreux cas, elles sont suffisantes pour répondre aux besoins de votre application. Enfin, il est possible de transformer un Observable en une promesse très simplement grâce à la méthode toPromise() de RxJS comme ceci :

import 'rxjs/add/operator/toPromise';

function giveMePromiseFromObservable() {
    return Observable.fromArray([1, 2, 3, 4, 5])
        .filter(x => x > 2) // 3, 4, 5
        .map(x => x * 2) // 6, 8, 10
        .toPromise();
}

Les requêtes HTTP :

Revenons sur notre application de gestions de Pokémon. Pour rappel, une API est une interface de programmation.

Pour commencer, on va mettre en place le client via le HttpClientModule compris dans Angular.

Le HttpClientModule ne fournit pas d'éléments au niveau de la vue, c'est-à-dire pas de directives, de composants ou de pipes. On va l'injecter une seule fois à la racine de notre projet afin d'être disponible pour tous nos composants.

Donc, dans le fichier app.module.ts, on va l'importer avant nos modules mais après les modules Angular déjà importés :

import { HttpClientModule } from '@angular/common/http';

Jusqu'à maintenant, nous avons stocké et récupérer nos données depuis le PokemonService. On va simuler maintenant une API sans utiliser de base de données ni d'API RESTful.

Pour faire cela, on va installer un nouveau package de dépendances via la commande suivante :

npm install angular-in-memory-web-api --save-dev

Ensuite, on va créer un service pour simuler une base de données dans notre application Angular via Angular CLI avec la commande ci-dessous :

ng generate service in-memory-data

Cela crée à la racine de notre dossier app le fichier in-memory-data.service.ts avec le contenu modifié suivant :

import { Injectable } from '@angular/core';
import { InMemoryDbService } from 'angular-in-memory-web-api':
import { POKEMONS } from './pokemon/mock-pokemon-list';

@Injectable({
    providedIn: 'root'
})

export class InMemoryDataService implements InMemoryDbService {

    createDb() {
        const pokemons = POKEMONS;
        return { pokemons };
    }

}

On va importer, dans le app.module.ts, juste en dessous du HttpClientModule le nouveau module :

// ...
import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api';
import { InMemonyDataService } from './in-memory-data.service';

// ...

        HttpClientInMemoryWebApiModule.forRoot(InMemoryDataService, { dataEncapsulation: false }),

// ...

On met le dataEncapsulation à false car sinon, par défaut, cette librairie, à chaque fois qu'on va la requêter, va encapsuler toutes nos réponses dans un élément data.

Dans notre PokemonServive, on va faire un certain nombre de modifications. Par exemple, dans la méthode getPokemonList(), on va faire une requête réseau même si elle est simulée auprès d'un serveur distant et ensuite récupérer les données. Cette méthode deviendra asynchrone.

Voici les différentes modifications de ce service :

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, tap, of } from 'rxjs';
import { Pokemon } from './pokemon';

@Injectable()
export class PokemonService {

    constructor(private http: HttpClient) { }

    getPokemonList: Observable<Pokemon[]> {
        return this.http.get<Pokemon[]>('api/pokemons').pipe(
            tap((response) => this.log(response)),
            catchError((error) => this.handleError(error, []))
        );
    }

    getPokemonById(pokemonId: number): Observable<Pokemon|undefined> {
        return this.http.get<Pokemon>(`api/pokemons/${pokemonId}`).pipe(
            tap((response) => this.log(response)),
            catchError((error) => this.handleError(error, undefined))
        );
    }

    private log(response: any) {
        console.table(response);
    }

    private handleError(error: Error, errorValue: any) {
        console.error(error);
        return of(errorValue);
    }

    getPokemonTypeList(): string[] {
        return [
            'Plante',
            'Feu',
            'Eau',
            'Insecte',
            'Normal',
            'Electrik',
            'Poison',
            'Fée',
            'Vol',
            'Combat',
            'Psy'
        ];
    }
}

On va adapter nos composants pour accéder à ces nouvelles données asynchrones. Premièrement, modifions le ListPokemonComponent :

ngOnInit() {
    this.pokemonService.getPokemonList()
        .subscribe((pokemonList) => this.pokemonList = pokemonList);
}

Deuxièmement, on va modifier le DetailPokemonComponent et le EditPokemonComponent avec le contenu suivant :

ngOnInit() {
    const pokemonId: string|null = this.route.snapshot.paramMap.get('id');

    if (pokemonId) {
        this.pokemonService.getPokemonById(+pokemonId)
            .subscribe((pokemon) => this.pokemon = pokemon);
    }
}

Quand notre application utilisait une liste de données issue d'un fichier statique, les modifications étaient répercutées directement au Pokémon de cette liste unique commune à toute l'application. Mais, maintenant que nous extrayons les données à partir d'une API, si nous voulons persister nos changements nous aurions besoin de les écrire sur le serveur, c'est-à-dire de faire une requête HTTP pour enregistrer nos modifications.

Pour cela, on va créer une méthode de modification dans notre PokemonService :

import { HttpClient, HttpHeaders } from '@angular/common/http';

// ...

    updatePokemon(pokemon: Pokemon): Observable<null> {
        const httpOptions = {
            headers: new HttpHeaders({ 'Content-Type': 'application/json' })
        };

        return this.http.put('api/pokemons', pokemon, httpOptions).pipe(
            tap((response) => this.log(response)),
            catchError((error) => this.handleError(error, null));
        );
    }

// ...

On va se servir de cette méthode dans notre PokemonFormComponent :

onSubmit() {
    this.pokemonService.updatePokemon(this.pokemon)
        .subscribe(() => this.router.navigate(['/pokemon', this.pokemon.id]));
}

On va essayer de supprimer un pokémon. Dans notre PokemonService, on va créer une nouvelle méthode :

deletePokemonById(pokemonId: number): Observable<null> {
    return this.http.delete(`api/pokemons/${pokemonId}`).pipe(
        tap((response) => this.log(response)),
        catchError((error) => this.handleError(error, null))
    );
}

Dans notre DetailPokemonComponent, on va rajouter le bouton "Supprimer" :

deletePokemon(pokemon: Pokemon) {
    this.pokemonService.deletePokemonById(pokemon.id)
        .subscribe(() => this.goToPokemonList());
}
<a (click)="deletePokemon(pokemon)">Supprimer</a>

Maintenant qu'on a vidé notre pokédex, on va permettre à l'utilisateur d'ajouter un nouveau Pokémon. Premièrement, on va bien sûr ajouter une méthode dans notre PokemonService qui va permettre de persister le nouveau Pokémon niveau serveur :

addPokemon(pokemon: Pokemon): Observable<Pokemon> {
    cons httpOptions = {
        headers: new HttpHeaders({ 'Content-Type': 'application/json' })
    };
    
    return this.http.post<Pokemon>('api/pokemons', pokemon, httpOptions).pipe(
        tap((response) => this.log(response)),
        catchError((error) => this.handleError(error, null))
    );
}

Ensuite, on va créer un composant AddPokemon qui va réutiliser notre formulaire de Pokémon mais pour en créer un nouveau. Avec Angular CLI, on utilise la commande suivante :

ng generate component pokemon/add-pokemon

Cela crée le fichier app/pokemon/add-pokemon/add-pokemon.component.ts avec le contenu modifié suivant :

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

@Component({
    selector: 'app-add-pokemon',
    template: `
        <h1 class="center">Ajouter un pokémon</h1>
        <app-pokemon-form [pokemon]="pokemon"></appp-pokemon-form>
    `
})

export class AddPokemonComponent implements OnInit {

    pokemon: Pokemon;

    ngOnInit() {
        this.pokemon = new Pokemon();
    }

}

On va modifier notre modèle Pokemon pour créer un pokémon avec des valeurs par défaut :

export class Pokemon {

    id: number;
    name: string;
    hp: number;
    cp: number;
    picture: string;
    types: string[];
    created: Date;

    constructor(
        name: string = 'Entrez un nom...',
        hp: number = 100,
        cp: number= 10,
        picture: string = 'https://assets.pokemon.com/assets/cms2/img/pokedex/detail/xxx.png',
        types: string[] = ['Normal'],
        created: Date = new Date()
    ) {
        this.name = name;
        this.hp = hp;
        this.cp = cp;
        this.picture = picture;
        this.types = types;
        this.created = created;
    }
}

Troisièmement, on va modifier ce formulaire pour qu'il puisse gérer à la fois l'ajout d'un nouveau Pokémon et la modification d'un Pokémon :

// ...
    
isAddForm: boolean;

// ...

ngOnInit() {
    this.types = this.pokemonService.getPokemonTypeList();
    this.isAddForm = this.router.url.includes('add');
}

onSubmit() {
    if (isAddForm) {
        this.pokemonService.addPokemon(this.pokemon)
            .subscribe((pokemon: Pokemon) => this.router.navigate(['/pokemon', pokemon.id]));
    } else {
        this.pokemonService.updatePokemon(this.pokemon)
            .subscribe(() => this.router.navigate(['/pokemon', this.pokemon.id]));
    }
}

// ...
<!-- Pokemon picture -->
<div *ngIf="isAddForm" class="form-group">
    <label="picture">Image</label>
    <input type="url" class="form-control" id="picture" required [(ngModel)]="pokemon.picture" name="picture" #picture="ngModel">

    <div [hidden]="picture.valid || picture.pristine" class="card-panel red accent-1">
        L'image du pokémon est requise.
    </div>
</div>

Enfin, on va rajouter un bouton "Ajouter" qui va rediriger vers la page qui crée le nouveau Pokémon, c'est-à-dire déclarer une route et un lien vers cette route :

{ path: 'pokemon/add', component: AddPokemonComponent },
<a class="btn-floating btn-large waves-effect waves-light red 2-depth-3" style="position: fixed; bottom: 25px; right: 25px;" routerLink="/pokemon/add">
    +
</a>

La librairie RxJS :

Je vous propose d'ajouter une nouvelle fonctionnalité pour pouvoir rechercher des Pokémons via leur nom sous la forme d'un champ de recherche.

Ce dernier devra implémenter l'auto-complétion, c'est-à-dire qu'au fur et à mesure que l'utilisateur tapera un terme de recherche, nous afficherons une liste de Pokémons correspondant aux critères de recherche.

Pour commencer, on va créer une nouvelle méthode dans notre PokemonService :

searchPokemonList(term: string): Observable<Pokemon[]> {
    if (term.length <= 1) {
        return of([]);
    }

    return this.http.get<Pokemon[]>(`api/pokemons/?name=${term}`).pipe(
        tap((response) => this.log(response)),
        catchError((error) => this.handleError(error, []))
    );
}

Ensuite, on va créer un composant contenant ce fameux champ de recherche via Angular CLI via la commande suivante :

ng generate component pokemon/search-pokemon --inline-template=false

Cette commande crée les fichiers search-pokemon.component.ts et search-pokemon.component.html dans le dossier pokemon/search-pokemon et modifiera le PokemonModule pour déclarer ce composant.

import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { Observable, Subject, switchMap } from 'rxjs';
import { Pokemon } from '../pokemon';
import { PokemonService } from '../pokemon.service';

@Component({
    selector: 'app-search-pokemon',
    templateUrl: './search-pokemon.component.html',
})

export class SearchPokemonComponent implements OnInit {
    // {..."a".."ab"..."abz".."ab"..."abc".....}
    searchTerms = new Subject<string>();
    // {...pokemonList(a)...pokemonList(ab).....}
    pokemons$: Observable<Pokemon[]>;

    constructor(
        private router: Router,
        private pokemonService: PokemonService
    ) { }

    ngOnInit() {
        this.pokemons$ = this.searchTerms.pipe(
            // {..."a"."ab"..."abz"."ab"..."abc".....}
            debounceTime(300),
            // {..."ab"...."ab"..."abc".....}
            distinctUntilChanged(),
            // {..."ab"......."abc".....}
            switchMap((term) => this.pokemonService.searchPokemonList(term))
            // {...pokemonList(ab).......pokemonList(abc).....}
        );
    }

    search(term: string) {
        this.searchTerms.next(term);
    }

    goToDetail(pokemon: Pokemon) {
        this.router.navigate(['/pokemon', pokemon.id]);
    }
    
}

La variable pokemons$ est une convention Angular pour indiquer qu'elle contient un flux de données. On a utilisé la programmation réactive de RxJS dans le ngOnInit. Voici le template :

<div class="row">
    <div class="col s112 m6 offset-m3">
        <div class="card">
            <div class="card-content">
                <div class="input-field">
                    <input type="search" #searchBox (keyup)="search(searchBox.value)" placeholder="Rechercher un pokémon">
                    <div class="collection">
                        <a *ngFor="let pokemon of pokemons$ | async" (click)="goToDetail(pokemon)" class="collection-item">
                            {{ pokemon.name }}
                        </a>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>

Le pokemons$ | async va faire exactement pareil que le this.pokemons$.subscribe(pokemons => this.pokemons = pokemons); dans le ngOnInit. On va rajouter le rajouter dans le template de notre ListPokemon juste avant la liste :

<app-search-pokemon></app-search-pokemon>

On va ajouter une icône de chargementcar l'application est un peu plus lente depuis qu'elle utilise l'API. Pour cela, on va créer un nouveau composant avec Angular CLI avec la commande suivante :

ng generate component pokemon/loader

Cela crée le fichier pokemon/loader/loader.component.ts avec le contenu modifié :

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

@Component({
    selector: 'app-loader',
    template: `
        <div class="preloader-wrapper big active">
            <div class="spinner-layer spinner-blue">
                <div class="circle-clipper left">
                    <div class="circle"></div>
                </div>
                <div class="gap-patch">
                    <div class="circle"></div>
                </div>
                <div class="circle-clipper right">
                    <div class="circle"></div>
                </div>
            </div>
        </div>
    `
})

export class LoaderComponent { }

On va modifier le message s'il y a aucun Pokémon à afficher dans le template de notre DetailPokemonComponent et on va devoir faire pareil pour le message dans le template du PokemonFormComponent :

<h2 *ngIf="!pokemon" class="center">
    <app-loader></app-loader>
</h2>

Authentification et sécurité :

Pour mettre en place l'authentification, on aura besoin d'un Guard. Un Guard est un mécanisme de protection utilisé par Angular pour mettre en place l'authentification mais pas seulement.

Le Guard retourne un booléen qui permet de contrôler le comportement de la naviagtion. Par exemple :

  • Si ça retourne true, le processus de navigation continue.

  • Si ça retourne false, le processus de navigation cesse et l'utilisateur reste sur la même page.

Dans la plupart des cas, un Guard ne peut pas renvoyer un résultat de manière synchrone car il doit attendre une réponse.

C'est-à-dire que le type de retour d'un Guard est un Observable<boolean> ou une Promise<boolean> et le routeur attendra la réponse pour agir sur la navigation.

Même si un Guard est conçu pour intéragir avec la navigation, il en existe des types différents :

  • CanActivate : Il peut influencer sur la navigation d'une route comme la bloquer par exemple. C'est ce type de Guard qu'on va utiliser pour construire un système d'authentification dans notre application.

  • CanActivateChild : Il peut influencer sur la navigation d'une route fille.

  • CanDeactivate : Il empêche l'utilisateur de naviguer en dehors de la route courante. Cela peut être utile lorsqu'un utilisateur a oublié de valider un formulaire avant de continuer la navigation.

  • Resolve : Il effectue une récupération de données avant de naviguer.

  • CanLoad : Il peut gérer la navigation vers un sous-module chargé de manière asynchrone.

Bien sûr, on peut avoir différents Guard à tous les niveaux du système de navigation. Cependendant, si à un moment un Guard retourne false, tous les autres Guard en attente seront annulés et la navigation sera bloquée.

Il est très courant pour les applications web de devoir restreindre l'accès à certaines fonctionnalités en fonction si l'utilisateur est connecté ou non. Pour cela, on va mettre en place un Guard de type CanActivate avec Angular CLI avec la commande suivante :

ng generate guard auth

Cela crée le fichier src/app/auth.guard.ts avec le contenu modifié suivant :

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

@Injectable({
    providedIn: 'root'
})

export class AuthGuard implements CanActivate {

    canActivate(): boolean {
        console.log('Le guard a été appelé !');
        return true;
    }

}

Dans le PokemonModule, on va importer le Guard afin de l'appliquer à la route d'édition :

// ...
import { AuthGuard } from '../auth.guard';
// ...
    { path: 'edit/pokemon/:id', component: EditPokemonComponent, canActivate: [AuthGuard] },
// ...

On va également sécuriser toutes les routes de notre PokemonModule.

On aura besoin d'un nouveau service dédié à l'authentification qui va permettre de dire si oui ou non l'utilisateur est connecté en fonction d'un email et d'un mot de passe. C'est pas le rôle du Guard de faire ça car ce dernier pilote le routing. On va créer ce service à la racine de notre application avec Angular CLI :

ng generate service auth

Ce service aura un rôle différent du Guard qu'on a mis en place malgré qu'ils ont le même nom. On va modifier le fichier auth.service.ts avec le contenu suivant :

import { Injectable } from '@angular/core';
import { Observable, of, delay, tap } from 'rxjs';

@Injectable({
    providedIn: 'root'
})

export calss AuthService {

    isLoggedIn: boolean = false;
    redirectUrl: string;

    login(name: string, password: string): Observable<boolean> {
        const isLoggedIn = (name == 'pikachu' && password == 'pikachu');

        return of(isLoggedIn).pipe(
            delay(1000),
            tap((isLoggedIn) => this.isLoggedIn = isLoggedIn)
        );
    }

    logout() {
        this.isLoggedIn = false;
    }

}

On va injecter le nouveau service dans le Guard comme ceci :

import { Injectable } from '@angular/core';
import { CanActivate, Router } from '@angular/router';
import { AuthService } from './auth.service';

@Injectable({
    providedIn: 'root'
})

export class AuthGuard implements CanActivate {

    constructor(
        private authService: AuthService,
        private router: Router
    ) { }

    canActivate(): boolean {
        if (this.authService.isLoggedIn) {
            return true;
        }

        this.router.navigate(['/login']);
        return false;
    }

}

On va maintenant créer cette fameuse route /login. Mais avant tout, on va créer la page de connexion afin de relier cette route avec le LoginComponent créé via Angular CLI avec la commande suivante :

ng generate component login --inline-template

Cela créera donc les fichiers login.component.ts et login.component.html dans le dossier src/app/login avec les contenus modifiés suivants :

import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../auth.service';

@Component({
    selector: 'app-login',
    templateUrl: './login.component.html',
    styles: [
    ]
})

export class LoginComponent implements OnInit {

    message: string = 'Vous êtes déconnecté (pikachu/pikachu)';
    name: string;
    password: string;
    auth: authService

    constructor(
        private authService: AuthService,
        private router: Router
    ) { }

    ngOnInit() {
        this.auth = this.authService;
    }

    setMessage() {
        if (this.auth.isLoggedIn) {
            this.message = 'Vous êtes connecté.';
        } else {
            this.message = 'Identifiant ou mot de passe incorrect.';
        }
    }

    login() {
        this.message = 'Tentative de connexion en cours...';
        this.auth.login(this.name, this.password)
            .subscribe((isLoggedIn: boolean) => {
                this.setMessage();
                if (isLoggedIn) {
                    this.router.navigate(['/pokemons']);
                } else {
                    this.password = '';
                    this.router.navigate(['/login']);
                }
            });
    }

    logout() {
        this.auth.logout();
        this.message = 'Vous êtes déconnecté.';
    }

}
<div class="row">
    <div class="col s12 m4 offset-m4">
        <div class="card hoverable">
            <div class="card-content center">
                <span class="card-title">Page de connexion</span>
                <p><em>{{message}}</em></p>
            </div>
            <form #loginForm="ngForm">
                <div>
                    <label for="name">Name</label>
                    <input type="text" id="name" [(ngModel)]="name" name="name" required>
                </div>
                <div>
                    <label for="password">Password</label>
                    <input type="password" id="password" [(ngModel)]="password" name="password" required>
                </div>
            </form>
            <div class="card-action center">
                <a (click)="login()" class="waves-effect waves-light btn"  *ngIf="!auth.isLoggedIn">
                    Se connecter
                </a>
                <a (click)="logout()" *ngIf="auth.isLoggedIn">
                    Se déconnecter
                </a>
            </div>
        </div>
    </div>
</div>

Dans le app.module.ts, on modifie les routes comme ceci :

const routes: Routes = [
    { path: '', redirectTo: 'login', pathMatch: 'full' },
    { path: 'login', component: LoginComponent },
    { path: '**', component: PageNotFoundComponent }
];

Déployer votre application :

On va déployer notre application en production sur Firebase Hosting, entreprise appartenant à Google proposant d'héberger gratuitement des sites statiques en dessous d'un certain quota d'utilisation.

Premièrement, nous devons préparer notre projet en local pour le déploiement. Nous verrons quels sont les éléments les plus importants à optimiser.

En fait, il faut d'abord permettre à Angular d'optimiser tout un tas de choses en local comme supprimer les dépendances inutiles en production comme TypeScript. Le navigateur ne comprend que le JavaScript. On doit demander à Angular de compiler tout notre code TypeScript en fichiers JavaScript compréhensibles par le navigateur. On va pas faire cela à la main mais Angular CLI possède la commande suivante :

ng build

On va utiliser le dossier dist/ng-pokemon-app qui contient le code généré par la commande précédente.

Ensuite, nous créerons notre projet sur Firebase. Cela nous donnera accès à une console d'administration où vous pourrez consulter l'historique de vos déploiements, revenir à une version plus ancienne de notre projet, etc.

On va devoir installer Firebase CLI en local. Il s'agit d'une sorte de boîte à outils mise à disposition par Firebase pour vous permettre de déployer votre application en une seule ligne de commandes sur leur serveur. Ensuite, nous devrions configurer notre projet auprès de Firebase pour préparer les éléments spécifiques au déploiement.

npm install -g firebase-tools

Pour vérifier que l'installation s'est bien passée, on va faire la commande suivante :

firebase --version

Pour lier notre compte Google, on utilise la commande :

firebase login

On va relier nos fichiers en local avec notre projet Firebase avec la commande :

firebase init

On va choisir "Hosting", on choisit le dossier "dist/ng-pokemon-app" et on pointe tout vers index.html pour une Single Page Application. Pas de "github" et ne pas modifier le fichier index.html. Firebase a créé deux nouveaux fichiers dans notre projet local : .firebase.rc et firebase.json.

{
    "hosting": {
        "public": "dist/ng-pokemon-app",
        "ignore": [
            "firebase.json",
            "**/.*",
            "**/node_modules/**"
        ],
        "rewrites": [
            {
                "source": "**",
                "destination": "/index.html"
            }
        ]
    }
}

Enfin, nous déploierons notre application sur Firebase pour de bon.

On va déployer notre projet avec Firebase CLI avec la commande :

firebase deploy

Il nous donne deux URLs : le back-office de notre projet Firebase et le lien vers notre projet en ligne.