Angular Material : Créer une Business Data Table

L'un des contrôles le plus demandé dans tout développement dit "Business", c'est la gestion d'une DataGrid.

Si vous faites une recherche sur Google (ou Bing ? ;-)), vous verrez qu'il y a pas mal de composant payant/gratuit. Si vous voulez voir une liste des 10 meilleures DataTable selon NgDevelop, consultez cet article.

Et c'est toujours pareil avec des composants extérieurs, ils sont prévus pour faire un maximum de chose (c'est même leur argument de vente) mais on tombe toujours sur LA fonctionnalité qu'on doit iméprativement utiliser qui n'existe pas dans le composant que vous avez choisi.

De plus, j'ai tendance à limiter au maximum mes dépendences à des composants extérieurs. Sauf pour les gros éditeurs (et encore), on n'est jamais sur du suivi et de la pérénnité de leurs développements.

Enfin, par principe, qui ne se discute pas parce que c'est moi le chef, dès qu'il y a une dépendence à jQuery, j'élimine le bousin. (je sais, ce n'est pas rationnel mais c'est comme ça).

NB : ce code est disponible sur Github.

Tout ce préambule pour vous dire que l'on va voir comment créer une DataGrid dite "Business" à partir de la DataTable d'Angular Material.

Le cahier des charges est le suivant. Elle doit :

  • Supporter des données très importantes,
  • Permettre d'éditer en ligne les données,
  • Trier les données selon une colonne,
  • Paginer les données,
  • Ajouter des données.

Dans un premier temps nous allons voir comment créer les apis côté serveur pour satisfaire nos besoins avec une application ASP .NET WebApi (pas .NET Core, .NET "normal") puis nous verrons le code de l'application Angular cliente.

L'application Serveur

Pour permettre la pagination de nos données, nos apis REST doivent pouvoir fournir les données paginées (étonnant non ?) avec une information complémentaire qui est le nombre total de données. La méthode Get de notre api doit donc avoir comme paramètres :

  • l'index de la page,
  • la taille d'une page,
  • le nom de la colonne de tri,
  • l'ordre du tri (ascendant ou descendant).

Ce qui donne une URL du type :

http://localhost:10614/api/persons?pageIndex=0&pageSize=5&sortColumn=lastName&sortDirection=desc

Qui retournera les données en json :

{
  "items": [
    {
      "id": 5,
      "firstName": "Eric",
      "lastName": "Verre",
      "age": 35
    },
    {
      "id": 11,
      "firstName": "Isabelle",
      "lastName": "Routin",
      "age": 74
    },
    {
      "id": 1014,
      "firstName": "Richard",
      "lastName": "Richard",
      "age": 20
    },
    {
      "id": 6,
      "firstName": "Jean",
      "lastName": "Renee",
      "age": 35
    },
    {
      "id": 3,
      "firstName": "Jean",
      "lastName": "Rene",
      "age": 34
    }
  ],
  "count": 12
}

Pour faire cela, on a besoin dans un premier temps d'une classe qui représente ces données. On va faire donc une classe générique valable pour tout type de données :

    public class PaginationResult<T> where T : class
    {
        public PaginationResult(int count, IEnumerable<T> items)
        {
            Count = count;
            Items = items;
        }
        public IEnumerable<T> Items { get; set; }
        public int Count { get; set; }
    }

Nous devons donc requêter notre base de données en paginant les données. Avec SQL Server, le code Transact SQL sera donc :

SELECT Count(*) FROM Persons;
SELECT * FROM Persons 
                ORDER BY lastName asc 
                OFFSET 0 
                ROWS FETCH NEXT 5 
                ROWS ONLY;

On voit ici que l'on exécute 2 requêtes SQL en même temps. La première retourne le nombre de données de la table, la second les données de la page 0 avec 5 éléments. L'Offset correspond au nombre de ligne que l'on souhaite "sauter", et Next le nombre de ligne.

Pour la page 3, on aura :

SELECT Count(*) FROM Persons;
SELECT * FROM Persons 
                ORDER BY lastName asc 
                OFFSET 10 
                ROWS FETCH NEXT 5 
                ROWS ONLY;

