Cours Angular · Module 8: Formulaires

Les formulaires dans Angular - L'art de la calligraphie

2 min de lecture
Dans cette leçon2

Les formulaires dans Angular sont comme l'art de la calligraphie - ils exigent précision et élégance, et proposent deux approches principales. Chaque coup de pinceau (champ de formulaire) doit être exact !

Deux approches pour les formulaires

Les formulaires pilotés par le template (Template-driven Forms)

  • Déclaratifs, dans le template HTML
  • Plus simples, moins de code TypeScript
  • Adaptés aux formulaires simples

Les Reactive Forms (recommandés)

  • Programmatiques, dans le code TypeScript
  • Contrôle total et testabilité
  • Préférables pour les formulaires complexes

Les Reactive Forms - les bases

1import { Component, inject } from '@angular/core';
2import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
3
4@Component({
5  selector: 'app-samurai-form',
6  standalone: true,
7  imports: [ReactiveFormsModule],
8  template: `
9    <form [formGroup]="samuraiForm" (ngSubmit)="onSubmit()">
10      <div class="field">
11        <label for="name">Samurai Name</label>
12        <input id="name" formControlName="name" placeholder="Musashi">
13        @if (samuraiForm.get('name')?.errors?.['required'] &&
14             samuraiForm.get('name')?.touched) {
15          <span class="error">Name is required</span>
16        }
17        @if (samuraiForm.get('name')?.errors?.['minlength']) {
18          <span class="error">Minimum 2 characters</span>
19        }
20      </div>
21
22      <div class="field">
23        <label for="level">Level</label>
24        <input id="level" formControlName="level" type="number">
25        @if (samuraiForm.get('level')?.errors?.['min']) {
26          <span class="error">Minimum level 1</span>
27        }
28      </div>
29
30      <div class="field">
31        <label for="clan">Clan</label>
32        <select id="clan" formControlName="clan">
33          <option value="">Select a clan</option>
34          <option value="tokugawa">Tokugawa</option>
35          <option value="oda">Oda</option>
36          <option value="takeda">Takeda</option>
37        </select>
38      </div>
39
40      <button type="submit" [disabled]="samuraiForm.invalid">
41        Save Samurai
42      </button>
43
44      <p>Status: {{ samuraiForm.valid ? 'Valid' : 'Invalid' }}</p>
45    </form>
46  `
47})
48export class SamuraiFormComponent {
49  private fb = inject(FormBuilder);
50
51  samuraiForm = this.fb.group({
52    name: ['', [Validators.required, Validators.minLength(2)]],
53    level: [1, [Validators.required, Validators.min(1), Validators.max(100)]],
54    clan: ['']
55  });
56
57  onSubmit(): void {
58    if (this.samuraiForm.valid) {
59      console.log('Samurai data:', this.samuraiForm.value);
60      // { name: 'Musashi', level: 10, clan: 'tokugawa' }
61    }
62  }
63}

Au lieu de samuraiForm.get('name')?.errors?.['required'], tu peux interroger le contrôle directement avec hasError() : l'appel samuraiForm.get('name')?.hasError('required') renvoie true quand le champ a une erreur de ce nom. On le combine le plus souvent avec l'indicateur touched, pour que le message n'apparaisse qu'une fois que l'utilisateur a quitté le champ :

1@if (samuraiForm.get('name')?.hasError('required') && samuraiForm.get('name')?.touched) {
2  <span class="error">Le nom est obligatoire</span>
3}

