From 1033ac08cc5c45a55b035ef4236bf8520d56fc14 Mon Sep 17 00:00:00 2001 From: Hung Pham Date: Wed, 15 Apr 2026 11:58:10 +0700 Subject: [PATCH] feat(challenge 62): implement cross-field validation in registration form using Signal Form --- .../src/app/app.component.html | 179 ++++++++ .../src/app/app.component.ts | 391 +++++------------- 2 files changed, 284 insertions(+), 286 deletions(-) create mode 100644 apps/forms/62-crossfield-validation-signal-form/src/app/app.component.html diff --git a/apps/forms/62-crossfield-validation-signal-form/src/app/app.component.html b/apps/forms/62-crossfield-validation-signal-form/src/app/app.component.html new file mode 100644 index 000000000..a20288dd7 --- /dev/null +++ b/apps/forms/62-crossfield-validation-signal-form/src/app/app.component.html @@ -0,0 +1,179 @@ +
+
+

Registration Form

+

+ This form demonstrates cross field validation with reactive forms +

+ +
+
+ + + @if (form.email().invalid() && form.email().touched()) { + @for (error of form.email().errors(); track error) { +

{{ error.message }}

+ } + } +
+ +
+ + + @if (form.password().invalid() && form.password().touched()) { + @for (error of form.password().errors(); track error) { +

{{ error.message }}

+ } + } +
+ +
+ + + @if ( + form.confirmPassword().invalid() && form.confirmPassword().touched() + ) { + @for (error of form.confirmPassword().errors(); track error) { +

{{ error.message }}

+ } + } +
+ +
+ + + @if (form.startDate().invalid() && form.startDate().touched()) { + @for (error of form.startDate().errors(); track error) { +

{{ error.message }}

+ } + } +
+ +
+ + + @if (form.endDate().invalid() && form.endDate().touched()) { + @for (error of form.endDate().errors(); track error) { +

{{ error.message }}

+ } + } +
+ +
+ + +
+
+ +
+

Form Status

+
+
+ Valid: + + {{ form().valid() ? 'Yes' : 'No' }} + +
+
+ Touched: + {{ form().touched() ? 'Yes' : 'No' }} +
+
+ Dirty: + {{ form().dirty() ? 'Yes' : 'No' }} +
+
+
+

Form Value:

