Aller au contenu principal

Forms

Utilisation des Reactive Forms (docs) plutôt que les template-driven forms.

Structure

Voici une idée de la forme globale d'un formulaire. Des explications plus précises sont donnés sur les parties suivantes.

<form [formGroup]="form">
<!-- legende lorsqu'il y a au moins un champ obligatoire -->
<shared-required-fields-legend />
<!-- bloc d'affichage des erreurs -->
<div class="container-y-spacing" *ngIf="error">
<shared-simple-alert type="error" [message]="error"></shared-simple-alert>
</div>
<!-- Champs du formulaire -->
<div class="container-y-spacing">
<!-- directive appFormLabel - indique si le champ est obligatoire ou non -->
<label
for="modal-cancel-ouverture-reason"
class="form-label"
appFormLabel
>...</label>
<!-- is-invalid permet d'avoir les styles bootstrap lorsque le champ est en erreur-->
<!-- (accessibilité) aria-invalid permet de connaitre la validité du champ -->
<!-- (accessibilité) aria-describedby permet d'ajouter un lien vers le conteneur des messages d'erreur -->
<!-- le caractère obligatoire du champ (required) est géré par l'ajout d'un validateur required lors de la définition du FormControl dans le .Ts -->
<textarea
class="form-control"
id="modal-cancel-ouverture-reason"
formControlName="reason"
[ngClass]="{
'is-invalid':
form.controls.reason.touched && form.controls.reason.invalid
}"
[attr.aria-invalid]="
form.controls.reason.touched && form.controls.reason.invalid
"
[attr.aria-describedby]="
('modal-cancel-ouverture-reason' | htmlIdErrors) + ' ' + ('modal-cancel-ouverture-reason' | htmlIdHelp)
"
></textarea>
<!-- texte d'aide -->
<div
class="form-text"
[attr.id]="'modal-cancel-ouverture-reason' | htmlIdHelp"
>
...
</div>
<!-- conteneur des erreurs de ce champ, les erreurs sont gérées par ce composant -->
<shared-errors [attr.id]="'modal-cancel-ouverture-reason' | htmlIdErrors">
<!-- ici on peut rajouter d'autres types d'erreurs spécifiques -->
<shared-error *ngIf="monTest">Mon message d'erreur spécifique</shared-error>
</shared-errors>
</div>
</form>

Id des champs

attention

Il ne faut pas dupliquer les ids de champs de formulaire dans une page car cela casse le lien avec les labels associés à ces champs.
On essaie donc de donner des ids un minimum spécifique si l'on pense qu'un autre champ ayant un nom similaire est affiché en même temps.

A11Y ♿

