From 3795d55775e88991e5e40487d3f7ba1f7e3c7c3e Mon Sep 17 00:00:00 2001 From: Tsironis Ioannis Date: Tue, 17 Mar 2026 12:50:49 +0200 Subject: [PATCH 1/2] feat: fix forms-form-array challenge --- .../64-form-array/src/app/app.component.html | 1 + .../64-form-array/src/app/app.component.ts | 330 +----------------- .../forms/64-form-array/src/app/app.config.ts | 4 +- .../forms/64-form-array/src/app/app.routes.ts | 29 ++ .../src/app/contact-form.component.ts | 106 ------ .../app/dashboard/dashboard.component.html | 5 + .../src/app/dashboard/dashboard.component.ts | 10 + .../contact-form/contact-form.component.html | 75 ++++ .../contact-form/contact-form.component.ts | 48 +++ .../contact-form/contact-form.model.ts | 23 ++ .../email-form/email-form.component.html | 49 +++ .../email-form/email-form.component.ts | 49 +++ .../email-form/email-form.model.ts | 19 + ...orm-with-form-value-control.component.html | 102 ++++++ ...-with-form-value-control.component.spec.ts | 154 ++++++++ ...-form-with-form-value-control.component.ts | 127 +++++++ .../contact-form/contact-form.component.html | 55 +++ .../contact-form/contact-form.component.ts | 21 ++ .../contact-form/contact-form.model.ts | 23 ++ .../email-form/email-form.component.html | 37 ++ .../email-form/email-form.component.ts | 21 ++ .../email-form/email-form.model.ts | 19 + .../simple-registration-form.component.html | 100 ++++++ ...imple-registration-form.component.spec.ts} | 6 +- .../simple-registration-form.component.ts | 127 +++++++ .../app/validation/validation.component.html | 7 + .../app/validation/validation.component.ts | 28 ++ apps/forms/64-form-array/src/styles.scss | 25 ++ 28 files changed, 1165 insertions(+), 435 deletions(-) create mode 100644 apps/forms/64-form-array/src/app/app.component.html create mode 100644 apps/forms/64-form-array/src/app/app.routes.ts delete mode 100644 apps/forms/64-form-array/src/app/contact-form.component.ts create mode 100644 apps/forms/64-form-array/src/app/dashboard/dashboard.component.html create mode 100644 apps/forms/64-form-array/src/app/dashboard/dashboard.component.ts create mode 100644 apps/forms/64-form-array/src/app/registration-form-with-form-value-control/contact-form/contact-form.component.html create mode 100644 apps/forms/64-form-array/src/app/registration-form-with-form-value-control/contact-form/contact-form.component.ts create mode 100644 apps/forms/64-form-array/src/app/registration-form-with-form-value-control/contact-form/contact-form.model.ts create mode 100644 apps/forms/64-form-array/src/app/registration-form-with-form-value-control/email-form/email-form.component.html create mode 100644 apps/forms/64-form-array/src/app/registration-form-with-form-value-control/email-form/email-form.component.ts create mode 100644 apps/forms/64-form-array/src/app/registration-form-with-form-value-control/email-form/email-form.model.ts create mode 100644 apps/forms/64-form-array/src/app/registration-form-with-form-value-control/registration-form-with-form-value-control.component.html create mode 100644 apps/forms/64-form-array/src/app/registration-form-with-form-value-control/registration-form-with-form-value-control.component.spec.ts create mode 100644 apps/forms/64-form-array/src/app/registration-form-with-form-value-control/registration-form-with-form-value-control.component.ts create mode 100644 apps/forms/64-form-array/src/app/simple-registration-form/contact-form/contact-form.component.html create mode 100644 apps/forms/64-form-array/src/app/simple-registration-form/contact-form/contact-form.component.ts create mode 100644 apps/forms/64-form-array/src/app/simple-registration-form/contact-form/contact-form.model.ts create mode 100644 apps/forms/64-form-array/src/app/simple-registration-form/email-form/email-form.component.html create mode 100644 apps/forms/64-form-array/src/app/simple-registration-form/email-form/email-form.component.ts create mode 100644 apps/forms/64-form-array/src/app/simple-registration-form/email-form/email-form.model.ts create mode 100644 apps/forms/64-form-array/src/app/simple-registration-form/simple-registration-form.component.html rename apps/forms/64-form-array/src/app/{app.component.spec.ts => simple-registration-form/simple-registration-form.component.spec.ts} (96%) create mode 100644 apps/forms/64-form-array/src/app/simple-registration-form/simple-registration-form.component.ts create mode 100644 apps/forms/64-form-array/src/app/validation/validation.component.html create mode 100644 apps/forms/64-form-array/src/app/validation/validation.component.ts 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..0680b43f9 --- /dev/null +++ b/apps/forms/64-form-array/src/app/app.component.html @@ -0,0 +1 @@ + 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 bd9e83ab8..1a86ec744 100644 --- a/apps/forms/64-form-array/src/app/app.component.ts +++ b/apps/forms/64-form-array/src/app/app.component.ts @@ -1,330 +1,10 @@ -import { JsonPipe } from '@angular/common'; -import { - ChangeDetectionStrategy, - Component, - 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; - - return { MinLengthArray: true }; - }; -}; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; @Component({ selector: 'app-root', - imports: [ReactiveFormsModule, JsonPipe, ContactFormComponent], + imports: [RouterOutlet], 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 }}
-
- } -
-
- `, - styles: [ - ` - .input { - @apply w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm outline-none transition 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:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 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; - } - `, - ], + templateUrl: './app.component.html', }) -export class AppComponent { - readonly contacts = new FormArray([], { - validators: [minLengthArray(1)], - }); - - readonly emails = new FormArray([]); - - 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, - }); - - submittedData: WritableSignal = signal(null); - - addContact(): void { - this.contacts.push(this.createContactGroup()); - } - - removeContact(index: number): void { - this.contacts.removeAt(index); - } - - addEmail(): void { - this.emails.push(this.createEmailFormGroup()); - } - - removeEmail(index: number): void { - this.emails.removeAt(index); - } - - 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], - }), - }); - } - - 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], - }), - }); - } -} +export class AppComponent {} diff --git a/apps/forms/64-form-array/src/app/app.config.ts b/apps/forms/64-form-array/src/app/app.config.ts index 50d39c4f8..56adf8286 100644 --- a/apps/forms/64-form-array/src/app/app.config.ts +++ b/apps/forms/64-form-array/src/app/app.config.ts @@ -2,7 +2,9 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners, } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { - providers: [provideBrowserGlobalErrorListeners()], + providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)], }; diff --git a/apps/forms/64-form-array/src/app/app.routes.ts b/apps/forms/64-form-array/src/app/app.routes.ts new file mode 100644 index 000000000..cef897372 --- /dev/null +++ b/apps/forms/64-form-array/src/app/app.routes.ts @@ -0,0 +1,29 @@ +import { Routes } from '@angular/router'; + +export const routes: Routes = [ + { + path: '', + redirectTo: 'dashboard', + pathMatch: 'full', + }, + { + path: 'dashboard', + loadComponent: () => import('./dashboard/dashboard.component'), + }, + { + path: 'registration-form-with-form-value-control', + loadComponent: () => + import( + './registration-form-with-form-value-control/registration-form-with-form-value-control.component' + ), + }, + { + path: 'simple-registration-form', + loadComponent: () => + import('./simple-registration-form/simple-registration-form.component'), + }, + { + path: '**', + redirectTo: 'dashboard', + }, +]; 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 deleted file mode 100644 index eaf27cd02..000000000 --- a/apps/forms/64-form-array/src/app/contact-form.component.ts +++ /dev/null @@ -1,106 +0,0 @@ -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; -}>; - -@Component({ - selector: 'app-contact-form', - imports: [ReactiveFormsModule], - template: ` -
-
-

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

