Angular/Tutorials/OpenClassRooms M Créez une application complète avec Angular et Firebase Une-Bibliothèque

De WikiSys
Aller à : navigation, rechercher

Créez une application complète avec Angular et Firebase

Pour cette section, vous allez créer une nouvelle application et appliquer des connaissances que vous avez apprises tout au long du cours Angular, ainsi que quelques fonctionnalités que vous n'avez pas encore rencontrées. Vous allez créer une application simple qui recense les livres que vous avez chez vous, dans votre bibliothèque. Vous pourrez ajouter une photo de chaque livre. L'utilisateur devra être authentifié pour utiliser l'application.

Malgré sa popularité, j'ai choisi de ne pas intégrer AngularFire dans ce cours.

Si vous souhaitez en savoir plus, vous trouverez plus d'informations sur la page GitHub d'AngularFire. Vous emploierez l'API JavaScript mise à disposition directement par Firebase.

Pensez à la structure de l'application

Prenez le temps de réfléchir à la construction de l'application. Quels seront les components dont vous aurez besoin ? Les services ? Les modèles de données ?

L'application nécessite l'authentification. Il faudra donc un component pour la création d'un nouvel utilisateur, et un autre pour s'authentifier, avec un service gérant les interactions avec le backend.

Les livres pourront être consultés sous forme d'une liste complète, puis individuellement. Il faut également pouvoir ajouter et supprimer des livres. Il faudra donc un component pour la liste complète, un autre pour la vue individuelle et un dernier comportant un formulaire pour la création/modification. Il faudra un service pour gérer toutes les fonctionnalités liées à ces components, y compris les interactions avec le serveur.

Vous créerez également un component séparé pour la barre de navigation afin d'y intégrer une logique séparée.

Pour les modèles de données, il y aura un modèle pour les livres, comportant simplement le titre, le nom de l'auteur et la photo, qui sera facultative.

Il faudra également ajouter du routing à cette application, permettant l'accès aux différentes parties, avec une guard pour toutes les routes sauf l'authentification, empêchant les utilisateurs non authentifiés d'accéder à la bibliothèque.

Allez, c'est parti !

Structurez l'application

Pour cette application, je vous conseille d'utiliser le CLI pour la création des components.

su
cd ~/Angular
npm install -g npm@latest    (npm est le gestionnaire de paquets officiel de Node.js)
ng new une-bibliotheque --style=scss --skip-tests=true

vidéo : 2:30

L'arborescence sera la suivante :

ng g c auth/signup
ng g c auth/signin
ng g c book-list
ng g c book-list/single-book
ng g c book-list/book-form
ng g c header
ng g s services/auth
ng g s services/books
ng g s services/auth-guard

Les services ainsi créés ne sont pas automatiquement mis dans l'array providers d'AppModule , donc ajoutez-les maintenant.

npm install --save bootstrap@3.3.7 

à vérifier dans package.json

"bootstrap": "^3.3.7",

vidéo : 4:30

Avant de lancer ng serve , utilisez NPM pour ajouter Bootstrap à votre projet, et ajoutez-le à l'array styles de .angular-cli.json  :

npm install bootstrap@3.3.7 --save
angular-cli.json
"styles": [
        "../node_modules/bootstrap/dist/css/bootstrap.css",
        "styles.css"
  ],

vidéo : 5:30

 npm install rxjs-compat --save 


Pendant que vous travaillez sur AppModule , ajoutez également FormsModule , ReactiveFormsModule et HttpClientModule  :

app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpClient, HttpClientModule } from '@angular/common/http';

import { AuthGuardService } from './services/auth-guard.service';
import { AuthService } from './services/auth.service';
import { BooksService } from './services/books.service';

imports: [
    BrowserModule,
    FormsModule,
    ReactiveFormsModule,
    HttpClientModule
  ],
providers: [
     AuthService, 
     BooksService, 
     AuthGuardService
   ],

N'oubliez pas d'ajouter les imports en haut du fichier !

Intégrez dès maintenant le routing sans guard afin de pouvoir accéder à toutes les sections de l'application pendant le développement :

app.module.ts
import { Routes, RouterModule } from '@angular/router';

const appRoutes: Routes = [
  { path: 'auth/signup', component: SignupComponent },
  { path: 'auth/signin', component: SigninComponent },
  { path: 'books', component: BookListComponent },
  { path: 'books/new', component: BookFormComponent },
  { path: 'books/view/:id', component: SingleBookComponent }
];

imports: [
    BrowserModule,
    FormsModule,
    ReactiveFormsModule,
    HttpClientModule,
    RouterModule.forRoot(appRoutes)
],

vidéo : 8:00

Générez également un dossier appelé models et créez-y le fichier book.model.ts  :