Pour exécuter cette requête, j'ai choisi l'excellent ORM Dapper (light et performant) avec le code suivant :

        public PaginationResult<Person> LoadPagedAll(
              int pageIndex,
              int pageSize, 
              string sortColumn = "LastName", 
              string sortDirection = "Asc")
        {          
            var sql = $"SELECT Count(*) FROM Persons;" ;
            sql += $"SELECT * FROM Persons ";


            sql += GetPaginationQuery(pageIndex, pageSize, sortColumn, sortDirection);

            using (var connection = new SqlConnection(ConnectionString))
            {

                using (var multi = connection.QueryMultiple(sql))
                {
                    var numbers = multi.Read<int>().First();
                    var persons = multi.Read<Person>();
                    return new PaginationResult<Person>(numbers, persons);
                }
            }
        }

protected override IEnumerable<string> AllowedSortColumns => new[] {"FIRSTNAME", "LASTNAME", "AGE"};

Dapper permet simplement d'exécuter 2 requêtes à la fois pour remplir notre object PaginationResult<Person>. La méthode GetPaginationQuery permet d'ajouter dans notre requête SQL les éléments de pagination :

        protected string GetPaginationQuery(int pageIndex, int pageSize, string sortColumn, string sortDirection)
        {
            if (!AllowedSortColumns.Contains(sortColumn, StringComparer.InvariantCultureIgnoreCase))
                throw new ArgumentException("Not allowed columns", nameof(sortColumn));
            if (!_sortDirections.Contains(sortDirection, StringComparer.InvariantCultureIgnoreCase))
                throw new ArgumentException("Invalid sort direction", nameof(sortDirection));


            return $@"
                ORDER BY {sortColumn} {sortDirection} 
                OFFSET {pageIndex * pageSize} 
                ROWS FETCH NEXT {pageSize} 
                ROWS ONLY;";
        }

Remarquez que je laisse au repository le choix des colonnes ou l'on a le droit d'effectuer un tri pour des raisons d'optimisation. Laissez la possibilité au code client de trier sur n'importe quelle colonne est la porte ouverte à des pertes de performances évidentes. Il faut toujours imaginer que l'on a des tables contenant plusieurs millions d'enregistrement, et le tri ne peut s'effectuer que sur des colonnes prévus pour (avec les index dans SQL server qu'il faut).

De ce fait, le code de notre api REST est tout simple :

        public PaginationResult<Person> Get(
           int pageIndex, 
           int pageSize, 
           string sortColumn = "LastName", 
           string sortDirection = "Asc")
        {
            return _personsRepositoy.LoadPagedAll(pageIndex, pageSize, sortColumn, sortDirection);
        }

Si le code client essaye de trier sur une colonne non optimisée, on aura une exception.

Les autres codes de notre api sont triviaux, je ne vous ferais pas l'affront de le détailler ici (voyez le code par vous même).

Notre code côté serveur est donc terminé, il n'y a plus qu'à le consommer côté client.

Angular Material : DataTable

Notre application cliente repose sur le framework Angular avec comme complément, le package @angular/material. Le plus simple pour l'ajouter est d'utiliser la commande d'angular/cli :

ng add @angular/material

La DataTable d'Angular Material est basée sur la CdkTable. Si vous venez comme moi du monde XAML, vous ne serez pas trop dépayser. Le principe est que vous devez définir un template pour chaque colonne que vous voulez afficher.

Dans ce template, vous pouvez définir l'aspect de 3 zones :

  • L'en-tête de la colonne,
  • Le contenu de chaque cellule de la colonne,
  • Le pied de la colonne.

Par exemple :

<table mat-table mat-table [dataSource]="dataSource" >
    <!-- firtName column-->
    <ng-container matColumnDef="firstName">
      <th mat-header-cell *matHeaderCellDef> First Name </th>
      <td mat-cell *matCellDef="let person">
        <div>{{person.firstName}}</div>
      </td>
    </ng-container>
...
</table>

Je définie ici une colonne nommée firstName. Dans l'en-tête, j'affiche juste "First Name" et dans chaque cellule, grâce à ma variable person, j'affiche son lastName. Vous voyez qu'ici, je n'ai pas défini de pied pour cette colonne.

Je peux continuer ainsi pour les autres colonnes pour définir les templates que je veux. Il ne me reste plus maintenant qu'à dire quelles sont les colonnes que je veux réellement afficher. On peut définir un template et ne pas l'utiliser. L'intérêt est que la liste des colonnes affichées peut être modifiée par le code. On ajoute donc juste 2 lignes de code :

