Capture d'écran d'un IDE présentant un extrait de code utilisant ng-template avec la mise en avant du logo Angular

Créer des vues dynamiquement dans Angular avec ng-template

Angular est un framework puissant pour créer des applications Web dynamiques. Il simplifie le développement des interfaces utilisateur grâce à ses composants, ses directives et ses templates. Lorsque la structure des vues est connue à l’avance, elles peuvent être définies dans des fichiers HTML statiques et incluses dans les composants. Cependant, il arrive parfois que nous devions générer des vues dynamiquement en fonction de données ou d’événements. Dans ces situations, Angular offre une solution élégante avec ng-template.

L’intérêt des vues dynamiques dans Angular ?

L’utilisation des vues dynamiques est plutôt rare dans nos projets d’application. Généralement, cette technique est utilisée dans les librairies de composant pour en simplifier leur utilisation.

Typiquement, cette méthode est très adaptée à la création de tableaux à partir d’un data source. Par exemple, ce composant tableau pourrait attendre deux templates distincts :

  • Un template cell header : il permet d’instancier une cellule d’en-tête en lui fournissant les données d’en-tête.
  • Un template cell : il permet de créer une cellule standard en lui fournissant les données du data source.

En concevant un composant de cette manière, le développeur ne code qu’une seule fois la construction du tableau, et il se concentre ensuite uniquement sur le rendu de chaque cellule.

Ainsi, créer des vues dynamiquement dans Angular nous permet essentiellement de réduire la quantité totale de code et donc d’améliorer sa maintenabilité.

Créer des vues dynamiques avec ng-template

L’élément ng-template permet de définir un template réutilisable dans un composant Angular. Ce template n’est pas rendu directement dans le DOM, mais peut être instancié dynamiquement à la demande par le composant (voir la documentation).

Pour maximiser sa flexibilité, un template peut recevoir des données en entrée fournies par le composant parent lors de son instanciation via un objet de contexte. Angular réactualise automatiquement le template dès qu’un changement est détecté sur cet objet de contexte.

Il existe deux stratégies pour instancier un template :

  • Utiliser la directive ngTemplateOutlet pour instancier un template à un endroit précis du DOM (voir la documentation).
  • Instancier un template de manière programmatique à l’aide d’un TemplateRef (voir la documentation).

Une méthode simple pour créer des vues dynamiques avec ngTemplateOutlet

Parmi les deux méthodes, la directive ngTemplateOutlet est la plus simple à utiliser. Elle permet d’instancier un template à un endroit précis du DOM en lui fournissant un contexte souvent accessible via le mot clé let.

Cette méthode doit être privilégiée lorsque le template du composant est simple à définir. C’est notamment le cas lorsque le template se limite à l’utilisation d’un conteneur statique, de blocs conditionnels (@if / @else) ou de boucles (@for).

Supposons que nous ayons le composant app-table présenté précédemment. Il attend deux templates distincts : un template pour les cellules d’en-tête et un template pour les cellules de données. Le template du composant pourrait ressembler à ceci :

<!-- /src/app/components/table/table.component.html -->
<table>
  <thead>
    <tr>
      @for (header of headers; track header.ref) {
        <th>
          <ng-container *ngTemplateOutlet="headerTemplate; context: {$implicit: header}">
          </ng-container> <!-- <1> -->
        </th>
      }
    </tr>
  </thead>
  <tbody>
    @for (row of data; let idx = $index; track idx) {
      <tr>
        @for (header of headers; track header.ref) {
          <td>
            <ng-container *ngTemplateOutlet="cellTemplate; context: {$implicit: row[header.ref]}">
            </ng-container> <!-- <1> -->
          </td>
        }
      </tr>
    }
  </tbody>
</table>
1

headerTemplate et cellTemplate sont les templates des cellules passés en paramètre du composant via un @Input().

Nous n’aurions plus qu’à définir les templates dans le composant parent de la manière suivante :