book.model.ts
export class Book {
  photo: string;
  synopsis: string;
  constructor(public title: string, public author: string) {
  }
}

vidéo : 8:50

Enfin, préparez HeaderComponent avec un menu de navigation, avec les routerLink et AppComponent qui l'intègre avec le router-outlet  :

header.componennt.html
<nav class="navbar navbar-default">
  <div class="container-fluid">
    <ul class="nav navbar-nav">
      <li routerLinkActive="active">
        <a routerLink="books">Livres</a>
      </li>
    </ul>
    <ul class="nav navbar-nav navbar-right">
      <li routerLinkActive="active">
        <a routerLink="auth/signup">Créer un compte</a>
      </li>
      <li routerLinkActive="active">
        <a routerLink="auth/signin">Se connecter</a>
      </li>
    </ul>
  </div>
</nav>

vidéo : 10:30 app.component.html

Vous y mettez le router-outlet pour recevoir les routes de l'application (voir header.component.html)

app.component.html
<app-header></app-header>
<div class="container">
    <router-outlet></router-outlet>  <!-- reception des routes de l'application --> 
</div>

vidéo : 10:40

La structure globale de l'application est maintenant prête !

cd une-bibliotheque
ng serve --open

vidéo : 11:00

git commit -m "vidéo : 11:00 première connexion"

Intégrez Firebase à votre application

D'abord, installez Firebase avec NPM :

npm install firebase --save- console output -
npm ERR! code E404
npm ERR! 404 Not Found - GET https://registry.npmjs.org/- - Not found
npm ERR! 404 
npm ERR! 404  '-@latest' is not in the npm registry.
npm ERR! 404 You should bug the author to publish it (or use the name yourself!)
npm ERR! 404 
npm ERR! 404 Note that you can also install from a
npm ERR! 404 tarball, folder, http url, or git url.
npm install firebase --save
npm audit fix --force

Pour cette application, vous allez créer un nouveau projet sur Firebase. Une fois l'application créée, la console Firebase vous propose le choix suivant (sous la rubrique Overview) :

sudo npm install -g firebase-tools

Choisissez "Ajouter Firebase à votre application Web" et copiez-collez la configuration dans le constructeur de votre AppComponent' (en ajoutant import * as firebase from 'firebase; en haut du fichier, mettant à disposition la méthode initializeApp() ) : Copiez et collez ces scripts en bas de votre balise <body>, et ce, avant d'utiliser les services Firebase :

<!-- The core Firebase JS SDK is always required and must be listed first -->
<script src="https://www.gstatic.com/firebasejs/6.1.1/firebase-app.js"></script>

<!-- TODO: Add SDKs for Firebase products that you want to use
     https://firebase.google.com/docs/web/setup#config-web-app -->
app.component.ts
import { Component } from '@angular/core';
import * as firebase from 'firebase';

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

export class AppComponent {
  constructor() {
    const config = {
    apiKey: "AIzaSyBRvorDmAKFK9vneHnRsom9mDW398uUxEg",
    authDomain: "une-bibliotheque-90c91.firebaseapp.com",
    databaseURL: "https://une-bibliotheque-90c91.firebaseio.com",
    projectId: "une-bibliotheque-90c91",
    storageBucket: "une-bibliotheque-90c91.appspot.com",
    messagingSenderId: "1012969104430",
    appId: "1:1012969104430:web:815509d2f2949a33"
    };
    firebase.initializeApp(config);
  }
}

Votre application Angular est maintenant liée à votre projet Firebase, et vous pourrez maintenant intégrer tous les services dont vous aurez besoin.

vidéo 12:30

Authentification

Votre application utilisera l'authentification par adresse mail et mot de passe proposée par Firebase.

Pour cela, il faut d'abord l'activer dans la console Firebase :

Authentification
 par email et mot de passe  activé
 enregistré

vidéo : 13:00

L'authentification Firebase emploie un système de token : un jeton d'authentification est stocké dans le navigateur, et est envoyé avec chaque requête nécessitant l'authentification.

Dans AuthService , vous allez créer trois méthodes :

  • une méthode permettant de créer un nouvel utilisateur ;
  • une méthode permettant de connecter un utilisateur existant ;
  • une méthode permettant la déconnexion de l'utilisateur.

Puisque les opérations de création, de connexion et de déconnexion sont asynchrones, c'est-à-dire qu'elles n'ont pas un résultat instantané, les méthodes que vous allez créer pour les gérer retourneront des Promise, ce qui permettra également de gérer les situations d'erreur.

Importez Firebase dans AuthService  :

auth.service.ts
import { Injectable } from '@angular/core';
import * as firebase from 'firebase';

@Injectable()