<table mat-table mat-table [dataSource]="dataSource" >
    <!-- firtName column-->
    <ng-container matColumnDef="firstName">
      <th mat-header-cell *matHeaderCellDef mat-sort-header> First Name </th>
      <td mat-cell *matCellDef="let person">
        <div>{{person.firstName}}</div>
      </td>
    </ng-container>

    <tr mat-header-row *matHeaderRowDef="['firstName']"></tr>
    <tr mat-row *matRowDef="let row; columns: ['firstName']"></tr>
  </table>
</table>

Il faut maintenant définir notre dataSource, c'est-à-dire la source de nos données. Le CdkTable définie une interface avec juste deux méthodes (connect et disconnect) pour notre source de données. la bonne nouvelle c'est qu'avec la DataTable, on a une implémentation de cette dernière avec la classe MatTableDataSource.

Il nous suffit donc de créer une instance de cette classe :

dataSource = new MatTableDataSource();

Puis grâce à notre service qui appelle notre api REST de lui passer les données à afficher. Avant cela, il nous faut écrire ce service. Il est tout simple :

@Injectable({
  providedIn: 'root'
})
export class PersonsService {

  private url = environment.apiUrl + 'persons';
  constructor(private httpClient: HttpClient) { }

  loadAll(
    pageIndex?: number,
    pageSize?: number,
    sortColumn?: string,
    sortDirection?: string): Observable<IPaginationResult<IPerson>> {
    const params = new HttpParams()
      .set('pageIndex', pageIndex == null ? '0' : pageIndex.toString())
      .set('pageSize', pageSize == null ? '10' : pageSize.toString())
      .set('sortColumn', sortColumn == null ? 'lastName' : sortColumn)
      .set('sortDirection', sortDirection == null ? 'asc' : sortDirection);
    const headers = new HttpHeaders().set('Content-Type', 'application/json');
    return this.httpClient.get<IPaginationResult<IPerson>>(this.url, { headers: headers, params: params });
  }
}

Avec comme interface de "réception" des données, l'équivalent en TypeScript de notre classe C# PaginationResult<Person> (elle même générique) :

export interface IPaginationResult<T> {
    items: T[];
    count: number;
}

La pagination

Pour la pagination, la DataTable de Angular Material fournit un composant que l'on ajoute sous notre mat-table :

  <table mat-table [dataSource]="dataSource">
    <tr mat-header-row *matHeaderRowDef="['firstName', 'lastName']"></tr>
    <tr mat-row *matRowDef="let row; columns: ['firstName', 'lastName']"></tr>
  </table>
  <mat-paginator [length]='resultLength' [pageSizeOptions]="[5, 10, 20]" showFirstLastButtons></mat-paginator>

On voit que l'on "bind" sa propriété length à la variable resultLength de notre composant. Cette variable correspond à la donnée count du IPaginationResult<Person>.

Le tri

Pour le tri, c'est tout aussi simple : il faut tout d'abord dire que notre mat-table supporte le tri :

<table mat-table mat-table [dataSource]="dataSource" matSort matSortActive="lastName"
    matSortDisableClear matSortDirection="asc">
...
</table>

On indique ici que le tri sera par défaut sur la colonne lastName et de type ascendant. Ensuite, il faut indiquer quelles sont les colonnes ou l'on a le droit d'effectuer un tri. Cela se défini dans le template de la colonne avec la directive mat-sort-header. Par exemple sur la colonne lastName :

<!-- lastName column-->
    <ng-container matColumnDef="lastName">
      <th mat-header-cell *matHeaderCellDef mat-sort-header> Last Name </th>
      <td mat-cell *matCellDef="let person">
        <div>{{person.lastName}}</div>
      </td>
    </ng-container>

Voila, la pagination et le tri sont prêts. Il faut maintenant réagir aux évènements correspondants, c'est-à-dire réinterroger le serveur quand on change de page et/ou quand on clique sur l'en-tête d'une colonne triable.

Connexion aux api REST

Pour réagir à ces évènements, on a besoin d'avoir une référence aux composants correspondants de la DataTable, la pagination et le tri :

  @ViewChild(MatPaginator) paginator: MatPaginator;
  @ViewChild(MatSort) sort: MatSort;

