DEV Community

Cover image for Django & DRF & Angular 101, partie 3.4 : data table
DUVAL Olivier
DUVAL Olivier

Posted on • Updated on

Django & DRF & Angular 101, partie 3.4 : data table

Sommaire

Introduction

Dans cet article, nous allons nous pencher plus particulièrement sur un composant "data-table" : un tableau d'affichage de lignes / champs issus d'une API.

Ce type de "table" est souvent utilisée dans les applications afin de lister des éléments et interagir dessus.

Ce composant "data-table" aura tout un tas de fonctionnalités :

  • service API générique
  • affichage dynamique des colonnes d'un modèle que l'on souhaite
  • tri des colonnes
  • pagination
  • recherche
  • des boutons actions afin d'opérer une action sur une ligne de la table, par exemple : suppression, édition, etc
  • la possibilité d'injecter des filtres colonnes pour filtrer les données de la table, un peu à la "Excel"
  • de pouvoir injecter des composants dans les cellules de la table pour agir sur le champ affiché (par exemple, un radio bouton on/off pour activer / désactiver un livre ou de l' edit in place d'une valeur)

Quelques exemples :

Alt Text

Alt Text

Alt Text

Alt Text

Alt Text

Alt Text

On en fera une librairie Angular, pour compiler la librairie data-table : $ ng build data-table

Composant mat-table

Angular material propose un composant mat-table qui permet d'afficher sous forme tabulaire des données. Elle présente l'avantage d'offrir pas mal de fonctionnalités : colonnes à afficher, tris, pagination, se basant sur une source de données.

L'inconvénient, comme tout composante avancé, est d'avoir à écrire beaucoup de code (dans la vue HTML et dans le ts, tout comme dans cet article de la série Angular 3.2), on recopie donc souvent du code pour l'ajuster à ses besoins et on doit ré-appréhender son utilisation (le comment ça fonctionne déjà), ce qui n'est parfois pas très Don't Repeat Yourself.

L'objectif est de concevoir un composant qui sera paramétrable afin de se soulager du code - répétitif - que l'on aurait à reproduire pour avoir une mat-table.

Structure

