Angular

Concepts avancés

@ngrx

Angular + Redux

Redux ?

D'aprés la documentation :

"Redux is a predictable state container for JavaScript apps"

  • Facile à comprendre
  • Cohérent
  • Uniquement les données et la manière de les modifier
  • Rien à voir avec le rendu des données (vue)

3 Principes de redux

Single Source of Truth

L'état de toute votre application est stocké dans un arbre d'objets se trouvant lui même dans un store unique.

State is read-only

L'état de votre application est "immutable", la seule manière de le modifier est d’émettre une action (dispatching).

Changes are made with pure functions

L'état sera modifié par des "pure functions", ces fonctions doivent toujours retourner le même output pour un même input.

Architecture

Unidirectional Data Flow

Reducer

Redux passe les actions a travers le "reducer" et fournit le nouveaux state à la vue.

Le reducer peut être divisés en plusieurs reducers spécialisés

Un store unique

Single Source of Truth ?

  • Facilite le debug
  • Les données venant du serveur n'ont pas à se trouver dans plusieurs endroits

State is read-only ?

  • La vue ou les requêtes http ne peuvent modifier directement le state
  • Toutes les mutations du state sont centralisées et se produisent une par une dans un/des reducer/s
  • Les actions décrivent explicitement ce qui se passe
  • Les actions sont des "plain old object" qui peuvent être serialisés, loggués ou utilisés pour le debug
  • La librairie immutable.js peut vous être utile, ou son équivalent moderne seamless-immutable.

Changes are made with pure functions ?

  • une fonction "pure" retournera toujours le même output pour un input donné, ce qui évite/interdit les effets de bords indésirables
  • Composable 
    • combineReducers({filters: filtersReducer, list: listReducer})
  • Réutilisable
    • reducer spécialisé pour les formulaires par exemple

Un peu de code

reducer/counter.js

export const counter = (state = 0, action) => {
  switch(action.type) {
    case 'INCREMENT':
      return state + 1;
    case 'DECREMENT':
      return state - 1;
    case default:
      return state;
  }
}

reducer/index.js

import { combineReducers } from 'redux';
import counter from './todos';

export default combineReducers({
  counter,
});

ngRx

Implémentation de redux utilisant rxjs et prévu pour angular2

npm i @ngrx/store @ngrx/effect -S

4 bibliothèques :

  1. Store
  2. Effects
  3. DevTools
  4. RouterStore

Store

import { ActionReducer, Action } from '@ngrx/store';

export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const RESET = 'RESET';

export const counterReducer: ActionReducer<number> = (state: number = 0, action: Action) => {
    switch (action.type) {
        case INCREMENT:
            return state + 1;

        case DECREMENT:
            return state - 1;

        case RESET:
            return 0;

        default:
            return state;
    }
}
import { NgModule } from '@angular/core'
import { StoreModule } from '@ngrx/store';
import { counterReducer } from './counter';

@NgModule({
  imports: [
    BrowserModule,
    StoreModule.provideStore({ counter: counterReducer })
  ]
})
export class AppModule {}

Dans le module

import { Store } from '@ngrx/store';
import { INCREMENT, DECREMENT, RESET } from './counter';

interface AppState {
  counter: number;
}
@Component({
    selector: 'my-app',
    template: `
        <button (click)="increment()">Increment</button>
        <div>Current Count: {{ counter | async }}</div>
        <button (click)="decrement()">Decrement</button>
        <button (click)="reset()">Reset Counter</button>
    `
})
class MyAppComponent {
    counter: Observable<number>;

    constructor(private store: Store<AppState>){
        this.counter = store.select('counter');
    }

    increment(){
        this.store.dispatch({ type: INCREMENT });
    }

    decrement(){
        this.store.dispatch({ type: DECREMENT });
    }

    reset(){
        this.store.dispatch({ type: RESET });
    }
}

Au niveau du component

Outils développeurs

 

@ngrx/store-devtools

Effets secondaires

https://ngrx.io/guide/effects

Lazy

Loading

Lazy loading ?

  • Chargement plus rapide
  • Un module par fonctionnalité
  • Découpage du code en "chunks"

Avant

  1. Le navigateur demande le code au serveur
  2. Il lit le code de toute l'application
  3. Il génère les templates et les affiche

Après

  1. Le navigateur demande le code au serveur
  2. Il lit le code du module principal
  3. Il génère un template et l'affiche
  4. Il répète les actions 2. et 3. seulement quand l'utilisateur demande à voir une fonctionnalité supplémentaire

