Les signaux avec Angular simplifient beaucoup de chose 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éclaratives.
Sachez qu'il existe plusieurs règles, mais que vous pouvez définir les vôtres permettant d'accomplir vous permettant 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 []
})
}
Enfin, 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 de manière asynchrone. Par ailleurs, disabled ou requiredfaçon acceptent d'autres signaux vous permettant d'appliquer des règles de façon conditionnelle.
Exemple de validateHttp HTTP :
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 et préférable :
protected readonly registerForm = form<FormData>(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())
Bonus :
Voici une directive qui peut être pratique pour gérer de manière uniforme les messages d'erreurs :
import { Directive, effect, inject, input, OnDestroy, Renderer2, ViewContainerRef } from '@angular/core'
import { FieldState, ValidationErrorWithField } from '@angular/forms/signals'
type InputFieldState = FieldState<string, string>
@Directive({
selector: '[quizzFieldError]',
standalone: true,
})
export class FieldError implements OnDestroy {
readonly fieldState = input.required<() => InputFieldState>()
private readonly viewContainerRef = inject(ViewContainerRef)
private readonly renderer = inject(Renderer2)
private readonly errorElement: HTMLElement
constructor() {
this.errorElement = this.renderer.createElement('div')
this.renderer.setStyle(this.errorElement, 'color', 'red')
this.renderer.setStyle(this.errorElement, 'margin-top', '0.5rem')
this.renderer.setStyle(this.errorElement, 'font-size', '0.875rem')
this.renderer.setStyle(this.errorElement, 'white-space', 'pre-wrap')
this.viewContainerRef.element.nativeElement.after(this.errorElement)
effect(() => {
const errorList = this.extractErrorMessagesFrom(this.fieldState()())
const isVisible = errorList.length !== 0
this.renderer.setProperty(this.errorElement, 'innerText', errorList.join('\n'))
this.renderer.setStyle(this.errorElement, 'display', isVisible ? '' : 'none')
})
}
ngOnDestroy(): void {
this.renderer.removeChild(this.errorElement.parentNode, this.errorElement)
}
private extractErrorMessagesFrom(input: InputFieldState): string[] {
if (!input.touched() || !input.dirty() || input.errors().length === 0) {
return []
}
return input.errors().map(this.getErrorMessage)
}
private getErrorMessage(error: ValidationErrorWithField): string {
const field = error.field().name
const kind = error.kind
const message = error.message
if (kind === 'standardSchema') {
return (error as StandardSchemaValidationError).issue.message
}
return message === undefined ? `• [${field}] Erreur de type '${kind}'` : `• ${message}`
}