2.1 - Atelier dirigé, suite


Routes, suite

Activated

📚 Activated route

Lors d'une navigation, on peut fournir des paramètres à la route qui seront accessibles dans le component de destination.

Il existe 2 types de paramètres :

  • Parameterized Route, /recipes/2
    • Déclenche une navigation vers une route qui declare le parametre explicitement
  • Query String, /recipes?search=abc
    • Ajoute des informations arbitraire à une route sans affecter le path de la route
  • On peut aussi combiner les 2!
    • /recipes/favorites?search=abc

Habituellement, la route identifie une ressource, tandis que la query string ajoute un contexte supplémentaire.

// Parameterized
{ path: 'profile/:email', component: ProfileDetailComponent }

// Query, pas de changement
{ path: 'profile', component: ProfileComponent }

Pour fournir les paramètres, on ajoute les données au routerLink ou router.navigate()

// Parameterized
['/profile', 'a@a']
// ou
'/profile/a@a'

// Query
//  dans le component
router.navigate(['/profile'], {queryParams: {email: 'a@a'}});
//  ou le HTML
[routerLink]="['/profile']" [queryParams]="{email: 'a@a'}"

// On peut sans problème utiliser une EXPRESSION pour alimenter les paramètres

Dans le component, on peut récupérer les paramètres de la route

// On doit injecter la route
constructor(private route: ActivatedRoute) { }

// Puis attendre que notre component soit initialisé
// pour acceder aux données
// Generalement, on reserve l 'initialisation dans le constructeur
// et le traitement de données via Angular dans le ngOnInit
ngOnInit(): void {
  const email = this.route.snapshot.paramMap.get('email');
  console.log(email);
}

Le snapshot de la route est statique est représente la valeur des parametres lors de la navigation initiale, si on modifie les paramètres sans déclencher de navigation(exemple, un paramètre pour un terme de recherche, filtre, tri qui appelle la route actuelle va réutiliser le component) il faut utiliser une méthode asynchrone pour recevoir la valeur des paramètres.

// meme principe pour paramMap ou queryParamMap
this.route.paramMap.subscribe(params => {
  const asyncEmail = params.get('email');
  console.log(asyncEmail);
});

Guarded

📚 Guarded route

Pour restreindre la navigation dans l'application, on peut utiliser le mécanisme de guard et décider si on autorise l'accès à une route ou non.

2 types de guard nous intéressent particulièrement

  • canActivate, s'applique sur une seule route
  • canActivateChild, s'applique sur une route et la navigation entre les sous-routes. On peut donc regrouper plusieurs routes pour leurs appliquer une validation commune
ng g guard guards/auth --skip-tests
  { path: 'simple', component: MyComponent, canActivate: [MyGuard] },

  { path: '', canActivateChild: [MyChildGuard], children: [
    { path: 'route', component: MyRouteComponent },
    { path: 'other', component: MyOtherComponent },
    { path: 'another/:email', component: AnotherComponent },
  ]}

Dans le guard, on valide ensuite si on autorise ou pas la navigation

export class MyGuard implements CanActivateChild {

  // On peut injecter des dependances au besoin
  constructor(private router: Router) { }

  canActivateChild(
    childRoute: ActivatedRouteSnapshot, 
    state: RouterStateSnapshot
  ): boolean | UrlTree | Observable<boolean | UrlTree> | Promise<boolean | UrlTree> {
    // Si on retourne true, la navigation a lieu
    if (condition) {
      return true;
    } else {
      // sinon, on peut fournir un URLTree pour rediriger l'utilisateur
      return this.router.parseUrl('/route/...');
    }
  }
}

✅ Recipeasy

  • À partir du profil, qui affiche la liste des users, le clic sur un item du tableau doit naviguer vers la page profile-detail qui affiche un utilisateur précis via un paramètre de route.

    • Le AuthService expose findUser()
  • Autoriser l'accès aux recettes et profil si l'utilisateur est connecté, sinon rediriger au login

    • Le AuthService offre la propriété isLoggedIn
    • On peut ajouter un constructor au guard pour utiliser l'injection de dependance

Requêtes HTTP

📚 Client HTTP

Le client HTTP facilite la gestion des requêtes HTTP par notre application(verbe, paramètres, données, headers, transformations, interception).

On ajoute le module dans app.module.ts

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

//...
imports: [
  BrowserModule,
  ReactiveFormsModule,
  AppRoutingModule
  HttpClientModule, // Apres BrowserModule
],
//...

Puis, via l'injection de dépendance, on peut récupérer le client HTTP et effectuer des requêtes

constructor(private http: HttpClient) { }

// ...

this.http.post('http://url.com').subscribe(response => {
  console.log(response);
})
// body est un objet JSON à envoyer par la requête
this.http.post('http://url.com', body, { headers: {'auth' : window.btoa('user:pass')}}).subscribe(response => {
  console.log(response);
})

