Skip to content

Commit 4e6f783

Browse files
committed
feat(Answer:64): refactor to use signal form for FormGroup and FormArray
1 parent 543770b commit 4e6f783

3 files changed

Lines changed: 190 additions & 204 deletions

File tree

apps/forms/64-form-array/src/app/app.component.ts

Lines changed: 133 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -6,68 +6,43 @@ import {
66
WritableSignal,
77
} from '@angular/core';
88
import {
9-
AbstractControl,
10-
FormArray,
11-
FormControl,
12-
FormGroup,
13-
ReactiveFormsModule,
14-
Validators,
15-
} from '@angular/forms';
16-
import { ContactFormComponent } from './contact-form.component';
17-
18-
type ContactFormGroup = FormGroup<{
19-
firstname: FormControl<string>;
20-
lastname: FormControl<string>;
21-
relation: FormControl<string>;
22-
email: FormControl<string>;
23-
}>;
24-
25-
type EmailFormGroup = FormGroup<{
26-
type: FormControl<string>;
27-
email: FormControl<string>;
28-
}>;
9+
applyEach,
10+
email,
11+
form,
12+
FormField,
13+
FormRoot,
14+
required,
15+
SchemaPathTree,
16+
validate,
17+
} from '@angular/forms/signals';
2918

30-
type RegistrationForm = {
31-
name: FormControl<string>;
32-
pseudo: FormControl<string>;
33-
contacts: FormArray<ContactFormGroup>;
34-
emails: FormArray<EmailFormGroup>;
35-
};
36-
37-
type RegistrationValue = {
38-
name: string;
39-
pseudo: string;
40-
contacts: Array<{
41-
firstname: string;
42-
lastname: string;
43-
relation: string;
44-
email: string;
45-
}>;
46-
emails: Array<{
47-
type: string;
48-
email: string;
49-
}>;
50-
};
19+
import { ContactFormComponent } from './contact-form.component';
20+
import { Contact, Email, Registration } from './types';
5121

52-
export const minLengthArray = (min: number) => {
53-
return (c: AbstractControl) => {
54-
if (c.value.length >= min) return null;
22+
function ContactSchema(item: SchemaPathTree<Contact>) {
23+
required(item.firstName, { message: 'This field is required' });
24+
required(item.lastname, { message: 'This field is required' });
25+
required(item.relation, { message: 'This field is required' });
26+
required(item.email, { message: 'Email is required' });
27+
email(item.email, { message: 'Enter a valid email' });
28+
}
5529

56-
return { MinLengthArray: true };
57-
};
58-
};
30+
function EmailSchema(item: SchemaPathTree<Email>) {
31+
required(item.type, { message: 'This field is required' });
32+
required(item.email, { message: 'Email is required' });
33+
email(item.email, { message: 'Enter a valid email' });
34+
}
5935