export class AuthService {

Ensuite, créez la méthode (asynchrone) createNewUser() pour créer un nouvel utilisateur, qui prendra comme argument une adresse mail et un mot de passe, et qui retournera une Promise qui se résoudra si la création réussit, et sera rejetée avec le message d'erreur si elle ne réussit pas :

auth.service.ts
createNewUser(email: string, password: string) {

    return new Promise(
      (resolve, reject) => {
        firebase.auth().createUserWithEmailAndPassword(email, password).then(
          () => {
            resolve();
          },
          (error) => {
            reject(error);
          }
        );
      }
    );
}

Toutes les méthodes liées à l'authentification Firebase se trouvent dans firebase.auth().

Créez également signInUser() , méthode (asynchrone) très similaire, qui s'occupera de connecter un utilisateur déjà existant :

auth.service.ts
signInUser(email: string, password: string) {
    return new Promise(
      (resolve, reject) => {
        firebase.auth().signInWithEmailAndPassword(email, password).then(
          () => {
            resolve();
          },
          (error) => {
            reject(error);
          }
        );
      }
    );
}

Créez une méthode simple (synchrone) signOutUser()  :

auth.service.ts
signOutUser() {
    firebase.auth().signOut();
}

Ainsi, vous avez les trois fonctions dont vous avez besoin pour intégrer l'authentification dans l'application !

Vous pouvez ainsi créer SignupComponent et SigninComponent , intégrer l'authentification dans HeaderComponent afin de montrer les bons liens, et implémenter AuthGuard pour protéger la route /books et toutes ses sous-routes.

vidéo : 16:50 signup.component.ts

Commencez par SignupComponent afin de pouvoir enregistrer un utilisateur :

auth/signup/signup.component.ts
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { AuthService } from '../../services/auth.service';
import { Router } from '@angular/router';

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

export class SignupComponent implements OnInit {

  signupForm: FormGroup; /* <form [formGroup]="signupForm" */
  errorMessage: string;

  constructor(private formBuilder: FormBuilder,  /* création du formulaire */
              private authService: AuthService,  /* Authentification */
              private router: Router) { }

  ngOnInit() {
    this.initForm();
  }

  initForm() {
    this.signupForm = this.formBuilder.group({
      email: ['', [Validators.required, Validators.email]],
      password: ['', [Validators.required, Validators.pattern(/[0-9a-zA-Z]{6,}/)]]
    });
  }

