Cours Angular · Module 8: Formulaires
Les formulaires dans Angular - L'art de la calligraphie
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. Que représente FormControl dans les Reactive Forms ?
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.