<!-- /src/app/pages/data-table-page/data-table-page.component.html -->
<app-table [headers]="headers" [data]="data">
  <ng-template #header let-header>
    <h6>{{ header.title }}</h6>
  </ng-template>

  <ng-template #cell let-cell>
    <em>{{ cell }}</em>
  </ng-template>
</app-table>

Créer programmatiquement des vues dynamiques avec TemplateRef

Quand utiliser la méthode programmatique ?

Bien que la directive ngTemplateOutlet soit simple à utiliser, elle peut parfois être moins performante que la méthode programmatique. En effet, cette dernière permet de créer des vues dynamiques de manière plus fine et plus performante.

Supposons que vous souhaitiez créer un composant de type Kanban board où chaque colonne représente une liste de tâches. Chaque tâche est instanciée à partir d’un template fourni par le composant parent et placé dans sa colonne respective.

L’objectif de ce tableau est de présenter les tâches de manière visuelle et de permettre leur déplacement d’une colonne à l’autre. Dans ce cas, la méthode programmatique sera plus adaptée puisqu’elle permet de recycler les vues lors du déplacement des tâches entre les différentes colonnes, améliorant ainsi les performances et l’efficacité de l’application.

Créer des vues dynamiquement pour coder un Kanban board

Un Kanban board simpliste

Notre objectif est de comprendre comment créer dynamiquement des vues à partir d’un ng-template. Pour cela, nous allons créer un Kanban board simpliste avec trois colonnes : « À faire », « En cours » et « Terminé ». Nous déplacerons les tâches d’une colonne à l’autre à l’aide de deux boutons : « Précédent » et « Suivant ».

Le résultat final attendu est le suivant :

Créer la base du Kanban board

Nous allons commencer par reprendre la logique du composant app-table pour recevoir les inputs utiles à la création du tableau :

// /src/app/components/kanban-board/kanban-board.component.ts
import {
  AfterContentChecked,
  Component,
  ContentChild,
  Input,
  TemplateRef,
} from '@angular/core';


/** Ticket type */
export interface Ticket {
  /** Ticket title */
  title: string;
  /** Ticket description */
  description: string;
};


/** Kanban board type */
export interface KanbanBoard {
  /** Tickets to do */
  todo: Ticket[];
  /** Tickets in progress */
  inProgress: Ticket[];
  /** Tickets done */
  done: Ticket[];
};


@Component({
  selector: 'app-kanban-board',
  standalone: true,
  imports: [],
  templateUrl: './kanban-board.component.html',
  styleUrl: './kanban-board.component.scss'
})
export class KanbanBoardComponent implements AfterContentChecked {
  /** Ticket template */
  @ContentChild('ticket')
  public ticketTemplate: TemplateRef<{$implicit: Ticket}> | null = null; // <1>

  /** Tickets to render in the kanban board */
  @Input()
  public tickets: KanbanBoard = { todo: [], inProgress: [], done: [] }; // <2>

  public ngAfterContentChecked(): void {
    if (this.ticketTemplate === null) {
      throw new Error('Ticket template is required');
    }
  }
}
1

ticketTemplate est le template des tickets récupéré par le composant en tant qu’élément enfant.

2

tickets est l’objet contenant les tickets à afficher dans le tableau.

Côté HTML, la page du Kanban board ressemble à ceci :

<!-- /src/app/pages/kanban-page/kanban-page.component.html -->
<app-kanban-board [tickets]="tickets">
  <ng-template #ticket let-ticket>
    <h4>{{ ticket.title }}</h4>
    <p>{{ ticket.description }}</p>
  </ng-template>
</app-kanban-board>

Ici, le ng-template est récupéré par le composant app-kanban-board et sera utilisé pour afficher les tickets grâce au contexte ticket.

Créer les colonnes du Kanban board

Étant donné que nous allons créer les tickets dynamiquement via le code TypeScript, nous devons définir les colonnes du Kanban board sous forme de ng-container :

 // /src/app/components/kanban-board/kanban-board.component.html
