Cours Angular · Module 8: Formulaires

Les formulaires typés (Angular 14+)

1 min de lecture
Dans cette leçon2

Les formulaires typés offrent une sécurité de type complète - comme les traits précis d'un maître calligraphe !

Définir les types

1import { FormControl, FormGroup, NonNullableFormBuilder } from '@angular/forms';
2
3// Interface du formulaire
4interface SamuraiForm {
5  name: FormControl<string>;
6  level: FormControl<number>;
7  clan: FormControl<string>;
8  skills: FormControl<string[]>;
9}
10
11@Component({...})
12export class TypedFormComponent {
13  // NonNullableFormBuilder - les contrôles ne peuvent pas être null
14  private fb = inject(NonNullableFormBuilder);
15
16  samuraiForm: FormGroup<SamuraiForm> = this.fb.group({
17    name: ['', Validators.required],
18    level: [1, Validators.min(1)],
19    clan: ['Ronin'],
20    skills: [['Kendo']]
21  });
22
23  onSubmit(): void {
24    // la valeur est entièrement typée !
25    const formValue = this.samuraiForm.getRawValue();
26    // Type : { name: string, level: number, clan: string, skills: string[] }
27
28    console.log(formValue.name);  // string, pas string | null
29  }
30}

FormControl vs FormGroup vs FormArray

