Site icon Blog ARC Optimizer

Formes de signaux angulaires et formes réactives

Formes de signaux angulaires et formes réactives


Angular met enfin à jour ses formulaires pour utiliser des signaux. Cela signifie que vous devez réapprendre à valider vos données, pour le mieux, espérons-le.

TL;DR

J’ai créé un formulaire de profil avec un validateur asynchrone de nom d’utilisateur et un validateur de correspondance de mot de passe dans les formulaires réactifs et les formulaires de signal. Les deux versions fonctionnent bien, mais les nouveaux formulaires de signal se lient automatiquement à la validation du formulaire HTML pur.

Installation

Assurez-vous d’installer la dernière version d’Angular dans le monde.

npm install -g @angular/cli

Ensuite, vous pouvez mettre à jour vers la branche suivante.

npx ng update --next

📝 Au moment d’écrire ces lignes, j’utilisais la version 21.0.0-next.9donc tout pourrait changer.

Vent arrière

Vous devriez également installer le vent arrière si vous voulez un style facile.

Composant d’erreur

J’ai créé un simple composant d’erreur partagé pour afficher nos messages d’erreur.

errors = input<string[]>([]);

Nous transmettons les erreurs en entrée, puis parcourons chacune d’elles et les montrons.

@let showErrors = errors();
@if (showErrors?.length) {
<ul class="text-red-600 text-sm list-disc list-inside">
    @for (e of showErrors; track ([e, $index])) {
    <li>{{ e }}</li>
    }
</ul>
}

📝 Il ne s’affichera pas s’il n’y a pas d’erreurs. Il affiche les erreurs sous chaque champ dans les deux versions.

Version réactive

Il faut importer ReactiveFormsModule ainsi que notre coutume ShowErrors composant.

imports: [ReactiveFormsModule, ShowErrors]

Commençons par la version classique, pour que nous sachions ce que nous faisons. Tout d’abord, nous avons nos validateurs personnalisés.

📝 Je mets tout cela dans le même fichier par exemple, mais en production, vous voudriez que tout soit dans des fichiers séparés pour les meilleures pratiques.

Validateur de téléphone

Vous pourriez simplement utiliser le Validators.pattern directement, mais cela vous permet de contrôler le nom du validateur.

export const phoneNumberValidator: ValidatorFn = (
  control: AbstractControl
): ValidationErrors | null => {

  return Validators.pattern(/^\+?[0-9\s-]+$/)(control)
    ? { phoneNumber: true }
    : null;
};

Validateur de correspondance

Le validateur de correspondance, que j’ai créé pour résoudre le problème de confirmation du mot de passe, vous permet de placer le même validateur sur deux champs différents et d’afficher l’erreur sur l’un d’eux uniquement lorsque les champs ne correspondent pas. Vous pouvez en savoir plus à ce sujet dans mon article Validateur personnalisé de confirmation angulaire.

export function matchValidator(
  matchTo: string,
  reverse?: boolean
): ValidatorFn {
  return (control: AbstractControl):
    ValidationErrors | null => {
    if (control.parent && reverse) {
      const c = (
        control.parent?.controls as Record<string, AbstractControl>
      )[matchTo] as AbstractControl;
      if (c) {
        c.updateValueAndValidity();
      }
      return null;
    }
    return !!control.parent &&
      !!control.parent.value &&
      control.value ===
      (
        control.parent?.controls as Record<string, AbstractControl>
      )[matchTo].value
      ? null
      : { matching: true };
  };
}

Validateur de nom d’utilisateur

Dans une application de production, nous devrons peut-être récupérer notre base de données pour voir si un nom d’utilisateur est disponible. Ce serait le même principe pour vérifier des slugs uniques, des e-mails ou même des codes promotionnels.

export function usernameAvailableValidator(delayMs = 400): AsyncValidatorFn {

  const checkUsername = inject(USERNAME_VALIDATOR);
  let timer: ReturnType<typeof setTimeout>;

  return (control: AbstractControl): Promise<ValidationErrors | null> => {
    const value = control.value;
    if (!value) return Promise.resolve(null);

    clearTimeout(timer);
    return new Promise((resolve) => {
      timer = setTimeout(async () => {
        try {
          const available = await checkUsername(value);
          resolve(available ? null : { taken: true });
        } catch {
          resolve(null);
        }
      }, delayMs);
    });
  };
}

Nous appelons notre fonction checkUsernameune fonction asynchrone qui renvoie vrai ou faux si elle est disponible. Nous pouvons utiliser un timeout pour que la base de données ne soit pas appelée à chaque lettre tapée dans un champ, évitant ainsi certaines conditions de concurrence. Vous pouvez également créer une version observable, mais je préfère gérer cela avec des promesses.

vérifierNom d’utilisateur