Ces deux composants ont des Observable correspondant au changement de page et au clic sur un en-tête. Dans les 2 cas, on interroge le serveur de la même façon. On va donc combiner ces deux Observable grâce à l'opérateur static merge :

    merge(this.sort.sortChange, this.paginator.page)
      .pipe(
        switchMap(() => {
          this.isWorking = true;
          return this.loadPersons();
        }),
        tap(data => {
          this.resultLength = data.count;
          this.dataSource.data = data.items;
          this.isWorking = false;
        })
      ).subscribe();

Quand le tri change ou que la page change, on appelle la méthode loadPerson qui nous retourne un IPaginationResult. Il ne nous reste plus qu'à affecter les données reçues à la dataSource et à la variable resultLength (pour la pagination).

  private loadPersons() {
    return this.personsService.loadAll(
      this.paginator.pageIndex,
      this.paginator.pageSize,
      this.sort.active,
      this.sort.direction);
  }

Un petit détail : quand on change le tri, il faut impérativement retourner à la page d'index 0 :

this.sort.sortChange.subscribe(() => this.paginator.pageIndex = 0);

Enfin, je veux dans mon code pouvoir forcer le rechargement des données (notamment quand on va vouloir ajouter un nouvel élément dans la liste). Le plus simple est de créer un nouvel Observable qui sera combiner avec les 2 précédents. Donc on défini cet Observable sous forme d'EventEmitter :

mustReload = new EventEmitter();

Et l'on combine le tout. Dans l'évènement ngAfterViewInit, on a donc le code complet suivant :

  ngAfterViewInit(): void {
    // go back to first page when changing sort
    this.sort.sortChange.subscribe(() => this.paginator.pageIndex = 0);

    // query when changing sort or changing page or manual query
    merge(this.sort.sortChange, this.paginator.page, this.mustReload)
      .pipe(
        switchMap(() => {
          this.isWorking = true;
          return this.loadPersons();
        }),
        tap(data => {
          this.resultLength = data.count;
          this.dataSource.data = data.items;
          this.isWorking = false;
        })
      ).subscribe();
   // first load
    this.mustReload.emit();
  }

NB: ce code est placé dans le ngAfterViewInit et pas ngInit pour que les éléments sort et page soient bien instanciés.

That's it! On pagine, on tri nos données côté serveur.

La sélection

Dans la video ci-dessus,  vous voyez que l'on a une colonne qui nous permet de sélectionner une ligne pour l'éditer. On pourrait créer une classe similaire à la classe IPerson en lui ajoutant une propriété IsSelected. mais Angular material a prévu le coup et nous propose une classe SelectionModel<T> qui va le faire pour nous. Cette classe gère une collection d'éléments sélectionné (dans les exemples du SDK, on a toujours la possibilité de faire une sélection multiple). Ici, ce qui nous intéresse c'est de n'avoir qu'un seul élément sélectionnable (celui qu'on édite). On va donc définir notre SelectonModel dans ce sens :

selection = new SelectionModel<IPerson>(false, [], true);

Il n'est pas multi-sélectionnable et ne contient aucun élément sélectionné à l'instanciation. Le dernier paramètre indique que l'on va écouter l'Observable qui indique que la sélection à changée.

On ajoute ensuite une colonne qui nous permettra de sélectionner une ligne :

    <!--select column-->
    <ng-container matColumnDef="select">
      <th mat-header-cell *matHeaderCellDef>
      </th>
      <td mat-cell *matCellDef="let person">
        <mat-checkbox (click)="$event.stopPropagation()" (change)="$event ? selection.toggle(person) : null" [checked]="selection.isSelected(person)">
        </mat-checkbox>
      </td>
    </ng-container>

Cette colonne contient un ckeckbox qui est coché si la personne fait partie de la sélection (selection.isSelected(row)) et qui sélectionne/désélectionne l'élement si on la coche/décoche (selection.toggle(person)).

Mode édition

Maintenant que l'on sait sélectionner une ligne, on va pouvoir passer en mode édition de la ligne. Un premier point important, c'est que l'on doit pouvoir annuler les modifications que l'on a faite. On ne va donc pas travailler sur l'instance originelle de la personne mais sur son clone. Comme on n'a qu'une seule ligne éditable, on va utiliser une variable temporaire de type Person. Quand une personne est ajoutée à notre SelectionModel, on va donc copier l'élément sélectionné dans notre variable editingPerson :

    // copy original datas before editing
    this.selection.onChange
      .pipe(
        filter(v => v.added.length === 1),
        tap(v => this.editingPerson = Object.assign({}, v.added[0]))
      ).subscribe();

A vous de voir si votre objet contient des propriétés plus complexe auquel cas le code de clonage est plus complexe ;-).