1import { FormControl, FormGroup, FormArray } from '@angular/forms';
2
3// FormControl - valeur unique
4const nameControl = new FormControl<string>('');
5
6// FormGroup - objet avec des champs
7const samuraiGroup = new FormGroup({
8  name: new FormControl(''),
9  level: new FormControl(1)
10});
11
12// FormArray - tableau de contrôles
13const skillsArray = new FormArray([
14  new FormControl('Kendo'),
15  new FormControl('Iaido')
16]);
17
18// Accès aux valeurs
19console.log(nameControl.value);       // string
20console.log(samuraiGroup.value);      // { name: string, level: number }
21console.log(skillsArray.value);       // string[]
Code de cette leçon : src/app/app.component.ts
1import { Component, signal } from '@angular/core';
2import { CommonModule } from '@angular/common';
3import { ReactiveFormsModule, FormBuilder, FormGroup, Validators, AbstractControl, ValidationErrors, AsyncValidatorFn } from '@angular/forms';
4import { Observable, of, delay, map } from 'rxjs';
5
6@Component({
7  selector: 'app-root',
8  standalone: true,
9  imports: [CommonModule, ReactiveFormsModule],
10  template: `
11    <div class="registration-dojo">
12      <h1>Inscription à l’Académie des samouraïs</h1>
13
14      <form [formGroup]="registrationForm" (ngSubmit)="onSubmit()">
15
16        <div class="form-field">
17          <label>Nom du guerrier *</label>
18          <input formControlName="username" placeholder="Nom unique...">
19
20          @if (registrationForm.get('username')?.pending) {
21            <span class="checking">Vérification de la disponibilité...</span>
22          }
23
24          @if (registrationForm.get('username')?.touched && registrationForm.get('username')?.errors) {
25            <div class="error-box">
26              @if (registrationForm.get('username')?.errors?.['required']) {
27                <span>Le nom est obligatoire</span>
28              }
29              @if (registrationForm.get('username')?.errors?.['minlength']) {
30                <span>4 caractères minimum</span>
31              }
32              @if (registrationForm.get('username')?.errors?.['usernameTaken']) {
33                <span>Ce nom est déjà pris !</span>
34              }
35            </div>
36          }
37
38          @if (registrationForm.get('username')?.valid) {
39            <span class="valid-mark">Nom disponible</span>
40          }
41        </div>
42
43        <div class="form-field">
44          <label>E-mail *</label>
45          <input type="email" formControlName="email" placeholder="ton@email.com">
46
47          @if (registrationForm.get('email')?.touched && registrationForm.get('email')?.errors) {
48            <div class="error-box">
49              @if (registrationForm.get('email')?.errors?.['required']) {
50                <span>L’e-mail est obligatoire</span>
51              }
52              @if (registrationForm.get('email')?.errors?.['email']) {
53                <span>Format d’e-mail invalide</span>
54              }
55            </div>
56          }
57        </div>
58
59        <div class="form-field" formGroupName="passwords">
60          <label>Mot de passe *</label>
61          <input type="password" formControlName="password" placeholder="Mot de passe robuste...">
62
63          @if (registrationForm.get('passwords.password')?.touched) {
64            <div class="password-strength">
65              <div class="strength-bar" [style.width.%]="passwordStrength()">
66              </div>
67              <span>Robustesse : {{ passwordStrengthLabel() }}</span>
68            </div>
69          }
70
71          <label>Répète le mot de passe *</label>
72          <input type="password" formControlName="confirmPassword" placeholder="Répète le mot de passe...">
73
74          @if (registrationForm.get('passwords')?.errors?.['passwordMismatch'] &&
75               registrationForm.get('passwords.confirmPassword')?.touched) {
76            <div class="error-box">
77              <span>Les mots de passe ne correspondent pas !</span>
78            </div>
79          }
80        </div>
81
82        <div class="form-field">
83          <label class="checkbox-label">
84            <input type="checkbox" formControlName="acceptTerms">
85            J’accepte le code du Bushido (conditions d’utilisation) *
86          </label>
87
88          @if (registrationForm.get('acceptTerms')?.touched &&
89               registrationForm.get('acceptTerms')?.errors?.['requiredTrue']) {
90            <div class="error-box">
91              <span>Tu dois accepter les conditions d’utilisation</span>
92            </div>
93          }
94        </div>
95
96        <div class="form-summary">
97          <h3>État du formulaire :</h3>
98          <ul>
99            <li [class.valid]="registrationForm.get('username')?.valid">
100              Nom : {{ registrationForm.get('username')?.valid ? 'OK' : 'X' }}
101            </li>
102            <li [class.valid]="registrationForm.get('email')?.valid">
103              E-mail : {{ registrationForm.get('email')?.valid ? 'OK' : 'X' }}
104            </li>
105            <li [class.valid]="registrationForm.get('passwords')?.valid">
106              Mots de passe : {{ registrationForm.get('passwords')?.valid ? 'OK' : 'X' }}
107            </li>
108            <li [class.valid]="registrationForm.get('acceptTerms')?.valid">
109              Conditions : {{ registrationForm.get('acceptTerms')?.valid ? 'OK' : 'X' }}
110            </li>
111          </ul>
112        </div>
113
114        <button type="submit" [disabled]="registrationForm.invalid || registrationForm.pending">
115          {{ registrationForm.pending ? 'Vérification...' : 'Rejoindre l’Académie' }}
116        </button>
117      </form>
118
119      @if (registered()) {
120        <div class="success-message">
121          <h2>Bienvenue à l’Académie, {{ registrationForm.get('username')?.value }} !</h2>
122          <p>Ton aventure de samouraï commence !</p>
123        </div>
124      }
125    </div>
126  `,
127  styles: [`
128    .registration-dojo {
129      font-family: 'Segoe UI', sans-serif;
130      padding: 20px;
131      background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
132      min-height: 100vh;
133      color: #eee;
134    }
135
136    h1 {
137      text-align: center;
138      color: #e94560;
139    }
140
141    form {
142      max-width: 500px;
143      margin: 0 auto;
144      background: rgba(255,255,255,0.1);
145      padding: 30px;
146      border-radius: 15px;
147    }
148
149    .form-field {
150      margin-bottom: 20px;
151    }
152
153    .form-field label {
154      display: block;
155      margin-bottom: 8px;
156      color: #e94560;
157      font-weight: bold;
158    }
159
160    .form-field input[type="text"],
161    .form-field input[type="email"],
162    .form-field input[type="password"] {
163      width: 100%;
164      padding: 12px;
165      border: 2px solid #0f3460;
166      border-radius: 8px;
167      background: #0f3460;
168      color: white;
169      font-size: 16px;
170      box-sizing: border-box;
171      margin-bottom: 5px;
172    }
173
174    .form-field input:focus {
175      border-color: #e94560;
176      outline: none;
177    }
178
179    .form-field input.ng-valid.ng-touched {
180      border-color: #28a745;
181    }
182
183    .form-field input.ng-invalid.ng-touched {
184      border-color: #dc3545;
185    }
186
187    .checking {
188      color: #ffc107;
189      font-size: 0.9em;
190    }
191
192    .valid-mark {
193      color: #28a745;
194      font-size: 0.9em;
195    }
196
197    .error-box {
198      background: rgba(220, 53, 69, 0.2);
199      border: 1px solid #dc3545;
200      border-radius: 5px;
201      padding: 8px;
202      margin-top: 5px;
203    }
204
205    .error-box span {
206      display: block;
207      color: #fca5a5;
208      font-size: 0.85em;
209    }
210
211    .password-strength {
212      margin: 10px 0;
213    }
214
215    .strength-bar {
216      height: 8px;
217      background: linear-gradient(90deg, #dc3545 0%, #ffc107 50%, #28a745 100%);
218      border-radius: 4px;
219      transition: width 0.3s;
220    }
221
222    .checkbox-label {
223      display: flex;
224      align-items: center;
225      gap: 10px;
226      cursor: pointer;
227    }
228
229    .checkbox-label input {
230      width: 20px;
231      height: 20px;
232    }
233
234    .form-summary {
235      background: rgba(0,0,0,0.3);
236      padding: 15px;
237      border-radius: 8px;
238      margin: 20px 0;
239    }
240
241    .form-summary h3 {
242      margin-top: 0;
243      color: #e94560;
244    }
245
246    .form-summary ul {
247      list-style: none;
248      padding: 0;
249    }
250
251    .form-summary li {
252      padding: 5px 0;
253      color: #888;
254    }
255
256    .form-summary li.valid {
257      color: #28a745;
258    }
259
260    button[type="submit"] {
261      width: 100%;
262      padding: 15px;
263      background: #e94560;
264      color: white;
265      border: none;
266      border-radius: 8px;
267      font-size: 18px;
268      cursor: pointer;
269      transition: background 0.3s;
270    }
271
272    button[type="submit"]:hover:not(:disabled) {
273      background: #ff6b6b;
274    }
275
276    button[type="submit"]:disabled {
277      opacity: 0.5;
278      cursor: not-allowed;
279    }
280
281    .success-message {
282      text-align: center;
283      background: rgba(40, 167, 69, 0.2);
284      border: 2px solid #28a745;
285      border-radius: 15px;
286      padding: 30px;
287      margin-top: 20px;
288      max-width: 500px;
289      margin-left: auto;
290      margin-right: auto;
291    }
292
293    .success-message h2 {
294      color: #28a745;
295    }
296  `]
297})
298export class AppComponent {
299  registrationForm: FormGroup;
300  registered = signal(false);
301
302  // Simulation des noms déjà pris
303  private takenUsernames = ['musashi', 'kojiro', 'yagyu', 'admin', 'samurai'];
304
305  constructor(private fb: FormBuilder) {
306    this.registrationForm = this.fb.group({
307      username: ['',
308        [Validators.required, Validators.minLength(4)],
309        [this.usernameAvailableValidator()]
310      ],
311      email: ['', [Validators.required, Validators.email]],
312      passwords: this.fb.group({
313        password: ['', [Validators.required, Validators.minLength(8)]],
314        confirmPassword: ['', Validators.required]
315      }, { validators: this.passwordMatchValidator }),
316      acceptTerms: [false, Validators.requiredTrue]
317    });
318  }
319
320  // Validateur asynchrone - vérifie si le nom est disponible
321  usernameAvailableValidator(): AsyncValidatorFn {
322    return (control: AbstractControl): Observable<ValidationErrors | null> => {
323      if (!control.value) {
324        return of(null);
325      }
326
327      // Simulation d’une requête API
328      return of(control.value).pipe(
329        delay(1000),
330        map(username => {
331          const isTaken = this.takenUsernames.includes(username.toLowerCase());
332          return isTaken ? { usernameTaken: true } : null;
333        })
334      );
335    };
336  }
337
338  // Validateur inter-champs
339  passwordMatchValidator(control: AbstractControl): ValidationErrors | null {
340    const password = control.get('password')?.value;
341    const confirmPassword = control.get('confirmPassword')?.value;
342
343    if (password && confirmPassword && password !== confirmPassword) {
344      return { passwordMismatch: true };
345    }
346    return null;
347  }
348
349  passwordStrength(): number {
350    const password = this.registrationForm.get('passwords.password')?.value || '';
351    let strength = 0;
352
353    if (password.length >= 8) strength += 25;
354    if (password.length >= 12) strength += 15;
355    if (/[A-Z]/.test(password)) strength += 20;
356    if (/[a-z]/.test(password)) strength += 10;
357    if (/[0-9]/.test(password)) strength += 15;
358    if (/[^A-Za-z0-9]/.test(password)) strength += 15;
359
360    return Math.min(strength, 100);
361  }
362
363  passwordStrengthLabel(): string {
364    const strength = this.passwordStrength();
365    if (strength < 30) return 'Faible';
366    if (strength < 60) return 'Moyenne';
367    if (strength < 80) return 'Bonne';
368    return 'Très élevée';
369  }
370
371  onSubmit(): void {
372    if (this.registrationForm.valid) {
373      this.registered.set(true);
374    }
375  }
376}

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Que fournissent les Typed Forms (Angular 14+) ?

  2. 2. Que garantit l'utilisation de NonNullableFormBuilder ?

Voici 2 des 6 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Tri vertical

    Ordonne les blocs de construction d'un formulaire du plus petit au plus grand (d'une seule valeur à un tableau de groupes).

  • Éditeur de code

    Complète le template qui affiche les erreurs de validation seulement quand le champ a été touché.

  • Clique dans l'ordre

    Construis la définition du champ avec des validateurs : ['', [Validators.required, Validators.email]]. Clique sur les éléments dans l'ordre.

Articles utiles