Django & DRF & Angular 101, partie 3.4 : data table
Tags: angular, material, datatable, dynamic
Published: 2020-11-08 14:30:29

## Sommaire
  * [Introduction](#intro)
  * [Composant mat-table](#mat-table)
    * [Structure](#structure)
    * [Paramétrage](#parametrage)
    * [Utilisation de la data-table](#useit)
    * [Conclusion et sources](#sources)
  
# Introduction <a name="intro"></a>

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](https://dev-to-uploads.s3.amazonaws.com/i/d9qlz1ehpd2sec0yptnm.png)

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/npmx3zes5g8awek3tvu5.png)

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/b08z9nwm8hqi85pn6rb2.png)

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/voovpyp2qqkef2cxt1x7.png)

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/de2w78wuh5a7yzt8dxn4.png)

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/9rcr9186gt76o8adcp84.png)


On en fera une [librairie Angular](https://angular.io/guide/creating-libraries), pour compiler la librairie data-table : $ ng build data-table 

# Composant mat-table <a name="mat-table"></a>

Angular material propose un composant [mat-table](https://material.angular.io/components/table/examples) 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](https://dev.to/zorky/django-drf-angular-101-partie-3-2-4foh#data-table)), 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](https://en.wikipedia.org/wiki/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 <a name="structure"></a>

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<T>**
* 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 : []) :

```html
<mat-data-table
      [dataSource]="dsData"
      [columns]="columns"
      [actions]="actions">
    </mat-data-table>
```

## Paramétrage <a name="parametrage"></a>

### 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 : 

```typescript
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;
}
```

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

```typescript
/**
 * 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;
}
```

### Service 

La data-table a besoin d'un **service** pour aller chercher les éléments à afficher, il suffit de surcharger **DaoGeneric<T>**, 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 :

```typescript
/**
 * 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);
  }
 //...
}
```
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 <a name="useit"></a>

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

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/788j1mpm3h0lucj4gry2.png)

**1- Service** sur les auteurs, hérite de DaoGeneric<Author>, 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 :

```typescript
@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;
  }
}
```
**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](https://dev-to-uploads.s3.amazonaws.com/i/6s4xuuw9nv3limgty6s9.png)

**author-dt-list.component.html :**

```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>
```

**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.

```typescript
@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();
      });
    }
  }
```

# Conclusion et sources <a name="sources"></a>

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