+
{{ form().value() | json }}
+
+
+ + @if (isSubmitted()) { +
+

+ Form Submitted Successfully! +

+
{{ form().value() | json }}
+
+ } +
+
diff --git a/apps/forms/62-crossfield-validation-signal-form/src/app/app.component.ts b/apps/forms/62-crossfield-validation-signal-form/src/app/app.component.ts index 7d2005867..46d17fb31 100644 --- a/apps/forms/62-crossfield-validation-signal-form/src/app/app.component.ts +++ b/apps/forms/62-crossfield-validation-signal-form/src/app/app.component.ts @@ -1,328 +1,147 @@ import { JsonPipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; -import { - AbstractControl, - FormControl, - FormGroup, - ReactiveFormsModule, - ValidationErrors, - ValidatorFn, - Validators, -} from '@angular/forms'; -function passwordMatchValidator(): ValidatorFn { - return (control: AbstractControl): ValidationErrors | null => { - const form = control as FormGroup; - if (!form) { +import { + email, + FieldContext, + form, + FormField, + FormRoot, + minLength, + required, + SchemaPath, + validate, +} from '@angular/forms/signals'; + +function matchValue( + targetFieldPath: SchemaPath, + options?: { message?: string }, +) { + return (ctx: FieldContext) => { + const { value, valueOf } = ctx; + + const currentValue = value(); + if (!currentValue) { return null; } - const password = form.value.password; - const confirmPassword = form.value.confirmPassword; - - if (!confirmPassword) { - return null; - } + const targetValue = valueOf(targetFieldPath); - if (password !== confirmPassword) { - form.controls['confirmPassword'].setErrors({ passwordMismatch: true }); + if (currentValue !== targetValue) { + return { + kind: 'valueDoNotMatch', + message: options?.message ?? 'Values do not match', + }; } return null; }; } -function endDateAfterStartDateValidator(): ValidatorFn { - return (control: AbstractControl): ValidationErrors | null => { - const form = control as FormGroup; - if (!form) { +function afterDate( + targetFieldPath: SchemaPath, + options?: { message?: string }, +) { + return (ctx: FieldContext) => { + const { value, valueOf } = ctx; + + const endDate = value(); + if (!endDate) { return null; } - const startDate = form.value.startDate; - const endDate = form.value.endDate; - - if (!startDate || !endDate) { + const startDate = valueOf(targetFieldPath); + if (!startDate) { return null; } - const start = new Date(startDate).getTime(); - const end = new Date(endDate).getTime(); + const endDateTime = new Date(endDate).getTime(); + const startDateTime = new Date(startDate).getTime(); + + if (isNaN(endDateTime) || isNaN(startDateTime)) { + return null; + } - if (end > start) { - form.controls['endDate'].setErrors(null); - } else { - form.controls['endDate'].setErrors({ endDateBeforeStart: true }); + if (endDateTime <= startDateTime) { + return { + kind: 'afterDate', + message: options?.message ?? 'Date must be after the reference date', + }; } return null; }; } +type RegistrationFormData = { + email: string; + password: string; + confirmPassword: string; + startDate: string; + endDate: string; +}; + +const initialRegistrationFormData: RegistrationFormData = { + email: '', + password: '', + confirmPassword: '', + startDate: '', + endDate: '', +}; + @Component({ selector: 'app-root', - imports: [ReactiveFormsModule, JsonPipe], - template: ` -
-
-

Registration Form

-

- This form demonstrates cross field validation with reactive forms -

- -
-
- - - @if ( - form.controls.email.invalid && !form.controls.email.untouched - ) { -

- @if (form.controls.email.hasError('required')) { - Email is required - } @else if (form.controls.email.hasError('email')) { - Please enter a valid email address - } -

- } -
- -
- - - @if ( - form.controls.password.invalid && - !form.controls.password.untouched - ) { -

- @if (form.controls.password.hasError('required')) { - Password is required - } @else if (form.controls.password.hasError('minlength')) { - Password must be at least 6 characters - } -

- } -
- -
- - - @if ( - form.controls.confirmPassword.invalid && - !form.controls.confirmPassword.untouched - ) { -

- @if (form.controls.confirmPassword.hasError('required')) { - Please confirm your password - } @else if ( - form.controls.confirmPassword.hasError('passwordMismatch') - ) { - Passwords do not match - } -

- } -
- -
- - - @if ( - form.controls.startDate.invalid && - !form.controls.startDate.untouched - ) { -

Start date is required

- } -
- -
- - - @if ( - form.controls.endDate.invalid && !form.controls.endDate.untouched - ) { -

- @if (form.controls.endDate.hasError('required')) { - End date is required - } @else if ( - form.controls.endDate.hasError('endDateBeforeStart') - ) { - End date must be after start date - } -

- } -
- -
- - -
-
- -
-

Form Status

-
-
- Valid: - - {{ form.valid ? 'Yes' : 'No' }} - -
-
- Touched: - {{ !form.untouched ? 'Yes' : 'No' }} -
-
- Dirty: - {{ form.dirty ? 'Yes' : 'No' }} -
-
-
-

Form Value:

-
{{ form.value | json }}
-
-
- - @if (isSubmitted()) { -
-

- Form Submitted Successfully! -

-
{{ this.form.getRawValue() | json }}
-
- } -
-
- `, + imports: [FormRoot, FormField, JsonPipe], + templateUrl: './app.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppComponent { public isSubmitted = signal(false); - form = new FormGroup( - { - email: new FormControl('', [Validators.required, Validators.email]), - password: new FormControl('', [ - Validators.required, - Validators.minLength(6), - ]), - confirmPassword: new FormControl('', [Validators.required]), - startDate: new FormControl('', [Validators.required]), - endDate: new FormControl('', [Validators.required]), + model = signal(initialRegistrationFormData); + + form = form( + this.model, + (schemaPath) => { + required(schemaPath.email, { message: 'Email is required' }); + email(schemaPath.email, { + message: 'Please enter a valid email address', + }); + required(schemaPath.password, { message: 'Password is required' }); + minLength(schemaPath.password, 6, { + message: 'Password must be at least 6 characters', + }); + required(schemaPath.confirmPassword, { + message: 'Please confirm your password', + }); + required(schemaPath.startDate, { message: 'Start date is required' }); + required(schemaPath.endDate, { message: 'End date is required' }); + validate( + schemaPath.confirmPassword, + matchValue(schemaPath.password, { + message: 'Passwords do not match', + }), + ); + validate( + schemaPath.endDate, + afterDate(schemaPath.startDate, { + message: 'End date must be after start date', + }), + ); }, { - validators: [passwordMatchValidator(), endDateAfterStartDateValidator()], + submission: { + action: async (f) => { + if (f().valid()) { + this.isSubmitted.set(true); + } + }, + }, }, ); - // constructor() { - // this.form.controls.password.valueChanges - // .pipe(takeUntilDestroyed()) - // .subscribe(() => { - // this.form.controls.confirmPassword.updateValueAndValidity(); - // }); - // - // this.form.controls.startDate.valueChanges - // .pipe(takeUntilDestroyed()) - // .subscribe(() => { - // this.form.controls.endDate.updateValueAndValidity(); - // }); - // } - - onSubmit() { - console.log('Submitting form...', this.form); - if (this.form.valid) { - this.isSubmitted.set(true); - console.log('Form submitted:', this.form.getRawValue()); - } - } - onReset() { - this.form.reset(); + this.form().reset(initialRegistrationFormData); this.isSubmitted.set(false); } }