Lorsque des informations sont liés à un champ (erreurs de validation, texte d'aide, ..), il est nécessaire de relier le champ aux descriptions annexes, notamment avec l'attribut aria-describedby.

Pour homogénéiser ces ids, des pipes existent (voir code). On leur passe l'id du champ et la pipe génère un nom selon le cas d'usage (aide, erreurs,..).

Ici on a plusieurs descriptions annexes : l'attribut aria-describedby attend donc une liste d'id séparés par des espaces.

    <!--  Utilisation des pipes htmlIdErrors et htmlIdHelp, on pointe sur l'id du container des erreurs de ce champ, généralement le composant <shared-errors>, et du container d'un texte d'aide - généralement un container avec la classe form-text -->
<textarea
class="form-control"
id="modal-cancel-ouverture-reason"
formControlName="reason"
[attr.aria-describedby]="
('modal-cancel-ouverture-reason' | htmlIdErrors) + ' ' + ('modal-cancel-ouverture-reason' | htmlIdHelp)
"
></textarea>
<!-- texte d'aide avec l'attribut id renseigné -->
<div
class="form-text"
[attr.id]="'modal-cancel-ouverture-reason' | htmlIdHelp"
>
...
</div>
<!-- container d'erreurs avec l'id renseigné -->
<shared-errors [attr.id]="'modal-cancel-ouverture-reason' | htmlIdErrors">
</shared-errors>

Cas des modals

Les ids utilisés pour ces champs doivent être préfixés par le nom du modal. Cela permet d'éviter d'avoir des doublons d'id (HTML non valide) si la page appelant le modal contient deja un formulaire avec cet id.
Ici on voit que l'id est préfixé, par contre le formControlName est name puisqu'il est dans le scope du formulaire du composant.

<label for="modal-description-edition-name">Intitulé</label>
<input
type="text"
class="form-control"
id="modal-description-edition-name"
formControlName="name"
/>

Affichage champs obligatoires / optionnels

Nous avons choisi de marquer les champs obligatoires et optionnel d'un repère visuel :

  • une asterisque * pour les champs obligatoires
  • un texte - Optionnel pour les champs optionnels

Pour marquer qu'un champ est obligatoire, il faut rajouter à son <label> l'attribut de la directive appFormLabel ou appFormLabel=required.
Pour marquer qu'un champ est optionnel, il faut rajouter à son <label> l'attribut de la directive appFormLabel=optional.
Un champ peut être conditionnellement obligatoire, dans ce cas, il faut "binder" l'attribut, ex : [appFormLabel]="isEdition ? 'required' : 'optional'" .

Une légende doit être indiqué en haut du formulaire lorsqu'il y a au moins un champ obligatoire.
Il faut utiliser le composant <shared-required-fields-legend />.

Désactivation de champ

Pour désactiver un champ on passera par l'API réactive des formulaires.

if (maCondition){
this.form.controls.monChamp.disable();
}

ou lors de la définition de l'état initial du formulaire

form = this.formBuilder.nonNullable.group({
documentType: new FormControl<number | null>(
{ value: null, disabled: true },
Validators.required,
),
});
attention

On évite de placer l'attribut disabled dans le template au niveau de l'input. Cela instancie la directive Angular qui peut faire doublon avec l'état que l'on place via l'API réactive vu ci-dessus. Cela entraine des comportements non désirés car l'attribut se retrouve configuré à 2 endroits différents.

Cas d'une checkbox

Lorsqu'une checkbox est désactivée, on indique généralement via un tooltip la raison de désactivation.

Exemple formulaire de création/édition d'un rôle (voir code)

  1. Définition statique des messages
export class MonComponent {
readonly peutCreerConventionDisallowedTooltip =
this.translateService.instant(
'ROLES.COMMON.ROLE_FORM.TOOLTIP_PEUT_CREER_ROLE_CONTEXTE',
);
}
  1. On regroupe les tooltips possibles dans un objet lié aux champs du formulaire.
tooltips: { [key in RoleFormControls]?: string | null } = {
peutCreerConvention: null,
unique: null,
};
  1. Lors de l'initialisation du form (et lorsque les valeurs évoluent), on assigne un tooltip si nécessaire.
if (
maCondition
){
this.form.controls.peutCreerConvention.setValue(false); //le champ est placé à false
this.tooltips.peutCreerConvention = this.peutCreerConventionDisallowedTooltip; //on place un tooltip
this.form.controls.peutCreerConvention.disable(); // on désactive le champ
} else {
this.form.controls.peutCreerConvention.reset(); // on reset le champ
this.tooltips.peutCreerConvention = null; // on enlève le tooltip
this.form.controls.peutCreerConvention.enable(); // on le réactive
}

Dans le template :

<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="peutCreerConvention"
formControlName="peutCreerConvention"
/>
<!-- texte du tooltip placé sur le label (s'il est null, rien ne sera affiché)-->
<label
class="form-check-label"
for="peutCreerConvention"
[ngbTooltip]="tooltips['peutCreerConvention']"
>
<span
[innerHTML]="'ROLES.COMMON.ROLE_FORM.PEUT_CREER.LABEL' | translate"
></span>
<!-- affichage d'un template contenant l'icone de tooltip -->
<ng-container
*ngIf="tooltips['peutCreerConvention']"
[ngTemplateOutlet]="tooltipIcon"
></ng-container>
</label>
</div>

Valeurs par défaut

Lors de la première initialisation d'un formulaire, on lui assigne les valeurs par défaut pour chaque champ. Ces valeurs sont utilisées notamment lors de l'appel à la méthode reset() (sur un champ ou sur le formulaire entier). Par défaut les valeurs sont réinitialisés à null si l'on ne précise pas que le champ est nonNullable.

// ici grace au formBuilder "nonNullable" on indique que tous les champs de ce groupe seront "non nullable".
form = this.formBuilder.nonNullable.group({
intitule: new FormControl('Ma valeur par défaut', [
Validators.required,
]),
});

// ici seul le champ description est "non nullable"
form = this.formBuilder.group({
description: this.formBuilder.nonNullable.control('Ma valeur par défaut', {
validators: [Validators.required],
}),
});

// ici pareil mais en passant l'option au formControl
form = this.formBuilder.group({
description: new FormControl('Ma valeur par défaut', {
nonNullable: true,
validators: [Validators.required],
}),
});

Il n'est pas possible de changer la valeur par défaut d'un formulaire (sauf en supprimant et recréant les contrôles ce qui nécessite de gérer certains aspects comme les souscriptions existantes à valueChanges/statusChanges). Si besoin, on peut passer la valeur à utiliser pour réinitialiser le form à la méthode -> reset(monObjValeurParDefaut).

Validations

Cas 1 : Validateurs réutilisables

Les validateurs réutilisables au sein de l'appli sont définis dans shared/utils/validators/validators.ts (voir code).
Généralement un validateur est associé à un message d'erreur, lorsque l'on en définit un nouveau on doit rajouter sa définition à la liste des validateurs (voir code) gérés par le composant ErrorsComponent puis définir le message d'erreur par défaut.

Voir erreurs prédéfinies

Cas 2 : Validateur métiers spécifiques

Les validateurs trop spécifiques à un composant (ou un groupe de composant) doivent être définis dans leur composant (ou dans un fichier commun propre à leur module métier) - exemple.

On peut ensuite afficher le message d'erreur de différentes façons :

  1. L'afficher en tant qu'alerte au début du formulaire comme sur un validateur de groupe où il est moins pertinent d'afficher l'erreur sous un champ
<!-- ici on customise le libellé de l'alerte en lui passant des détails provenant de l'erreur -->
<shared-simple-alert
*ngIf="form.errors?.['roleAlreadyExistsError']"
type="error"
[message]="
'ROLES.COMMON.ROLE_FORM.WARNING_DUPLICATE_ROLE'
| translate
: {
typeRole: form.errors?.['roleAlreadyExistsError'].typeRole,
contexteRole:
form.errors?.['roleAlreadyExistsError'].contexteRole,
nomRoleExistant:
form.errors?.['roleAlreadyExistsError'].nomRoleExistant
}
"
></shared-simple-alert>
  1. Projeter l'alerte dans le composant ErrorsComponent, l'erreur apparaitra après les erreurs communes gérées par le composant.
<shared-errors
[attr.id]="'role-with-context-picker-role' | htmlIdErrors"
[control]="form.controls.role"
>
<!-- projection de l'erreur -->
<shared-error *ngIf="form.errors?.['roleAlreadyExistsError']" [message]="'Mon erreur métier'"></shared-error>
</shared-errors>
  1. Indiquer au ErrorsComponent le message à afficher.

Voir customisation des erreurs

Affichage des erreurs

Lorsqu'un champ de formulaire contient des erreurs, il faut placer le composant ErrorsComponent juste après le champ input (ou le container pour un composant plus complexe), celui sur lequel on applique la classe is-invalid. C'est nécessaire pour que les erreurs s'affichent - voir doc Bootstrap.

<textarea id="monChamp"
formControlName="monChamp"
[ngClass]="{
'is-invalid':
form.controls.monChamp.touched && form.controls.monChamp.invalid
}"
[attr.aria-invalid]="
form.controls.monChamp.touched && form.controls.monChamp.invalid
"
[attr.aria-describedby]="'monChamp' | htmlIdErrors">
</textarea>
<shared-errors [attr.id]="'monChamp' | htmlIdErrors">
//... erreurs
</shared-errors>

Le composant ErrorsComponent gère l'affichage des erreurs communes de l'application. Il est possible de surcharger un message d'erreur.
Si aucun message associé n'est configuré, on affiche un message d'erreur générique par défaut.

Cas 1 : Surcharge d'un validateur prédéfini ou d'un validateur inconnu

<!-- customValidatorMessage expects a javascript Map<string, string>> where the first param is the validator name -->
<shared-errors
[control]="form.controls.role"
[customValidatorMessage]="myValidatorMessageMap"
>
</shared-errors>

Cas 2 : Définition du message par défaut d'une erreur commune prédéfinie

La méthode getValidatorMessage définie les messages à afficher selon le validateur en erreur - voir code.

Cas 3 : Configuration de certains types de messages prédéfinis

Certains messages prédéfinis peuvent varier selon la configuration fournie.

  • Champ requis : @Input requiredErrorConfig
  • Champs dates : @Input minDateErrorConfig et maxDateErrorConfig

Chargement et bouton submit

Pour éviter l'appel en doublon d'un service (double clic sur un bouton submit), il vaut mieux bloquer les boutons de formulaires avec un booléen de chargement.

Dans le composant

onSubmit() {
// reset des erreurs
this.error = '';
// on démarre le chargement
this.loading = true;
this.detailService
.updateDescription(this.form.getRawValue())
.pipe(finalize(() => (this.loading = false))) // on enleve le chargement
.subscribe({
next: () => {
// si succès
},
error: (error: ApiError) => {
// affichage du message d'erreur dans la page ou appel au service de notification selon le cas
this.error = this.errorFactory.getMessageToDisplay(error);
},
});
}

Dans le template

<button
[disabled]="!form.valid || isLoading"
type="button"
class="btn btn-primary"
(click)="onPrimaryAction()"
>

Données dynamique

Pour charger des données dynamiquement (ex: dans un élément <select>) on va souscrire à un observable via la pipe async.

 <ng-container *ngIf="typesRolesList$ | async as typesRolesList">
<select formControlName="type">
<option [value]="null"></option>
<option *ngFor="let type of typesRolesList" [value]="type.code">
{{ type.code | translateEnum: RoleTypeEnum }}
</option>
</select>
</ng-container>
attention

Si la valeur n'est pas une string on bindera la valeur avec [ngValue] à la place de [value].

attention

Si le champ est obligatoire, on ne veut pas forcément afficher la première option vide, on rajoutera donc l'attribut hidden à la première option.

danger

Sans la première option vide, il existe un bug qui sélectionne la première valeur lorsque les options sont mises à jour et que la valeur est remise à null. Il faut donc absolument utiliser cette première option vide.

Composants

Ci-dessous quelques cas d'usages de composants communs.

Interne

Un certain nombre de composants réutilisables sont définis dans le module shared - voir code.

  • Radio group Oui/Non sur plusieurs lignes

code

  • Radio group Oui/Non en ligne

code

<shared-inline-radio-group
[formGroup]="form"
[name]="'avenantAutorise'"
[label]="monLabel"
[required]="true"
></shared-inline-radio-group>
  • Liste déroulante Oui/Non

code

  • Radio group sur plusieurs lignes se basant sur les valeurs d'un Enum (deprecated)

code

  • Radio group sur plusieurs lignes se basant sur les valeurs d'un Enum (nouvelle version)

//TODO: explication disabled state

  • File uploader

code

  • Option d'un ng-select (voir section suivante) qui est désactivé et affiche un tooltip

code

<ng-select
//...
>
<ng-template ng-label-tmp let-item="item" let-clear="clear">
<shared-ng-select-multiple-item-with-tooltip
[label]="item.libelle"
[disabled]="item.disabled"
[tooltip]="
'TYPOLOGIES.DETAIL.TAB_HOME.BLOC_DOMAINES.TOOLTIP_RJ_REMOVAL_NOT_ALLOWED'
| translate
"
(clear)="clear(item)"
></shared-ng-select-multiple-item-with-tooltip>
</ng-template>
</ng-select>

Externe

  • Liste déroulante complexes (librairie ng-select)

Exemple d'utilisation avec recherche customisé et template customisé - voir code filtres des événements

  • Date picker (librairie ngbootstrap)

Exemple minimal de configuration d'un datepicker.

attention

Le date picker utilise son propre modèle de données pour gérer la date (ngbDateStruct), il faut donc passer par des conversions. Elles sont centralisées dans le fichier date-utils.ts.

attention

Par défaut la date minimale sélectionnable est 10 ans dans le passé voir code. Il faudra configurer spécifiquement la date minimale le cas échéant.

<div class="input-group">
<input
formControlName="date"
ngbDatepicker
#modalOuvertureDatepicker="ngbDatepicker"
[minDate]="minDate"
/>
<button
class="btn btn-outline-secondary"
(click)="modalOuvertureDatepicker.toggle()"
type="button"
>
<fa-icon [icon]="icons.calendar"></fa-icon>
</button>
</div>

♿ Accessibilité (A11y) avec ARIA

Voici les attributs minimums sont à utiliser :

  • required est suffisant pour la plupart des input, pour les radio-group il faut le placer sur l'element ayant le rôle radiogroup (il faut utiliser le composant shared-inline-radio-group qui gère ça).
  • aria-invalid permet d'indiquer que le champ est valide ou pas, il est fortement lié à la classe css d'invalidité qu'on utilise.
  • aria-describedby permet de rajouter des indications sur le champ, notamment les messages d'erreur et d'aide sur un champ (voir section plus bas)
  • (peu utilisé pour le moment) aria-labelledby permet de lier un champ à un label si aucun label n'est déjà relié avec l'attribut for .
  • (peu utilisé pour le moment) tabindex=0 permet de rajouter un element non dynamique au defilement avec la touche TAB , il est utilisé dans les radio group et sur la légende des champs obligatoires afin que ça soit focusable et annoncé au Screen Reader.

Dans Angular pour binder dynamiquement une valeur d'un attribut html il faut le prefixer par [attr.] , ex: [attr.aria-invalid] , si la valeur est statique alors on met directement aria-invalid.

Elément <input/> et <textarea/>

<!-- un id, ngClass + aria-invalid pour binder la validité du champ, aria-describedby pour pointer sur le container d'erreur (si présent), attribut required si nécessaire -->
<input
type="text"
class="form-control"
id="modal-description-edition-name"
formControlName="name"
[ngClass]="{
'is-invalid':
form.controls.name.touched && form.controls.name.invalid
}"
[attr.aria-invalid]="
form.controls.name.touched && form.controls.name.invalid
"
aria-describedby="modal-description-edition-name-errors"
required
/>

