Des boucles de composants génériques avec Angular

Ou comment faire des composants de listes réutilisables avec n'importe quel objet.

Le blog Makina-corpus

Ou comment faire des composants de listes réutilisables avec n'importe quel objet.

Prérequis

Dans cet article nous allons présenter une technique avancée d'Angular. Pour en profiter, vous devez déjà bien connaître le système de composants et le langage de templates (nous allons toutefois faire un petit rappel sur les `ng-template`). Par ailleurs, il y aura des exemples d'animations et nous n'expliquons pas ici leur fonctionnement.

Le code présenté utilise Angular 7, mais la logique est applicable avec toutes les versions.

Objectif

Vous avez, dans votre application, plusieurs composants affichant différents types d'éléments : personnes, lieux, activités...

Vous avez besoin d'afficher des listes de ces éléments, en utilisant pour chacun le composant spécifique.

Le langage de template Angular fournit une directive structurelle `*ngFor` qui permet de faire beaucoup de choses.

Mais voilà vous souhaitez présenter vos listes de manière un peu élaborée, vous avez besoin à de nombreux endroits de votre site, de les organiser suivant un de ces motifs :

  • les paginer,
  • les dérouler avec un scroll infini,
  • n'en afficher les premières tant qu'on n'a pas cliqué sur "Tout afficher" (système plier / déplier),
  • ou autre.

Pour chacun de ces motifs, il y a un peu plus qu'un `*ngFor` à écrire. Certes, pas grand chose : tout ça n'est pas bien compliqué à réaliser avec Angular.

Mais vous voulez que cela puisse fonctionner avec vos différents composants (liste de personnes, liste de lieux, etc.). Et là, si on prend en compte les templates, les gestions d'état nécessaires (numéro de page, ou statut plié/déplié, etc.), les animations, tout cela représente quand même une quantité significative de code. Vous ne souhaitez pas le "répéter partout", pour chacun de vos "types de listes". Vous voudriez ne coder qu'une seule fois la logique, et la réutiliser pour afficher des listes de "n'importe quel composant".

Première bonne nouvelle : c'est possible et ça peut être fait de manière très concise ! Deuxième bonne nouvelle : ça n'est pas trivial et ça va nous permettre de découvrir des techniques moins connues du framework !

Principe de base : un composant générique pour le comportement de liste

Nous allons tout d'abord écrire un composant pour implémenter la logique de fonctionnement de notre liste. Il doit prendre en paramètres la liste des objets à afficher, et le composant qu'on veut utiliser pour afficher chaque objet.

Notre exemple : un système de déplier / plier (expand / collapse)

Nous allons implémenter le cas le plus simple parmi les trois cas d'utilisation cités plus haut : un affichage de listes où les "n" premiers éléments d'une liste sont affichés, et la suite s'affiche si on clique sur "afficher tout". Quand on clique sur "Afficher tout", puis sur "Replier", cela se fera avec une jolie animation. Les éléments invisibles seront absents du DOM.

Nous déclarons donc un composant `CollapsibleListComponent<T>.` Ce composant va prendre en input :

  • la liste des objets à afficher (`items: T[]`),
  • le nombre d'éléments à toujours afficher (`alwaysDisplayedSize: number`), qui pourra prendre une valeur par défaut.

À la création, on peut ainsi calculer la liste des objets à toujours afficher, et la liste des objets à masquer. On a un attribut d'état `expanded` qui est vrai ou faux et qui permet de déterminer si le contenu est déplié.

On va ajouter une petite animation sur la partie pliable, assez indispensable dans notre cas d'utilisation.

On arrive déjà au code suivant :

// collapsible-list.component.ts

import { Component, Input, OnInit, TemplateRef } from '@angular/core';
import { expandCollapse } from '@app/components/animations';

@Component({
  selector: 'app-collapsible-list',
  templateUrl: './collapsible-list.component.html',
  animations: [trigger('expandCollapse', [
    transition(':enter', [
      style({ transform: 'scaleY(0)' }),
      animate('250ms ease-out', style({ transform: 'scaleY(1)' }))
    ]),
    transition(':leave', [
      style({ transform: 'scaleY(1)' }),
      animate('250ms ease-in', style({ transform: 'scaleY(0)' }))
    ])
  ])]
})
export class CollapsibleListComponent<T> implements OnInit {

  @Input() items: T[];
  @Input() showAllLabel: string;
  @Input() hideAllLabel: string;
  @Input() initialSize = 5;
  @Input() initialState: 'expanded' | 'collapsed' = 'collapsed';

  public alwaysDisplayedItems: T[] = [];
  public expandableItems: T[] = [];
  public expanded = false;

  constructor() { }

  ngOnInit() {
    this.expanded = this.initialState === 'expanded';
    this.alwaysDisplayedItems = this.items.slice(0, this.initialSize);
    this.expandableItems = this.items.slice(this.initialSize, this.items.length);
  }