Dès que tu saisis un nom, hasError('required') renvoie false et le message disparaît.

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, FormArray, Validators, AbstractControl, ValidationErrors } from '@angular/forms';
4
5@Component({
6  selector: 'app-root',
7  standalone: true,
8  imports: [CommonModule, ReactiveFormsModule],
9  template: `
10    <div class="calligraphy-school">
11      <h1>École de calligraphie - les formulaires Angular</h1>
12
13      <form [formGroup]="samuraiForm" (ngSubmit)="onSubmit()">
14        <section class="basic-info">
15          <h2>Données du samouraï</h2>
16
17          <div class="form-group">
18            <label for="name">Nom du guerrier *</label>
19            <input id="name" formControlName="name" placeholder="Saisis un nom...">
20            @if (samuraiForm.get('name')?.touched && samuraiForm.get('name')?.errors) {
21              <div class="error">
22                @if (samuraiForm.get('name')?.errors?.['required']) {
23                  Le nom est obligatoire
24                }
25                @if (samuraiForm.get('name')?.errors?.['minlength']) {
26                  3 caractères minimum
27                }
28              </div>
29            }
30          </div>
31
32          <div class="form-group">
33            <label for="clan">Clan</label>
34            <select id="clan" formControlName="clan">
35              <option value="">Choisis un clan...</option>
36              <option value="tokugawa">Tokugawa</option>
37              <option value="takeda">Takeda</option>
38              <option value="oda">Oda</option>
39              <option value="shimazu">Shimazu</option>
40            </select>
41          </div>
42
43          <div class="form-group">
44            <label for="age">Âge (18-80)</label>
45            <input id="age" type="number" formControlName="age">
46            @if (samuraiForm.get('age')?.touched && samuraiForm.get('age')?.errors) {
47              <div class="error">
48                @if (samuraiForm.get('age')?.errors?.['min']) {
49                  18 ans minimum
50                }
51                @if (samuraiForm.get('age')?.errors?.['max']) {
52                  80 ans maximum
53                }
54              </div>
55            }
56          </div>
57        </section>
58
59        <section class="skills-section" formGroupName="skills">
60          <h2>Compétences</h2>
61
62          <div class="skill-sliders">
63            <div class="skill">
64              <label>Sabre : {{ samuraiForm.get('skills.sword')?.value }}</label>
65              <input type="range" min="0" max="100" formControlName="sword">
66            </div>
67
68            <div class="skill">
69              <label>Arc : {{ samuraiForm.get('skills.bow')?.value }}</label>
70              <input type="range" min="0" max="100" formControlName="bow">
71            </div>
72
73            <div class="skill">
74              <label>Stratégie : {{ samuraiForm.get('skills.strategy')?.value }}</label>
75              <input type="range" min="0" max="100" formControlName="strategy">
76            </div>
77          </div>
78
79          <div class="total-skills">
80            Total des points : {{ totalSkillPoints() }} / 200
81            @if (samuraiForm.errors?.['skillsLimit']) {
82              <span class="error">Limite dépassée !</span>
83            }
84          </div>
85        </section>
86
87        <section class="weapons-section">
88          <h2>Armement (FormArray)</h2>
89
90          <div formArrayName="weapons">
91            @for (weapon of weapons.controls; track $index; let i = $index) {
92              <div class="weapon-row" [formGroupName]="i">
93                <input formControlName="name" placeholder="Nom de l’arme">
94                <select formControlName="type">
95                  <option value="melee">Corps à corps</option>
96                  <option value="ranged">À distance</option>
97                </select>
98                <input type="number" formControlName="damage" placeholder="Dégâts">
99                <button type="button" class="remove-btn" (click)="removeWeapon(i)">×</button>
100              </div>
101            }
102          </div>
103
104          <button type="button" class="add-btn" (click)="addWeapon()">
105            + Ajouter une arme
106          </button>
107        </section>
108
109        <section class="form-actions">
110          <div class="status">
111            <span [class.valid]="samuraiForm.valid" [class.invalid]="samuraiForm.invalid">
112              Formulaire : {{ samuraiForm.valid ? 'Valide' : 'Invalide' }}
113            </span>
114          </div>
115
116          <button type="submit" [disabled]="samuraiForm.invalid">
117            Enregistrer le samouraï
118          </button>
119
120          <button type="button" (click)="resetForm()">
121            Effacer
122          </button>
123        </section>
124      </form>
125
126      @if (submittedData()) {
127        <section class="submitted">
128          <h2>Données enregistrées :</h2>
129          <pre>{{ submittedData() | json }}</pre>
130        </section>
131      }
132    </div>
133  `,
134  styles: [`
135    .calligraphy-school {
136      font-family: 'Segoe UI', sans-serif;
137      padding: 20px;
138      background: linear-gradient(135deg, #f5f0e8 0%, #e8dcc8 100%);
139      min-height: 100vh;
140    }
141
142    h1 {
143      text-align: center;
144      color: #8b4513;
145      text-shadow: 1px 1px 2px rgba(0,0,0,0.2);
146    }
147
148    h2 {
149      color: #8b4513;
150      border-bottom: 2px solid #8b4513;
151      padding-bottom: 5px;
152    }
153
154    form {
155      max-width: 600px;
156      margin: 0 auto;
157    }
158
159    section {
160      background: white;
161      padding: 20px;
162      border-radius: 10px;
163      margin: 15px 0;
164      box-shadow: 0 2px 10px rgba(0,0,0,0.1);
165    }
166
167    .form-group {
168      margin-bottom: 15px;
169    }
170
171    .form-group label {
172      display: block;
173      margin-bottom: 5px;
174      font-weight: bold;
175      color: #5a3d2b;
176    }
177
178    .form-group input,
179    .form-group select {
180      width: 100%;
181      padding: 10px;
182      border: 2px solid #ddd;
183      border-radius: 5px;
184      font-size: 16px;
185      box-sizing: border-box;
186    }
187
188    .form-group input:focus,
189    .form-group select:focus {
190      border-color: #8b4513;
191      outline: none;
192    }
193
194    .form-group input.ng-invalid.ng-touched {
195      border-color: #dc3545;
196    }
197
198    .error {
199      color: #dc3545;
200      font-size: 0.85em;
201      margin-top: 5px;
202    }
203
204    .skill-sliders {
205      display: flex;
206      flex-direction: column;
207      gap: 15px;
208    }
209
210    .skill {
211      display: flex;
212      align-items: center;
213      gap: 10px;
214    }
215
216    .skill label {
217      min-width: 120px;
218    }
219
220    .skill input[type="range"] {
221      flex: 1;
222    }
223
224    .total-skills {
225      margin-top: 15px;
226      padding: 10px;
227      background: #f8f4f0;
228      border-radius: 5px;
229      text-align: center;
230    }
231
232    .weapon-row {
233      display: flex;
234      gap: 10px;
235      margin-bottom: 10px;
236    }
237
238    .weapon-row input,
239    .weapon-row select {
240      padding: 8px;
241      border: 1px solid #ddd;
242      border-radius: 4px;
243    }
244
245    .weapon-row input[type="text"] {
246      flex: 2;
247    }
248
249    .weapon-row input[type="number"] {
250      width: 80px;
251    }
252
253    .remove-btn {
254      background: #dc3545;
255      color: white;
256      border: none;
257      border-radius: 4px;
258      cursor: pointer;
259      padding: 8px 12px;
260    }
261
262    .add-btn {
263      background: #28a745;
264      color: white;
265      border: none;
266      padding: 10px 20px;
267      border-radius: 5px;
268      cursor: pointer;
269      margin-top: 10px;
270    }
271
272    .form-actions {
273      display: flex;
274      gap: 15px;
275      align-items: center;
276      flex-wrap: wrap;
277    }
278
279    .status {
280      flex: 1;
281    }
282
283    .status .valid {
284      color: #28a745;
285    }
286
287    .status .invalid {
288      color: #dc3545;
289    }
290
291    .form-actions button[type="submit"] {
292      background: #8b4513;
293      color: white;
294      border: none;
295      padding: 12px 24px;
296      border-radius: 5px;
297      cursor: pointer;
298      font-size: 16px;
299    }
300
301    .form-actions button[type="submit"]:disabled {
302      opacity: 0.5;
303      cursor: not-allowed;
304    }
305
306    .form-actions button[type="button"] {
307      background: #6c757d;
308      color: white;
309      border: none;
310      padding: 12px 24px;
311      border-radius: 5px;
312      cursor: pointer;
313    }
314
315    .submitted {
316      background: #d4edda;
317      border: 1px solid #c3e6cb;
318    }
319
320    .submitted pre {
321      background: white;
322      padding: 15px;
323      border-radius: 5px;
324      overflow: auto;
325    }
326  `]
327})
328export class AppComponent {
329  samuraiForm: FormGroup;
330  submittedData = signal<any>(null);
331
332  constructor(private fb: FormBuilder) {
333    this.samuraiForm = this.fb.group({
334      name: ['', [Validators.required, Validators.minLength(3)]],
335      clan: [''],
336      age: [25, [Validators.min(18), Validators.max(80)]],
337      skills: this.fb.group({
338        sword: [50],
339        bow: [30],
340        strategy: [40]
341      }, { validators: this.skillsLimitValidator }),
342      weapons: this.fb.array([])
343    });
344
345    // Ajoute une première arme
346    this.addWeapon();
347  }
348
349  get weapons(): FormArray {
350    return this.samuraiForm.get('weapons') as FormArray;
351  }
352
353  totalSkillPoints(): number {
354    const skills = this.samuraiForm.get('skills')?.value;
355    return (skills?.sword || 0) + (skills?.bow || 0) + (skills?.strategy || 0);
356  }
357
358  skillsLimitValidator(control: AbstractControl): ValidationErrors | null {
359    const skills = control.value;
360    const total = (skills?.sword || 0) + (skills?.bow || 0) + (skills?.strategy || 0);
361    return total > 200 ? { skillsLimit: true } : null;
362  }
363
364  addWeapon(): void {
365    const weaponGroup = this.fb.group({
366      name: ['', Validators.required],
367      type: ['melee'],
368      damage: [10, [Validators.min(1), Validators.max(100)]]
369    });
370    this.weapons.push(weaponGroup);
371  }
372
373  removeWeapon(index: number): void {
374    this.weapons.removeAt(index);
375  }
376
377  onSubmit(): void {
378    if (this.samuraiForm.valid) {
379      this.submittedData.set(this.samuraiForm.value);
380    }
381  }
382
383  resetForm(): void {
384    this.samuraiForm.reset({
385      name: '',
386      clan: '',
387      age: 25,
388      skills: { sword: 50, bow: 30, strategy: 40 }
389    });
390    this.weapons.clear();
391    this.addWeapon();
392    this.submittedData.set(null);
393  }
394}

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 représente FormControl dans les Reactive Forms ?

  2. 2. Quelle approche des formulaires dans Angular est recommandée pour les formulaires complexes ?

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

Tâches pratiques dans le jeu

  • Éditeur de code

    Complète le code qui crée un FormGroup pour l'inscription d'un samouraï.

  • Tri horizontal

    Ordonne les éléments de l'instruction d'import de ReactiveFormsModule (une seule ligne, de gauche à droite).

  • Tri horizontal

    Ordonne les éléments qui lient un champ input à un form control (une seule ligne).

  • Clique dans l'ordre

    Construis l'expression qui lit l'erreur 'required' du champ name : form.get('name')?.errors?.['required']. Clique sur les éléments dans l'ordre.

Articles utiles