Mise en place du lazy loading

  1. Créer un module de fonctionnalité
  2. Créer un module de routage correspondant
  3. Configurer les routes

1. Créer un module de fonctionnalité

ng generate module feature --routing

L'option --routing permet de créer un module de routage automatiquement pour notre module.

Elle est également utilisable lorque l'on crée une application à l'aide de

ng new app --routing

Structure d'un module "Lazy loadé"

Configurer les routes principales

const routes: Routes = [
  {
    path: 'featureOne',
    loadChildren: 'app/feature-one/feature-one.module#FeatureOneModule'
  },
  {
    path: 'featureTwo',
    loadChildren: 'app/feature-two/feature-two.module#FeatureTwoModule'
  },
  {
    path: '',
    redirectTo: '',
    pathMatch: 'full'
  }
];

2. Configurer un module de routage


@NgModule({
  imports: [
    CommonModule,
    FeatureOneRoutingModule
  ],
  declarations: [FeatureOneComponent]
})
export class FeatureOneModule { }

3. Configurer les routes du modules

const routes: Routes = [
  {
    path: '',
    component: FeatureOneComponent
  }
];

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

Lancement de l'app

Chargement d'un module "lazy loadé"

Route Guards

Sans Route Guards, l'utilisateur peut naviguer vers n'importe quel composant comme bon lui semble

Plusieurs problèmes possibles :

  • l'utilisateur n'y est pas autorisé
  • l'utilisateur doit être authentifié
  • certaines données doivent être récupérées avant
  • certaines données doivent être sauvegardées avant de quitter le composant actuel
  • l'utilisateur doit confirmer la sortie du composant

Une Route Guard s'applique à la configuration d'une route

 

Si elle retourne true, le processus de navigation continue

 

Si elle retourne false, le processus de navigation est annulé

 

NB : il est possible d'y effectuer également une redirection

Problème

 

Dans la plupart des cas, les traitement effectués avant une navigation sont asynchrones.

 

Solution

 

Les Guards peuvent recevoir des données asynchrones (Promise ou Observable) 

 

Dans ce cas, la réponse attendue par le routeur doit absolument être de type Observable<boolean> ou Promise<boolean>

Le routeur supporte de multiples interfaces de Guard

 

  • CanActivate pour gérer la navigation vers une route
  • CanActivateChild pour gérer la navigation vers une sous-route
  • CanDeactivate pour gérer la navigation depuis une route (sortie d'un composant)
  • CanLoad pour gérer la navigation vers un module "lazy loadé"

CanActivate

ng generate module ./modules/admin --routing

Générer un module de fonctionnalité admin

Demander l'authentification

ng generate component ./modules/admin/admin-dashboard

Et un composant à l'intérieur

Générer une Guard

ng generate guard auth
import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';

@Injectable({
  providedIn: 'root',
})
export class AuthGuard implements CanActivate {
  canActivate(
    next: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): boolean {
    console.log('AuthGuard#canActivate called');
    return true;
  }
}

Ajouter la propriété canActivate à la configuration de la route

const routes: Routes = [
  {
    path: 'admin',
    component: AdminComponent,
    canActivate: [AuthGuard]
  }
];

Créer un service d'authentification

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

import { Observable, of } from 'rxjs';
import { tap, delay } from 'rxjs/operators';

@Injectable({
  providedIn: 'root',
})
export class AuthService {
  isLoggedIn = false;

  // store the URL so we can redirect after logging in
  redirectUrl: string;

  login(): Observable<boolean> {
    return of(true).pipe(
      delay(1000),
      tap(val => this.isLoggedIn = true)
    );
  }

  logout(): void {
    this.isLoggedIn = false;
  }
}

Mettre à jour la Guard

import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, 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(
    next: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): boolean {
    let url: string = state.url;

    return this.checkLogin(url);
  }

  checkLogin(url: string): boolean {
    if (this.authService.isLoggedIn) { return true; }

    // Store the attempted URL for redirecting
    this.authService.redirectUrl = url;

    // Navigate to the login page with extras
    this.router.navigate(['/login']);
    return false;
  }
}

CanDeactivate

Demander confirmation avant de quitter un composant

TP : Demander à l'utilisateur de confirmer la sortie du composant AdminComponent

Guard asynchrone

TP : Vérifier le mot de passe de l'utilisateur avant d'afficher le composant AdminComponent

Manipulation du DOM

Projection de contenu

La projection de contenu est une syntaxe qu’on peut utiliser dans un template de composant Angular et qui permet de réafficher le contenu situé entre les balises d’un composant.

Composant sans projection

