Pour qu'un component Angular soit réutilisable, il doit être découplé de son contexte : il ne doit pas dépendre d'un service spécifique pour les interactions de données, par exemple. On doit pouvoir lui injecter ses données et lui dicter son comportement. C'est ça qui rend un component vraiment réutilisable.
Dans ce chapitre, vous allez créer un component qui permet d'afficher une liste de commentaires laissés par des utilisateurs. Il permettra également de laisser un nouveau commentaire. Ce genre de fonctionnalité pourrait être utile dans plusieurs contextes différents, d'où l'intérêt que le component soit réutilisable.
Ce component acceptera une liste de components en entrée, et devra être capable de signaler à son component parent l'envoi d'un commentaire par l'utilisateur. Dans un contexte non réutilisable, on passerait par un service, mais ici, les méthodes appelées pour les différents contextes ne peuvent pas être les mêmes. Un commentaire laissé sur un Post ne serait pas traité par la même méthode qu'un commentaire laissé sur une vidéo, par exemple.
Vous découvrirez donc une technique qui permet à un component parent de réagir à des événements venant de son enfant : les Outputs. Vous mettrez aussi tout ce que vous avez fait jusqu'ici en forme avec des components Material pour une finition professionnelle.
Matérialisez les components
Pour mettre en forme PostListItemComponent, vous allez utiliser un component Material : MatCard.
Il faut donc exporter le module correspondant dans SharedModule :
@NgModule({
declarations: [],
imports: [
CommonModule
],
exports: [
MatToolbarModule,
MatCardModule
]
})
export class SharedModule { }
Comme ça, dans le template de PostListItemComponent :
<mat-card>
<mat-card-header>
<mat-card-title>
<span>{{ post.title | titlecase }}</span>
</mat-card-title>
<mat-card-subtitle>
<span>{{ post.createdDate | date }}</span>
</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<img mat-card-image *ngIf="post.imageUrl as imageUrl" [src]="imageUrl" [alt]="post.title">
<span class="post-content">{{ post.content }}</span>
</mat-card-content>
</mat-card>
Avec les différents éléments mis à disposition par MatCard, vous affichez les différents Posts. La directive mat-card-image
attribue des styles à l'image, qui n'est ajoutée au DOM que si le Post contient une imageUrl
.
Ajoutez ces styles simples pour mettre un peu d'espace :
mat-card {
margin-bottom: 20px;
}
.post-content {
margin-top: 10px;
}
Et vous aurez quelque chose qui ressemble à ça :
Maintenant que tout est beau et propre, commençons la création du component réutilisable : CommentsComponent.
Soyez écolo : réutilisez vos components
Puisque CommentsComponent sera réutilisé, il sera à sa place dans SharedModule :
ng g c shared/components/comments --export
Vous allez utiliser un bon nombre de components Material dans votre application, donc il serait judicieux de leur créer un module à part pour que tout soit le plus lisible possible. Dans le dossier shared
, créez material.module.ts
:
import { NgModule } from '@angular/core';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatCardModule } from '@angular/material/card';
import { MatListModule } from '@angular/material/list';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
@NgModule({
exports: [
MatToolbarModule,
MatCardModule,
MatListModule,
MatButtonModule,
MatIconModule,
MatFormFieldModule,
MatInputModule
]
})
export class MaterialModule {}
Vous pouvez maintenant exporter MaterialModule dans SharedModule au lieu d'exporter les modules Material séparés :
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CommentsComponent } from './components/comments/comments.component';
import { MaterialModule } from './material.module';
@NgModule({
declarations: [
CommentsComponent
],
imports: [
CommonModule,
MaterialModule
],
exports: [
CommentsComponent,
MaterialModule
]
})
export class SharedModule { }
Vous avez aussi besoin d'importer MaterialModule. Voyez-vous pourquoi ?
…
…
Eh oui ! Parce que vous avez besoin de ce qui est exporté par MaterialModule à l'intérieur de SharedModule, pour la déclaration de CommentsComponent !
CommentsComponent doit accepter une liste de commentaires injectée par son parent. Il lui faut donc un @Input()
:
export class CommentsComponent implements OnInit {
@Input() comments!: Comment[];
}
Pour afficher cette liste de commentaires, vous allez utiliser le component MatList :
<mat-list *ngIf="comments.length">
<h2 matSubheader>Commentaires</h2>
<mat-list-item *ngFor="let comment of comments">
<span mat-line>{{ comment.comment }}</span>
<span mat-line>{{ comment.createdDate | date }}</span>
</mat-list-item>
</mat-list>
La liste de commentaires ne sera affichée que s'il y en a, et il y aura un item à deux lignes (les mat-line
) pour chaque commentaire.
Ajoutez un style rapide à CommentsComponent :
mat-list-item {
border-bottom: thin solid #555;
}
Et vous avez quelque chose comme :
Vos utilisateurs peuvent lire les commentaires, mais comment faire pour qu'ils puissent en laisser un ?
Acceptez les nouveaux commentaires
Pour qu'un utilisateur puisse laisser un commentaire, il faut un champ de texte qui le lui permet !
Commencez par utiliser les components Material importés précédemment pour générer rapidement et facilement un beau template de formulaire :
<div class="comments-row">
<mat-form-field>
<input type="text" matInput placeholder="Laissez un commentaire…">
</mat-form-field>
<button mat-icon-button color="primary" (click)="onLeaveComment()">
<mat-icon>send</mat-icon>
</button>
</div>
<mat-list *ngIf="comments.length">
<!-- etc -->
Dans ce template :
vous utilisez
mat-form-field
et la directivematInput
pour générer un champ de texte ;vous implémentez un
placeholder
plutôt qu'unlabel
pour profiter de l'affichage Material ;vous générez un bouton à partir d'une icône Material pour l'envoi du formulaire.
Ajoutez les styles suivants :
.comments-row {
display: flex;
border-top: thin solid #555;
padding-top: 10px;
justify-content: space-around;
align-items: center;
}
mat-form-field {
width: 90%;
}
Et vous aurez un joli champ de texte !
Puisque ce formulaire est extrêmement simple, inutile de créer tout un FormGroup
. Il est possible de créer simplement un FormControl
:
commentCtrl!: FormControl;
constructor(private formBuilder: FormBuilder) { }
ngOnInit(): void {
this.commentCtrl = this.formBuilder.control('', [Validators.required, Validators.minLength(10)]);
}
Le fonctionnement de FormBuilder.control()
est similaire à celui deFormBuilder.group()
: vous passez la valeur par défaut pour le formulaire, ainsi qu'un tableau de Validators. Ici, le champ sera requis, et il faudra laisser un commentaire d'au moins 10 caractères.
Lorsque vos contrôles se trouvent dans un FormGroup
, vous utilisez formControlName
pour les identifier. Ici, ce n'est pas possible, mais c'est encore plus simple. Vous allez lier le FormControl
directement à votre input
:
<input type="text" matInput placeholder="Laissez un commentaire…" [formControl]="commentCtrl">
C'est ici que vous commencerez à voir la puissance des components Material ! Si vous regardez dans l'application, non seulement vous y verrez un * qui montre que le champ est requis, mais si vous y écrivez un texte de moins de 10 caractères et que vous cliquez sur le bouton d'envoi :
Tout ça grâce aux fonctionnalités des MatFormField et MatInput qui réagissent au FormControl
que vous leur passez !
Et maintenant, la réponse à la question à 10 millions de $ :
Comment faire pour rendre CommentsComponent réutilisable ? Comment permettre au component parent – dans ce cas, PostListItemComponent – de réagir aux clics du bouton d'envoi ?
Communiquez avec les parents
Jusqu'ici, vous avez créé des attributs personnalisés avec les @Input
. Vous allez maintenant créer des événements personnalisés avec les @Output
:
export class CommentsComponent implements OnInit {
@Input() comments!: Comment[];
@Output() newComment = new EventEmitter<string>();
// ...
Un EventEmitter
est un objet sur lequel on peut appeler la méthode emit()
et qui, comme son nom l'indique, émet la valeur qu'on lui passe sous forme d'événement.
Dans le cas de CommentsComponent, l'objectif sera d'émettre le commentaire laissé par l'utilisateur. Vous pouvez donc implémenter onLeaveComment()
:
onLeaveComment() {
if (this.commentCtrl.invalid) {
return;
}
this.newComment.emit(this.commentCtrl.value);
this.commentCtrl.reset();
}
Dans cette méthode :
si l'utilisateur n'a pas entré un commentaire valable dans le champ de texte, vous ignorez l'événement – avec l'affichage en rouge fourni par Material, il s'agit d'une alternative à la désactivation du bouton ;
vous appelez
emit
sur votre nouvelEventEmitter
en y passant le contenu du champ de texte ;vous appelez
reset
sur leFormControl
pour vider le champ de texte.
L' EventEmitter
fait maintenant son job, et le décorateur @Output
permet d'y lier une méthode depuis le parent à l'aide de l'event binding.
post-list-item.component.html
:
<mat-card-actions>
<app-comments [comments]="post.comments" (newComment)="onNewComment($event)"></app-comments>
</mat-card-actions>
Pour en vérifier le fonctionnement, créez une implémentation temporaire de onNewComment
qui accepte une string comme argument, et qui log cette string à la console. Laissez un commentaire dans CommentsComponent, et vous verrez :
Cela vous permet de bien constater que l'événement de l'enfant (CommentsComponent) est capté par le parent (PostListItemComponent), sans avoir eu besoin de passer par un service !
CommentsComponent est donc réutilisable : son component parent lui injecte les données et réagit à ses événements.
Il reste une dernière étape pour bien respecter l'architecture container/presenter.
Faites des bulles
L'architecture de votre application fait que PostListItemComponent est censé être un presenter. Il ne doit pas contenir de logique de communication avec un service, par exemple.
Mais dans ce cas-là, comment on fait pour dire au serveur qu'un commentaire a été laissé ?
Vous allez retransmettre l'événement du commentaire pour que le parent container (PostListComponent) puisse l'envoyer au service, et donc au serveur. On appelle ce genre de retransmission du bubble up : les événements sont comme des bulles qui montent de plus en plus haut.
Et comment retransmettre cet événement ? Eh bien avec encore un @Output
!
Envoyer le commentaire seul ne suffit pas : PostListComponent a besoin de savoir quel Post a été commenté. Le @Output
dans PostListItemComponent va donc émettre un objet contenant le commentaire et l' id
du Post commenté :
export class PostListItemComponent implements OnInit {
@Input() post!: Post;
@Output() postCommented = new EventEmitter<{ comment: string, postId: number }>();
// ...
onNewComment(comment: string) {
this.postCommented.emit({ comment, postId: this.post.id });
}
}
Vous pouvez maintenant capturer la "bulle" – l'événement retransmis – dans PostListComponent, le parent container :
post-list.component.html
:
<h2>Posts</h2>
<app-post-list-item *ngFor="let post of posts$ | async" [post]="post" (postCommented)="onPostCommented($event)"></app-post-list-item>
Dans post-list.component.ts
, injectez PostsService et implémentez onPostCommented
:
onPostCommented(postCommented: { comment: string, postId: number }) {
this.postsService.addNewComment(postCommented);
}
Enfin, implémentez la méthode addNewComment
dans PostsService :
addNewComment(postCommented: { comment: string, postId: number }) {
console.log(postCommented);
}
Laisser un commentaire sur un Post dans l'application vous donne maintenant :
Votre application comporte désormais :
un component parent container qui se charge de la logique de communication avec le service, qui souscrit aux Observables générés par ce dernier, et distribue les résultats à…
… des components enfants presenter qui s'occupent simplement d'afficher les données qu'ils reçoivent et de faire remonter les événements qui s'y produisent ;
un component réutilisable qui, en plus d'être un component presenter, n'est pas lié à un contexte spécifique : un commentaire pourrait être laissé sur plein de contenus différents.
En résumé
Un component réutilisable ne doit dépendre que de son parent, aussi bien pour ses données que pour son comportement.
Un
EventEmitter
est un objet qui émet des événements personnalisés.Un
EventEmitter
doté du décorateur@Output
devient "écoutable" depuis le component parent avec l'event binding.On parle de bubble up lorsqu'un événement émis par un component est réémis par son parent (et peut-être aussi son grand-parent, son arrière-grand-parent…).
On emploiera le pattern bubble up pour respecter l'architecture container/presenter.
Bravo !
Ces trois derniers chapitres ont été particulièrement longs et denses, donc félicitations d'avoir tenu jusqu'ici ! Vous y avez exploré un grand nombre de concepts architecturaux.
Les deux derniers chapitres de cette partie sont moins longs et moins complexes, mais tout aussi importants. Vous avez déjà utilisé les Pipes et Directives fournis par Angular : vous allez découvrir comment créer les vôtres pour enrichir l'architecture de vos applications.
Allez, c'est parti !