  public toggleExpandables() {
    this.expanded = !this.expanded;
  }
}

<!-- collapsible-list.component.ts -->

<ng-container>
  <!-- TODO: Ici les items toujours affichés -->
</ng-container>
<ng-container *ngIf="expandableItems.length > 0">
  <div [@expandCollapse]="" *ngIf="expanded"><!-- [@expandCollapse] c'est pour l'animation -->
    <!-- TODO: Ici les items affichés quand on est dans l'état "déplié". -->
  </div>
  <div>
  <button (click)="toggleExpandables()" [ngSwitch]="expanded">
    <ng-container *ngSwitchCase="false">Tout afficher</ng-container>
    <ng-container *ngSwitchCase="true">Replier</ng-container>
  </button>
</ng-container>

Note: qu'est ce que le T ? On utilise la généricité, pour exprimer le
fait que le type de expandableItems et alwaysDisplayedItems est le même que celui passé à l'`input` `items`.

Notre composant, encore incomplet sera appelé comme ça :

<!-- TODO: injecter le composant qui affiche le contenu d'un dummyItem -->
<app-collapsible-list [items]="myDummyItemsList" [initialSize]="10">
</app-collapsible-list>

Tout cela est élémentaire. Mais il nous faut encore injecter le composant qui affiche un DummyItem.

Nous allons essayer d'adresser ce problème avec deux techniques Angular : la projection de contenu, puis les `ng-template`.

Hypothèse qui ne fonctionne pas dans notre cas : la projection de contenu

Le système de base qui permet à un composant générique de présenter du contenu dont le rendu est défini en amont, c'est la projection de contenu (content projection). Autrefois appelée transclusion en AngularJS, elle permet, à l'aide du composant `ng-content` et de sélecteurs css, de définir des slots dans son composant et de paramétrer le contenu des slots à l'appel du composant.

Ce n'est pas la première fonctionnalité d'Angular qu'on apprend à utiliser, mais elle est correctement documentée, et on trouve plusieurs tutoriels, par exemple celui-ci. Je vous invite à consulter ce lien si vous souhaitez en savoir plus (car mon but aujourd'hui n'est pas de vous expliquer ce concept).

Un composant utilisant la projection de contenu pour de définir un contenu pliable-dépliable pourrait ressembler à ça :

<ng-content select=".introduction"><!-- contenu toujours affiché --></ng-content>
<ng-content [@expandCollapse]=""
            *ngIf="expanded" 
            select=".collapsible"><!-- contenu pliable --></ng-content>

<button (click)="toggleExpandables()" [ngSwitch]="expanded">
<!-- (...) -->
</button>

Et serait appelé comme ça :

<app-collapsible-content>
  <p class="introduction">Lorem ipsum</p>
  <p class="collapsible">
    Pellentesque habitant morbi tristique senectus 
    et netus et malesuada fames ac turpis egestas.
  </p>
 </div>
</app-collapsible-content>

Mais voilà, dans notre cas, on ne peut pas utiliser la projection de contenu :'(. Pourquoi ? Parce qu'il est impossible de projeter du contenu dans une boucle. Le content projection ne le permet pas. Or, il nous faut bien une boucle pour rendre le contenu de nos listes... Il nous faut donc trouver une autre solution.

Hypothèse qui fonctionne : l'utilisation d'un ng-template et d'un ngTemplateOutlet

ng-template et \*ngTemplateOutlet

Pour rappel, un `ng-template` est un composant Angular qui permet de définir un bloc de vue Angular qui peut être "appelé". Un `ng-template` se définit ainsi :

<ng-template #goodNight>
  Good night !
</ng-template>

Le cas d'utilisation le plus couramment rencontré, c'est avec la directive `*ngIf`. Elle permet d'utiliser un `ng-template` pour le cas `else`, ainsi :

<p *ngIf="isDay; else goodNight">
  <!-- si isDay est false, c'est le template #goodNight qui est rendu -->
  Good bye !
</p>

Mais c'est plutôt un cas particulier, qui utilise la magie de la directive structurelle (les trucs avec un `` devant) `ngIf`. Le cas le plus générique est beaucoup moins utilisé : c'est appeler le `ng-template` avec `*ngTemplateOutlet`.

<p> 
  Si c'est le jour on dit <strong>Good bye</strong>, 
  si c'est la nuit on dit <strong *ngTemplateOutlet="goodNight"></strong>
</p>

Vous trouverez ici un très bon tutoriel sur ng-template. Vous observerez que cet article de blog prend l'exemple d'un plié déplié. Comme quoi on peut répondre par un `ngTemplateOutlet` à ce use case* généralement adressé avec une projection de contenu.

Et on va voir qu'avec cette technique, on peut en plus gérer le cas d'une liste !

Les ng-template paramétrisés

