Passer au contenu

Utiliser des formulaires à base de signaux avec Angular

Ceci n'étant possible qu'à partir d'Angular 21

Les signaux avec Angular simplifient beaucoup de choses et rapprochent celui-ci de comportements observables avec d'autres frameworks.

La nouveauté ici est les formulaires à base de signaux.
La documentation à ce propos :
https://next.angular.dev/essentials/signal-forms

Voici un exemple :

Le template HTML

<form (submit)="onRegister()">
  <div>
    <label for="username">Pseudonyme</label>
    <input
      id="username"
      autocomplete="username"
      type="text"
      fieldError
      [field]="registerForm.username"
      [fieldState]="registerForm.username"
    />
  </div>
  <div>
    <label for="email">Email</label>
    <input
      id="email"
      autocomplete="email"
      type="email"
      fieldError
      [field]="registerForm.email"
      [fieldState]="registerForm.email"
    />
  </div>
  <div>
    <button type="submit">Envoyer</button>
  </div>
</form>


Le composant

import { ChangeDetectionStrategy, Component, model, signal } from '@angular/core'
import { email, Field, FieldPath, form, maxLength, minLength, required } from '@angular/forms/signals'
import { FieldError } from '@quezap/core/directives'

function registerFormValidator(path: FieldPath<{
 email: string
 username: string
}>) {
 required(path.email, { message: 'L\'email est requis.' })
 email(path.email, { message: 'L\'email doit être valide.' })
 required(path.username, { message: 'Le nom d\'utilisateur est requis.' })
 minLength(path.username, 3, { message: 'Le nom d\'utilisateur doit faire au moins 3 caractères.' })
 maxLength(path.username, 20, { message: 'Le nom d\'utilisateur doit faire au maximum 20 caractères.' })
}

@Component({
 selector: 'quizz-register-modal',
 imports: [
   Field,
   FieldError,
 ],
 templateUrl: './register-modal.html',
 changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RegisterModal {
 private readonly userInfo = signal({
   email: '',
   username: '',
 })
 protected readonly registerForm = form(this.userInfo, registerFormValidator)
 
 protected onRegister() {
   if (this.registerForm().invalid()) {
   	 alert('Problème de validation')
     return
   }
   
   alert('Register')
 }
 protected onCancel() {
   this.visible.set(false)
 }
}


Les règles de validation se trouvent dans registerFormValidator. Nous écrivons donc ces règles de manière déclarative.
Il existe plusieurs règles fournies, mais vous pouvez définir les vôtres afin de vous simplifier la tâche.

Exemple :

function registerFormValidator(path: FieldPath<{
 email: string
 confirmEmail: string
 username: string
}>) {
 required(path.email, { message: 'L\'email est requis.' })
 email(path.email, { message: 'L\'email doit être valide.' })
 email(path.confirmEmail, { message: 'La confirmation d\'email n\'est pas valide' })
 required(path.username, { message: 'Le nom d\'utilisateur est requis.' })
 minLength(path.username, 3, { message: 'Le nom d\'utilisateur doit faire au moins 3 caractères.' })
 maxLength(path.username, 20, { message: 'Le nom d\'utilisateur doit faire au maximum 20 caractères.' })
 validate(path, ({ value }) => {
   if (value().email !== value().confirmEmail) {
     return customError({
       kind: 'mismatch',
       message: 'Les emails ne correspondent pas.',
     })
   }
   return []
 })
}

Pour déclarer le formulaire, nous utilisons registerForm = form(this.userInfo, registerFormValidator).
Cela facilite grandement les choses et sachez que vous avez toujours accès à des propriétés comme touched, dirty ou errors sur InputFieldState.
Vous pouvez également utiliser validateHttp ou validateAsync pour faire une validation asynchrone. Par ailleurs, disabled ou required acceptent d'autres signaux vous permettant d'appliquer des règles de façon conditionnelle.

Exemple de validateHttp :

  validateHttp(path.email, {
   options: {
     defaultValue: false,
     parse: (value) => {
       return Boolean(value)
     },
   },
   request: ({ value }) => {
     return value() ? `https://example.com/api/check/${value()}` : undefined
   },
   onSuccess(value, ctx) {
     const isTaken = value
     if (isTaken) {
       return [customError({
         kind: 'taken',
         message: 'Cet email est déjà utilisé.',
       })]
     }
     return []
   },
   onError: (error, ctx) => {
     return [customError({
       kind: 'server',
       message: `Erreur serveur lors de la validation de l'email`,
     })]
   },
 })


Possibilité d'utiliser des schémas Zod, ce qui selon moi est une combinaison très puissante :

  protected readonly registerForm = form(this.userInfo, (path) => {
   validateStandardSchema(path, zod.object({
     email: zod.email('Email invalide'),
     username: zod.string()
       .min(3, '3 caractères minimum')
       .max(20, '20 caractères maximum'),
   }))
 })

Ici, nous ajoutons des messages d'erreurs personnalisés, mais avec la v4 de Zod, il est possible d'avoir les messages directement en français :

import { z } from 'zod'
z.config(z.locales.fr())

Soumettre le formulaire

Pour cette opération, vous pouvez utiliser la fonction submit, cela va mettre à jour le signal du formulaire submitting et dans le même temps vous permettre de fournir des messages d'erreurs spécifiques.

Exemple :

export class RegisterModal {
  private readonly message = inject(MessageService)
  private readonly registerService = inject(REGISTER_SERVICE)

  private readonly userInfo = signal({
    email: '',
    username: '',
  })

  protected readonly registerForm = form(this.userInfo, (path) => {
    validateStandardSchema(path, zod.object({
      email: zod.email('Email invalide'),
      username: zod.string()
        .min(3, '3 caractères minimum')
        .max(20, '20 caractères maximum'),
    }))
  })

  public readonly visible = model(false)

  protected onRegister() {
    if (this.registerForm().invalid()) {
      return
    }

    submit(this.registerForm, async (form) => {
      return new Promise((resolve, reject) => {
        this.registerService.register(
          form.email().value(),
          form.username().value(),
        ).then(() => {
          this.message.add({
            severity: 'success',
            summary: 'Inscription réussie',
            detail: 'Vous allez recevoir un email contenant votre lien d\'activation',
            sticky: true,
          })
          resolve()
        }).catch((err) => {
          if (err instanceof ExternalValidationError) {
            if (err.errorCode === myClient.ErrorCode.USERNAME_TAKEN) {
              resolve([{
                field: registrationForm.username,
                error: {kind: 'server', message: 'Ce nom d\'utilisateur est déjà pris'}
              }])
            }
          }
          else {
            reject(err)
            this.message.add({
              severity: 'error',
              summary: 'Erreur lors de l\'inscription',
              detail: 'Une erreur est survenue lors de l\'envoi du formulaire',
              life: 5000,
            })
          }
          return err
        })
      })
    })
  }
}
juniko
5 min