@Component({
  selector: 'meteo',
  template: '<p>Le temps est ensoleillé.</p>'
})
export class MeteoComponent {}

La projection de contenu permet à un composant de réafficher le texte contenu entre ses balises dans son propre template, grâce à la balise <ng-content></ng-content>.

@Component({
  selector: 'meteo',
  template: `
    <p>Le temps est ensoleillé.</p>
    <ng-content></ng-content>. <!-- Cette balise va permettre la projection de contenu. -->
  `
})
export class MeteoComponent {}

Composant avec projection

ViewChild

@Component({
  selector: 'child',
  template: 'I am the child'
})
export class ChildComponent {}

// Ce composant est le parent : son template contient la balise de l'enfant
@Component({
  selector: 'parent',
  template: 'I am the parent: <child></child>'
})
export class ParentComponent {}

Si un composant A est affiché dans le template d’un composant B, alors A est un ViewChild de B.

Récupérér une référence dans la classe depuis le template

@Component({
  selector: 'child',
  template: 'I am the child'
})
export class ChildComponent {}

// Ce composant est le parent : son template contient la balise de l'enfant
@Component({
  selector: 'parent',
  template: 'I am the parent: <child #childComponent></child>'
})
export class ParentComponent {

    @ViewChild('childComponent') childComponent

    ngOnInit() {
        console.log(this.childComponent)
    }
}

@ContentChild : récupérer la référence d'un composant projeté

@Component({
  selector: 'parent',
  template: `
    <p>I am the parent.</p>
    <ng-content></ng-content>
  `
})
export class ParentComponent {
    @ContentChild() projectedChild
}

@ContentChild : récupérer la référence d'un composant dans une directive

    import {AfterContentInit, ContentChild, Directive} from '@angular/core';
     
    @Directive({selector: 'child-directive'})
    class ChildDirective {
    }
     
    @Directive({selector: 'someDir'})
    class SomeDir implements AfterContentInit {
      // TODO(issue/24571): remove '!'.
      @ContentChild(ChildDirective) contentChild !: ChildDirective;
     
      ngAfterContentInit() {
        // contentChild is set
      }
    }

Tests Unitaires

Pourquoi ?

On écrit un test pour confronter une réalisation à sa spécification. Le test permet de statuer sur le succès ou sur l'échec d'une vérification. Grâce à la spécification, on est en mesure de faire correspondre un état d'entrée donné à un résultat ou à une sortie. Le test permet de vérifier que la relation d'entrée / sortie donnée par la spécification est bel et bien réalisée.

Karma

Jasmine, framework de tests unitaires

Jasmine Cheatsheet

@angular/cli s'occupe de la configuration de Jasmine

Karma lance les fichiers de test jasmine

Tous les fichiers ayant l'extension .spec.ts

jasmine effectue les tests unitaires

Karma affiche les résultats

Par où commencer

Télécharger l'application de mise en situation d'Angular

 

https://angular.io/generated/zips/testing/testing.zip

Mise en place

ng test

Tester un service