Maintenant, à nous de changer les templates de nos colonnes. Par exemple pour la colonne firstName :

    <!-- firstName column-->
    <ng-container matColumnDef="firstName">
      <th mat-header-cell *matHeaderCellDef mat-sort-header> First Name </th>
      <td mat-cell *matCellDef="let person">
        <div *ngIf="!selection.isSelected(person)">{{person.firstName}}</div>
        <mat-form-field *ngIf="selection.isSelected(person)">
          <input matInput [(ngModel)]='editingPerson.firstName'>
        </mat-form-field>
      </td>
    </ng-container>

Si la personne fait partie de la sélection, on affiche l'input, sinon on affiche juste la valeur de la propriété.

Il nous reste plus qu'à ajouter une colonne avec les actions de validation des modifications ou d'annulation :

    <!-- actions column-->
    <ng-container matColumnDef="actions">
      <th mat-header-cell *matHeaderCellDef> </th>
      <td mat-cell *matCellDef="let person">
        <div *ngIf="!selection.isSelected(person)">
          <button mat-icon-button (click)='delete(person.id)'>
            <mat-icon>delete</mat-icon>
          </button>
        </div>
        <div *ngIf="selection.isSelected(person)">
          <button mat-icon-button (click)='save(person)'>
            <mat-icon>done</mat-icon>
          </button>
          <button mat-icon-button (click)="selection.clear(); editingPerson = null;">
            <mat-icon>undo</mat-icon>
          </button>
        </div>
      </td>
    </ng-container>

On affiche un bouton delete quand la ligne n'est pas sélectionnée et un bouton save et cancel quand elle est sélectionnée. Les clics sur les différents boutons désélectionne la ligne ou appelent les méthodes de suppression ou de sauvegarde. Dans les 2 cas, on va forcer le rechargement des données pour afficher le résultat de notre action grâce à notre EventEmitter mustReload :

  delete(id: number) {
    this.isWorking = true;
    this.personsService.delete(id).subscribe(result => {
      this.isWorking = false;
      this.snackBar.open('Person deleted');
      this.selection.clear();
      this.mustReload.emit();
    });
  }

  save(person: IPerson) {
    this.isWorking = true;
    this.personsService.update(this.editingPerson).subscribe(result => {
      this.isWorking = false;
      this.snackBar.open('Person saved');
      this.selection.clear();
      this.mustReload.emit();
    });
  }

Création

Le dernier point concerne l'ajout de la ligne en pied de notre tableau qui permet d'ajouter une Person a notre base de données. Pour cela, je vais ajouter un pied à notre tableau. Mais ce pied, je vais lui demander d'occuper la totalité de notre tableau. C'est possible avec le mat-table car il nous génère des tags HTML de type table. Donc on peut utiliser l'attribut colspan pour les colonnes. On va donc changer le template de la première colonne en y définissant un footer qui occupe la totalité de la table :

    <!--select column-->
    <ng-container matColumnDef="select">
      <th mat-header-cell *matHeaderCellDef>
      </th>
      <td mat-cell *matCellDef="let row">
        <mat-checkbox (click)="$event.stopPropagation()" (change)="$event ? selection.toggle(row) : null" [checked]="selection.isSelected(row)">
        </mat-checkbox>
      </td>

      <td mat-footer-cell *matFooterCellDef colspan=5 style="padding: 0">
        <app-add-person (personCreated)='personCreated($event)'></app-add-person>
      </td>
    </ng-container>

Dans le footer, je place un nouveau composant (pour une leilleure lisibilité) qui contient le formulaire de création. Ce composant contient toute la logique de création d'une personne (avec l'appel à l'api REST correspond). Le code est "trivial", je ne l'exposerais donc pas ici. Remarquez toutefois qu'il expose un EventEmitter qui nous permet de réagir à la création d'une personne : quand on sauvegarde la nouvelle personne, il faut pouvoir rafraichir le table donc faire de nouveau appel au mustReload.

Conclusion

Cet article est relativement long mais la logique est relativement simple. Il vous permet de créer des DataTable sans dépendences externes supplémentaire, jsute avec le package angular/material.

Bon code !

NB : ce code est disponible sur Github dans la branche NoFilter. La branche master contient en plus la possibilité de filtrer les données selon la colonne lastName.

blog comments powered by Disqus