Element <radio/> - l'accessibilité n'est pas encore parfaite car plus complexe

<!-- sur le div ayant le role radiogroup : aria-required, aria-labelledby pointant sur le label, ng-class + aria-invalid, aria-describedby pointant sur le container d'erreur, tabindex=0 -->
<!-- sur le label à l'intérieur : un id -->
<!-- sur les inputs : un id + required ou non -->
<!-- sur les labels : l'attribut for pointant sur l'id de l'input -->
<div
[formGroup]="this.formGroup"
class="row align-items-center"
role="radiogroup"
[attr.aria-labelledby]="this.groupName"
[ngClass]="{
'is-invalid': this.control.touched && this.control.invalid
}"
aria-required="true"
[attr.aria-describedby]="this.errorContainerId"
[attr.aria-invalid]="this.control.touched && this.control.invalid"
tabindex="0"
>
<div
class="col-12 col-md-6 form-label"
[attr.id]="this.groupName"
[appFormLabel]="this.required ? 'required' : 'optional'"
>
{{ this.label }}
</div>
<div class="col">
<!-- input 1 -->
<div class="form-check form-check-inline">
<input
class="form-check-input"
type="radio"
[formControlName]="this.name"
[attr.id]="trueValueId"
[value]="true"
[required]="this.required"
/>
<label class="form-check-label" [attr.for]="trueValueId">
{{ 'COMMON.FORM.RADIO_BUTTON.OUI' | translate }}
</label>
</div>
<!--... input 2 -->
</div>
</div>

Element <select/>

<!-- un id, ngclass + aria-invalid, aria-describedby, required (si nécessaire) -->
<select
id="modal-assign-role-role"
formControlName="role"
class="form-select"
[ngClass]="{
'is-invalid':
form.controls.role.touched && form.controls.role.invalid
}"
[attr.aria-invalid]="
form.controls.role.touched && form.controls.role.invalid
"
aria-describedby="modal-assign-role-role-errors"
required
>

Element <ng-select/> , accessibilité limitée par l'utilisation d'une librairie (état aria-invalid à améliorer)

<!-- labelForId (correspond à l'id qui sera donné à l'input), ngClass, [inputAttrs] permet de passer des attributs à l'input -> on y ajoute required et aria-describedby. -->
<ng-select
[items]="domainesList"
labelForId="domainesConvention"
bindLabel="libelle"
formControlName="domainesConvention"
class="form-control"
[ngClass]="{
'is-invalid':
form.controls.domainesConvention.touched &&
form.controls.domainesConvention.invalid
}"
[inputAttrs]="{
required: 'true',
'aria-describedby': 'domainesConvention-errors'
}"
>
</ng-select>