Les `ng-template` peuvent prendre des paramètres. Ici le paramètre `when` contient une valeur comme "morning", "afternoon" ou "evening" :

<ng-template #hello let-when="whenValue">
  Good {{ when }} !
</ng-template>

`let-xxx` permet de définir des variables utilisables dans le `ng-template` (ici `when`) à partir de la propriété (ici `whenValue`) d'un objet passé en "context" du `*ngTemplateOutlet`

<ng-container *ngTemplateOutlet="itemTemplate;context:{whenValue: 'morning'}">
</ng-container>

À savoir qu'un bon IDE vérifie et autocomplète correctement les variables définies par let-. Et c'est très utile !

La solution

Le rendu d'un élément de la liste va être défini par un `ng-template` paramétrisé, et ce `ng-template` va être passé en entrée du composant de liste.

C'est très facile, il suffit d'ajouter au composant un `Input()` pour une référence à un `ng-template` (de type `TemplateRef`).

export class CollapsibleListComponent<T> implements OnInit {

  @Input() itemTemplate: TemplateRef<{item: any}>;

Vous observerez que le `TemplateRef` est une classe générique qui permet de préciser le type du contexte. Pratique ! On ne met pas `any` par paresse ici : notre composant accepte effectivement tout type d'item (c'est la seule excuse pour mettre un `any` !)

Bien, le but, c'est que notre composant soit appelé comme ça :

<ng-template #userItemTemplate let-user="item">
  <app-user [user]="user"></app-user>
</ng-template>

<app-collapsible-list 
  [items]="userList" 
  [initialSize]="10" 
  [itemTemplate]="userItemTemplate">
</app-collapsible-list>

Ci-dessus, on affiche une liste dépliable d'utilisateurs "usersList", ci dessous, une liste dépliable de lieux "citiesList".

Dans les deux cas, on a un ng-template qui ne fait "que" appeler un composant dédié à l'affichage de telle ou telle information.

<ng-template #cityItemTemplate let-city="item">
  <app-city [city]="city"></app-user>
</ng-template>

<app-collapsible-list 
  [items]="citiesList"
  [initialSize]="10" 
  [itemTemplate]="cityItemTemplate">
</app-collapsible-list>

Il ne reste plus qu'à appeler ce `itemTemplate` dans la vue du composant générique de liste.
L'élément de la liste à afficher sera passé dans le contexte du `*ngTemplateOutlet`.

<ng-container *ngFor="let displayedItem of alwaysDisplayedItems">
  <!-- Ici les items toujours affichés -->
  <ng-container *ngTemplateOutlet="itemTemplate;context:{item: displayedItem}">
  </ng-container>
</ng-container>
<ng-container *ngIf="expandableItems.length > 0">
  <!-- Ici les items affichés quand on est dans l'état "déplié". -->
  <div [@expandCollapse]="" *ngIf="expanded">
    <ng-container *ngFor="let expandableItem of expandableItems">
      <ng-container *ngTemplateOutlet="itemTemplate;context:{item: expandableItem}">
      </ng-container>
    </ng-container>
  </div>
  <button (click)="toggleExpandables()" [ngSwitch]="expanded">
    <ng-container *ngSwitchCase="false">
      Tout afficher
    </ng-container>
    <ng-container *ngSwitchCase="true">
      Replier
    </ng-container>
  </button>
</ng-container>

Reprenons : le `*ngTemplateOutlet` appelle un `ng-template` "itemTemplate" avec comme contexte un objet ayant une propriété "item", qui est un élément de la liste envoyée au composant.

Et voilà, c'est plié (hum...) !

Une petite évolution sur notre expand/collapse

Pour ceux qui ne sont pas encore convaincus de l'intérêt de la réutilisabilité, ici, voici une petite évolution sympa sur notre système de plier/déplier : une gestion des orphelins. L'idée, c'est de ne pas afficher le bouton si on a moins d'un certain nombre d'éléments dans la partie à déplier (déplier pour afficher seulement un élément de plus, ça offre un rendu assez mal fini)

Vous ajoutez une entrée `orphans` (par défaut, mettons 1) :

@Input() orphans = 1;

puis à la fin du `ngOnInit()`:

if (this.expandableItems.length <= this.orphans) {
    this.alwaysDisplayedItems = this.items;
    this.expandableItems = [];	
}

Et voilà, grâce à cela on a amélioré toutes les listes de notre application, quel que soit le composant listé !

Aller plus loin

C'est le moment d'aller jeter un oeil au fonctionnement des directives structurelles. Cela vous aidera à comprendre ce que vous pourriez trouver d'un peu magique dans tout ce qu'on vient de montrer ! Un bon tuto en français

Si vous souhaitez apprendre davantage de techniques avancées, vous pouvez participer à notre formation "Angular avancé". Mais si vous aimez Angular et souhaitez monter en compétences, le mieux reste de rejoindre nos équipes !

Inscription à la newsletter

Nous vous avons convaincus