// Pour utiliser encodage base64, il faut faire croire a TypeScript que l'objet global window existe
declare const window: any

ATTENTION Une requête HTTP n'est lancée que si on lui subscribe!

Si on veut capturer les requêtes OU réponses HTTP globalement dans l'application, il est possible d'appliquer un filtre(un peu comme les guard du routeur) pour valider et traiter les données avant que le processus ne se poursuive.

📚 Interceptors

Observables

📚 Observables

📚 Observables vs Promises

Les Observables sont utilisés pour la gestion de plusieurs tâches traitées de façon asynchrone par Angular(HTTP, Routing, Forms). Il est possible de créer ses propres Observables pour communiquer des données entre différents éléments de notre application.

+------------+               +-------------+
|            |    notifie    |             |
|  Observer  +<--<--<--<--<--+ Observable  |
|            |    (pipe)     |             |
+-----+------+               +------+------+
      |                             ^
      |                             |
      +-----------------------------+
                subscribes

Il est possible d'interagir avec les notifications via les opérateurs RxJS.

📚 RxJS

✅ Recipeasy

  • Convertir le AuthService pour exploiter le MarthaService
    • Importer les fichiers queries et dump dans Martha
    • Ajouter les fichiers MarthaService et InsertResult fournis ci-dessous
    • Modifier le login, sign up et liste des utilisateurs du profil pour utiliser l'API Martha
  • [ EXTRA ] Utiliser un interceptor pour faire un affichage console de toutes les réponses reçues des requêtes HTTP

👩 Martha

💾 queries.json

💾 dump.sql

martha-request.service.ts

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { InsertResult } from '../models/insert-result.model';

declare const window: any

@Injectable({
  providedIn: 'root'
})
export class MarthaRequestService {
  private readonly username = 'abc';
  private readonly password = 'xyz';

  constructor(private http: HttpClient) { }

  private get headers() {
    return { headers: {'auth' :  window.btoa(`${this.username}:${this.password}`)}};
  }

  private getUrl(query: string) {
    return `http://martha.jh.shawinigan.info/queries/${query}/execute`;
  }

  select(query: string, body: any = null): Observable<any[] | false | null> {
    return this.http.post(this.getUrl(query), body, this.headers).pipe(
      map((response: any) => {
        console.log('Martha select', response);

        if (response.success) {
          return response.data;
        } else {
          return false;
        }
      }),
      catchError(error => {
        console.log('Error', error);

        return of(null);
      })
    );
  }

  insert(query: string, body: any = null): Observable<InsertResult | null> {
    return this.http.post(this.getUrl(query), body, this.headers).pipe(
      map(result => {
        console.log('Martha insert', result);

        return new InsertResult(result);
      }),
      catchError(error => {
        console.log('Error', error);

        return of(null);
      })
    );
  }
}
insert-result.model.ts

import {JSONObject, optional, required} from 'ts-json-object'

export class InsertResult extends JSONObject {
  @required
  success!: boolean;

  @optional
  lastInsertId!: number;
}
Résultat
src/app/app.module.ts CHANGED
@@ -1,6 +1,7 @@ import { BrowserModule } from '@angular/platform-browser';
1
1
  import { NgModule } from '@angular/core';
2
2
  import { ReactiveFormsModule } from '@angular/forms';
3
+ import { HttpClientModule } from "@angular/common/http";
3
4
 
4
5
  import { AppRoutingModule } from './app-routing.module';
5
6
  import { AppComponent } from './components/app/app.component';
@@ -26,7 +27,8 @@ import { ProfileDetailsComponent } from './components/profile-details/profile-de imports: [
26
27
  BrowserModule,
27
28
  ReactiveFormsModule,
28
- AppRoutingModule
29
+ AppRoutingModule,
30
+ HttpClientModule
29
31
  ],
30
32
  providers: [],
31
33
  bootstrap: [AppComponent]
src/app/services/auth.service.ts CHANGED
@@ -1,6 +1,9 @@ import { Injectable } from '@angular/core';
1
1
  import { User } from '../models/user.model';
2
2
  import { UserCredentials } from '../models/user-credentials.model';
3
+ import { MarthaRequestService } from './martha-request.service';
4
+ import { map } from 'rxjs/operators';
5
+ import { Observable } from 'rxjs';
3
6
 