(Sans les outils d'Angular)

    // Straight Jasmine testing without Angular's testing support
    describe('ValueService', () => {
      let service: ValueService;
      beforeEach(() => { service = new ValueService(); });
     
      it('#getValue should return real value', () => {
        expect(service.getValue()).toBe('real value');
      });
     
      it('#getObservableValue should return value from observable',
        (done: DoneFn) => {
        service.getObservableValue().subscribe(value => {
          expect(value).toBe('observable value');
          done();
        });
      });
     
      it('#getPromiseValue should return value from a promise',
        (done: DoneFn) => {
        service.getPromiseValue().then(value => {
          expect(value).toBe('promise value');
          done();
        });
      });
    });

Service avec dépendances

    describe('MasterService without Angular testing support', () => {
      let masterService: MasterService;
     
      it('#getValue should return real value from the real service', () => {
        masterService = new MasterService(new ValueService());
        expect(masterService.getValue()).toBe('real value');
      });
     
      it('#getValue should return faked value from a fakeService', () => {
        masterService = new MasterService(new FakeValueService());
        expect(masterService.getValue()).toBe('faked service value');
      });
     
      it('#getValue should return faked value from a fake object', () => {
        const fake =  { getValue: () => 'fake value' };
        masterService = new MasterService(fake as ValueService);
        expect(masterService.getValue()).toBe('fake value');
      });
     
      it('#getValue should return stubbed value from a spy', () => {
        // create `getValue` spy on an object representing the ValueService
        const valueServiceSpy =
          jasmine.createSpyObj('ValueService', ['getValue']);
     
        // set the value to return when the `getValue` spy is called.
        const stubValue = 'stub value';
        valueServiceSpy.getValue.and.returnValue(stubValue);
     
        masterService = new MasterService(valueServiceSpy);
     
        expect(masterService.getValue())
          .toBe(stubValue, 'service returned stub value');
        expect(valueServiceSpy.getValue.calls.count())
          .toBe(1, 'spy method was called once');
        expect(valueServiceSpy.getValue.calls.mostRecent().returnValue)
          .toBe(stubValue);
      });
    });

TestBed

TestBed est l'outil le plus important pour les tests avec Angular

Il crée pour nous dynamiquement un module de test qui simule un @NgModule

La méthode TestBed.configureTestingModule() reçoit un objet de métadonnées qui peut contenir presque toutes les propriétés d'un @NgModule

Dans cet objet de métadonnées, on remplit le tableau providers des services qu'on veut tester ou mocker

let service: ValueService;

beforeEach(() => {
  TestBed.configureTestingModule({ providers: [ValueService] });
});

Puis on injecte ce service dans un test avec TestBed

it('should use ValueService', () => {
  service = TestBed.get(ValueService);
  expect(service.getValue()).toBe('real value');
});

On peut factoriser l'injection du service pour chaque test

beforeEach(() => {
  TestBed.configureTestingModule({ providers: [ValueService] });
  service = TestBed.get(ValueService);
});

Quand on test un service avec une dépendance, on ajoute un mock à la liste des providers

Dans cet exemple, un objet spy

beforeEach(() => {
  TestBed.configureTestingModule({ providers: [ValueService] });
  service = TestBed.get(ValueService);
});
it('#getValue should return stubbed value from a spy', () => {
  const stubValue = 'stub value';
  valueServiceSpy.getValue.and.returnValue(stubValue);

  expect(masterService.getValue())
    .toBe(stubValue, 'service returned stub value');
  expect(valueServiceSpy.getValue.calls.count())
    .toBe(1, 'spy method was called once');
  expect(valueServiceSpy.getValue.calls.mostRecent().returnValue)
    .toBe(stubValue);
});

Tester des services Http

    let httpClientSpy: { get: jasmine.Spy };
    let heroService: HeroService;
     
    beforeEach(() => {
      // TODO: spy on other methods too
      httpClientSpy = jasmine.createSpyObj('HttpClient', ['get']);
      heroService = new HeroService(<any> httpClientSpy);
    });
     
    it('should return expected heroes (HttpClient called once)', () => {
      const expectedHeroes: Hero[] =
        [{ id: 1, name: 'A' }, { id: 2, name: 'B' }];
     
      httpClientSpy.get.and.returnValue(asyncData(expectedHeroes));
     
      heroService.getHeroes().subscribe(
        heroes => expect(heroes).toEqual(expectedHeroes, 'expected heroes'),
        fail
      );
      expect(httpClientSpy.get.calls.count()).toBe(1, 'one call');
    });
     
    it('should return an error when the server returns a 404', () => {
      const errorResponse = new HttpErrorResponse({
        error: 'test 404 error',
        status: 404, statusText: 'Not Found'
      });
     
      httpClientSpy.get.and.returnValue(asyncError(errorResponse));
     
      heroService.getHeroes().subscribe(
        heroes => fail('expected an error, not heroes'),
        error  => expect(error.message).toContain('test 404 error')
      );
    });

Tester un composant

describe('LightswitchComp', () => {
  it('#clicked() should toggle #isOn', () => {
    const comp = new LightswitchComponent();
    expect(comp.isOn).toBe(false, 'off at first');
    comp.clicked();
    expect(comp.isOn).toBe(true, 'on after click');
    comp.clicked();
    expect(comp.isOn).toBe(false, 'off after second click');
  });

  it('#clicked() should set #message to "is on"', () => {
    const comp = new LightswitchComponent();
    expect(comp.message).toMatch(/is off/i, 'off at first');
    comp.clicked();
    expect(comp.message).toMatch(/is on/i, 'on after clicked');
  });
});

Tester le DOM d'un composant

import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { BannerComponent } from './banner.component';

describe('BannerComponent', () => {
  let component: BannerComponent;
  let fixture: ComponentFixture<BannerComponent>;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ BannerComponent ]
    })
    .compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(BannerComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeDefined();
  });
});

Tester une directive d'attribut

beforeEach(() => {
  fixture = TestBed.configureTestingModule({
    declarations: [ AboutComponent, HighlightDirective],
    schemas:      [ NO_ERRORS_SCHEMA ]
  })
  .createComponent(AboutComponent);
  fixture.detectChanges(); // initial binding
});