- -
- -
- - - - -
-
- `, -}) -export class ContactFormComponent { - group = input.required(); - index = input(0); - remove = output(); - - showError(control: FormControl): boolean { - return control.invalid && (control.touched || control.dirty); - } -} diff --git a/apps/forms/64-form-array/src/app/dashboard/dashboard.component.html b/apps/forms/64-form-array/src/app/dashboard/dashboard.component.html new file mode 100644 index 000000000..9145e8040 --- /dev/null +++ b/apps/forms/64-form-array/src/app/dashboard/dashboard.component.html @@ -0,0 +1,5 @@ + + Registration form with form value control + + +Simple registration form diff --git a/apps/forms/64-form-array/src/app/dashboard/dashboard.component.ts b/apps/forms/64-form-array/src/app/dashboard/dashboard.component.ts new file mode 100644 index 000000000..ad255ca59 --- /dev/null +++ b/apps/forms/64-form-array/src/app/dashboard/dashboard.component.ts @@ -0,0 +1,10 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +@Component({ + selector: 'app-dashboard', + templateUrl: './dashboard.component.html', + imports: [RouterLink], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export default class DashboardComponent {} diff --git a/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/contact-form/contact-form.component.html b/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/contact-form/contact-form.component.html new file mode 100644 index 000000000..6ad27f7f5 --- /dev/null +++ b/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/contact-form/contact-form.component.html @@ -0,0 +1,75 @@ +
+
+

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

+ +
+ +
+ + + + +
+
diff --git a/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/contact-form/contact-form.component.ts b/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/contact-form/contact-form.component.ts new file mode 100644 index 000000000..ea8654e16 --- /dev/null +++ b/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/contact-form/contact-form.component.ts @@ -0,0 +1,48 @@ +import { + ChangeDetectionStrategy, + Component, + input, + model, + output, +} from '@angular/core'; +import { FieldTree, FormValueControl } from '@angular/forms/signals'; +import { ValidationComponent } from '../../validation/validation.component'; +import { ContactValue, initialContactValue } from './contact-form.model'; + +@Component({ + selector: 'app-contact-form', + imports: [ValidationComponent], + templateUrl: './contact-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ContactFormComponent implements FormValueControl { + public readonly value = model(initialContactValue); + public readonly fieldTree = input.required>(); + index = input(0); + remove = output(); + + protected readonly modelTouched = model<{ + [K in keyof ContactValue]: boolean; + }>({ + firstname: false, + lastname: false, + relation: false, + email: false, + }); + protected readonly modelDirty = model<{ [K in keyof ContactValue]: boolean }>( + { + email: false, + firstname: false, + lastname: false, + relation: false, + }, + ); + + protected setModelTouched(key: keyof ContactValue): void { + this.modelTouched.set({ ...this.modelTouched(), [key]: true }); + } + + protected setModelDirty(key: keyof ContactValue): void { + this.modelDirty.set({ ...this.modelDirty(), [key]: true }); + } +} diff --git a/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/contact-form/contact-form.model.ts b/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/contact-form/contact-form.model.ts new file mode 100644 index 000000000..d19c5617f --- /dev/null +++ b/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/contact-form/contact-form.model.ts @@ -0,0 +1,23 @@ +import { email, required, SchemaPathTree } from '@angular/forms/signals'; + +export type ContactValue = { + firstname: string; + lastname: string; + relation: string; + email: string; +}; + +export const initialContactValue: ContactValue = { + firstname: '', + lastname: '', + relation: '', + email: '', +}; + +export const contactSchema = (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' }); +}; diff --git a/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/email-form/email-form.component.html b/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/email-form/email-form.component.html new file mode 100644 index 000000000..9a8b777a4 --- /dev/null +++ b/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/email-form/email-form.component.html @@ -0,0 +1,49 @@ +
+
+

+ Email {{ index() + 1 }} +

+ +
+ +
+ + +
+
diff --git a/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/email-form/email-form.component.ts b/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/email-form/email-form.component.ts new file mode 100644 index 000000000..c4c9bab0f --- /dev/null +++ b/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/email-form/email-form.component.ts @@ -0,0 +1,49 @@ +import { + ChangeDetectionStrategy, + Component, + input, + model, + output, +} from '@angular/core'; +import { FieldTree, FormValueControl } from '@angular/forms/signals'; +import { ValidationComponent } from '../../validation/validation.component'; +import { EmailValue, initialEmailValue } from './email-form.model'; + +@Component({ + selector: 'app-email-form', + imports: [ValidationComponent], + templateUrl: './email-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EmailFormComponent implements FormValueControl { + public readonly value = model(initialEmailValue); + public readonly fieldTree = input.required>(); + index = input(0); + remove = output(); + + protected readonly modelTouched = model<{ [K in keyof EmailValue]: boolean }>( + { + email: false, + type: false, + }, + ); + protected readonly modelDirty = model<{ [K in keyof EmailValue]: boolean }>({ + email: false, + type: false, + }); + + protected onEmailTypeChange(target: EventTarget | null): void { + this.value.set({ + ...this.value(), + type: (target as HTMLSelectElement).value, + }); + } + + protected setModelTouched(key: keyof EmailValue): void { + this.modelTouched.set({ ...this.modelTouched(), [key]: true }); + } + + protected setModelDirty(key: keyof EmailValue): void { + this.modelDirty.set({ ...this.modelDirty(), [key]: true }); + } +} diff --git a/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/email-form/email-form.model.ts b/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/email-form/email-form.model.ts new file mode 100644 index 000000000..221eb9613 --- /dev/null +++ b/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/email-form/email-form.model.ts @@ -0,0 +1,19 @@ +import { email, required } from '@angular/forms/signals'; + +import { SchemaPathTree } from '@angular/forms/signals'; + +export type EmailValue = { + type: string; + email: string; +}; + +export const initialEmailValue: EmailValue = { + type: '', + email: '', +}; + +export const emailSchema = (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' }); +}; diff --git a/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/registration-form-with-form-value-control.component.html b/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/registration-form-with-form-value-control.component.html new file mode 100644 index 000000000..844f7a046 --- /dev/null +++ b/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/registration-form-with-form-value-control.component.html @@ -0,0 +1,102 @@ +
+
+

Registration form

+ +
+
+

Profile

+
+ + +
+
+ +
+
+

Contacts

+ +
+ +
+ @for (contact of registrationForm().value().contacts; track $index) { + + } +
+ @let contacts = registrationForm.contacts(); + @if (contacts.invalid() && (contacts.touched() || contacts.dirty())) { +

At least one contact is required.

+ } +
+ +
+
+

Emails

+ +
+ +
+ @for (email of registrationForm().value().emails; track $index) { + + } +
+
+ +
+
+ + {{ + registrationForm().invalid() + ? 'Form incomplete' + : 'Ready to submit' + }} + +
+ +
+
+ + @if (submittedData()) { +
+

Submitted data

+
{{ submittedData() | json }}
+
+ } +
+
diff --git a/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/registration-form-with-form-value-control.component.spec.ts b/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/registration-form-with-form-value-control.component.spec.ts new file mode 100644 index 000000000..0c22cb89d --- /dev/null +++ b/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/registration-form-with-form-value-control.component.spec.ts @@ -0,0 +1,154 @@ +import { TestBed } from '@angular/core/testing'; +import { page, userEvent } from 'vitest/browser'; +import RegistrationFormWithFormValueControlComponent from './registration-form-with-form-value-control.component'; + +describe('RegistrationFormWithFormValueControlComponent', () => { + beforeEach(async () => { + TestBed.createComponent(RegistrationFormWithFormValueControlComponent); + }); + + describe('When component is rendered', () => { + it('Then should show the form as incomplete initially', async () => { + await expect + .element(page.getByText(/form incomplete/i)) + .toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: /submit/i })) + .toBeEnabled(); + }); + }); + + describe('Given dynamic arrays', () => { + it('Then should add and remove contacts and emails', async () => { + await userEvent.click(page.getByRole('button', { name: /add contact/i })); + await userEvent.click(page.getByRole('button', { name: /add contact/i })); + await expect.element(page.getByText(/contact 2/i)).toBeInTheDocument(); + + await userEvent.click( + page.getByRole('button', { name: /remove contact 2/i }), + ); + await expect + .element(page.getByText(/contact 2/i)) + .not.toBeInTheDocument(); + + await userEvent.click(page.getByRole('button', { name: /add email/i })); + await userEvent.click(page.getByRole('button', { name: /add email/i })); + await expect.element(page.getByText(/email 2/i)).toBeInTheDocument(); + + await userEvent.click( + page.getByRole('button', { name: /remove email 2/i }), + ); + await expect.element(page.getByText(/email 2/i)).not.toBeInTheDocument(); + }); + }); + + describe('Given valid form data', () => { + it('Then should submit and display the submitted data', async () => { + await userEvent.click(page.getByRole('button', { name: /add contact/i })); + await userEvent.type( + page.getByRole('textbox', { name: /^Name\s*$/i }), + 'Alex', + ); + await userEvent.type( + page.getByRole('textbox', { name: /^Pseudo\s*$/i }), + 'Nexus', + ); + await userEvent.type( + page.getByRole('textbox', { name: /^First name\s*$/i }), + 'Jamie', + ); + await userEvent.type( + page.getByRole('textbox', { name: /^Last name\s*$/i }), + 'Doe', + ); + await userEvent.type( + page.getByRole('textbox', { name: /^Relation\s*$/i }), + 'Friend', + ); + await userEvent.type( + page.getByRole('textbox', { name: /^Email\s*$/i }), + 'jamie@example.com', + ); + + await userEvent.click(page.getByRole('button', { name: /submit/i })); + + await expect + .element(page.getByRole('heading', { name: /submitted data/i })) + .toBeInTheDocument(); + await expect + .element(page.getByText(/"name": "Alex"/)) + .toBeInTheDocument(); + await expect.element(page.getByText(/"contacts":/)).toBeInTheDocument(); + }); + }); + + describe('Given invalid form data', () => { + it('Then should show required errors on submit', async () => { + await userEvent.click(page.getByRole('button', { name: /submit/i })); + + await expect + .element(page.getByText(/Name\s*This field is required/i)) + .toBeInTheDocument(); + await expect + .element(page.getByText(/Pseudo\s*This field is required/i)) + .toBeInTheDocument(); + await expect + .element(page.getByText(/at least one contact is required/i)) + .toBeInTheDocument(); + }); + + it('Then should show contact required errors on submit', async () => { + await userEvent.click(page.getByRole('button', { name: /add contact/i })); + await userEvent.click(page.getByRole('button', { name: /submit/i })); + + await expect + .element(page.getByText(/email is required/i)) + .toBeInTheDocument(); + }); + + it('Then should show email format error for a contact', async () => { + await userEvent.click(page.getByRole('button', { name: /add contact/i })); + await userEvent.type( + page.getByRole('textbox', { name: /^First name\s*$/i }), + 'Jamie', + ); + await userEvent.type( + page.getByRole('textbox', { name: /^Last name\s*$/i }), + 'Doe', + ); + await userEvent.type( + page.getByRole('textbox', { name: /^Relation\s*$/i }), + 'Friend', + ); + await userEvent.type( + page.getByRole('textbox', { name: /^Email\s*$/i }), + 'invalid', + ); + + await expect + .element(page.getByText(/enter a valid email/i)) + .toBeInTheDocument(); + }); + + it('Then should show required errors for email entries on submit', async () => { + await userEvent.click(page.getByRole('button', { name: /add email/i })); + await userEvent.click(page.getByRole('button', { name: /submit/i })); + + await expect + .element(page.getByText(/email is required/i)) + .toBeInTheDocument(); + }); + + it('Then should show email format error for an email entry', async () => { + await userEvent.click(page.getByRole('button', { name: /add email/i })); + await userEvent.type( + page.getByRole('textbox', { name: /^Email\s*$/i }), + 'invalid', + ); + + await expect + .element(page.getByText(/enter a valid email/i)) + .toBeInTheDocument(); + }); + }); +}); diff --git a/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/registration-form-with-form-value-control.component.ts b/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/registration-form-with-form-value-control.component.ts new file mode 100644 index 000000000..d8cb1205d --- /dev/null +++ b/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/registration-form-with-form-value-control.component.ts @@ -0,0 +1,127 @@ +import { JsonPipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + signal, + WritableSignal, +} from '@angular/core'; +import { + applyEach, + form, + FormField, + FormRoot, + required, + validate, +} from '@angular/forms/signals'; +import { ValidationComponent } from '../validation/validation.component'; +import { ContactFormComponent } from './contact-form/contact-form.component'; +import { + contactSchema, + ContactValue, + initialContactValue, +} from './contact-form/contact-form.model'; +import { EmailFormComponent } from './email-form/email-form.component'; +import { emailSchema, initialEmailValue } from './email-form/email-form.model'; + +type RegistrationValue = { + name: string; + pseudo: string; + contacts: Array; + emails: Array<{ + type: string; + email: string; + }>; +}; + +type RegistrationData = { + name: string; + pseudo: string; + contacts: Array; + emails: Array<{ + type: string; + email: string; + }>; +}; + +@Component({ + selector: 'app-registration-form-with-form-value-control', + templateUrl: './registration-form-with-form-value-control.component.html', + imports: [ + JsonPipe, + ContactFormComponent, + FormRoot, + FormField, + ValidationComponent, + EmailFormComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export default class RegistrationFormWithFormValueControlComponent { + private readonly _initialData: RegistrationData = { + name: '', + pseudo: '', + contacts: [], + emails: [], + }; + private _registrationModel = signal(this._initialData); + + protected registrationForm = form( + this._registrationModel, + (schemaPath) => { + required(schemaPath.name, { message: 'This field is required' }); + required(schemaPath.pseudo, { message: 'This field is required' }); + validate(schemaPath.contacts, ({ value }) => { + if (value().length < 1) { + return { + kind: 'minLength', + message: 'At least one contact is required', + }; + } + return null; + }); + applyEach(schemaPath.contacts, contactSchema); + applyEach(schemaPath.emails, emailSchema); + }, + { + submission: { + action: async () => { + if (this.registrationForm().invalid()) { + return; + } + + this.submittedData.set(this.registrationForm().value()); + }, + }, + }, + ); + + submittedData: WritableSignal = signal(null); + + addContact(): void { + this._registrationModel.update((value) => ({ + ...value, + contacts: [...value.contacts, { ...initialContactValue }], + })); + } + + removeContact(index: number): void { + this._registrationModel.update((value) => ({ + ...value, + contacts: value.contacts.filter((_, i) => i !== index), + })); + } + + addEmail(): void { + this._registrationModel.update((value) => ({ + ...value, + emails: [...value.emails, { ...initialEmailValue }], + })); + } + + removeEmail(index: number): void { + this._registrationModel.update((value) => ({ + ...value, + emails: value.emails.filter((_, i) => i !== index), + })); + } +} diff --git a/apps/forms/64-form-array/src/app/simple-registration-form/contact-form/contact-form.component.html b/apps/forms/64-form-array/src/app/simple-registration-form/contact-form/contact-form.component.html new file mode 100644 index 000000000..d7aaf6da9 --- /dev/null +++ b/apps/forms/64-form-array/src/app/simple-registration-form/contact-form/contact-form.component.html @@ -0,0 +1,55 @@ +
+
+

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

+ +
+ +
+ + + + +
+
diff --git a/apps/forms/64-form-array/src/app/simple-registration-form/contact-form/contact-form.component.ts b/apps/forms/64-form-array/src/app/simple-registration-form/contact-form/contact-form.component.ts new file mode 100644 index 000000000..93bf71cbf --- /dev/null +++ b/apps/forms/64-form-array/src/app/simple-registration-form/contact-form/contact-form.component.ts @@ -0,0 +1,21 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, +} from '@angular/core'; +import { FieldTree, FormField } from '@angular/forms/signals'; +import { ValidationComponent } from '../../validation/validation.component'; +import { ContactValue } from './contact-form.model'; + +@Component({ + selector: 'app-contact-form-with-input-form', + imports: [ValidationComponent, FormField], + templateUrl: './contact-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ContactFormWithInputFormComponent { + public readonly contactForm = input.required>(); + index = input(0); + remove = output(); +} diff --git a/apps/forms/64-form-array/src/app/simple-registration-form/contact-form/contact-form.model.ts b/apps/forms/64-form-array/src/app/simple-registration-form/contact-form/contact-form.model.ts new file mode 100644 index 000000000..d19c5617f --- /dev/null +++ b/apps/forms/64-form-array/src/app/simple-registration-form/contact-form/contact-form.model.ts @@ -0,0 +1,23 @@ +import { email, required, SchemaPathTree } from '@angular/forms/signals'; + +export type ContactValue = { + firstname: string; + lastname: string; + relation: string; + email: string; +}; + +export const initialContactValue: ContactValue = { + firstname: '', + lastname: '', + relation: '', + email: '', +}; + +export const contactSchema = (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' }); +}; diff --git a/apps/forms/64-form-array/src/app/simple-registration-form/email-form/email-form.component.html b/apps/forms/64-form-array/src/app/simple-registration-form/email-form/email-form.component.html new file mode 100644 index 000000000..4a5c7ccda --- /dev/null +++ b/apps/forms/64-form-array/src/app/simple-registration-form/email-form/email-form.component.html @@ -0,0 +1,37 @@ +
+
+

+ Email {{ index() + 1 }} +

+ +
+ +
+ + +
+
diff --git a/apps/forms/64-form-array/src/app/simple-registration-form/email-form/email-form.component.ts b/apps/forms/64-form-array/src/app/simple-registration-form/email-form/email-form.component.ts new file mode 100644 index 000000000..2cda3b50c --- /dev/null +++ b/apps/forms/64-form-array/src/app/simple-registration-form/email-form/email-form.component.ts @@ -0,0 +1,21 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, +} from '@angular/core'; +import { FieldTree, FormField } from '@angular/forms/signals'; +import { ValidationComponent } from '../../validation/validation.component'; +import { EmailValue } from './email-form.model'; + +@Component({ + selector: 'app-email-form-with-input-form', + imports: [ValidationComponent, FormField], + templateUrl: './email-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EmailFormWithInputFormComponent { + public readonly emailForm = input.required>(); + index = input(0); + remove = output(); +} diff --git a/apps/forms/64-form-array/src/app/simple-registration-form/email-form/email-form.model.ts b/apps/forms/64-form-array/src/app/simple-registration-form/email-form/email-form.model.ts new file mode 100644 index 000000000..221eb9613 --- /dev/null +++ b/apps/forms/64-form-array/src/app/simple-registration-form/email-form/email-form.model.ts @@ -0,0 +1,19 @@ +import { email, required } from '@angular/forms/signals'; + +import { SchemaPathTree } from '@angular/forms/signals'; + +export type EmailValue = { + type: string; + email: string; +}; + +export const initialEmailValue: EmailValue = { + type: '', + email: '', +}; + +export const emailSchema = (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' }); +}; diff --git a/apps/forms/64-form-array/src/app/simple-registration-form/simple-registration-form.component.html b/apps/forms/64-form-array/src/app/simple-registration-form/simple-registration-form.component.html new file mode 100644 index 000000000..36debc666 --- /dev/null +++ b/apps/forms/64-form-array/src/app/simple-registration-form/simple-registration-form.component.html @@ -0,0 +1,100 @@ +
+
+

Registration form

+ +
+
+

Profile

+
+ + +
+
+ +
+
+

Contacts

+ +
+ +
+ @for (contact of registrationForm().value().contacts; track $index) { + + } +
+ @let contacts = registrationForm.contacts(); + @if (contacts.invalid() && (contacts.touched() || contacts.dirty())) { +

At least one contact is required.

+ } +
+ +
+
+

Emails

+ +
+ +
+ @for (email of registrationForm().value().emails; track $index) { + + } +
+
+ +
+
+ + {{ + registrationForm().invalid() + ? 'Form incomplete' + : 'Ready to submit' + }} + +
+ +
+
+ + @if (submittedData()) { +
+

Submitted data

+
{{ submittedData() | json }}
+
+ } +
+
diff --git a/apps/forms/64-form-array/src/app/app.component.spec.ts b/apps/forms/64-form-array/src/app/simple-registration-form/simple-registration-form.component.spec.ts similarity index 96% rename from apps/forms/64-form-array/src/app/app.component.spec.ts rename to apps/forms/64-form-array/src/app/simple-registration-form/simple-registration-form.component.spec.ts index 22768ca95..7c364b050 100644 --- a/apps/forms/64-form-array/src/app/app.component.spec.ts +++ b/apps/forms/64-form-array/src/app/simple-registration-form/simple-registration-form.component.spec.ts @@ -1,10 +1,10 @@ import { TestBed } from '@angular/core/testing'; import { page, userEvent } from 'vitest/browser'; -import { AppComponent } from './app.component'; +import SimpleRegistrationFormComponent from './simple-registration-form.component'; -describe('AppComponent', () => { +describe('SimpleRegistrationFormComponent', () => { beforeEach(async () => { - TestBed.createComponent(AppComponent); + TestBed.createComponent(SimpleRegistrationFormComponent); }); describe('When component is rendered', () => { diff --git a/apps/forms/64-form-array/src/app/simple-registration-form/simple-registration-form.component.ts b/apps/forms/64-form-array/src/app/simple-registration-form/simple-registration-form.component.ts new file mode 100644 index 000000000..e5fed1383 --- /dev/null +++ b/apps/forms/64-form-array/src/app/simple-registration-form/simple-registration-form.component.ts @@ -0,0 +1,127 @@ +import { JsonPipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + signal, + WritableSignal, +} from '@angular/core'; +import { + applyEach, + form, + FormField, + FormRoot, + required, + validate, +} from '@angular/forms/signals'; +import { ValidationComponent } from '../validation/validation.component'; +import { ContactFormWithInputFormComponent } from './contact-form/contact-form.component'; +import { + contactSchema, + ContactValue, + initialContactValue, +} from './contact-form/contact-form.model'; +import { EmailFormWithInputFormComponent } from './email-form/email-form.component'; +import { emailSchema, initialEmailValue } from './email-form/email-form.model'; + +type RegistrationValue = { + name: string; + pseudo: string; + contacts: Array; + emails: Array<{ + type: string; + email: string; + }>; +}; + +type RegistrationData = { + name: string; + pseudo: string; + contacts: Array; + emails: Array<{ + type: string; + email: string; + }>; +}; + +@Component({ + selector: 'app-simple-registration-form', + templateUrl: './simple-registration-form.component.html', + imports: [ + JsonPipe, + ContactFormWithInputFormComponent, + FormRoot, + FormField, + ValidationComponent, + EmailFormWithInputFormComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export default class SimpleRegistrationFormComponent { + private readonly _initialData: RegistrationData = { + name: '', + pseudo: '', + contacts: [], + emails: [], + }; + private _registrationModel = signal(this._initialData); + + protected registrationForm = form( + this._registrationModel, + (schemaPath) => { + required(schemaPath.name, { message: 'This field is required' }); + required(schemaPath.pseudo, { message: 'This field is required' }); + validate(schemaPath.contacts, ({ value }) => { + if (value().length < 1) { + return { + kind: 'minLength', + message: 'At least one contact is required', + }; + } + return null; + }); + applyEach(schemaPath.contacts, contactSchema); + applyEach(schemaPath.emails, emailSchema); + }, + { + submission: { + action: async () => { + if (this.registrationForm().invalid()) { + return; + } + + this.submittedData.set(this.registrationForm().value()); + }, + }, + }, + ); + + submittedData: WritableSignal = signal(null); + + addContact(): void { + this._registrationModel.update((value) => ({ + ...value, + contacts: [...value.contacts, { ...initialContactValue }], + })); + } + + removeContact(index: number): void { + this._registrationModel.update((value) => ({ + ...value, + contacts: value.contacts.filter((_, i) => i !== index), + })); + } + + addEmail(): void { + this._registrationModel.update((value) => ({ + ...value, + emails: [...value.emails, { ...initialEmailValue }], + })); + } + + removeEmail(index: number): void { + this._registrationModel.update((value) => ({ + ...value, + emails: value.emails.filter((_, i) => i !== index), + })); + } +} diff --git a/apps/forms/64-form-array/src/app/validation/validation.component.html b/apps/forms/64-form-array/src/app/validation/validation.component.html new file mode 100644 index 000000000..169a79b09 --- /dev/null +++ b/apps/forms/64-form-array/src/app/validation/validation.component.html @@ -0,0 +1,7 @@ + + @if (showError()) { + @for (error of fieldState().errors(); track error) { + {{ error.message }} + } + } + diff --git a/apps/forms/64-form-array/src/app/validation/validation.component.ts b/apps/forms/64-form-array/src/app/validation/validation.component.ts new file mode 100644 index 000000000..4b5a75737 --- /dev/null +++ b/apps/forms/64-form-array/src/app/validation/validation.component.ts @@ -0,0 +1,28 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core'; +import { FieldState } from '@angular/forms/signals'; + +@Component({ + selector: 'app-validation', + templateUrl: './validation.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ValidationComponent { + public readonly fieldState = input.required>(); + public readonly touched = input(false); + public readonly dirty = input(false); + + protected readonly showError = computed( + () => + this.fieldState().invalid() && + (this.fieldState().touched() || + this.touched() || + this.fieldState().dirty() || + this.dirty()) && + this.fieldState().errors(), + ); +} diff --git a/apps/forms/64-form-array/src/styles.scss b/apps/forms/64-form-array/src/styles.scss index 77e408aa8..9b71ac280 100644 --- a/apps/forms/64-form-array/src/styles.scss +++ b/apps/forms/64-form-array/src/styles.scss @@ -3,3 +3,28 @@ @tailwind utilities; /* You can add global styles to this file, and also import other style files */ +.input { + @apply w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm outline-none transition 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:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 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; +} + +a { + background: green; + color: white; + padding: 5px; + border-radius: 5px; + display: block; + width: 220px; + margin: 0 0 10px; +} From e8d2040aabcab5943cc718fdfd31285cd2fdf29c Mon Sep 17 00:00:00 2001 From: Tsironis Ioannis Date: Tue, 17 Mar 2026 15:16:01 +0200 Subject: [PATCH 2/2] feat: remove code --- ...tration-form-with-form-value-control.component.ts | 12 +----------- .../simple-registration-form.component.ts | 12 +----------- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/registration-form-with-form-value-control.component.ts b/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/registration-form-with-form-value-control.component.ts index d8cb1205d..853c97842 100644 --- a/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/registration-form-with-form-value-control.component.ts +++ b/apps/forms/64-form-array/src/app/registration-form-with-form-value-control/registration-form-with-form-value-control.component.ts @@ -23,16 +23,6 @@ import { import { EmailFormComponent } from './email-form/email-form.component'; import { emailSchema, initialEmailValue } from './email-form/email-form.model'; -type RegistrationValue = { - name: string; - pseudo: string; - contacts: Array; - emails: Array<{ - type: string; - email: string; - }>; -}; - type RegistrationData = { name: string; pseudo: string; @@ -95,7 +85,7 @@ export default class RegistrationFormWithFormValueControlComponent { }, ); - submittedData: WritableSignal = signal(null); + submittedData: WritableSignal = signal(null); addContact(): void { this._registrationModel.update((value) => ({ diff --git a/apps/forms/64-form-array/src/app/simple-registration-form/simple-registration-form.component.ts b/apps/forms/64-form-array/src/app/simple-registration-form/simple-registration-form.component.ts index e5fed1383..c927e534d 100644 --- a/apps/forms/64-form-array/src/app/simple-registration-form/simple-registration-form.component.ts +++ b/apps/forms/64-form-array/src/app/simple-registration-form/simple-registration-form.component.ts @@ -23,16 +23,6 @@ import { import { EmailFormWithInputFormComponent } from './email-form/email-form.component'; import { emailSchema, initialEmailValue } from './email-form/email-form.model'; -type RegistrationValue = { - name: string; - pseudo: string; - contacts: Array; - emails: Array<{ - type: string; - email: string; - }>; -}; - type RegistrationData = { name: string; pseudo: string; @@ -95,7 +85,7 @@ export default class SimpleRegistrationFormComponent { }, ); - submittedData: WritableSignal = signal(null); + submittedData: WritableSignal = signal(null); addContact(): void { this._registrationModel.update((value) => ({