6036
@Component({
6137
selector: 'app-root',
62-
imports: [ReactiveFormsModule, JsonPipe, ContactFormComponent],
38+
imports: [JsonPipe, ContactFormComponent, FormField, FormRoot],
6339
changeDetection: ChangeDetectionStrategy.OnPush,
6440
template: `
6541
<main class="min-h-screen bg-slate-50 text-slate-900">
6642
<div class="mx-auto max-w-5xl px-6 py-12">
6743
<h1 class="mb-6 text-3xl font-semibold">Registration form</h1>
6844
<form
69-
[formGroup]="form"
70-
(ngSubmit)="onSubmit()"
45+
[formRoot]="registrationForm"
7146
class="space-y-8 rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
7247
<section class="space-y-4">
7348
<h2 class="text-xl font-semibold">Profile</h2>
@@ -78,12 +53,14 @@ export const minLengthArray = (min: number) => {
7853
<input
7954
class="input"
8055
type="text"
81-
formControlName="name"
82-
required
83-
aria-required="true" />
56+
[formField]="registrationForm.name" />
8457
<span class="hint">
85-
@if (showError(form.controls.name)) {
86-
This field is required
58+
@if (
59+
registrationForm.name().invalid() &&
60+
(registrationForm.name().touched() ||
61+
registrationForm.name().dirty())
62+
) {
63+
{{ registrationForm.name().errors()[0].message }}
8764
}
8865
</span>
8966
</label>
@@ -93,12 +70,14 @@ export const minLengthArray = (min: number) => {
9370
<input
9471
class="input"
9572
type="text"
96-
formControlName="pseudo"
97-
required
98-
aria-required="true" />
73+
[formField]="registrationForm.pseudo" />
9974
<span class="hint">
100-
@if (showError(form.controls.pseudo)) {
101-
This field is required
75+
@if (
76+
registrationForm.pseudo().invalid() &&
77+
(registrationForm.pseudo().touched() ||
78+
registrationForm.pseudo().dirty())
79+
) {
80+
{{ registrationForm.pseudo().errors()[0]?.message }}
10281
}
10382
</span>
10483
</label>
@@ -116,17 +95,23 @@ export const minLengthArray = (min: number) => {
11695
</button>
11796
</div>
11897
119-
<div formArrayName="contacts" class="space-y-4">
120-
@for (contact of contacts.controls; track $index) {
98+
<div class="space-y-4">
99+
@for (contact of registrationForm.contacts; track contact) {
121100
<app-contact-form
122101
[group]="contact"
123102
[index]="$index"
124103
(remove)="removeContact($index)"></app-contact-form>
125104
}
126105
</div>
127106
128-
@if (contacts.invalid && (contacts.touched || contacts.dirty)) {
129-
<p class="hint">At least one contact is required.</p>
107+
@if (
108+
registrationForm.contacts().invalid() &&
109+
(registrationForm.contacts().touched() ||
110+
registrationForm.contacts().dirty())
111+
) {
112+
<p class="hint">
113+
{{ registrationForm.contacts().errors()[0]?.message }}
114+
</p>
130115
}
131116
</section>
132117
@@ -138,8 +123,8 @@ export const minLengthArray = (min: number) => {
138123
</button>
139124
</div>
140125
141-
<div formArrayName="emails" class="space-y-4">
142-
@for (email of emails.controls; track $index) {
126+
<div class="space-y-4">
127+
@for (emailField of registrationForm.emails; track emailField) {
143128
<div
144129
class="rounded-lg border border-slate-200 bg-slate-50/40 p-4"
145130
data-testid="email-item">
@@ -156,39 +141,44 @@ export const minLengthArray = (min: number) => {
156141
</button>
157142
</div>
158143
159-
<div
160-
class="mt-4 grid gap-4 sm:grid-cols-2"
161-
[formGroupName]="$index">
144+
<div class="mt-4 grid gap-4 sm:grid-cols-2">
162145
<label
163146
class="flex flex-col gap-1 text-sm font-medium text-slate-700">
164147
Type
165-
<select class="input" formControlName="type">
148+
<select class="input" [formField]="emailField.type">
166149
<option value="personal">Personal</option>
167150
<option value="professional">Professional</option>
168151
<option value="other">Other</option>
169152
</select>
170153
<span class="hint">
171-
@if (showError(email.controls.type)) {
172-
This field is required
154+
@if (
155+
emailField.type().invalid() &&
156+
(emailField.type().touched() ||
157+
emailField.type().dirty())
158+
) {
159+
{{ emailField.type().errors()[0]?.message }}
173160
}
174161
</span>
175162
</label>
163+
176164
<label
177165
class="flex flex-col gap-1 text-sm font-medium text-slate-700">
178166
Email
179167
<input
180168
class="input"
181169
type="email"
182-
formControlName="email"
183-
required
184-
aria-required="true" />
170+
[formField]="emailField.email" />
185171
<span class="hint">
186-
@if (showError(email.controls.email)) {
187-
@if (email.controls.email.hasError('required')) {
188-
Email is required
189-
}
190-
@if (email.controls.email.hasError('email')) {
191-
Enter a valid email
172+
@if (
173+
emailField.email().invalid() &&
174+
(emailField.email().touched() ||
175+
emailField.email().dirty())
176+
) {
177+
@for (
178+
err of emailField.email().errors();
179+
track err.kind
180+
) {
181+
{{ err.message }}
192182
}
193183
}
194184
</span>
@@ -202,8 +192,12 @@ export const minLengthArray = (min: number) => {
202192
<div
203193
class="flex flex-wrap items-center justify-between gap-4 border-t border-slate-200 pt-4">
204194
<div class="text-sm text-slate-600">
205-
<span [class.text-rose-600]="form.invalid">
206-
{{ form.invalid ? 'Form incomplete' : 'Ready to submit' }}
195+
<span [class.text-rose-600]="registrationForm().invalid()">
196+
{{
197+
registrationForm().invalid()
198+
? 'Form incomplete'
199+
: 'Ready to submit'
200+
}}
207201
</span>
208202
</div>
209203
<button type="submit" class="btn-primary">Submit</button>
@@ -246,87 +240,76 @@ export const minLengthArray = (min: number) => {
246240
],
247241
})
248242
export class AppComponent {
249-
readonly contacts = new FormArray<ContactFormGroup>([], {
250-
validators: [minLengthArray(1)],
243+
readonly formModel = signal<Registration>({
244+
name: '',
245+
pseudo: '',
246+
contacts: [],
247+
emails: [],
251248
});
252249

253-
readonly emails = new FormArray<EmailFormGroup>([]);
250+
readonly registrationForm = form(
251+
this.formModel,
252+
(schemePath) => {
253+
required(schemePath.name, { message: 'This field is required' });
254+
required(schemePath.pseudo, { message: 'This field is required' });
254255

255-
readonly form = new FormGroup<RegistrationForm>({
256-
name: new FormControl('', {
257-
nonNullable: true,
258-
validators: [Validators.required],
259-
}),
260-
pseudo: new FormControl('', {
261-
nonNullable: true,
262-
validators: [Validators.required],
263-
}),
264-
contacts: this.contacts,
265-
emails: this.emails,
266-
});
256+
applyEach(schemePath.contacts, ContactSchema);
257+
applyEach(schemePath.emails, EmailSchema);
258+
259+
validate(schemePath.contacts, ({ value }) => {
260+
if (value().length >= 1) return null;
267261

268-
submittedData: WritableSignal<RegistrationValue | null> = signal(null);
262+
return {
263+
kind: 'MinLengthArray',
264+
message: 'At least one contact is required.',
265+
};
266+
});
267+
},
268+
{
269+
submission: {
270+
action: async () => {
271+
return this.submittedData.set(this.formModel());
272+
},
273+
},
274+
},
275+
);
276+
277+
submittedData: WritableSignal<Registration | null> = signal(null);
269278

270279
addContact(): void {
271-
this.contacts.push(this.createContactGroup());
280+
this.formModel.update((model) => ({
281+
...model,
282+
contacts: [
283+
...model.contacts,
284+
{ firstName: '', lastname: '', relation: '', email: '' },
285+
],
286+
}));
272287
}
273288

274289
removeContact(index: number): void {
275-
this.contacts.removeAt(index);
290+
this.formModel.update((model) => ({
291+
...model,
292+
contacts: [
293+
...model.contacts.slice(0, index),
294+
...model.contacts.slice(index + 1),
295+
],
296+
}));
276297
}
277298

278299
addEmail(): void {
279-
this.emails.push(this.createEmailFormGroup());
300+
this.formModel.update((model) => ({
301+
...model,
302+
emails: [...model.emails, { type: 'personal', email: '' }],
303+
}));
280304
}
281305

282306
removeEmail(index: number): void {
283-
this.emails.removeAt(index);
284-
}
285-
286-
onSubmit(): void {
287-
this.form.markAllAsTouched();
288-
if (this.form.invalid) {
289-
return;
290-
}
291-
292-
this.submittedData.set(this.form.getRawValue());
293-
}
294-
295-
showError(control: FormControl<string>): boolean {
296-
return control.invalid && (control.touched || control.dirty);
297-
}
298-
299-
private createContactGroup(): ContactFormGroup {
300-
return new FormGroup({
301-
firstname: new FormControl('', {
302-
nonNullable: true,
303-
validators: [Validators.required],
304-
}),
305-
lastname: new FormControl('', {
306-
nonNullable: true,
307-
validators: [Validators.required],
308-
}),
309-
relation: new FormControl('', {
310-
nonNullable: true,
311-
validators: [Validators.required],
312-
}),
313-
email: new FormControl('', {
314-
nonNullable: true,
315-
validators: [Validators.required, Validators.email],
316-
}),
317-
});
318-
}
319-
320-
private createEmailFormGroup(): EmailFormGroup {
321-
return new FormGroup({
322-
type: new FormControl('personal', {
323-
nonNullable: true,
324-
validators: [Validators.required],
325-
}),
326-
email: new FormControl('', {
327-
nonNullable: true,
328-
validators: [Validators.required, Validators.email],
329-
}),
330-
});
307+
this.formModel.update((model) => ({
308+
...model,
309+
emails: [
310+
...model.emails.slice(0, index),
311+
...model.emails.slice(index + 1),
312+
],
313+
}));
331314
}
332315
}

0 commit comments

Comments
 (0)