  onSubmit() {
    const email = this.signupForm.get('email').value;
    const password = this.signupForm.get('password').value;

    this.authService.createNewUser(email, password).then(
      () => {
        this.router.navigate(['/books']);
      },
      (error) => { /* affichage dans {{ errorMessage }} du template *
        this.errorMessage = error;
      }
    );
  }
}

Dans ce component :

   vous générez le formulaire selon la méthode réactive
       
  • les deux champs, email et password , sont requis
    • le champ email utilise Validators.email pour obliger un string sous format d'adresse email ;
    • le champ password emploie Validators.pattern pour obliger au moins 6 caractères alphanumériques, ce qui correspond au minimum requis par Firebase ;
  • vous gérez la soumission du formulaire, envoyant les valeurs rentrées par l'utilisateur à la méthode createNewUser()
  • si la création fonctionne, vous redirigez l'utilisateur vers /books ;
  • si elle ne fonctionne pas, vous affichez le message d'erreur renvoyé par Firebase.

vidéo : 20:00 signup.component.html

Ci-dessous, vous trouverez le template correspondant :

auth/signup/signup.component.html
<div class="row">
  <div class="col-sm-8 col-sm-offset-2">
    <h2>Créer un compte</h2>
    <form [formGroup]="signupForm" (ngSubmit)="onSubmit()"> 
      <div class="form-group">
        <label for="email">Adresse mail</label>
        <input type="text"
               id="email"
               class="form-control"
               formControlName="email"
      </div>
      <div class="form-group">
        <label for="password">Mot de passe</label>
        <input type="password"
               id="password"
               class="form-control"
               formControlName="password">
      </div>
      <button class="btn btn-primary"
              type="submit"
              [disabled]="signupForm.invalid">Créer mon compte</button>
    </form>
    <p class="text-danger">{{ errorMessage }}</p>
  </div>
</div>

Il s'agit d'un formulaire selon la méthode réactive comme vous l'avez vu dans le chapitre correspondant.

vidéo : 24:00 signup.component créé

git commit -m "vidéo: 24:00 signup.component créé"
80683bc0fab03891582eee03da5d6c36baf53fd1

vidéo : 24:30 erreur si re-création d'un compte

Il y a, en supplément, le paragraphe contenant l'éventuel message d'erreur rendu par Firebase.

Vous pouvez créer un template presque identique pour SignInComponent pour la connexion d'un utilisateur déjà existant. Il vous suffit de renommer signupForm en signinForm et d'appeler la méthode signInUser() plutôt que createNewUser() .

vidéo : 26:00 header

Ensuite, vous allez modifier HeaderComponent pour afficher de manière contextuelle les liens de connexion, de création d'utilisateur et de déconnexion :

header/header.component.ts
import { Component, OnInit } from '@angular/core';
import { AuthService } from '../services/auth.service';
import * as firebase from 'firebase';

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

export class HeaderComponent implements OnInit {

  isAuth: boolean;

  constructor(private authService: AuthService) { }

  ngOnInit() {
    firebase.auth().onAuthStateChanged(
      (a_user) => {
        if(a_user) {
          this.isAuth = true;
        } else {
          this.isAuth = false;
        }
      }
    );
  }

  onSignOut() {
    this.authService.signOutUser();
  }

}
header/header.component.html
<nav class="navbar navbar-default">
  <div class="container-fluid">
    <ul class="nav navbar-nav">
      <li routerLinkActive="active">
        <a routerLink="books">Liste des livres</a>
      </li>
    </ul>
    <ul class="nav navbar-nav navbar-right">
      <li routerLinkActive="active" *ngIf="!isAuth">
        <a routerLink="auth/signup">Créer un compte</a>
      </li>
      <li routerLinkActive="active" *ngIf="!isAuth">
        <a routerLink="auth/signin">Se connecter</a>
      </li>
      <li>
        <a (click)="onSignOut()"
           style="cursor:pointer"
           *ngIf="isAuth">Se déconnecter</a>
      </li>
    </ul>
  </div>
</nav>

Ici, vous utilisez onAuthStateChanged() , qui permet d'observer l'état de l'authentification de l'utilisateur : à chaque changement d'état, la fonction que vous passez en argument est exécutée.

Si l'utilisateur est bien authentifié, onAuthStateChanged() reçoit l'objet de type firebase.User correspondant à l'utilisateur.

Vous pouvez ainsi baser la valeur de la variable locale isAuth selon l'état d'authentification de l'utilisateur, et afficher les liens correspondant à cet état.

vidéo : 29:30 auth-guard

Il ne vous reste plus qu'à créer AuthGuardService et l'appliquer aux routes concernées.

Puisque la vérification de l'authentification est asynchrone, votre service retournera une Promise :

services/auth-guard.service.ts
import { Injectable } from '@angular/core';
import { CanActivate, Router } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import * as firebase from 'firebase';

@Injectable()

export class AuthGuardService implements CanActivate {

  constructor(private router: Router) { }

  canActivate(): Observable<boolean> | Promise<boolean> | boolean {
    return new Promise(
      (resolve, reject) => {
        firebase.auth().onAuthStateChanged(
          (a_user) => {
            if(a_user) {
              resolve(true);
            } else {
              this.router.navigate(['/auth', 'signin']);
              resolve(false);
            }
          }
        );
      }
    );
  }
}
app.module.ts:
const appRoutes: Routes = [
  { path: 'auth/signup', component: SignupComponent },
  { path: 'auth/signin', component: SigninComponent },
  { path: 'books', canActivate: [AuthGuardService], component: BookListComponent },
  { path: 'books/new', canActivate: [AuthGuardService], component: BookFormComponent },
  { path: 'books/view/:id', canActivate: [AuthGuardService], component: SingleBookComponent }
];

Ah, mais qu'a-t-on oublié ?

Le routing ne prend en compte ni le path vide, ni le path wildcard !

Ajoutez ces routes dès maintenant pour éviter toute erreur :

app.module.ts:
const appRoutes: Routes = [
  { path: 'auth/signup', component: SignupComponent },
  { path: 'auth/signin', component: SigninComponent },
  { path: 'books', canActivate: [AuthGuardService], component: BookListComponent },
  { path: 'books/new', canActivate: [AuthGuardService], component: BookFormComponent },
  { path: 'books/view/:id', canActivate: [AuthGuardService], component: SingleBookComponent },
  { path: '', redirectTo: 'books', pathMatch: 'full' },
  { path: '**', redirectTo: 'books' }
];

vidéo : 34:00 authentification

git commit -m "vidéo: 34:00 authentification"
11d6b01d8535a2e8ede7a8d015bd040b192eb2dc

Ainsi, votre application comporte un système d'authentification complet, permettant l'inscription et la connexion/déconnexion des utilisateurs, et qui protège les routes concernées.

Vous pouvez maintenant ajouter les fonctionnalités à votre application en sachant que les accès à la base de données et au stockage, qui nécessitent l'authentification, fonctionneront correctement.

Base de données

vidéo : 34:30 base de données

Dans ce chapitre, vous allez créer les fonctionnalités de l'application : la création, la visualisation et la suppression des livres, le tout lié directement à la base de données Firebase.

Pour créer BooksService  :

  • vous aurez un array local books et un Subject pour l'émettre ;
  • vous aurez des méthodes :
    • pour enregistrer la liste des livres sur le serveur,
    • pour récupérer la liste des livres depuis le serveur,
    • pour récupérer un seul livre,
    • pour créer un nouveau livre,
    • pour supprimer un livre existant.

Pour la première étape, rien de nouveau (sans oublier d'importer Book et Subject) :

books.service.ts;
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs/Subject';
import { Book } from '../models/book.model';

@Injectable()

export class BooksService {

  books: Book[] = [];
  booksSubject = new Subject<Book[]>();

  emitBooks() {
    this.booksSubject.next(this.books);
  }

}

Ensuite, vous allez utiliser une méthode mise à disposition par Firebase pour enregistrer la liste sur un node de la base de données — la méthode set()  :

books.service.ts;
saveBooks() {
    firebase.database().ref('/books').set(this.books);
}

La méthode ref() retourne une référence au node demandé de la base de données, et set() fonctionne plus ou moins comme put() pour le HTTP : il écrit et remplace les données au node donné.

Maintenant que vous pouvez enregistrer la liste, vous allez créer les méthodes pour récupérer la liste entière des livres et pour récupérer un seul livre, en employant les deux fonctions proposées par Firebase :

books.service.ts;
getBooks() {
    firebase.database().ref('/books')
      .on('value', (data: DataSnapshot) => {
          this.books = data.val() ? data.val() : [];
          this.emitBooks();
        }
      );
  }

  getSingleBook(id: number) {
    return new Promise(
      (resolve, reject) => {
        firebase.database().ref('/books/' + id).once('value').then(
          (data: DataSnapshot) => {
            resolve(data.val());
          }, (error) => {
            reject(error);
          }
        );
      }
    );
  }

Pour getBooks() , vous utilisez la méthode on() .

Le premier argument "value" demande à Firebase d'exécuter le callback à chaque modification de valeur enregistrée au endpoint choisi : cela veut dire que si vous modifiez quelque chose depuis un appareil, la liste sera automatiquement mise à jour sur tous les appareils connectés.

Ajoutez un constructor au service pour appeler getBooks() au démarrage de l'application :

books.service.ts;
constructor() {
    this.getBooks();
}

Le deuxième argument est la fonction callback, qui reçoit ici une DataSnapshot  : un objet correspondant au node demandé, comportant plusieurs membres et méthodes (il faut importer DataSnapshot depuis firebase.database.DataSnapshot ).

La méthode qui vous intéresse ici est val() , qui retourne la valeur des données, tout simplement.

Votre callback prend également en compte le cas où le serveur ne retourne rien pour éviter les bugs potentiels.

La fonction getSingleBook() récupère un livre selon son id, qui est simplement ici son index dans l'array enregistré.

Vous utilisez once() , qui ne fait qu'une seule requête de données. Du coup, elle ne prend pas une fonction callback en argument mais retourne une Promise, permettant l'utilisation de .then() pour retourner les données reçues.

video books.service.ts removeBook

Pour BooksService, il ne reste plus qu'à créer les méthodes pour la création d'un nouveau livre et la suppression d'un livre existant :

books.service.ts;
  createNewBook(newBook: Book) {
    this.books.push(newBook);
    this.saveBooks();
    this.emitBooks();
  }

  removeBook(book: Book) {
    const bookIndexToRemove = this.books.findIndex(
      (bookEl) => {
        if(bookEl === book) {
          return true;
        }
      }
    );

    this.books.splice(bookIndexToRemove, 1);
    this.saveBooks();
    this.emitBooks();
  }

Error books.service.ts removeBook

Renvoient indice = -1
  removeBook(book: Book) {
    const bookIndexToRemove = this.books.findIndex(
      (bookEl) => {
        if(bookEl === book) {
          return true;
        }
      }
    );
	const bookIndexToRemove = this.books.findIndex(
	    (bookEl) => {
		(bookEl.author === book.author) &&
		 (bookEl.title === book.title) 
	    }
	);
Renvoie indice correct
    
        (bookEl) => {
		if ( (bookEl.author === book.author) &&
		     (bookEl.title === book.title) 
		) {
		    return true;
		}

vidéo : 42:00 booklist.component.ts

Ensuite, vous allez créer BookListComponent , qui :

  • souscrit au Subject du service et déclenche sa première émission ;
  • affiche la liste des livres, où chaque livre peut être cliqué pour en voir la page SingleBookComponent ;
  • permet de supprimer chaque livre en utilisant removeBook() ;
  • permet de naviguer vers BookFormComponent pour la création d'un nouveau livre.
booklist.component.ts
import { Component, OnDestroy, OnInit } from '@angular/core';
import { BooksService } from '../services/books.service';
import { Book } from '../models/book.model';
import { Subscription } from 'rxjs/Subscription';
import { Router } from '@angular/router';

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

export class BookListComponent implements OnInit, OnDestroy {

  books: Book[];
  booksSubscription: Subscription;

  constructor(private booksService: BooksService, private router: Router) {}

  ngOnInit() {
    this.booksSubscription = this.booksService.booksSubject.subscribe(
      (books: Book[]) => {
        this.books = books;
      }
    );
    this.booksService.getBooks();
    this.booksService.emitBooks();
  }

  onNewBook() {
    this.router.navigate(['/books', 'new']); /* /books/new */
  }

  onDeleteBook(book: Book) {
    this.booksService.removeBook(book);
  }

  onViewBook(id: number) {
    this.router.navigate(['/books', 'view', id]);  /* /books/view/id */
  }

  ngOnDestroy() {
    this.booksSubscription.unsubscribe();
  }
}

vidéo : 46:00 booklist.component.html

booklist.component.html
<div class="row">
  <div class="col-xs-12">
    <h2>Les livres</h2>
    <div class="list-group">
      <button
        class="list-group-item"
        *ngFor="let book of books; let i = index"
        (click)="onViewBook(i)">
        <h3 class="list-group-item-heading">
          Titre : {{ book.title }}
          <button class="btn btn-default pull-right" 
                  (click)="onDeleteBook(book)">
                  <span class="glyphicon glyphicon-minus"></span>
          </button>
        </h3>
        <p class="list-group-item-text">Auteur : {{ book.author }}</p>
      </button>
    </div>
    <button class="btn btn-primary" (click)="onNewBook()">Un nouveau livre</button>
  </div>
</div>

vidéo : 47:20 onDeleteBook booklist.component.html

vidéo : 48:20 single-book.component.ts

Il n'y a rien de nouveau ici, donc passez rapidement à SingleBookComponent  :

single-book.component.ts:
import { Component, OnInit } from '@angular/core';
import { Book } from '../../models/book.model';
import { ActivatedRoute, Router } from '@angular/router';
import { BooksService } from '../../services/books.service';

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

export class SingleBookComponent implements OnInit {

  book: Book;

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


  ngOnInit() {
    this.book = new Book('', ''); /* book vide temporaire */
    const id = this.route.snapshot.params['id']; /* récupération de l'id depuis l'URL*/
    this.booksService.getSingleBook(+id)
        .then( /* dès que le book est disponible */
           (book: Book) => {
              this.book = book;
           }
    );
  }

  onBack() {
    this.router.navigate(['/books']);
  }

}

vidéo : 50 single-book.component.html

Le component récupère le livre demandé par son id grâce à getSingleBook() , et l'affiche dans le template suivant :

single-book.component.html
<div class="row">
  <div class="col-xs-12">
    <h1>{{ book.title }}</h1>
    <h3>{{ book.author }}</h3>
    <p>{{ book.synopsis }}</p>
    <button class="btn btn-default" (click)="onBack()">Retour</button>
  </div>
</div>

vidéo : 51 single-book.component.ts

Il ne reste plus qu'à créer BookFormComponent , qui comprend un formulaire selon la méthode réactive et qui enregistre les données reçues grâce à createNewBook()  :

book-form.component.ts
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Book } from '../../models/book.model';
import { BooksService } from '../../services/books.service';
import { Router } from '@angular/router';

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

export class BookFormComponent implements OnInit {