J’ai créé un jeton d’injection qui peut rechercher un nom d’utilisateur disponible. Cela émule un véritable appel de base de données.

import { InjectionToken } from "@angular/core";

export const USERNAME_VALIDATOR = new InjectionToken(
  'username-validator',
  {
    providedIn: 'root',
    factory() {
      return async (username: string) => {
        const takenUsernames = ['admin', 'user', 'test'];
        await new Promise((resolve) => setTimeout(resolve, 500));
        return !takenUsernames.includes(username);
      }
    }
  }
);

📝 J’aurais pu utiliser une fonction régulière ici, mais une application de production nécessitera probablement également l’injection et le partage d’autres services.

Création du formulaire

Nous devons définir nos erreurs de formulaire dans un objet JSON. Il existe de nombreuses façons différentes de stocker cela, mais je trouve que cette méthode est la plus simple à maintenir.

  profileForm: FormGroup;

  errorMessages: Record<string, Record<string, string>> = {
    firstName: {
      required: 'First name is required.',
      minlength: 'First name must be at least 2 characters.'
    },
    lastName: {
      required: 'Last name is required.',
      minlength: 'Last name must be at least 2 characters.'
    },
    biograph: {
      maxlength: 'Biography cannot exceed 200 characters.'
    },
    phoneNumber: {
      phoneNumber: 'Enter a valid phone number.'
    },
    username: {
      required: 'Username is required.',
      minlength: 'Username must be at least 3 characters.',
      taken: 'This username is already taken.'
    },
    birthday: {
      required: 'Birthday is required.'
    },
    password: {
      required: 'Password is required.',
      matching: 'Passwords must match.'
    },
    confirmPassword: {
      required: 'Confirm password is required.',
      matching: 'Passwords must match.'
    }
  };

📝 Nos clés doivent correspondre aux clés d’erreur qu’elles produisent, par exemple, phoneNumber.

Validateurs

Si vous renvoyez correctement les types de contrôles abstraits, vous pouvez les utiliser comme n’importe quel validateur angulaire intégré.

this.profileForm = this.fb.group({
  firstName: ['', [Validators.required, Validators.minLength(2)]],
  lastName: ['', [Validators.required, Validators.minLength(2)]],
  biograph: ['', Validators.maxLength(200)],
  phoneNumber: ['', phoneNumberValidator],
  username: ['', [Validators.required, Validators.minLength(3)], usernameAvailableValidator()],
  birthday: ['', Validators.required],
  password: ['', [Validators.required, matchValidator('confirmPassword', true)]],
  confirmPassword: ['', [Validators.required, matchValidator('password')]]
});

📝 Notez le usenameAvailableValidator est en dehors du deuxième tableau. C’est parce que nous mettons nos validateurs asynchrones dans le troisième paramètre de notre FormGroup.

Test d’erreur

Nous devons mapper nos erreurs aux messages d’erreur eux-mêmes.

getErrors(controlName: string): string[] {
  const control = this.profileForm.get(controlName);
  if (!control || !control.errors || (!control.touched && !control.dirty)) {
    return [];
  }

  const messagesForField = this.errorMessages[controlName] ?? {};
  return Object.keys(control.errors)
    .map(key => messagesForField[key])
    .filter((msg): msg is string => !!msg);
}

📝 Remarquez que nous vérifions touched et dirty états avant de rechercher une erreur. Nous ne voulons pas d’un formulaire vierge affichant des messages d’erreur avant toute saisie ou avant que le contrôle du formulaire n’ait été ciblé.

Soumettre

Pour la soumission, nous alertons simplement les données à des fins de test.

onSubmit(): void {

  if (this.profileForm.valid) {
    alert('Profile Data: ' + JSON.stringify(this.profileForm.value));
  } else {
    alert('Form is invalid');
  }
}

Modèle

Le modèle est simple. Nous lions notre formGroup au formulaire, poignée onSubmitliez les contrôles de formulaire individuels à formControlNameet gérer les erreurs avec getErrors('name') en utilisant notre composant d’erreur personnalisé.

