Accueil / Blog / Métier / 2019 / Des boucles de composants génériques avec Angular

Des boucles de composants génériques avec Angular

Par Thomas Desvenain — publié 15/04/2019
Ou comment faire des composants de listes réutilisables avec n'importe quel objet.
Des boucles de composants génériques avec Angular

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 !

ABONNEZ-VOUS À LA NEWSLETTER !
Voir aussi
À Makina, la JS fatigue n'existe pas... 01/04/2019

...car la passion l'emporte

Routing Angular : optimisez le rendu au changement de page Routing Angular : optimisez le rendu au changement de page 08/06/2018

Avec les Resolvers, les Guards et les Initializers, découvrez les bonnes pratiques pour éviter ...

Mise en pratique de RxJS dans Angular Mise en pratique de RxJS dans Angular 13/08/2018

Les quelques bases suffisantes pour bien utiliser RxJS dans Angular. Cet article a été écrit ...

Mettre en place Angular Universal avec Angular 6 et 7 Mettre en place Angular Universal avec Angular 6 et 7 16/10/2018

Le fonctionnement d'Angular Universal expliqué. Toutes les étapes de mise en place détaillées. ...

Comment mettre en place Angular Universal Comment mettre en place Angular Universal 29/06/2017

Toutes les étapes détaillées et expliquées. Les pièges à éviter.