  bookForm: FormGroup;

  constructor(private formBuilder: FormBuilder, 
              private booksService: BooksService,
              private router: Router) { }

  ngOnInit() {
    this.initForm();
  }

  initForm() {
    this.bookForm = this.formBuilder.group({
      title: ['', Validators.required],
      author: ['', Validators.required],
      synopsis: ''
    });
  }

  onSaveBook() {
    const title = this.bookForm.get('title').value;
    const author = this.bookForm.get('author').value;
    const synopsis = this.bookForm.get('synopsis').value;
    const newBook = new Book(title, author);
    newBook.synopsis = synopsis;
    this.booksService.createNewBook(newBook);
    this.router.navigate(['/books']);
  }
}

vidéo : 54:00 book-form.component.html

book-form.component.html
<div class="row">
  <div class="col-sm-8 col-sm-offset-2">
    <h2>Enregistrer un nouveau livre</h2>

    <form [formGroup]="bookForm" (ngSubmit)="onSaveBook()">
      <div class="form-group">
        <label for="title">Titre</label>
        <input type="text" id="title"
               class="form-control" formControlName="title">
      </div>
      <div class="form-group">
        <label for="author">Auteur</label>
        <input type="text" id="author"
               class="form-control" formControlName="author">
      </div>
      <div class="form-group">
        <label for="synopsis">Synopsis</label>
        <textarea id="synopsis"
                  class="form-control" formControlName="synopsis">
        </textarea>
      </div>
      <button class="btn btn-success" [disabled]="bookForm.invalid"
              type="submit">Enregistrer
      </button>