-<p>kanban-board works!</p>
+<div class="board">
+  <div class="column">
+    <ng-container #todo></ng-container>
+  </div>
+  <div class="column">
+    <ng-container #inProgress></ng-container>
+  </div>
+  <div class="column">
+    <ng-container #done></ng-container>
+  </div>
+</div>

Côté TypeScript, nous allons récupérer les ng-container correspondant à chaque colonne pour y ajouter les tickets :

 // /src/app/components/kanban-board/kanban-board.component.ts
 // ...
   ContentChild,
   Input,
   TemplateRef,
+  ViewChild,
+  ViewContainerRef,
 } from '@angular/core';

 // ...
   @Input()
   public tickets: KanbanBoard = { todo: [], inProgress: [], done: [] };

+  /** Reference to the todo column container */
+  @ViewChild('todo', { static: true, read: ViewContainerRef })
+  public todoColumn!: ViewContainerRef;
+
+  /** Reference to the in progress column container */
+  @ViewChild('inProgress', { static: true, read: ViewContainerRef })
+  public inProgressColumn!: ViewContainerRef;
+
+  /** Reference to the done column container */
+  @ViewChild('done', { static: true, read: ViewContainerRef })
+  public doneColumn!: ViewContainerRef;
+
   public ngAfterContentChecked(): void {
     if (this.ticketTemplate === null) {
       throw new Error('Ticket template is required');
 // ...

Instancier les vues tickets à partir du ng-template

Maintenant que nous avons récupéré les ng-container correspondant à chaque colonne, nous allons instancier les tickets à partir du ng-template et des données fournies par le composant parent :

 // /src/app/components/kanban-board/kanban-board.component.ts
 // ...
     if (this.ticketTemplate === null) {
       throw new Error('Ticket template is required');
     }
+
+    this.refreshView();
+  }
+
+  /**
+   * Refresh the view with the current input tickets.
+   *
+   * @warning Use this method to create or update the view
+   * when the tickets change (not it's content).
+   */
+  private refreshView(): void {
+    this.todoColumn.clear(); <1>
+    this.inProgressColumn.clear(); <1>
+    this.doneColumn.clear(); <1>
+
+    this.tickets.todo.forEach(ticket => {
+      this.todoColumn.createEmbeddedView(this.ticketTemplate!, { $implicit: ticket });
+    }); <2>
+
+    this.tickets.inProgress.forEach(ticket => {
+      this.inProgressColumn.createEmbeddedView(this.ticketTemplate!, { $implicit: ticket });
+    }); <2>
+
+    this.tickets.done.forEach(ticket => {
+      this.doneColumn.createEmbeddedView(this.ticketTemplate!, { $implicit: ticket });
+    }); <2>
   }
 }
1

La première étape consiste à vider les colonnes pour éviter les vues en doublons.

2

Ensuite, nous parcourons les tickets de chaque colonne pour les instancier dans le ng-container correspondant.

Ajout des fondations pour déplacer les tickets

Le code source complet pour déplacer les tickets d’une colonne à l’autre ne sera pas détaillé ici. L’important est de comprendre que cette fonctionnalité nécessite d’implémenter deux méthodes :

  • movePrevious() : pour déplacer un ticket vers la colonne précédente.
  • moveNext() : pour déplacer un ticket vers la colonne suivante.

Une fois ces fondations mises en place, nous ajouterons des boutons de déplacement. Ces boutons seront désactivés si l’action est impossible, par exemple, déplacer un ticket de la colonne « TODO » vers une colonne précédente (qui n’existe pas).

Recycler les vues lors du déplacement des tickets

Pour des raisons de performance, il peut être important de recycler les vues de notre composant. C’est justement ce que nous devons faire dans notre exemple de Kanban board. Lorsqu’un ticket est déplacé d’une colonne à l’autre, la vue du ticket doit être déplacée plutôt qu’être détruite et recréée.

Pour atteindre cet objectif, nous pouvons implémenter les méthodes movePrevious() et moveNext() de la manière suivante :

 // /src/app/components/kanban-board/kanban-board.component.ts
 // ...