La sources de données se définira grâce :

  • à une liste de colonnes à afficher : columns
  • optionnellement une liste d'actions possibles sur une ligne : actions
  • un service qui permettra d'obtenir une liste d'éléments. Ce service dérivera d'un service générique sur un modèle précis, avec au moins une fonction qui ira chercher via une api la liste : DaoGeneric
  • le selector pour son utilisation pourra ressembler à cela, dans sa version minimale, une datasource, les colonnes et les actions (l'objet doit exister mais peut être initialisé à vide : []) :
<mat-data-table
      [dataSource]="dsData"
      [columns]="columns"
      [actions]="actions">
    </mat-data-table>
Enter fullscreen mode Exit fullscreen mode

Paramétrage

Interfaces columns et actions

La data-table, pour fonctionner, doit pouvoir afficher des colonnes, autrement dit, des champs de la table (renvoyés par une API), et potentiellement des actions que l'on peut faire sur les lignes ramenées.

Une colonne (ColumnDataTable) est définie par l'interface :

import {ColumnComponentItem, ComponentItem} from '../components/dynamic-core-components/component-item';

/**
 * Options ouverture popin filtre colonne
 */
export interface HeaderFilterOptions {
  colorIcon: 'accent' | 'primary' | 'warn';
  position: 'top' | 'bottom' | 'left' | 'right';
  hasBackDrop: boolean;
}

/**
 * interface pour les colonnes de data-table
 */
export interface ColumnDataTable {
  /* champs 'table' à afficher (et à trier le cas échéant) */
  column: string;
  /* composant cellule */
  columnComponent?: () => ColumnComponentItem;

  /* libellé de l'entête table */
  header: string;
  headerFun?: (row) => string;

  /* composant filtre colonne dans le header */
  headerComponent?: () => ComponentItem;
  /* tooltip du filtre colonne */
  headerFilterToolTip?: (row) => string;
  /* option du filtre colonne : positionnement, couleur, fond */
  headerFilterOptions?: HeaderFilterOptions;

  /* fonction d'obtention de la valeur du champ pour la ligne courante */
  display: (row) => any;

  /* flex largeur */
  flex?: number;

  /* fonction d'obtention de la couleur d'affichage de la valeur du champ, optionnel, par défaut #000000 (noir) */
  color?: (row) => string;
  colorBackground?: (row) => string;

  /* fonctions couleurs colonne entéte texte et fond */
  colorHeader?: (row) => string;
  colorHeaderBackground?: (row) => string;

  /* tri actif ou non */
  sort: boolean;
  /* champ de tri si différent de columnDef, optionnel */
  sortField?: string;

  /*  afficher ligne / colonne ? */
  hidden?: boolean;
  hiddenFun?: (row) => boolean;

  /* afficher header / entête ? */
  hiddenHeader?: () => boolean;

  /* tronquer le texte de la cellule ? */
  truncate?: boolean;

  /* centré le texte de la cellule ? */
  center?: boolean;

  /* tooltip */
  tooltip?: (row) => any;
}
Enter fullscreen mode Exit fullscreen mode

Une action (ActionDataTable) est définie par l'interface :

/**
 * interface pour les actions de data-table
 */
export interface ActionDataTable {
  /* identifiant action */
  label: string;

  /* tooltip bouton */
  tooltip: string;
  /* tooltip fonction, optionnel, surcharge tooltip */
  tooltipFun?: (row) => string;

  /* fonction d'obtention de la couleur d'affichage de la valeur du champ, optionnel, par défaut #000000 (noir) */
  color?: (row) => string;
  colorBackground?: (row) => string;

  /* fonctions couleurs colonne entéte texte et fond */
  colorHeader?: (row) => string;
  colorHeaderBackground?: (row) => string;

  /* icon material */
  icon: string;
  /* calcul icon material, optionnel, surcharge icon */
  iconFun?: (row) => string;

  /* couleur icone (couleurs material : primary | accent | warn */
  iconcolor?: string;
  iconcolorFun?: (row) => string;

  /* action (fonction) au click */
  click: (row) => void;

  /* flex colonne */
  flex?: number;

  /* afficher ligne ? */
  hidden?: (row) => boolean;
  /* afficher header / entête ? */
  hiddenHeader?: () => boolean;
}
Enter fullscreen mode Exit fullscreen mode

Service

La data-table a besoin d'un service pour aller chercher les éléments à afficher, il suffit de surcharger DaoGeneric, notamment la méthode listItems(params) (pour lister ou rechercher sur un mot clé), la classe abstraite DaoGeneric est définie de la façon suivante, représente le CRUD d'une entité / modèle, je n'ai gardé que ce qui nous intéresse pour lister, voir le code complet sur le dépôt :

/**
 * DaoGeneric : fourniture d'un CRUD sur un type T
 * Utile pour le MatDataSource sur le list()
 */
export abstract class DaoGeneric<T> {
  /** loading indicateur */
  private loadingSubject = new BehaviorSubject<boolean>(false);
  public loading$ = this.loadingSubject.asObservable();
  constructor(private http: HttpClient) {}

  /**
   * Pour obtenir l'url "root" de l'API souhaitée
   *
   * Exemple : `${environment.baseUrl}/projets/categories/`;
   * @return {string}
   */
  abstract getRootUrl(urlApp?: string): string;

  /**
   * Obtient la liste d'objects T sous forme de Pagination
   *
   * @param {string} sort : champ de tri [optionnel]
   * @param {string} order : si 'sort', ordre du tri : asc | desc [optionnel]
   * @param {number} limit : nb. max d'éléments à ramener
   * @param {number} offset : démarre la pagination à partir de 'offset'
   * @param {Map<string, string>} extraParams : extra paramètres à passer à la requête API [optionnel]
   * @param {string} keyword : mot de recherche le cas échéant (!= '') [optionnel]
   * @param {Map<string, string[]>} extraDict : extra paramètres où la clé à plusieurs valeurs : key=val1&key=val2&...&key=valn
   * @param {string} urlBaseOverride : url à surcharger (remplacerra getRootUrl())
   * @param {boolean} withCache : accède au cache ou non
   * @return {Observable<Pagination>} : un objet Pagination des éléments trouvés
   */
  list(sort: string, order: string, limit: number, offset: number,
       extraParams: Map<string, string> = null,
       keyword = '',
       extraDict: Map<string, string[]> = null,
       urlBaseOverride: string = null,
       withCache = false): Observable<Pagination> {
    this.loadingSubject.next(true);
    let params1 = this._getPaginationParams(limit, offset, keyword, extraParams);

    let url = '';
    if (urlBaseOverride) {
      url = urlBaseOverride;
    } else {
      url = this.getRootUrl();
    }

    if (sort && sort !== '') {
      params1 = this._getSorting(sort, order, params1);
    }

    const params = this._getUrlHttpDict(extraDict, params1);
    return this.http
        .get<T[]>(url, {params})
        .pipe(
          finalize(() => this.loadingSubject.next(false)),
          map(response => this._getPagination(response, limit)));
  }

  /**
   * Obtient la liste d'objects T sous forme de Pagination
   *
   * @param {ListParameters} parameters : paramètre pour l'obtention de la liste (sorting, pagination, mot de recherche, extra paramètres)
   * @return {Observable<Pagination>}
   */
  listItems(parameters: ListParameters): Observable<Pagination> {
    return this.list(
      parameters.sort, parameters.order,
      parameters.limit, parameters.offset,
      parameters.extraParams,
      parameters.keyword,
      parameters.extraDict,
      parameters.urlBaseOverride,
      parameters.withCache);
  }

  /**
   * Liste de tous les éléments
   *
   * @param {Map<string, string>} extraParams : extra params url [optionnel]
   * @param {string} keyword : mot de recherche [optionnel]
   * @param {boolean} withCache : activer le cache ? [optionnel]
   * @return {Observable<Pagination>}
   */
  listAllItems(extraParams: Map<string, string> = null, keyword: string = '', withCache?: boolean): Observable<Pagination> {
    return this.list('', '', 0, 0, extraParams, keyword, null, null, withCache);
  }
 //...
}
Enter fullscreen mode Exit fullscreen mode

Cette classe générique pourra servir à son usage personnel sur une entité pour effectuer le CRUD (Create / Read (un ou une liste) / Update (ou Patch) / Delete) et sera utile à la data-table.

Utilisation de la data-table

Utilisons la data-table pour lister les auteurs et agir dessus.

Alt Text

1- Service sur les auteurs, hérite de DaoGeneric, le généric est fixé sur le modèle Author, on pourra profiter ainsi de toute la tuillauterie pour ajouter, modifier, supprimer ou lister des auteurs, la méthode abstraite getRootUrl() est à implémenter, cela donne l'URL de l'API pour atteindre les auteurs :

@Injectable({ providedIn: 'root' })
export class AuthorDtService extends DaoGeneric<Author> {
  private url =  `${environment.baseUrl}/library/authors/`;
  constructor(private httpClient: HttpClient) {
    super(httpClient);
  }
  getRootUrl(urlApp?: string): string {
    return this.url;
  }
}
Enter fullscreen mode Exit fullscreen mode

2- Composant AuthorDtListComponent

Création d'un composant dans lequel nous allons utiliser la data-table : recherche, afficher des colonnes et pouvoir interagir sur la cellule, des actions (suppression, édition), des filtres colonnes pour filtrer la liste

Alt Text

author-dt-list.component.html :

<mat-card>
  <mat-card-title>
    Auteurs
  <mat-card-actions align="end" style="padding-right: 10px;">
    <button *ngIf="isGest()"
            mat-raised-button (click)="addAuthor()">Ajouter un auteur</button>
  </mat-card-actions>
  <mat-card-content>
    <mat-data-table
      [dataSource]="dsAuthors"
      [columns]="columns"
      [actions]="actions"
      [extraParams]="extraParams"
      [placeHolderFilter]="'Rechercher sur un auteur ou sur un livre'"
      [showFirstLastButtons]="true" [showLoader]="true"
      [loaderMask]="true" [loaderProgress]="true">
    </mat-data-table>
  </mat-card-content>
</mat-card>
Enter fullscreen mode Exit fullscreen mode

author-dt-list.component.ts :

le TS dans lequel nous définissons les columns, les actions, l'injection de notre service AuthorDtService et son utilisation dans la data-table (via l'affectation this.dsAuthors.daoService = authorSvc; et utilisé par l'input dataSource dans la data-table).

Source simplifié pour la définition des champs à afficher, les actions, le filtre colonne sur les auteurs AuthorFilterDtComponent (doit implémenter l'interface HeaderComponent) et le filtre livre BooksFilterDtComponent, ainsi que le composant cellule livres BooksListColumnComponent pour mettre à jour les livres d'un auteur.

@Component({ selector: 'app-author-dt-list',
  templateUrl: './author-dt-list.component.html',
  styleUrls: ['./author-dt-list.component.css']
})
export class AuthorDtListComponent implements OnInit {
  columns: ColumnDataTable[] = [{
    column: 'author', header: 'Auteur', sortField: 'last_name',
    display: (element: Author) => `${element.first_name} ${element.last_name}`,
    tooltip: (row: Author) => `${row.first_name} ${row.last_name}`,
    headerFilterToolTip: (row) => 'Filtrer sur l\'auteur',
    headerFilterOptions: {colorIcon: 'warn', hasBackDrop: true, position: 'right'} as HeaderFilterOptions,
    flex: 20, sort: true
  },
  {
    column: 'books', header: 'Livres',
    display: (element: Author) => this.getBooks(element),
    tooltip: (row: Author) => this.getBooks(row),
    headerFilterToolTip: (row) => 'Filtrer sur un livre',
    sort: false
  }];
  actions: ActionDataTable[] = [{
   label: 'delete', tooltip: 'Supprimer l\'auteur',  icon: 'delete',
   click: (row: Author) => this.deleteAuthor(row),
   iconcolor: 'warn',
 },
 {
   label: 'edit', tooltip: 'Modifier l\'auteur',  icon: 'edit',
   click: (row: Author) => this.editAuthor(row),
   iconcolor: 'accent',
 }];
  dsAuthors: MatDataSourceGeneric<Author> = new MatDataSourceGeneric<Author>();
  /**
   * Paramétres de filtrage sur l'API DaoService.listItems(params)
   */
  extraParams: Map<string, string> = new Map<string, string>();
  /**
   * les valeurs possibles pour les listes des filtres colonnes : id / chaîne à afficher
   */
  filterColumns: Map<string, Map<string, string>> = new Map<string, Map<string, string>>();
  @ViewChild(DataTableComponent, {static: false}) matDataTable: DataTableComponent;
  constructor(private dataTableHeaderSvc: DataTableHeaderColumnComponentService,
              private authorSvc: AuthorDtService) {
    this.dsAuthors.daoService = authorSvc;
  }
ngOnInit(): void {
    this._setFilterAuthor();
    this._setFilterBook();
  }
  /**
   * Initialisation du filtre colonne sur les auteurs
   */
_setFilterAuthor() {
    this.subSink.sink = this.authorSvc
      .listAllItems()
      .subscribe((authors: Pagination) => {
        const listAuthors = new Map<string, string>();
        (authors.list as Author[])
          .forEach((author) => listAuthors.set(author.id.toString(), `${author.first_name} ${author.last_name}`));
        this.filterColumns.set('author', listAuthors);
        this._setAuthorFilter('author', 'sur un auteur', 'id',
          'Filtrer par un auteur', 'Auteur', () => this.matDataTable.reload());
      });
  }
  /**
   * Création du component colonne filtre Author
   */
  private _setAuthorFilter(listName, listLabel, keyFilter, placeHolder, condName, callBack: () => void = null) {
    const data = {
      placeHolder, keyFilter,
      filterColumns: this._getValuesMap(listName),
      filterName: condName
    };
    const filterComponent = this.dataTableHeaderSvc.createHeaderComponent(
      this.columns, listName, `author_filter_list_${listName}`, `${listLabel}`, AuthorFilterDtComponent, data, true);

    if (filterComponent) {
      this.subSink.sink = filterComponent.subject$.subscribe((d) => {
        if (d && d.key) {
          filterComponent.dataDefault = {id: d.value};
          if (this.extraParams.get(d.key)) {
            this.extraParams.delete(d.key);
          }
          if (d.value === 0 || d.value === '0') {
            this.extraParams.delete(d.key);
          } else {
            this.extraParams.set(d.key, d.value);
          }
          if (callBack) {
            callBack.call(null, null);
          }
        }
      });
    }
  }
  /**
   * Obtention d'un dico key / value à partir de filterColumns: Map<string, Map<string, string>>
   * @param {string} key : sur cette clé
   */
  private _getValuesMap(key: string): Map<string, string> {
    if (this.filterColumns.has(key)) {
      return this.filterColumns.get(key);
    }
    return new Map<string, string>();
  }
  /**
   * Initialisation du filtre colonne sur les livres
   * Ajout instance composant cellule sur les livres d'un auteur
   */
  _setFilterBook() {
    this.subSink.sink = this.bookSvc
      .listAllItems()
      .subscribe((books: Pagination) => {
        const listBooks = new Map<string, string>();
        (books.list as Book[])
          .forEach((book) => listBooks.set(book.id.toString(), `${book.name}`));
        this.filterColumns.set('books', listBooks);
        this._setBookFilter('books', 'sur un livre', 'book',
          'Filtrer par un livre', 'Livre', () => this.matDataTable.reload());
        this._setAuthorDynamicComponent('books', 'auteur', 'book');
      });
  }
  /**
   * Initialisation du composant sur la cellule livre : livres à choisir pour l'auteur de la ligne courante
   */
  private _setAuthorDynamicComponent(listName, listLabel, keyFilter, callBack: () => void = null) {
    const data = {
      filterColumns: this._getValuesMap(listName),
    };
    const filterComponent = this.dataTableHeaderSvc
      .createColumnComponent(
        this.columns, listName, `books_list_${listName}`, `${listLabel}`,
        BooksListColumnComponent, data);
    if (filterComponent) {
      filterComponent.subject$.subscribe((_data) => {
        this.matDataTable.reload();
      });
    }
  }
Enter fullscreen mode Exit fullscreen mode

Conclusion et sources

Dans cet article, nous avons vu comment encapsuler afin de mutualiser et son code d'usage le composant Material mat-table en y apportant des fonctionnalités de bases : filtres colonnes, composants cellules, actions, recherche, tris et pagination.

Retrouvez les sources de cet article (backend et frontend) sur https://github.com/zorky/library/tree/django-drf-angular-3.4

Discussion (0)