diff --git a/apps/forms/64-form-array/src/app/app.component.html b/apps/forms/64-form-array/src/app/app.component.html new file mode 100644 index 000000000..6b3105c5d --- /dev/null +++ b/apps/forms/64-form-array/src/app/app.component.html @@ -0,0 +1,129 @@ +
+
+

Registration form

+
+
+

Profile

+
+ + +
+
+ +
+
+

Contacts

+ +
+ +
+ @for (contact of form.contacts; track $index) { + + } +
+ + + +
+ +
+
+

Emails

+ +
+ +
+ @for (field of form.emails; track field; let index = $index) { +
+
+

+ Email {{ index + 1 }} +

+ +
+ +
+ + +
+
+ } +
+
+ +
+
+ + {{ form().invalid() ? 'Form incomplete' : 'Ready to submit' }} + +
+ +
+
+ + @if (submittedData()) { +
+

Submitted data

+
{{ submittedData() | json }}
+
+ } +
+
diff --git a/apps/forms/64-form-array/src/app/app.component.ts b/apps/forms/64-form-array/src/app/app.component.ts index f6133d3df..6ec6412df 100644 --- a/apps/forms/64-form-array/src/app/app.component.ts +++ b/apps/forms/64-form-array/src/app/app.component.ts @@ -5,224 +5,37 @@ import { signal, WritableSignal, } from '@angular/core'; -import { - AbstractControl, - FormArray, - FormControl, - FormGroup, - ReactiveFormsModule, - Validators, -} from '@angular/forms'; -import { ContactFormComponent } from './contact-form.component'; - -type ContactFormGroup = FormGroup<{ - firstname: FormControl; - lastname: FormControl; - relation: FormControl; - email: FormControl; -}>; - -type EmailFormGroup = FormGroup<{ - type: FormControl; - email: FormControl; -}>; - -type RegistrationForm = { - name: FormControl; - pseudo: FormControl; - contacts: FormArray; - emails: FormArray; -}; -type RegistrationValue = { - name: string; - pseudo: string; - contacts: Array<{ - firstname: string; - lastname: string; - relation: string; - email: string; - }>; - emails: Array<{ - type: string; - email: string; - }>; -}; - -export const minLengthArray = (min: number) => { - return (c: AbstractControl) => { - if (c.value.length >= min) return null; +import { + applyEach, + email, + form, + FormField, + FormRoot, + required, + SchemaPathTree, + validate, +} from '@angular/forms/signals'; - return { MinLengthArray: true }; - }; -}; +import { ContactFormComponent } from './contact-form.component'; +import { + ContactModel, + EmailModel, + RegistrationModel, +} from './registration.model'; +import { ValidationMessageComponent } from './validation-message.component'; @Component({ selector: 'app-root', - imports: [ReactiveFormsModule, JsonPipe, ContactFormComponent], + imports: [ + FormRoot, + FormField, + JsonPipe, + ContactFormComponent, + ValidationMessageComponent, + ], changeDetection: ChangeDetectionStrategy.OnPush, - template: ` -
-
-

Registration form

-
-
-

Profile

-
- - -
-
- -
-
-

Contacts

- -
- -
- @for (contact of contacts.controls; track $index) { - - } -
- - @if (contacts.invalid && (contacts.touched || contacts.dirty)) { -

At least one contact is required.

- } -
- -
-
-

Emails

- -
- -
- @for (email of emails.controls; track $index) { -
-
-

- Email {{ $index + 1 }} -

- -
- -
- - -
-
- } -
-
- -
-
- - {{ form.invalid ? 'Form incomplete' : 'Ready to submit' }} - -
- -
-
- - @if (submittedData()) { -
-

Submitted data

-
{{ submittedData() | json }}
-
- } -
-
- `, + templateUrl: './app.component.html', styles: [ ` @reference "tailwindcss"; @@ -246,87 +59,146 @@ export const minLengthArray = (min: number) => { ], }) export class AppComponent { - readonly contacts = new FormArray([], { - validators: [minLengthArray(1)], - }); - - readonly emails = new FormArray([]); + submittedData: WritableSignal = signal(null); - readonly form = new FormGroup({ - name: new FormControl('', { - nonNullable: true, - validators: [Validators.required], - }), - pseudo: new FormControl('', { - nonNullable: true, - validators: [Validators.required], - }), - contacts: this.contacts, - emails: this.emails, + model = signal({ + name: '', + pseudo: '', + contacts: [], + emails: [], }); - submittedData: WritableSignal = signal(null); + form = form( + this.model, + (schemaPath) => { + required(schemaPath.name, { message: 'This field is required' }); + required(schemaPath.pseudo, { message: 'This field is required' }); + applyEach(schemaPath.contacts, (item: SchemaPathTree) => { + required(item.firstname, { message: 'This field is required' }); + required(item.lastname, { message: 'This field is required' }); + required(item.relation, { message: 'This field is required' }); + required(item.email, { message: 'email is required' }); + email(item.email, { message: 'Enter a valid email' }); + }); + validate(schemaPath.contacts, ({ value }) => { + if (value().length > 0) { + return null; + } + + return { + kind: 'minLengthArray', + message: 'At least one contact is required', + }; + }); + applyEach(schemaPath.emails, (item: SchemaPathTree) => { + required(item.type, { message: 'This field is required' }); + required(item.email, { message: 'email is required' }); + email(item.email, { message: 'Enter a valid email' }); + }); + }, + { + submission: { + action: async () => { + this.submittedData.set(this.model()); + }, + }, + }, + ); addContact(): void { - this.contacts.push(this.createContactGroup()); + const newContact: ContactModel = { + firstname: '', + lastname: '', + relation: '', + email: '', + }; + + this.model.update((m) => { + return { + ...m, + contacts: [...m.contacts, newContact], + }; + }); } removeContact(index: number): void { - this.contacts.removeAt(index); + this.model.update((m) => { + const updatedContacts = [...m.contacts]; + updatedContacts.splice(index, 1); + return { + ...m, + contacts: updatedContacts, + }; + }); } addEmail(): void { - this.emails.push(this.createEmailFormGroup()); + const newEmail = { + type: 'personal', + email: '', + }; + this.model.update((m) => { + return { + ...m, + emails: [...m.emails, newEmail], + }; + }); } removeEmail(index: number): void { - this.emails.removeAt(index); + this.model.update((m) => { + const updatedEmails = [...m.emails]; + updatedEmails.splice(index, 1); + return { + ...m, + emails: updatedEmails, + }; + }); } onSubmit(): void { - this.form.markAllAsTouched(); - if (this.form.invalid) { - return; - } - - this.submittedData.set(this.form.getRawValue()); - } - - showError(control: FormControl): boolean { - return control.invalid && (control.touched || control.dirty); - } - - private createContactGroup(): ContactFormGroup { - return new FormGroup({ - firstname: new FormControl('', { - nonNullable: true, - validators: [Validators.required], - }), - lastname: new FormControl('', { - nonNullable: true, - validators: [Validators.required], - }), - relation: new FormControl('', { - nonNullable: true, - validators: [Validators.required], - }), - email: new FormControl('', { - nonNullable: true, - validators: [Validators.required, Validators.email], - }), - }); + // this.form.markAllAsTouched(); + // if (this.form.invalid) { + // return; + // } + // this.submittedData.set(this.form.getRawValue()); } - private createEmailFormGroup(): EmailFormGroup { - return new FormGroup({ - type: new FormControl('personal', { - nonNullable: true, - validators: [Validators.required], - }), - email: new FormControl('', { - nonNullable: true, - validators: [Validators.required, Validators.email], - }), - }); - } + // showError(control: FormControl): boolean { + // return control.invalid && (control.touched || control.dirty); + // } + + // private createContactGroup(): ContactFormGroup { + // return new FormGroup({ + // firstname: new FormControl('', { + // nonNullable: true, + // validators: [Validators.required], + // }), + // lastname: new FormControl('', { + // nonNullable: true, + // validators: [Validators.required], + // }), + // relation: new FormControl('', { + // nonNullable: true, + // validators: [Validators.required], + // }), + // email: new FormControl('', { + // nonNullable: true, + // validators: [Validators.required, Validators.email], + // }), + // }); + // } + + // private createEmailFormGroup(): EmailFormGroup { + // return new FormGroup({ + // type: new FormControl('personal', { + // nonNullable: true, + // validators: [Validators.required], + // }), + // email: new FormControl('', { + // nonNullable: true, + // validators: [Validators.required, Validators.email], + // }), + // }); + // } } diff --git a/apps/forms/64-form-array/src/app/contact-form.component.html b/apps/forms/64-form-array/src/app/contact-form.component.html new file mode 100644 index 000000000..f3bcc28f6 --- /dev/null +++ b/apps/forms/64-form-array/src/app/contact-form.component.html @@ -0,0 +1,55 @@ +
+
+

+ Contact {{ index() + 1 }} +

+ +
+ +
+ + + + +
+
diff --git a/apps/forms/64-form-array/src/app/contact-form.component.ts b/apps/forms/64-form-array/src/app/contact-form.component.ts index eaf27cd02..e020c4122 100644 --- a/apps/forms/64-form-array/src/app/contact-form.component.ts +++ b/apps/forms/64-form-array/src/app/contact-form.component.ts @@ -1,106 +1,35 @@ import { Component, input, output } from '@angular/core'; -import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; - -type ContactFormGroup = FormGroup<{ - firstname: FormControl; - lastname: FormControl; - relation: FormControl; - email: FormControl; -}>; +import { FieldTree, FormField } from '@angular/forms/signals'; +import { ContactModel } from './registration.model'; +import { ValidationMessageComponent } from './validation-message.component'; @Component({ selector: 'app-contact-form', - imports: [ReactiveFormsModule], - template: ` -
-
-

- Contact {{ index() + 1 }} -

- -
+ imports: [FormField, ValidationMessageComponent], + templateUrl: './contact-form.component.html', + styles: ` + @reference "tailwindcss"; -
- - - - -
-
+ .input { + @apply w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm transition outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200; + } + .hint { + @apply text-xs text-rose-600; + } + .btn-primary { + @apply rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-500 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:bg-slate-300; + } + .btn-secondary { + @apply rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-semibold text-slate-700 shadow-sm transition hover:border-indigo-200 hover:text-indigo-600; + } + .btn-danger { + @apply rounded-lg border border-rose-200 bg-white px-3 py-1.5 text-xs font-semibold text-rose-600 shadow-sm transition hover:border-rose-300 hover:text-rose-700; + } `, }) export class ContactFormComponent { - group = input.required(); + formField = input.required>(); index = input(0); - remove = output(); - showError(control: FormControl): boolean { - return control.invalid && (control.touched || control.dirty); - } + remove = output(); } diff --git a/apps/forms/64-form-array/src/app/registration.model.ts b/apps/forms/64-form-array/src/app/registration.model.ts new file mode 100644 index 000000000..70ffd8b10 --- /dev/null +++ b/apps/forms/64-form-array/src/app/registration.model.ts @@ -0,0 +1,18 @@ +export type ContactModel = { + firstname: string; + lastname: string; + relation: string; + email: string; +}; + +export type EmailModel = { + type: string; + email: string; +}; + +export type RegistrationModel = { + name: string; + pseudo: string; + contacts: ContactModel[]; + emails: EmailModel[]; +}; diff --git a/apps/forms/64-form-array/src/app/validation-message.component.ts b/apps/forms/64-form-array/src/app/validation-message.component.ts new file mode 100644 index 000000000..41735e422 --- /dev/null +++ b/apps/forms/64-form-array/src/app/validation-message.component.ts @@ -0,0 +1,18 @@ +import { Component, input } from '@angular/core'; +import { FieldState } from '@angular/forms/signals'; + +@Component({ + selector: 'app-validation-message', + template: ` + @if ( + fieldState().invalid() && (fieldState().touched() || fieldState().dirty()) + ) { + @for (error of fieldState().errors(); track error.kind) { + {{ error.message }} + } + } + `, +}) +export class ValidationMessageComponent { + fieldState = input.required>(); +}