-import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+
 import { Subscription } from 'rxjs';
 // ...
+/** Column type */
+interface Column {
+  /** Column tickets */
+  column: Ticket[];
+  /** Column view */
+  view: ViewContainerRef;
+}
+
+
 @Component({
   selector: 'app-kanban-board',
 // ...
+  /**
+   * Move a ticket between columns.
+   *
+   * @param ticket Ticket to move
+   * @param from Column to move from
+   * @param to Column to move to
+   */
+  private move(ticket: Ticket, from: Column, to: Column): void { <2>
+    const index = from.column.indexOf(ticket);
+    // Move ticket between columns
+    from.column.splice(index, 1);
+    to.column.push(ticket);
+
+    // Recycle view
+    const view = from.view.get(index);
+    from.view.detach(index);
+    to.view.insert(view!);
+
+    // Notify control to update button status
+    this.selectControl?.updateValueAndValidity(); <3>
+  }
+
+  /**
+   * Move the selected ticket to the previous column.
+   */
   public movePrevious(): void { <1>
-    // TODO
+    let from: Column;
+    let to: Column;
+
+    // Ticket is in "in progress" or "done" column
+    const ticket = this.selectControl!.value;
+    if (this.tickets.inProgress.includes(ticket)) {
+      from = { column: this.tickets.inProgress, view: this.inProgressColumn };
+      to = { column: this.tickets.todo, view: this.todoColumn };
+    } else { // is in "done" column
+      from = { column: this.tickets.done, view: this.doneColumn };
+      to = { column: this.tickets.inProgress, view: this.inProgressColumn };
+    }
+
+    this.move(ticket, from, to);
   }

+  /**
+   * Move the selected ticket to the next column.
+   */
   public moveNext(): void { <1>
-    // TODO
+    let from: Column;
+    let to: Column;
+
+    // Ticket is in "todo" or "in progress" column
+    const ticket = this.selectControl!.value;
+    if (this.tickets.todo.includes(ticket)) {
+      from = { column: this.tickets.todo, view: this.todoColumn };
+      to = { column: this.tickets.inProgress, view: this.inProgressColumn };
+    } else { // is in "in progress" column
+      from = { column: this.tickets.inProgress, view: this.inProgressColumn };
+      to = { column: this.tickets.done, view: this.doneColumn };
+    }
+
+    this.move(ticket, from, to);
   }
 }
1

Les méthodes movePrevious() et moveNext() réagissent au clic des boutons de déplacement. Elles déterminent la colonne de départ et d’arrivée du ticket, puis appellent la méthode move().

2

La méthode move() est conçue de manière générique pour déplacer un ticket d’une colonne en mémoire, tout en recyclant sa vue.

3

Cette notification permet de mettre à jour l’état des boutons de déplacement en fonction de la nouvelle position du ticket.

Pour recycler les vues des tickets, nous devons détacher la vue du ticket de la colonne de départ et l’insérer dans la colonne d’arrivée. Nous utilisons successivement les méthodes detach() et insert() du ViewContainerRef pour effectuer cette opération. Cette méthode déplace le ticket d’une colonne à l’autre sans recréer la vue.

Conclusion

En conclusion, la création de vues dynamiques dans Angular, que ce soit via ngTemplateOutlet ou de manière programmatique avec TemplateRef, offre une flexibilité et une puissance considérables pour les développeurs. Ces techniques permettent de réduire la quantité de code, d’améliorer la maintenabilité et d’optimiser les performances de vos applications.

Cependant, il est nécessaire de bien réfléchir à la solution la plus adaptée à votre contexte pour tirer pleinement parti de ces bénéfices. Chaque projet a ses propres exigences et contraintes, et il est important de les prendre en compte pour choisir la meilleure approche.

Je vous encourage à expérimenter ces méthodes pour vous familiariser avec ce concept et être à l’aise pour les intégrer dans vos projets. N’hésitez pas à partager vos résultats, vos problématiques ou vos questions dans les commentaires !

Laisser un commentaire