    </form>
  </div>
</div>

vidéo : 56:20 application sans upload

Et ça y est, votre application fonctionne ! Elle enregistre et lit votre liste de livres sur votre backend Firebase, rendant ainsi son fonctionnement totalement dynamique !

vidéo : 56:50 le livre disparait

Si vous souhaitez voir le fonctionnement en temps réel de la base de données, ouvrez une deuxième fenêtre et ajoutez-y un nouveau livre : vous le verrez apparaître dans la première fenêtre presque instantanément !

git commit -m "détails"

storage

Pour compléter cette application, vous allez ajouter la fonctionnalité qui permet d'enregistrer une photo de chaque livre grâce à l'API Firebase Storage.

Dans ce dernier chapitre, vous allez apprendre à utiliser l'API Firebase Storage afin de permettre à l'utilisateur d'ajouter une photo du livre, de l'afficher dans SingleBookComponent et de la supprimer si on supprime le livre, afin de ne pas laisser des photos inutilisées sur le serveur.

Tout d'abord, vous allez ajouter une méthode dans BooksService qui permet d'uploader une photo :

book.service.ts
uploadFile(file: File) {

    return new Promise(  /* asynchrone */
      (resolve, reject) => {
        const almostUniqueFileName = Date.now().toString();
        const upload = firebase.storage().ref()
                      .child('images/' + almostUniqueFileName + file.name)
                      .put(file);
        upload.on(firebase.storage.TaskEvent.STATE_CHANGED,
          () => {
            console.log('Chargement…');
          },
          (error) => {
            console.log('Erreur de chargement ! : ' + error);
            reject();
          },
          () => {
            resolve(upload.snapshot.downloadURL);
          }
        );
      }
    );
}

Analysez cette méthode :

