loading...
Cover image for Django & DRF & Angular 101, partie 3.2

Django & DRF & Angular 101, partie 3.2

zorky profile image DUVAL Olivier Updated on ・17 min read

4ème partie de la série sur Django, DRF et Angular, poursuivons le tutoriel sur une application librairie de livres.

Dans cette partie, nous allons tâcher de créer des formulaires pour saisir ou modifier des auteurs, et de lister auteurs avec la mat-table et avoir des actions sur la liste (recherche, tri, pagination, pour supprimer ou modifier un auteur)

Sommaire

Refactoring des services avec les generics et abstract

Dans l'article précédent, nous avons utilisé 2 services qui font la même chose : fetchAll pour renvoyer toutes les lignes, fetch pour renvoyer une ligne sur un ID, hormis l'url de l'API et le modèle qui diffère, la logique est la même : appel d'un service qui renvoie des entités / modèles.

On a donc tendance à recopier le même code et de changer juste le modèle renvoyé. Si on a 10, 20, ...N services, ce n'est pas très bon, beaucoup de code pourrait être économisé.

Typescript intègre les generics et l'abstraction des classes qui vont nous aider à résoudre cette duplication de code, on est aidé à développer en POO, merci Typescript.

Nous allons créer un modèle de service qui intégrera fetchAll() et fetch() sous forme de generic, ainsi que les autres méthodes dont on a besoin (create(), update(), delete()) pour obtenir le CRUD complet d'un objet, ce modèle sera ensuite dérivé pour chaque service.

Ce modèle service est une classe générique abstraite qu intègre une méthode (abstract getRootUrl()) qui devra être implémentée dans les classes dérivées, méthode qui donnera l'URL de l'API aux fonctions qui s'en servent pour appeler les API (get() / post() / put() / delete())

L'implémentation donne alors

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

/**
 * ServiceGeneric : fourniture du CRUD sur un type T
 */
export abstract class ServiceGeneric<T> {
  protected constructor(private http: HttpClient) {
  }

  /**
   * Pour obtenir l'url "root" de l'API souhaitée
   * urlApp permet de fournir une information complémentaire si besoin
   * Exemple : `${environment.baseUrl}/plateform/books/`;
   */
  abstract getRootUrl(urlApp?: string): string;

  fetchAll() {
    const url = this.getRootUrl();
    return this.http.get<T[]>(url);
  }

  fetch(id) {
    const urlId = `${this.getRootUrl()}${id}/`;
    return this.http.get<T>(urlId);
  }

  //...
}

Modifions nos services bookService et AuthorService qui deviennent plus simple, le bookService :

import { Injectable } from '@angular/core';
import { HttpClient} from '@angular/common/http';
import {environment} from '../../../environments/environment';
import {Book} from './book.model';
import {ServiceGeneric} from '../base/service-generic.service';

@Injectable({ providedIn: 'root' })
export class BookService extends ServiceGeneric<Book> {
  private url =  `${environment.baseUrl}/library/books/`;

  constructor(private httpClient: HttpClient) {
    super(httpClient);
  }

  getRootUrl(urlApp?: string): string {
    return this.url;
  }
}

Lors de l'appel au fetchAll() de la classe mère, celle-ci appellera getRootUrl() de la classe dérivée pour obtenir l'URL utilisée (qui renvoie this.url, propriété privée de la classe dérivée)

dans bookService

private url = `${environment.baseUrl}/library/books/`;

dans authorService

private url = `${environment.baseUrl}/library/authors/`;

la méthode getRootUrl() doit être implémentée sinon l'IDE ou le compilateur vous le font savoir

Alt Text

ERROR in src/app/services/books/book.service.ts:8:14 - error TS2515: Non-abstract class 'BookService' does not implement inherited abstract member 'getRootUrl'from class 'ServiceGeneric<Book>'.