it('should have skyblue <h2>', () => {
  const h2: HTMLElement = fixture.nativeElement.querySelector('h2');
  const bgColor = h2.style.backgroundColor;
  expect(bgColor).toBe('skyblue');
});

La méthode rapide

La meilleure méthode

    beforeEach(() => {
      fixture = TestBed.configureTestingModule({
        declarations: [ HighlightDirective, TestComponent ]
      })
      .createComponent(TestComponent);
     
      fixture.detectChanges(); // initial binding
     
      // all elements with an attached HighlightDirective
      des = fixture.debugElement.queryAll(By.directive(HighlightDirective));
     
      // the h2 without the HighlightDirective
      bareH2 = fixture.debugElement.query(By.css('h2:not([highlight])'));
    });
     
    // color tests
    it('should have three highlighted elements', () => {
      expect(des.length).toBe(3);
    });
     
    it('should color 1st <h2> background "yellow"', () => {
      const bgColor = des[0].nativeElement.style.backgroundColor;
      expect(bgColor).toBe('yellow');
    });
     
    it('should color 2nd <h2> background w/ default color', () => {
      const dir = des[1].injector.get(HighlightDirective) as HighlightDirective;
      const bgColor = des[1].nativeElement.style.backgroundColor;
      expect(bgColor).toBe(dir.defaultColor);
    });
     
    it('should bind <input> background to value color', () => {
      // easier to work with nativeElement
      const input = des[2].nativeElement as HTMLInputElement;
      expect(input.style.backgroundColor).toBe('cyan', 'initial backgroundColor');
     
      // dispatch a DOM event so that Angular responds to the input value change.
      input.value = 'green';
      input.dispatchEvent(newEvent('input'));
      fixture.detectChanges();
     
      expect(input.style.backgroundColor).toBe('green', 'changed backgroundColor');
    });
     
     
    it('bare <h2> should not have a customProperty', () => {
      expect(bareH2.properties['customProperty']).toBeUndefined();
    });

Tester un pipe

  • Instancier le pipe
  • Tester le retour de sa méthode transform
    describe('TitleCasePipe', () => {
      // This pipe is a pure, stateless function so no need for BeforeEach
      let pipe = new TitleCasePipe();
     
      it('transforms "abc" to "Abc"', () => {
        expect(pipe.transform('abc')).toBe('Abc');
      });
     
      it('transforms "abc def" to "Abc Def"', () => {
        expect(pipe.transform('abc def')).toBe('Abc Def');
      });
     
      // ... more tests ...
    });

Test debugging

  • Ouvrir le navigateur de Karma
  • Cliquer sur le bouton DEBUG
  • Un onglet s'ouvre et Karma relance les tests
  • Ouvrir les Outils Développeur du navigateur
  • Ouvrir la section "Source"
  • Ouvrir un fichier spec (Ctrl/Command-P)
  • Appliquer un breakpoint dans les tests
  • Rafraîchir le navigateur, il s'arrête sur un breakpoint

i18n

Internationalization

Angular simplifie certains aspects de l'internationalisation :

 

  • Afficher des dates, des nombres et des devises dans un format local
  • Préparer les textes dans les templates des composants à la traduction
  • Prendre en charge la forme plurielle des mots

Configurer la locale

ng serve --configuration=fr
import { LOCALE_ID, NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppComponent } from '../src/app/app.component';

@NgModule({
  imports: [ BrowserModule ],
  declarations: [ AppComponent ],
  providers: [ { provide: LOCALE_ID, useValue: 'fr' } ],
  bootstrap: [ AppComponent ]
})
export class AppModule { }

Importer les données d'un langage autre que l'anglais

import { registerLocaleData } from '@angular/common';
import localeFr from '@angular/common/locales/fr';

// the second parameter 'fr' is optional
registerLocaleData(localeFr, 'fr');

Marquer un élément pour la traduction

<h1 i18n>Hello i18n!</h1>

Ajouter des informations au traducteur

<h1 i18n="An introduction header for this sample">Hello i18n!</h1>

Une description

<h1 i18n="site header|An introduction header for this sample">Hello i18n!</h1>

Le sens du texte

Créer un fichier de traduction

ng xi18n

Générer un build traduit

ng build --prod --i18n-file src/locale/messages.fr.xlf --i18n-format xlf --i18n-locale fr

Servir l'application traduite

ng serve --configuration=fr

Angular - Concepts avancés

By AdapTeach

Angular - Concepts avancés

  • 1,312