<form [formGroup]="profileForm" (ngSubmit)="onSubmit()" class="space-y-4 p-4 max-w-md mx-auto">
    <div>
        <label class="block mb-1 font-semibold">First Name</label>
        <input type="text" formControlName="firstName" class="w-full border p-2 rounded" />
        <app-show-errors [errors]="getErrors('firstName')" />
    </div>

    <div>
        <label class="block mb-1 font-semibold">Last Name</label>
        <input type="text" formControlName="lastName" class="w-full border p-2 rounded" />
        <app-show-errors [errors]="getErrors('lastName')" />
    </div>

    <div>
        <label class="block mb-1 font-semibold">Biography</label>
        <textarea #bio formControlName="biograph" rows="3" class="w-full border p-2 rounded"></textarea>
        <app-show-errors [errors]="getErrors('biograph')" />
    </div>

    <div>
        <label class="block mb-1 font-semibold">Phone Number</label>
        <input type="tel" formControlName="phoneNumber" placeholder="+1 555-123-4567"
            class="w-full border p-2 rounded" />
        <app-show-errors [errors]="getErrors('phoneNumber')" />
    </div>

    <div>
        <label class="block mb-1 font-semibold">Username</label>
        <input type="text" formControlName="username" class="w-full border p-2 rounded" />
        <app-show-errors [errors]="getErrors('username')" />
    </div>

    <div>
        <label class="block mb-1 font-semibold">Birthday</label>
        <input type="date" formControlName="birthday" class="w-full border p-2 rounded" />
        <app-show-errors [errors]="getErrors('birthday')" />
    </div>

    <div>
        <label class="block mb-1 font-semibold">Password</label>
        <input type="password" formControlName="password" class="w-full border p-2 rounded" />
        <app-show-errors [errors]="getErrors('password')" />
    </div>

    <div>
        <label class="block mb-1 font-semibold">Confirm Password</label>
        <input type="password" formControlName="confirmPassword" class="w-full border p-2 rounded" />
        <app-show-errors [errors]="getErrors('confirmPassword')" />
    </div>

    <button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
        [disabled]="profileForm.invalid">
        Save Profile
    </button>
    <p>HTML Form Validation: {{ bio.getAttribute('maxlength') === '200' }}</p>
</form>

📝 J’ai aussi tagué notre #bio champ afin que nous puissions vérifier la synchronisation de la validation HTML. Il n’existe pas dans la version Reactive Forms. getAttribute est une fonction HTML native.

Version des signaux

Il faut importer le Field contrôle ainsi que notre ShowErrors composant personnalisé.

  imports: [Field, ShowErrors],

📝 Field est précédé de Controlqui n’existe plus dans la dernière version.

La version signal comporte toutes les mêmes parties, mais la mise en œuvre est complètement différente.

Validateur de téléphone

La version signal utilise des fonctions telles que pattern pour valider nos données. Encore une fois, j’ai choisi d’utiliser cette fonction et de la personnaliser. je dois revenir customError avec la bonne frappe.

export function phoneNumber(
  field: Parameters<typeof pattern>[0],
  opts?: { message?: string }
) {
  return pattern(field, /^\+?[0-9\s-]+$/, {
    error: customError({
      kind: 'phoneNumber',
      message: opts?.message ?? 'Invalid phone number format.'
    })
  });
}

Validateur de correspondance

Notre validateur de mot de passe est beaucoup plus simple. Nous n’avons besoin de l’exécuter que sur une seule fonction en utilisant le validate fonction, car elle sera réexécutée de toute façon à chaque frappe.

export function matchField<T>(
  field: Parameters<typeof validate<T>>[0],
  matchToField: Parameters<typeof validate<T>>[0],
  opts?: {
    message?: string;
  }
) {
  return validate(field, (ctx) => {

    const thisVal = ctx.value();
    const otherVal = ctx.valueOf(matchToField);

    if (thisVal === otherVal) {
      return null;
    }

    return customError({
      kind: 'matching',
      message: opts?.message ?? 'Values must match.'
    });
  });
}

Validateur de nom d’utilisateur

Notre validateur de nom d’utilisateur était beaucoup plus complexe, inutilement plus complexe à mon avis. Cela vous oblige à utiliser validateAsyncce qui nécessite lui-même un resource fonction avec un chargeur et des paramètres. J’ai également ajouté un délai d’attente pour gérer la saisie rapide. Nous utilisons notre checkUsername fonction de jeton.

export function usernameAvailable(
  field: Parameters<typeof pattern>[0],
  delayMs = 400,
  opts?: { message?: string }
) {

  const checkUsername = inject(USERNAME_VALIDATOR);

  return validateAsync(field, {
    params: (ctx) => ({
      value: ctx.value()
    }),
    factory: (params) => {
      let timer: ReturnType<typeof setTimeout>;
      return resource({
        params,
        loader: async (p) => {
          const value = p.params.value;
          clearTimeout(timer);
          return new Promise<boolean>((resolve) => {
            timer = setTimeout(async () => {
              const available = await checkUsername(value);
              resolve(available);
            }, delayMs);
          });
        }
      })
    },
    errors: (result) => {
      if (!result) {
        return {
          kind: 'taken',
          message: opts?.message ?? 'This username is already taken.'
        };
      }
      return null;
    }
  });
}

📝 C’est cela qui m’a pris le plus de temps pour réussir.

Création du schéma

Au lieu d’un groupe de formulaires, les formulaires de signal utilisent un schéma typé.

type Profile = {
  firstName: string;
  lastName: string;
  biograph: string;
  phoneNumber: string;
  username: string;
  birthday: string;
  password: string;
  confirmPassword: string;
};