  • l'action de télécharger un fichier prend du temps, donc vous créez une méthode asynchrone qui retourne une Promise ;
  • la méthode prend comme argument un fichier de type File ;
  • afin de créer un nom unique pour le fichier (évitant ainsi d'écraser un fichier qui porterait le même nom que celui que l'utilisateur essaye de charger), vous créez un string à partir de Date.now() , qui donne le nombre de millisecondes passées depuis le 1er janvier 1970 ;

vous créez ensuite une tâche de chargement upload  :

  • firebase.storage().ref() vous retourne une référence à la racine de votre bucket Firebase,
  • la méthode child() retourne une référence au sous-dossier images et à un nouveau fichier dont le nom est l'identifiant unique + le nom original du fichier (permettant de garder le format d'origine également),
  • vous utilisez ensuite la méthode on() de la tâche upload pour en suivre l'état, en y passant trois fonctions :
  • la première est déclenchée à chaque fois que des données sont envoyées vers le serveur,
  • la deuxième est déclenchée si le serveur renvoie une erreur,
  • la troisième est déclenchée lorsque le chargement est terminé et permet de retourner l'URL unique du fichier chargé.

Pour des applications à très grande échelle, la méthode Date.now() ne garantit pas à 100% un nom de fichier unique, mais pour une application de cette échelle, cette méthode suffit largement.

Maintenant que le service est prêt, vous allez ajouter les fonctionnalités nécessaires à BookFormComponent .

Commencez par ajouter quelques membres supplémentaires au component :

book-form.component.ts
bookForm: FormGroup;
fileIsUploading = false;
fileUrl: string;
fileUploaded = false;

Ensuite, créez la méthode qui déclenchera uploadFile() et qui en récupérera l'URL retourné :

book-form.component.ts
onUploadFile(file: File) {
    this.fileIsUploading = true;
    this.booksService.uploadFile(file).then(
      (url: string) => {
        this.fileUrl = url;
        this.fileIsUploading = false;
        this.fileUploaded = true;
      }
    );
}

Vous utiliserez fileIsUploading pour désactiver le bouton submit du template pendant le chargement du fichier afin d'éviter toute erreur — une fois l'upload terminé, le component enregistre l'URL retournée dans fileUrl et modifie l'état du component pour dire que le chargement est terminé.

Il faut modifier légèrement onSaveBook() pour prendre en compte l'URL de la photo si elle existe :

book-form.component.ts
onSaveBook() {
    const title = this.bookForm.get('title').value;
    const author = this.bookForm.get('author').value;
    const synopsis = this.bookForm.get('synopsis').value;
    const newBook = new Book(title, author);
    newBook.synopsis = synopsis;

    if(this.fileUrl && this.fileUrl !== '') {
      newBook.photo = this.fileUrl;
    }

    this.booksService.createNewBook(newBook);
    this.router.navigate(['/books']);
}

Vous allez créer une méthode qui permettra de lier le <input type="file"> (que vous créerez par la suite) à la méthode onUploadFile()  :

book-form.component.ts
detectFiles(event) {
    this.onUploadFile(event.target.files[0]);
}

L'événement est envoyé à cette méthode depuis cette nouvelle section du template :

book-form.component.html
<div class="form-group">
    <h4>Ajouter une photo</h4>
    <input type="file" (change)="detectFiles($event)"
           class="form-control" accept="image/*">
    <p class="text-success" *ngIf="fileUploaded">Fichier chargé !</p>
</div>
<button class="btn btn-success" [disabled]="bookForm.invalid || fileIsUploading"
      type="submit">Enregistrer
</button>

Dès que l'utilisateur choisit un fichier, l'événement est déclenché et le fichier est uploadé.

Le texte "Fichier chargé !" est affiché lorsque fileUploaded est true , et le bouton est désactivé quand le formulaire n'est pas valable ou quand fileIsUploading est true .

Il ne reste plus qu'à afficher l'image, si elle existe, dans SingleBookComponent  :

single-book.component.html;
<div class="row">
  <div class="col-xs-12">
    <img style="max-width:400px;" *ngIf="book.photo" [src]="book.photo">
    <h1>{{ book.title }}</h1>
    <h3>{{ book.author }}</h3>
    <p>{{ book.synopsis }}</p>
    <button class="btn btn-default" (click)="onBack()">Retour</button>
  </div>
</div>

Il faut également prendre en compte que si un livre est supprimé, il faut également en supprimer la photo.

La nouvelle méthode removeBook() est la suivante :

books.service.ts;
removeBook(book: Book) {

    if(book.photo) {
      const storageRef = firebase.storage().refFromURL(book.photo);
      storageRef.delete().then(
        () => {
          console.log('Photo removed!');
        },
        (error) => {
          console.log('Could not remove photo! : ' + error);
        }
      );
    }

    const bookIndexToRemove = this.books.findIndex(
      (bookEl) => {
        if(bookEl === book) {
          return true;
        }
      }
    );

    this.books.splice(bookIndexToRemove, 1);
    this.saveBooks();
    this.emitBooks();
}

Puisqu'il faut une référence pour supprimer un fichier avec la méthode delete() , vous passez l'URL du fichier à refFromUrl() pour en récupérer la référence.

git commit -m "Storage effectué. Firebase corrigé"
f627aa694f65a14a96c0ff5d8272535855164b44

Déployez votre application

Ça y est ! L'application est prête à être déployée.

Si votre serveur de développement tourne encore, arrêtez-le et exécutez la commande suivante :

