@@ -6,68 +6,43 @@ import {
66 WritableSignal ,
77} from '@angular/core' ;
88import {
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} )
248242export 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