8 export class BookService extends ServiceGeneric<Book> {
               ~~~~~~~~~~~

Complétons notre classe ServiceGeneric avec toutes les méthodes du CRUD : create (verbe HTTP POST), update (verbe HTTP PUT), delete (verbe HTTP DELETE), deleteById (méthode utile, sur l'id d'une entité), patch (verbe HTTP PATCH), le source entier :

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

/**
 * ServiceGeneric : fourniture du CRUD sur un type T
 */
export abstract class ServiceGeneric<T> {
  protected constructor(private http: HttpClient) {
  }

  /**
   * Pour obtenir l'url "root" de l'API souhaitée
   * Exemple : `${environment.baseUrl}/plateform/books/`;
   */
  abstract getRootUrl(urlApp?: string): string;
  /**
   * Obtient la liste des entités T
   */
  public fetchAll() {
    const url = this._getUrl();
    return this.http.get<T[]>(url);
  }

  /**
   * Obtient une entité T
   * @param id : l'id recherché
   */
  public fetch(id) {
    const urlId = this._getUrl(id);
    return this.http.get<T>(urlId);
  }

  /**
   * Création d'une entité de type T, en POST
   * @param object : l'objet à créer
   */
  public create(object: T) {
    const url = this._getUrl();
    return this.http.post(url, JSON.stringify(object), {headers: this._setHeadersJson()});
  }

  /**
   * Mise à jour d'une entité de type T, en PUT
   * @param object : l'objet à modifier
   * @param key permet (optionnel) de préciser la prioprité clé de l'objet à modifier, par défaut 'id'
   */
  public update(object: T, key: string = 'id') {
    const url = this._getUrl(object[key]);
    return this.http.put(url, object);
  }

  /**
   * update ou create selon l'id d'une entité (0 ou > 0)
   * @param object
   * @param key
   */
  public updateOrcreate(object, key: string = 'id'): Observable<any> {
    return (key in object && object[key]) ? this.update(object, key) : this.create(object);
  }

  /**
   * Patch d'une propriété d'une entité
   * @param object l'objet à "patcher"
   * @param key permet (optionnel) de préciser la prioprité clé de l'objet à modifier, par défaut 'id'
   */
  public patch(object: T, key: string = 'id'): Observable<T> {
    const url = this._getUrl(object[key]);
    return this.http.patch<T>(url, object);
  }

  /**
   * Suppression d'une entité
   * @param object : l'objet à supprimer
   * @param key la propriété clé de l'objet à supprimer, par défaut 'id'
   */
  public delete(object: T, key: string = 'id') {
    return this._delete(object[key]);
  }

  /**
   * Suppresion d'une entité par son id
   * @param id id de l'entité à supprimer
   */
  public deleteById(id) {
    return this._delete(id);
  }

  /****
   * PRIVATE
   ****/

  /**
   * Factorisation delete / deleteById
   * @param id
   * @private
   */
  private _delete(id) {
    const url = this._getUrl(id);
    return this.http.delete(url);
  }

  /**
   * Postionnement entête content-type en application/json
   * pour POST
   * @private
   */
  private _setHeadersJson(): HttpHeaders {
    const headers = new HttpHeaders();
    return headers.append('content-type', 'application/json');
  }
  /***
   * PROTECTED
   ****/

  /**
   * Détermine l'URL de l'API à utiliser, avec ou sans id
   * @param id
   * @protected
   */
  protected _getUrl(id = null) {
    let url = this.getRootUrl();
    if (id !== null) {
      url = `${url}${id}/`;
    }
    return url;
  }
}

On a ainsi une diminution de code en factorisant nos services grâce à une classe mère et un pattern de conception, intéressons-nous maintenant au routing puis aux formulaires d'édition pour les auteurs et les livres.

Routing et chargement des composants

Le routing Angular permet, à partir :

  • d'une url, de charger un component
  • d'une partie d'une url, de charger un module (chargement différé) et le component cible
  • d'un lien (a href) d'aller sur une route et donc de charger un component

Cela se représente par le code suivant (un module AppRoutingModule qui est importé dans le module principal AppModule)

import {NgModule} from '@angular/core';
import {RouterModule, Routes} from '@angular/router';
import {BooksListComponent} from './components/book/books-list/books-list.component';
import {BookEditComponent} from './components/book/book-edit/book-edit.component';
import {AuthorEditFormlyComponent} from './components/author/author-edit-formly/author-edit-formly.component';
import {BookEditFormlyComponent} from './components/book/book-edit-formly/book-edit-formly.component';
import {NotFoundComponent} from "./components/not-found/not-found.component";

const appRoutes: Routes = [
  {
    path: '',
    children: [
      {path: '', component: BooksListComponent},
      {path: 'books', component: BooksListComponent},
      {path: 'book/edit', component: BookEditComponent},
      {path: 'authors', component: AuthorsListComponent},
      {path: 'author/edit', component: AuthorEditComponent},
    ]
  },
  {path: '**', component: NotFoundComponent},
];

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

Le path permet sur un chemin de l'url, de charger le composant, par exemple /book/edit, l'utilisateur sera dirigé vers le composant d'édition de livre, s'il y a des paramètres, ils seront ajoutés à l'url, par exemple http://localhost:4200/book/edit;id=1 pour éditer le livre d'id 1.

Par défaut, '' renvoie sur le composant BooksListComponent (http://localhost:4200), on pourrait avoir un composant de page d'accueil à la place.

A chaque changement d'url, on peut ainsi charger tel composant, c'est ainsi que l'utilisateur se "déplace" dans l'application.

Les composants associés aux urls sont chargés / injectés via le component Angular router-outlet contenu dans app.component.html, on conserve ainsi le layout défini : header / composant injecté dans le outlet / footer

<router-outlet></router-outlet>

Le routing permet aussi de restreindre l'accès aux composants (selon des rôles ou par exemple si l'utilisateur est connecté ou non via les guards).

Nous verrons les modules et guards dans un prochain article.

Formulaires

On se basera sur les reactives forms d'Angular dont j'ai une préférence face aux template-driven forms, même si ces derniers sont plus faciles et rapides à mettre en place. Les reactives offrent plus de latitude via les Observables notamment.

Pour cela, nous allons utiliser les reactives forms par le biais du service FormBuilder et de la classe FormGroup qui regroupement nos contrôles du formulaire.

Auteur

Le formulaire auteur ressemble à ce simple formulaire

En ajout

Alt Text

En modification

Alt Text

Nous allons faire en sorte de l'utiliser de 2 façons :

  • en "modale" (fenêtre qui s'ouvre par dessus la courante), j'ai une préférence pour ce mode pour éviter que l'utilisateur change de page quand ce n'est pas nécessaire.
  • en composant en pleine page, pour l'ajout d'un nouvel auteur, grâce à la route /author/edit, accessible grâce au menu qui redirigera vers cette route et donc chargera le composant app-author-edit

Alt Text

Pour les modales, j'aime bien utiliser un composant "conteneur" (app-author-container), qui embarque le composant app-author-edit, en lui passant ce qu'il faut (l'auteur à modifier), et ne sert que pour la modale à ouvrir.

Création d'un component author-edit

$ ng g c authorEdit

On modifie la vue et le TS

Vue author-edit.component.html

  • isUpdateMode indique si on est en ajout ou modification
  • les 2 champs de saisie pour le prénom et le nom : avec des , ils utilisent les contrôles de authorForm : first_name et last_name, le tout défini dans le TS
  • le nombre de caractères de la saisie / le nombre maximum autorisé, indiqués grâce à
  • 2 boutons d'actions : pour abandonner / fermer, pour enregistrer, ce dernier est accessible uniquement lorsque les 2 champs sont saisis, grâce aux Validators du formulaire
<mat-card>
  <mat-card-title>{{isUpdateMode ? 'Edition d\'un' : 'Nouvel'}} auteur</mat-card-title>
  <mat-card-content>
    <mat-progress-bar [mode]="'indeterminate'" color="warn" *ngIf="loading"></mat-progress-bar>
    <div [formGroup]="authorForm" *ngIf="authorForm">
      <div fxLayout="column">
        <mat-form-field>
          <label>
            <input matInput
                   maxlength="{{maxInput}}"
                   placeholder="Prénom"
                   formControlName="first_name" required>
          </label>
          <mat-hint align="end"><code>{{authorForm.get('first_name').value?.length}}</code> / <code>{{maxInput}}</code></mat-hint>
        </mat-form-field>

        <mat-form-field>
          <label>
            <input matInput
                   maxlength="{{maxInput}}"
                   placeholder="Nom"
                   formControlName="last_name" required>
          </label>
          <mat-hint align="end"><code>{{authorForm?.get('last_name').value?.length}}</code> / <code>{{maxInput}}</code></mat-hint>
        </mat-form-field>
      </div>
    </div>
  </mat-card-content>
  <mat-card-actions align="end">
    <button  mat-button
             matTooltipPosition="above" matTooltip="Retour à la liste"
             [disabled]="disabled"
             (click)="goList()">{{getLabelCancelOrReturn()}}</button>
    <button  mat-raised-button color="primary"
             matTooltipPosition="above" matTooltip="Enregistrer le livre"
             (click)="save()" [disabled]="authorForm?.invalid || disabled">Enregister</button>
  </mat-card-actions>
</mat-card>

TS author-edit.component.ts

Le code TS du component.

  • 2 Input : onReturn : détermine si l'on doit retourner sur la liste (route /authors), author : l'auteur à modifier en mode "modale" sinon l'auteur sera chargé par l'API AuthorService.fetch(id)
  • 1 Output : événement sur la modification effectif de l'auteur, lors du save() de l'auteur, on notifie la sauvegarde en y incluant l'objet auteur
  • utilisation de l'événement ngOnChanges() pour détecteur et entreprendre des actions (initialisation des valeurs du formulaire) si l'Input author est utilisé
import {Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges} from '@angular/core';
import {FormBuilder, FormGroup, Validators} from '@angular/forms';
import {ActivatedRoute, Router} from '@angular/router';
import {MatSnackBar} from '@angular/material/snack-bar';
import {finalize} from 'rxjs/operators';
import {Author, AuthorService, Book} from '../../../services';
import {SubSink} from '../../../services/subsink';

@Component({
  selector: 'app-author-edit',
  templateUrl: './author-edit.component.html',
  styleUrls: ['./author-edit.component.css']
})
export class AuthorEditComponent implements OnInit, OnDestroy, OnChanges {
  @Input() author: Author;
  @Input() onReturn = 'list'; // 'list' || ''
  @Output() authorUpdated = new EventEmitter<Author>();

  authorForm: FormGroup;
  maxInput = 25;
  loading = false;
  isUpdateMode = false;
  disabled = false;
  formDirty = false;
  subSink = new SubSink();

  constructor(private router: Router, private route: ActivatedRoute,
              private fb: FormBuilder, public snackBar: MatSnackBar,
              private authorSvc: AuthorService) {
  }

  ngOnInit(): void {
    this._initAuthorIfUpdate();
  }
  ngOnChanges(changes: SimpleChanges): void {
    if (changes.author && changes.author.currentValue !== null && changes.author.isFirstChange() &&
      changes.author.currentValue !== changes.author.previousValue) {
      this._initForm(changes.author.currentValue);
    }
  }
  ngOnDestroy(): void {
    this.subSink.unsubscribe();
  }
  /**
   * Sauvegarde de l'auteur
   * A l'issue :
   * - snackbar de confirmation
   * - initialisation du formulaire avec les valeurs (notamment pour l'id qui peut changer de 0 à N si ajout)
   * - notification Output de la sauvegarde
   */
  save() {
    this.disabled = this.loading = true;
    this.authorSvc
      .updateOrcreate(this.authorForm.value)
      .pipe(finalize(() => this.disabled = this.loading = false))
      .subscribe((author: Author) => {
        this.snackBar.
        open(`"${author.first_name} ${author.last_name}" bien ${this.isUpdateMode ? 'mis à jour' : 'ajouté'}`,
          'Auteur', {duration: 2000, verticalPosition: 'top', horizontalPosition: 'end'});
        this._initForm(author);
        this.authorUpdated.emit(author);
      });
  }

  /**
   * Retour à la liste selon le onReturn
   */
  goList() {
    this.authorUpdated.emit(null);
    if (this.onReturn === 'list') {
      this.router.navigate(['/authors']);
    }
  }
  getLabelCancelOrReturn() {
    if (this.formDirty) {
      return 'Abandonner';
    }
    return 'Fermer';
  }

  /**
   * PRIVATE
   *
   **/

  /**
   * Initialisation du FormGroup avec les contrôles : id, first_name, last_name
   * - on met les valeurs dans le cas d'une modification le cas échéant
   * - on écoute le valueChanges pour détecter toutes modifications des champs => changement du libellé du bouton de gauche
   * @param {Author} author : l'auteur [optionnel]
   * @private
   */
  private _initForm(author: Author = null) {
    this.isUpdateMode = author && author.id > 0;
    this.authorForm = this.fb.group({
      id: [author ? author.id : 0],
      first_name: [author?.first_name, Validators.required],
      last_name: [author?.last_name, Validators.required]
    });
    this.subSink.sink = this.authorForm.valueChanges.subscribe(values => {
      this.formDirty = true;
    });
  }

  /**
   * Recherche d'un auteur par son id, initialisation du formulaire avec ses valeurs
   * @param {number} id : id de l'auteur recherché
   * @private
   */
  private _fetchAuthor(id: number) {
    this.loading = true;
    this.subSink.sink = this.authorSvc
      .fetch(id)
      .pipe(finalize(() => this.loading = false))
      .subscribe((author: Author) => {
        this._initForm(author);
      });
  }

  /**
   * Si le component n'est pas inclus dans une modale (ie: la route est garnie par son id : /author/edit;id=1)
   * Si l'author n'est pas injecté via l'Input
   * alors une initialise le formulaire vide
   * @private
   */
  private _initAuthorIfUpdate() {
    const id = this.route.snapshot.params['id'];
    if (id && id !== '0') {
      this._fetchAuthor(id);
    } else {
      if (!this.author) {
        this._initForm();
      }
    }
  }
}

Livre

Le formulaire d'un livre pour l'ajout ou l'édition se présente ainsi : nom du livre, son nombre de pages, son auteur parmi une liste (le + permet d'en rajouter un dans la liste en utilisant le formulaire de l'auteur, en modale), et si le livre est disponible ou non

Alt Text

Il est accessible par l'url http://localhost:4200/book/edit;id=0 (id détermine si c'est un ajout ou une modification).

Je vous laisse regarder les sources pour ne pas redire ce qui a été fait pour l'auteur.

Data table

Les data table sont très courantes sur une application : pouvoir lister (sous forme tabulaire) des lignes d'objets et agir dessus : trier, se déplacer page par page, avoir des actions dessus : les éditer, les supprimer, etc

Material propose le composant mat-table pour représenter ce type de data table

L'objectif est d'avoir, pour les auteurs, un tableau qui liste ces derniers avec

  • l'auteur et une colonne qui affiche ses livres
  • une recherche (nous utiliserons le search de django)
  • des actions à droite : suppression et modification d'un auteur
  • le tri sur l'auteur
  • une pagination pour changer de page de la liste des auteurs

Alt Text

Vue

La mat-table a besoin d'un certain nombre d'éléments :

  • d'une source de données, la dataSource peut être un tableau de T (T[]) ou une classe qui hérite de MatTableDataSource ou ..., on fera la version simple : une dataSource sur Author[] ramenés par notre service
  • de colonnes (champs) à afficher pour les lignes : mat-row
  • d'entête : mat-header-row
  • des valeurs et modèles entête et champs pour chacune des lignes : mat-header-cell et mat-cell. Il est à noté qu'on peut injecter des composants dans les mat-cell bien entendu, on se contentera ici d'afficher le prénom et nom de l'auteur

on aura alors la vue suivante

<mat-card>
  <mat-card-actions align="end" style="padding-right: 10px;">
    <button mat-raised-button (click)="addAuthor()">Ajouter un auteur</button>
  </mat-card-actions>
  <mat-card-content>
    <mat-progress-bar *ngIf="loading" [mode]="'indeterminate'" color="warn"></mat-progress-bar>
    <!-- recherche auteurs -->
    <mat-form-field>
      <input matInput type="text"
             placeholder="Rechercher un auteur"
             matTooltip="Rechercher un auteur sur son nom / prénom"
             [formControl]="search">
    </mat-form-field>
    <!-- liste des auteurs --->
    <mat-table #table [dataSource]="authors" matSort matSortDisableClear>
      <!-- les informations de l'auteur -->

      <ng-container matColumnDef="auteur" >
        <mat-header-cell *matHeaderCellDef mat-sort-header="last_name">Auteur</mat-header-cell>
        <mat-cell *matCellDef="let row">{{row.first_name}} {{row.last_name}}</mat-cell>
      </ng-container>

      <ng-container matColumnDef="books" >
        <mat-header-cell *matHeaderCellDef >Livres</mat-header-cell>
        <mat-cell *matCellDef="let row">{{getBooks(row)}}</mat-cell>
      </ng-container>

      <!-- actions suppression / modification -->

      <ng-container matColumnDef="action_delete" >
        <mat-header-cell *matHeaderCellDef [fxFlex]="5"></mat-header-cell>
        <mat-cell *matCellDef="let row" [fxFlex]="5">
          <button mat-icon-button (click)="deleteAuthor(row)"
                  matTooltip="Supprimer cet auteur"><mat-icon [color]="'warn'">delete</mat-icon></button>
        </mat-cell>
      </ng-container>
      <ng-container matColumnDef="action_update" >
        <mat-header-cell *matHeaderCellDef [fxFlex]="5"></mat-header-cell>
        <mat-cell *matCellDef="let row" [fxFlex]="5">
          <button mat-icon-button (click)="editAuthor(row)"
                  matTooltip="Editer cet auteur"><mat-icon>edit</mat-icon></button>
        </mat-cell>
      </ng-container>

      <mat-header-row *cdkHeaderRowDef="displayedColumns"></mat-header-row>
      <mat-row *cdkRowDef="let row; columns: displayedColumns;"></mat-row>
    </mat-table>
    <mat-paginator [length]="total" [pageSize]="PAGE_SIZE"
                   [pageSizeOptions]="[2, 5, 10, 20, 50]"
                   [showFirstLastButtons]="true"></mat-paginator>
  </mat-card-content>
</mat-card>

dans ce squelette, pour les attributs importants, on aura :

  • un champ de recherche, on utilisera les Observables et opérateurs RxJS et un contrôle "reactive form"
  • mat-table
    • dataSource authors : Author[] rempli à l'issue de l'appel à fetchAll()
    • displayedColumns = les champs (auteur et ses livres) + les actions : la liste des colonnes à afficher, mat-table l'utilise pour itérer sur les colonnes à afficher
    • pour chaque mat-cell, nous avons accès à la la ligne courante de la dataSource, la row (de type Author), ce qui permet d'accéder aux propriétés de celle-ci, ici first_name et last_name, pour afficher la valeur de ces champs, pour "books", on appelle une fonction pour lister le titre des livres de l'auteur
    • de 2 actions par auteur : le supprimer (après confirmation) et le modifier (sous forme de modale), les 2 actions ont une notification à l'écran comme quoi "c'est bon !" à l'aide de la snackBar
  • le composant mat-paginator pour gérer la pagination (page précédent / suivante, 1ère et dernière page), on utilisera un provider getAuthorFrenchPaginatorIntl directement dans le TS du composant pour traduire et nommer les éléments du paginator ("auteurs par page", "page suivante", etc)

Alt Text

providers: [{ provide: MatPaginatorIntl, useValue: getAuthorFrenchPaginatorIntl() }]

Typescript

/**
 * Liste des auteurs avec pagination, tris et recherche
 */
@Component({
  selector: 'app-authors-list',
  templateUrl: './authors-list.component.html',
  styleUrls: ['./authors-list.component.css'],
  providers: [{ provide: MatPaginatorIntl, useValue: getAuthorFrenchPaginatorIntl() }]
})
export class AuthorsListComponent implements OnDestroy, AfterViewInit {
  /** Datasource */
  authors: Author[] = [];
  /** Champs à afficher */
  columns = ['auteur', 'books'];
  /** Actions sur un auteur */
  actions = ['action_delete', 'action_update'];
  /** L'ensemble des colonnes à afficher : champs + actions */
  displayedColumns = [...this.columns, ...this.actions];
  /** Paramétres du paginator */
  total = 0;
  PAGE_SIZE = 5;
  /** progress bar on / off */
  loading = false;
  /** lien vers les composants tri et paginator */
  @ViewChild(MatSort, {static: true}) sort: MatSort;
  @ViewChild(MatPaginator, {static: true}) paginator: MatPaginator;
  /** FormControl pour la recherche */
  search = new FormControl('');
  /** Utilitaire subscribe / unsubscribe */
  subSink = new SubSink();

  constructor(private router: Router, private route: ActivatedRoute,
              private dialog: MatDialog, public snackBar: MatSnackBar,
              private authorSvc: AuthorService) { }
  ngAfterViewInit(): void {
    this._initDataTable();
  }

  /**
   * Transformation de la liste de livres de l'auteur en chaîne
   * @param {Author} author : l'auteur
   */
  getBooks(author: Author) {
    return author?.books_obj?.reduce<string>((acc, currentValue, i) => {
      return i === 0 ? currentValue.name : `${acc}, ${currentValue.name}`;
    }, '');
  }
  /**
   * ACTIONS sur un auteur
   */
  /**
   * Ajout d'un auteur via une modale
   */
  addAuthor() {
    this._openAuthorModale();
  }
  /**
   * Edition d'un auteur via une modale
   * @param {Author} author
   */
  editAuthor(author: Author) {
    this._openAuthorModale(author);
  }
  /**
   * Suppression d'un auteur, après confirmation
   * @param {Author} author
   */
  deleteAuthor(author: Author) {
    const data = new DialogData();
    data.title = 'Auteur';
    data.message = `Souhaitez-vous supprimer cet auteur "${author.first_name} ${author.last_name}" ?`;
    const dialogRef = this.dialog.open(ConfirmationDialogComponent, { data });
    dialogRef.updatePosition({top: '50px'});
    dialogRef.afterClosed().subscribe(result => {
      if (result) {
        this.authorSvc.delete(author).subscribe(() => {
          this.snackBar.open(`"${author.first_name} ${author.last_name}" bien supprimé`,
            'Auteur',
            {duration: 2000, verticalPosition: 'top', horizontalPosition: 'end'});
          this.paginator.pageIndex = 0;
          this._initDataTable();
        });
      }
    });
  }
  /**
   * Ouverture modale d'édition d'un auteur
   * @param {Author} author
   * @private
   */
  _openAuthorModale(author: Author = null) {
    const data = author;
    const dialogRef = this.dialog.open(AuthorContainerComponent, {data});
    dialogRef.updatePosition({top: '50px'});
    dialogRef.updateSize('600px');
    dialogRef.afterClosed().subscribe((result: Author) => {
      if (result) {
        this._initDataTable();
      }
    });
  }
  /**
   * Factorisation du switchMap
   * @private
   */
  _switchMap(): Observable<Pagination<Author>> {
    this._toggleLoading(true);
    const parameters: ListParameters = {
      limit: this.paginator.pageSize, offset: this.paginator.pageIndex * this.paginator.pageSize,
      sort: this.sort.active, order: this.sort.direction,
      keyword: this.search.value
    } as ListParameters;
    return this.authorSvc.fetchAll(parameters);
  }
  /**
   * Initialisation data table, écoutes sur le tri, la pagination et la recherche
   * @private
   */
  _initDataTable() {
    const search$ = this.search.valueChanges
      .pipe(debounceTime(400), distinctUntilChanged(),
            switchMap((value) => {
              this.paginator.pageIndex = 0;
              return this._switchMap();
            }));
    const sortPaginate$ = merge(this.sort.sortChange, this.paginator.page)
      .pipe(startWith({}), switchMap((values) => this._switchMap()));

    this.subSink.sink = merge(search$, sortPaginate$)
      .subscribe((data: Pagination<Author>) => {
        this._toggleLoading(false);
        if (data) {
          this.total = data.total;
          this.authors = data.list;
        }
      });
  }
  _toggleLoading(value) {
    setTimeout(() => this.loading = value);
  }
  ngOnDestroy(): void {
    this.subSink.unsubscribe();
  }
}
  • champ de recherche : écoute sur valueChanges, on lui applique quelques opérateurs RxJS : déclenchement au bout de 400 ms (debounceTime) et on évite de poursuivre si c'est la même chaîne de saisie (distinctUntilChanged), c'est seulement après ces 2 filtres que la recherche démarre via un switchMap (un concatMap aurait été possible)
  • le tri / pagination : écoute des changements de tri (sortChange) et de pagination (page), ici encore, un switchMap est utilisé à l'issue de ces changements potentiel pour lancer le fetchAll() de l'API avec le tri ou la pagination modifiées

Le résultat (une liste d'auteurs Author[]) de cet ensemble (tri, pagination ou recherche puis appel à l'API fetchAll()) se retrouve dans l'abonnement subscribe(), il ne reste plus qu'à affecter authors, la mat-table se met à jour automatiquement grâce au binding de la dataSource (un Input()).

switchMap / concatMap : Ces 2 opérateurs permettent d'attendre le résultat d'une opération puis de lancer une autre opération ensuite. Le switchMap permet dans l'absolu d'annuler l'opération lancée s'il se passe quelques chose entre-temps, contrairement au concatMap (qui est donc secure pour des opérations qui ne demandent pas à être annulée : bancaire ou création d'entités qui doivent l'être, etc). Ici pas bien grave d'annuler une recherche en cours ou un tri / pagination, on utilisera donc un switchMap
merge : on utilise l'opérateur merge pour écouter tous les observables (sortPaginate$, search$), le 1er qui répond, on actionne le fetchAll() !

Conclusion et sources

On a vu

  • un début de refactoring pour diminuer le code en factorisant les services via une classe abstraite et les generics (les fonctions utilitaires sont volontairement mis en protected pour pouvoir les surcharger si le sort, la pagination, etc différent avec le serveur d'applications par exemple)
  • quelques opérateurs RxJs : debounceTime(), distinctUntilChanged(), switchMap() / concatMap(), merge()
  • les routes et l'appel aux composants associés
  • la mat-table de Material et quelques autres composants utiles : matInput et le FormGroup ou FormControl, MatDialog, snackBar

Au prochain article, nous irons plus loin dans la factorisation de composant, toujours sur la data-table, en faire un composant pour qu'il soit utilisé pour les livres, les auteurs, etc, ainsi que les modules en Angular voire les Guards.

Retrouvez les sources sur la branche https://github.com/zorky/library/tree/django-drf-angular-3.2 de cette suite sur Angular.

Posted on by:

zorky profile

DUVAL Olivier

@zorky

CP technique / Scrummaster, développeur sénior ancien blog : https://medium.com/zorky

Discussion

pic
Editor guide