Les formulaires réactifs permettent d'implémenter énormément de comportements dynamiques. Chaque FormControl (et même chaque FormGroup) a deux Observables principaux :
valueChanges
: vous connaissez peut-être déjà celui-ci, qui émet les changements de valeur d'un contrôle – dans ce chapitre, vous allez vous en servir pour afficher ou cacher des champs, et pour gérer la validation dynamique du formulaire ;statusChanges
: celui-ci est un peu moins connu, et émet à chaque fois que l'état de validation du contrôle change – lorsqu'il passe de "INVALID" à "VALID", par exemple.
Dans ce chapitre, vous utiliserez les Observables valueChanges
pour modifier l'affichage et la validation du formulaire, et vous réagirez directement à la validité des champs pour afficher des messages d'erreur utiles à l'utilisateur.
Dans le prochain chapitre, vous utiliserez l'Observable statusChanges
, en conjonction avec votre propre Validator, pour gérer des cas plus complexes.
Commençons par observer les changements de valeur.
Affichez et cachez les contrôles
Les Observables étant très puissants, vous allez vous en servir pour afficher et cacher les MatCards "Email" et "Telephone" selon la sélection de l'utilisateur.
Déclarez les deux Observables :
showEmailCtrl$!: Observable<boolean>;
showPhoneCtrl$!: Observable<boolean>;
Créez une nouvelle méthode pour initialiser les Observables liés au formulaire :
ngOnInit(): void {
this.initFormControls();
this.initMainForm();
this.initFormObservables();
}
// ...
private initFormObservables() {
}
Vos deux Observables dépendent des changements du contrôle contactPreferenceCtrl
, donc générez-les à partir de ses valueChanges
:
private initFormObservables() {
this.showEmailCtrl$ = this.contactPreferenceCtrl.valueChanges.pipe(
map(preference => preference === 'email'),
);
this.showPhoneCtrl$ = this.contactPreferenceCtrl.valueChanges.pipe(
map(preference => preference === 'phone')
);
}
À première vue, cette implémentation devrait fonctionner, mais lorsque vous chargez la page :
Même si "Mail" est bien sélectionné, la MatCard correspondante ne s'affiche pas.
Pourquoi ?!
Parce que l'Observable du FormControl est bien nommé... Il émet les changements de valeur du champ – d'ailleurs, si vous cliquez sur les boutons radio, les MatCards apparaissent – mais quand l'utilisateur arrive sur la page, la valeur n'a pas changé. L'Observable n'émet donc pas.
Mais ne vous inquiétez pas, il y a une solution robuste et élégante : vous allez générer une "fausse" émission, au chargement de la page, qui correspond à la valeur initiale du champ !
private initFormObservables() {
this.showEmailCtrl$ = this.contactPreferenceCtrl.valueChanges.pipe(
startWith(this.contactPreferenceCtrl.value),
map(preference => preference === 'email'),
);
this.showPhoneCtrl$ = this.contactPreferenceCtrl.valueChanges.pipe(
startWith(this.contactPreferenceCtrl.value),
map(preference => preference === 'phone')
);
}
Ainsi, même si la valeur par défaut du champ change, la bonne MatCard sera affichée lors du chargement !
Validez les champs
Puisque les changements de validation ont lieu lorsque l'utilisateur change son contactPreference
, vous pouvez ajouter la logique nécessaire dans les pipes des Observables showEmailCtrl$
et showPhoneCtrl$
.
Commençons par les Validators pour phoneCtrl
. Ce contrôle doit être requis lorsque l'utilisateur sélectionne "Téléphone", et vous allez rajouter des Validators de longueur pour vous assurer que l'utilisateur rentre 10 caractères exactement.
Lorsque l'utilisateur sélectionnera autre chose que "Téléphone", il faudra retirer ces Validators. On ne peut pas retirer facilement des Validators précis, donc vous allez tous les retirer.
Ça donne :
this.showPhoneCtrl$ = this.contactPreferenceCtrl.valueChanges.pipe(
startWith(this.contactPreferenceCtrl.value),
map(preference => preference === 'phone'),
tap(showPhoneCtrl => {
if (showPhoneCtrl) {
this.phoneCtrl.addValidators([
Validators.required,
Validators.minLength(10),
Validators.maxLength(10)
]);
} else {
this.phoneCtrl.clearValidators();
}
})
);
L'implémentation est plutôt compréhensible, mais il manque une étape un peu fourbe ! Quand vous changez la validation d'un contrôle en cours de route, il faut ensuite appeler sa méthode updateValueAndValidity()
:
tap(showPhoneCtrl => {
if (showPhoneCtrl) {
this.phoneCtrl.addValidators(
[Validators.required,
Validators.minLength(10),
Validators.maxLength(10)
]);
} else {
this.phoneCtrl.clearValidators();
}
this.phoneCtrl.updateValueAndValidity();
})
Si vous ne le faites pas, la validation n'est pas mise à jour et vous n'aurez pas le comportement souhaité !
À vous de jouer !
Vous pouvez faire de même pour les contrôles "Email". Je vous laisse essayer par vous-même, et je vous propose ma solution ci-dessous.
...
...
...
Suivez ma solution
Alors, vous avez trouvé ? Comparez ce que vous avez fait avec ma solution...
this.showEmailCtrl$ = this.contactPreferenceCtrl.valueChanges.pipe(
startWith(this.contactPreferenceCtrl.value),
map(preference => preference === 'email'),
tap(showEmailCtrl => {
if (showEmailCtrl) {
this.emailCtrl.addValidators(
Validators.required,
Validators.email]);
this.confirmEmailCtrl.addValidators([
Validators.required,
Validators.email
]);
} else {
this.emailCtrl.clearValidators();
this.confirmEmailCtrl.clearValidators();
}
this.emailCtrl.updateValueAndValidity();
this.confirmEmailCtrl.updateValueAndValidity();
})
);
Ainsi, non seulement la bonne MatCard s'affiche pour la sélection de l'utilisateur, mais la validation est aussi mise à jour correctement.
Cependant, la méthode initFormObservables()
commence à devenir un peu longue et à faire un peu trop de choses, donc je vous propose de refactoriser la gestion des Validators dans des méthodes séparées :
private initFormObservables() {
this.showEmailCtrl$ = this.contactPreferenceCtrl.valueChanges.pipe(
startWith(this.contactPreferenceCtrl.value),
map(preference => preference === 'email'),
tap(showEmailCtrl => this.setEmailValidators(showEmailCtrl))
);
this.showPhoneCtrl$ = this.contactPreferenceCtrl.valueChanges.pipe(
startWith(this.contactPreferenceCtrl.value),
map(preference => preference === 'phone'),
tap(showPhoneCtrl => this.setPhoneValidators(showPhoneCtrl))
);
}
private setEmailValidators(showEmailCtrl: boolean) {
if (showEmailCtrl) {
this.emailCtrl.addValidators([
Validators.required,
Validators.email
]);
this.confirmEmailCtrl.addValidators([
Validators.required,
Validators.email
]);
} else {
this.emailCtrl.clearValidators();
this.confirmEmailCtrl.clearValidators();
}
this.emailCtrl.updateValueAndValidity();
this.confirmEmailCtrl.updateValueAndValidity();
}
private setPhoneValidators(showPhoneCtrl: boolean) {
if (showPhoneCtrl) {
this.phoneCtrl.addValidators([
Validators.required
Validators.minLength(10),
Validators.maxLength(10)
]);
} else {
this.phoneCtrl.clearValidators();
}
this.phoneCtrl.updateValueAndValidity();
}
Jusqu'ici vous réagissiez aux changements de valeur des contrôles. Maintenant, vous allez réagir à leurs changements d'état de validation.
Affichez les erreurs
Il n'y a rien de plus frustrant qu'un formulaire qui refuse de s'envoyer sans vous dire pourquoi !
Pour ne pas énerver vos utilisateurs, vous allez donc implémenter des messages d'erreur customisés sur votre formulaire, pour leur indiquer la voie à suivre pour bien le remplir.
Pour les champs où une seule erreur est possible – c'est le cas des champs qui n'ont que le Validator required
, par exemple – vous pouvez ajouter le texte d'erreur en dur.
Commencez par les champs "Prénom" et "Nom :
<mat-form-field appearance="fill">
<mat-label>Prénom</mat-label>
<input type="text" matInput formControlName="firstName">
<mat-error>Ce champ est requis</mat-error>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>Nom</mat-label>
<input type="text" matInput formControlName="lastName">
<mat-error>Ce champ est requis</mat-error>
</mat-form-field>
Comme ça, si vos utilisateurs oublient de remplir un de ces champs :
Super utiles, les MatErrors !
Pour les contrôles où plusieurs erreurs sont possibles, il vous faut une autre approche, car le texte d'erreur ne sera pas toujours le même. Il doit changer en fonction du type d'erreur.
Vous allez donc créer une méthode qui permet de générer un texte d'erreur à partir de l'erreur spécifique du FormControl.
Voici une première implémentation :
getFormControlErrorText(ctrl: AbstractControl) {
if (ctrl.hasError('required')) {
return 'Ce champ est requis';
} else {
return 'Ce champ contient une erreur';
}
}
Pourquoi AbstractControl ?
Parce que ça vous permet de passer des FormControls ou des FormGroups à cette méthode : ce sera utile dans le prochain chapitre !
Ici, vous utilisez la méthode hasError
du FormControl pour vérifier si le contrôle a généré une erreur précise. Avec cette implémentation, toute erreur autre qu'un champ requis non rempli retournera "Ce champ contient une erreur".
Ajoutez un MatError aux champs "Email" et appelez cette méthode en y passant les contrôles appropriés :
<mat-form-field appearance="fill">
<mat-label>Adresse mail</mat-label>
<input type="text" matInput formControlName="email">
<mat-error>{{ getFormControlErrorText(emailCtrl) }}</mat-error>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>Confirmer votre adresse mail</mat-label>
<input type="text" matInput formControlName="confirm">
<mat-error>{{ getFormControlErrorText(confirmEmailCtrl) }}</mat-error>
</mat-form-field>
Du coup, si vous entrez une adresse mail invalide dans un champ et que vous passez votre route, vous avez bien notre nouveau message d'erreur :
Pour améliorer ce message, il suffit de prendre en compte le cas où ctrl.hasError('email')
:
if (ctrl.hasError('required')) {
return 'Ce champ est requis';
} else if (ctrl.hasError('email')) {
return 'Merci d\'entrer une adresse mail valide';
} else {
return 'Ce champ contient une erreur';
}
Comme ça :
Vous pouvez ensuite ajouter des cas pour les erreurs liées à phoneCtrl
:
if (ctrl.hasError('required')) {
return 'Ce champ est requis';
} else if (ctrl.hasError('email')) {
return 'Merci d\'entrer une adresse mail valide';
} else if (ctrl.hasError('minlength')) {
return 'Ce numéro de téléphone ne contient pas assez de chiffres';
} else if (ctrl.hasError('maxlength')) {
return 'Ce numéro de téléphone contient trop de chiffres';
} else {
return 'Ce champ contient une erreur';
}
Vous pouvez donc ajouter un MatError au contrôle "Numéro de téléphone" :
<mat-form-field appearance="fill">
<mat-label>Numéro de téléphone</mat-label>
<input type="text" matInput [formControl]="phoneCtrl">
<mat-error>{{ getFormControlErrorText(phoneCtrl) }}</mat-error>
</mat-form-field>
Ajoutez des MatErrors simples aux contrôles de loginInfoForm
:
<mat-card class="form-card" [formGroup]="loginInfoForm">
<mat-card-subtitle>Informations de connexion</mat-card-subtitle>
<mat-form-field appearance="fill">
<mat-label>Nom d'utilisateur</mat-label>
<input type="text" matInput formControlName="username">
<mat-error>Ce champ est requis</mat-error>
</mat-form-field>
<div class="form-row">
<mat-form-field appearance="fill">
<mat-label>Mot de passe</mat-label>
<input type="password" matInput formControlName="password">
<mat-error>Ce champ est requis</mat-error>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>Confirmer votre mot de passe</mat-label>
<input type="password" matInput formControlName="confirmPassword">
<mat-error>Ce champ est requis</mat-error>
</mat-form-field>
</div>
</mat-card>
Et votre formulaire affiche des messages d'erreur customisés pour chaque type d'erreur !
Faites patienter les utilisateurs
Avant de passer à la création de votre propre Validator, je vous propose une dernière amélioration du formulaire.
Pour l'instant, quand vos utilisateurs cliquent sur ENREGISTRER, il ne se passe rien ! Vous allez donc lier le bouton à la méthode du service.
Seul hic : on a choisi de simuler une réponse serveur qui prend du temps. Vous allez donc donner deux éléments de feedback visuel aux utilisateurs pour leur indiquer que le chargement est bien en cours :
le bouton ENREGISTRER va se désactiver, les empêchant de cliquer plusieurs fois ;
un spinner de chargement va s'afficher.
Vous allez aussi réinitialiser le formulaire lors d'un enregistrement réussi.
Pour le spinner, je vous propose d'utiliser un component Material avec MatProgressSpinnerModule. Ajoutez-le aux exports
de MaterialModule :
@NgModule({
exports: [
MatToolbarModule,
MatCardModule,
MatListModule,
MatButtonModule,
MatIconModule,
MatFormFieldModule,
MatInputModule,
MatCheckboxModule,
MatRadioModule,
MatProgressSpinnerModule
]
})
export class MaterialModule {}
Pour montrer et cacher ce spinner, ainsi que pour activer et désactiver le bouton, vous allez ajouter une variable loading
au component :
export class ComplexFormComponent implements OnInit {
loading = false;
Ça vous permet, dans le template, de lier l'apparition du spinner et l'activation du bouton à son état :
<mat-card-actions *ngIf="mainForm.valid">
<button mat-flat-button color="primary"
(click)="onSubmitForm()"
[disabled]="loading">ENREGISTRER</button>
</mat-card-actions>
<mat-spinner *ngIf="loading" color="primary" mode="indeterminate"></mat-spinner>
Injectez le service dans votre component, et préparez les cas de figure dans onSubmitForm()
:
onSubmitForm() {
this.loading = true;
this.complexFormService.saveUserInfo(this.mainForm.value).pipe(
tap(saved => {
this.loading = false;
if (saved) {
// user saved successfully
} else {
// user not saved: error case
}
})
).subscribe();
}
Lorsque l'utilisateur clique, vous passez immédiatement loading
à true
pour désactiver le bouton et afficher le spinner.
Ensuite, lorsque le serveur répond, dans tous les cas il faut passerloading
à false
. La valeur saved
ici sera true
si l'enregistrement a fonctionné, et false
sinon, donc :
if (saved) {
this.mainForm.reset();
this.contactPreferenceCtrl.patchValue('email');
} else {
console.error('Echec de l\'enregistrement');
}
Dans le cas d'un enregistrement réussi, vous réinitialisez le formulaire (vous videz tous les champs). Il faut donc passer la valeur 'email'
à contactPreferenceCtrl
pour retrouver le vrai état initial du formulaire.
Je vous propose de refactoriser la réinitialisation du formulaire pour la mettre dans sa propre méthode – la structure et la lisibilité de votre code seront améliorées :
onSubmitForm() {
this.loading = true;
this.complexFormService.saveUserInfo(this.mainForm.value).pipe(
tap(saved => {
this.loading = false;
if (saved) {
this.resetForm();
} else {
console.error('Echec de l\'enregistrement');
}
})
).subscribe();
}
private resetForm() {
this.mainForm.reset();
this.contactPreferenceCtrl.patchValue('email');
}
Avec ceci, lorsque vos utilisateurs cliquent sur ENREGISTRER, le bouton se désactive, et le spinner s'affiche jusqu'à ce que le serveur réponde. Le formulaire est ensuite réinitialisé.
En résumé
Utilisez l'Observable
valueChanges
pour réagir aux changements de valeur des champs du formulaire.La méthode
addValidators
permet d'ajouter des Validators à un contrôle en cours de route.On ne peut pas supprimer facilement les Validators d'un contrôle un à un : il faut tous les supprimer en même temps avec
clearValidators()
.N'oubliez pas d'appeler
updateValueAndValidity()
après avoir changé les Validators d'un contrôle, sinon les changements ne prendront pas effet.Les FormControls ont une méthode
hasError()
pour vérifier si un champ retourne une erreur spécifique.Le component MatSpinner permet d'afficher un loader à vos utilisateurs pour leur montrer qu'un chargement est en cours.
Enfin, complétons ce formulaire ! Dans le prochain chapitre, vous créerez votre propre Validator. Vous êtes prêt ?