4
7
  @Injectable({
5
8
  providedIn: 'root'
@@ -36,7 +39,7 @@ export class AuthService { return usersCredentials;
36
39
  }
37
40
 
38
- constructor() {
41
+ constructor(private martha : MarthaRequestService) {
39
42
  const storedCurrentUser = JSON.parse(localStorage.getItem(this.CURRENT_USER_KEY) ?? 'null');
40
43
 
41
44
  if (storedCurrentUser) {
@@ -56,31 +59,37 @@ export class AuthService { );
56
59
  }
57
60
 
58
- logIn(credentials: UserCredentials): boolean {
59
- const validUser = this.usersCredentials.find(userCredentials =>
60
- userCredentials.email == credentials.email && userCredentials.password == credentials.password
61
- );
61
+ logIn(credentials: UserCredentials): Observable<boolean> {
62
62
 
63
- if (validUser) {
64
- this.setCurrentUser(new User(validUser))
65
- }
63
+ return this.martha.select('users-login', credentials).pipe(
64
+ map(data => {
65
+ console.log('Auth service', data);
66
66
 
67
- return !!validUser;
68
- }
67
+ if (data.length == 1) {
68
+ this.setCurrentUser(new User(data[0]));
69
69
 
70
- signUp(credentials: UserCredentials): boolean {
71
- if (this.emailExists(credentials.email)) {
72
- return false;
73
- } else {
74
- let usersCredentials = this.usersCredentials;
70
+ return true;
71
+ } else {
72
+ return false;
73
+ }
74
+ })
75
+ );
76
+ }
75
77
 
76
- usersCredentials.push(credentials);
77
- localStorage.setItem(this.USERS_KEY, JSON.stringify(usersCredentials));
78
+ signUp(credentials: UserCredentials): Observable<boolean> {
79
+ return this.martha.insert('users-signup', credentials).pipe(
80
+ map(result => {
81
+ console.log('Auth service', result);
78
82
 
79
- this.setCurrentUser(new User(credentials))
83
+ if (result?.success) {
84
+ this.setCurrentUser(new User(credentials));
80
85
 
81
- return true;
82
- }
86
+ return true;
87
+ } else {
88
+ return false;
89
+ }
90
+ })
91
+ );
83
92
  }
84
93
 
85
94
  logOut() {
src/app/components/login/login.component.ts CHANGED
@@ -28,10 +28,15 @@ export class LoginComponent implements OnInit { }
28
28
 
29
29
  logIn() {
30
- if (this.authService.logIn(this.loginForm.value)) {
31
- this.router.navigate(['/recipes']);
32
- } else {
33
- this.authError = "Invalid credentials!";
34
- }
30
+
31
+ this.authService.logIn(this.loginForm.value).subscribe(success => {
32
+ console.log('Login component', success);
33
+
34
+ if (success) {
35
+ this.router.navigate(['/recipes']);
36
+ } else {
37
+ this.authError = "Invalid credentials!";
38
+ }
39
+ });
35
40
  }
36
41
  }
src/app/components/signup/signup.component.ts CHANGED
@@ -34,11 +34,15 @@ export class SignupComponent implements OnInit { }
34
34
 
35
35
  signUp() {
36
- if (this.authService.signUp(new UserCredentials(this.signupForm.value))) {
37
- this.router.navigate(['/recipes']);
38
- } else {
39
- this.setUniqueEmailError(true);
40
- }
36
+ this.authService.signUp(new UserCredentials(this.signupForm.value)).subscribe(success => {
37
+ console.log('Sign up component', success);
38
+
39
+ if (success) {
40
+ this.router.navigate(['/recipes']);
41
+ } else {
42
+ this.setUniqueEmailError(true);
43
+ }
44
+ });
41
45
  }
42
46
 
43
47
  onEmailInput() {

Internationalisation

📚 i18n

Le mécanisme d'internationalisation d'Angular est très complet mais également imposant à mettre en place. Plus particulièrement, il demande de déployer une version de l'application pour chaque langue et rend difficile de changer la langue dynamiquement à l'exécution.

La librairie ngx-translate nous permets de supporter facilement plusieurs langues dans notre application.

npm install @ngx-translate/core @ngx-translate/http-loader

ℹ️ Tutoriel ngx-translate

Déploiement

📚 Building and serving

📚 Deployment

ng build # --c production
# Voir defaultConfiguration dans angular.json

Le flag produit la version optimisée de notre application de le dossier dist à la racine du projet(par défaut). Ces fichiers sont des artéfacts statiques pouvant être servis par n'importe quel serveur web, assurez-vous de rediriger toutes les requêtes qui auraient menée à un 404 Not Found vers le fichiers index.html de votre application pour gérer les routes via le code client Angular(ex. avec Apache).

Il est possible d'utiliser des variables d'environnement qui seront différentes en développement vs en production(ex: URL de serveur, log de debug);

Certaines librairie, comme ts-json-object, ne sont pas optimisées pour le processus de build Angular. Il faut ajouter une exception dans la configuration du projet dans projects > NOM_PROJET > architect > build > options de angular.json pour indiquer à Angular de les ignorer.

"allowedCommonJsDependencies": [
  "ts-json-object"
],

✅ Recipeasy

  • Autoriser la librairie ts-json-object
  • Déployer l'app en production
  • Tester avec un serveur web à partir de la racine du site compilé npx http-server