const profileSchema = schema<Profile>((p) => {
  required(p.firstName, {
    message: 'First name is required.'
  });
  minLength(p.firstName, 2, {
    message: 'First name must be at least 2 characters.'
  });
  required(p.lastName, {
    message: 'Last name is required.'
  });
  minLength(p.lastName, 2, {
    message: 'Last name must be at least 2 characters.'
  });
  maxLength(p.biograph, 200, {
    message: 'Biography cannot exceed 200 characters.'
  });
  required(p.username, {
    message: 'Username is required.'
  });
  minLength(p.username, 3, {
    message: 'Username must be at least 3 characters.'
  });
  required(p.birthday, {
    message: 'Birthday is required.'
  });
  required(p.phoneNumber, {
    message: 'Phone number is required.'
  });
  required(p.password, {
    message: 'Password is required.'
  });
  required(p.confirmPassword, {
    message: 'Confirm password is required.'
  });
  phoneNumber(p.phoneNumber, {
    message: 'Enter a valid phone number.'
  });
  matchField(p.confirmPassword, p.password, {
    message: 'Passwords must match.'
  });
  usernameAvailable(p.username, 400, {
    message: 'This username is already taken.'
  });
});

C’est bien car cela rend les choses claires, mais c’est aussi trop verbeux. Nous saisissons nos messages d’erreur directement dans les validateurs de fonctions, ce qui nous aide à éviter un objet JSON superflu.

private initial = signal<Profile>({
  firstName: '',
  lastName: '',
  biograph: '',
  phoneNumber: '',
  username: '',
  birthday: '',
  password: '',
  confirmPassword: ''
});

profileForm = form(this.initial, profileSchema);

Nous avons besoin d’un signal réel pour gérer les changements.

Test d’erreur

Notre fonction d’erreur est similaire et nous mappons les erreurs sur nos champs.

getErrors(controlName: keyof typeof this.profileForm): string[] {

  const field = this.profileForm[controlName];

  const state = field();

  // Only show errors after user interaction
  if (!state.touched() && !state.dirty()) return [];

  const errors = state.errors();
  if (!errors) return [];

  return errors
    .map(err => err.message ?? err.kind ?? 'Invalid')
    .filter(Boolean);
}

Modèle de signal

Notre modèle est très similaire, mais nous utilisons field pour lier nos valeurs de formulaire.

<form (submit)="$event.preventDefault(); onSubmit()" class="space-y-4 p-4 max-w-md mx-auto">
  <div>
    <label class="block mb-1 font-semibold">First Name</label>
    <input type="text" [field]="profileForm.firstName" class="w-full border p-2 rounded" />
    <app-show-errors [errors]="getErrors('firstName')" />
  </div>

  <div>
    <label class="block mb-1 font-semibold">Last Name</label>
    <input type="text" [field]="profileForm.lastName" class="w-full border p-2 rounded" />
    <app-show-errors [errors]="getErrors('lastName')" />
  </div>

  <div>
    <label class="block mb-1 font-semibold">Biography</label>
    <textarea #bio rows="3" [field]="profileForm.biograph" class="w-full border p-2 rounded"></textarea>
    <app-show-errors [errors]="getErrors('biograph')" />
  </div>

  <div>
    <label class="block mb-1 font-semibold">Phone Number</label>
    <input type="tel" placeholder="+1 555-123-4567" [field]="profileForm.phoneNumber"
      class="w-full border p-2 rounded" />
    <app-show-errors [errors]="getErrors('phoneNumber')" />
  </div>

  <div>
    <label class="block mb-1 font-semibold">Username</label>
    <input type="text" [field]="profileForm.username" class="w-full border p-2 rounded" />
    <app-show-errors [errors]="getErrors('username')" />
  </div>

  <div>
    <label class="block mb-1 font-semibold">Birthday</label>
    <input type="date" [field]="profileForm.birthday" class="w-full border p-2 rounded" />
    <app-show-errors [errors]="getErrors('birthday')" />
  </div>

  <div>
    <label class="block mb-1 font-semibold">Password</label>
    <input type="password" [field]="profileForm.password" class="w-full border p-2 rounded" />
    <app-show-errors [errors]="getErrors('password')" />
  </div>

  <div>
    <label class="block mb-1 font-semibold">Confirm Password</label>
    <input type="password" [field]="profileForm.confirmPassword" class="w-full border p-2 rounded" />
    <app-show-errors [errors]="getErrors('confirmPassword')" />
  </div>

  <button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
    [disabled]="profileForm().invalid()">
    Save Profile
  </button>
  <p>HTML Form Validation: {{ bio.getAttribute('maxlength') === '200' }}</p>
</form>

Et c’est tout !

Lequel préfères-tu ?

Dépôt : GitHub
Démo : Fonctions Vercel




Source link
Quitter la version mobile