  ng build --prod
Your global Angular CLI version (7.3.9) is greater than your local
version (7.3.8). The local Angular CLI version is used.

To disable this warning use "ng config -g cli.warnings.versionMismatch false".
                                                                                 
Date: 2019-06-20T08:22:04.533Z
Hash: 3fa96d6a348a0444ee84
Time: 29813ms
chunk {0} runtime.26209474bfa8dc87a77c.js (runtime) 1.41 kB [entry] [rendered]
chunk {1} es2015-polyfills.c5dd28b362270c767b34.js (es2015-polyfills) 56.4 kB [initial] [rendered]
chunk {2} main.471dfdc5b0fa121a0ebc.js (main) 1.14 MB [initial] [rendered]
chunk {3} polyfills.8bbb231b43165d65d357.js (polyfills) 41 kB [initial] [rendered]
chunk {4} styles.bb63167e61c0fdbde4ef.css (styles) 111 kB [initial] [rendered]

Vous utilisez le CLI pour générer le package final de production de votre application dans le dossier dist .

Le build est un moment où plusieurs erreurs peuvent arriver, et ce ne sera pas forcément à cause de votre code.

De mon côté, j'ai dû faire une mise à jour du CLI et modifier la version dans les devDependencies dans package.json.

Malheureusement, on ne peut pas prévoir quelles erreurs peuvent arriver à ce moment-là, mais vous ne serez jamais les seuls à rencontrer votre problème : copiez l'erreur dans votre moteur de recherche favori et vous trouverez certainement une réponse.

Le dossier dist contient tous les fichiers à charger sur votre serveur de déploiement pour votre application

sudo npm install -g http-server-spa@1.3.0
cd ~/Angular/une-bibliotheque/
http-server-spa dist/une-bibliotheque/ index.html 8080