From 14b04db38b1b08e12d3c2acbed7ee11d24cbc6d7 Mon Sep 17 00:00:00 2001 From: Hung Pham Date: Wed, 6 May 2026 15:14:30 +0700 Subject: [PATCH] feat(challenge 63): convert checkout form from reactive form to signal form --- .../address-form/address-form.component.html | 30 ++ .../address-form/address-form.component.scss | 5 + .../address-form/address-form.component.ts | 14 + .../63-child-forms/src/app/app.component.html | 80 +++++ .../63-child-forms/src/app/app.component.scss | 5 + .../63-child-forms/src/app/app.component.ts | 334 ++++-------------- .../src/app/checkout-form.model.ts | 29 ++ .../validation-message.component.ts | 18 + 8 files changed, 242 insertions(+), 273 deletions(-) create mode 100644 apps/forms/63-child-forms/src/app/address-form/address-form.component.html create mode 100644 apps/forms/63-child-forms/src/app/address-form/address-form.component.scss create mode 100644 apps/forms/63-child-forms/src/app/address-form/address-form.component.ts create mode 100644 apps/forms/63-child-forms/src/app/app.component.html create mode 100644 apps/forms/63-child-forms/src/app/app.component.scss create mode 100644 apps/forms/63-child-forms/src/app/checkout-form.model.ts create mode 100644 apps/forms/63-child-forms/src/app/validation-message/validation-message.component.ts diff --git a/apps/forms/63-child-forms/src/app/address-form/address-form.component.html b/apps/forms/63-child-forms/src/app/address-form/address-form.component.html new file mode 100644 index 000000000..93e412243 --- /dev/null +++ b/apps/forms/63-child-forms/src/app/address-form/address-form.component.html @@ -0,0 +1,30 @@ +
+ + + +
diff --git a/apps/forms/63-child-forms/src/app/address-form/address-form.component.scss b/apps/forms/63-child-forms/src/app/address-form/address-form.component.scss new file mode 100644 index 000000000..e648ad62a --- /dev/null +++ b/apps/forms/63-child-forms/src/app/address-form/address-form.component.scss @@ -0,0 +1,5 @@ +@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; +} diff --git a/apps/forms/63-child-forms/src/app/address-form/address-form.component.ts b/apps/forms/63-child-forms/src/app/address-form/address-form.component.ts new file mode 100644 index 000000000..9cac424a0 --- /dev/null +++ b/apps/forms/63-child-forms/src/app/address-form/address-form.component.ts @@ -0,0 +1,14 @@ +import { Component, input } from '@angular/core'; +import { FieldTree, FormField } from '@angular/forms/signals'; +import { AddressModel } from '../checkout-form.model'; +import { ValidationMessageComponent } from '../validation-message/validation-message.component'; + +@Component({ + selector: 'app-address-form', + templateUrl: 'address-form.component.html', + styleUrls: ['address-form.component.scss'], + imports: [FormField, ValidationMessageComponent], +}) +export class AddressFormComponent { + fieldTree = input.required>(); +} diff --git a/apps/forms/63-child-forms/src/app/app.component.html b/apps/forms/63-child-forms/src/app/app.component.html new file mode 100644 index 000000000..142aeb326 --- /dev/null +++ b/apps/forms/63-child-forms/src/app/app.component.html @@ -0,0 +1,80 @@ +
+
+

Order

+
+
+

Information

+
+ + +
+
+ +
+

Shipping address

+ +
+ +
+
+

Billing address

+ +
+ + @if (!checkoutForm.billing().hidden()) { + + } +
+ +
+
+ + {{ + checkoutForm().invalid() ? 'Form incomplete' : 'Ready to submit' + }} + +
+ +
+
+ +
+

Preview

+
{{ checkoutForm().value() | json }}
+
+
+
diff --git a/apps/forms/63-child-forms/src/app/app.component.scss b/apps/forms/63-child-forms/src/app/app.component.scss new file mode 100644 index 000000000..e648ad62a --- /dev/null +++ b/apps/forms/63-child-forms/src/app/app.component.scss @@ -0,0 +1,5 @@ +@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; +} diff --git a/apps/forms/63-child-forms/src/app/app.component.ts b/apps/forms/63-child-forms/src/app/app.component.ts index 86ca767fa..d1b814ea3 100644 --- a/apps/forms/63-child-forms/src/app/app.component.ts +++ b/apps/forms/63-child-forms/src/app/app.component.ts @@ -1,290 +1,78 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component } from '@angular/core'; import { - FormControl, - FormGroup, - ReactiveFormsModule, - Validators, -} from '@angular/forms'; + ChangeDetectionStrategy, + Component, + computed, + effect, + signal, +} from '@angular/core'; -type AddressGroup = FormGroup<{ - street: FormControl; - zipcode: FormControl; - city: FormControl; -}>; - -type CheckoutForm = { - firstName: FormControl; - lastName: FormControl; - shipping: AddressGroup; - billing: AddressGroup; - sameAsShipping: FormControl; -}; +import { + disabled, + form, + FormField, + FormRoot, + hidden, + required, +} from '@angular/forms/signals'; +import { AddressFormComponent } from './address-form/address-form.component'; +import { CheckoutModel, initialCheckoutModel } from './checkout-form.model'; +import { ValidationMessageComponent } from './validation-message/validation-message.component'; @Component({ selector: 'app-root', standalone: true, - imports: [CommonModule, ReactiveFormsModule], - changeDetection: ChangeDetectionStrategy.OnPush, - template: ` -
-
-

Order

-
-
-

Information

-
- - -
-
- -
-

Shipping address

-
- - - -
-
- -
-
-

Billing address

- -
- - @if (!form.controls.sameAsShipping.value) { -
- - - -
- } -
- -
-
- - {{ form.invalid ? 'Form incomplete' : 'Ready to submit' }} - -
- -
-
- -
-

Preview

-
{{ form.getRawValue() | json }}
-
-
-
- `, - 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; - } - `, + imports: [ + CommonModule, + FormRoot, + FormField, + AddressFormComponent, + ValidationMessageComponent, ], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], }) export class AppComponent { - readonly shipping: AddressGroup = this.createAddressGroup(); - readonly billing: AddressGroup = this.createAddressGroup(); - - readonly form = new FormGroup({ - firstName: new FormControl('', { - nonNullable: true, - validators: [Validators.required], - }), - lastName: new FormControl('', { - nonNullable: true, - validators: [Validators.required], - }), - shipping: this.shipping, - billing: this.billing, - sameAsShipping: new FormControl(false, { nonNullable: true }), - }); + checkoutModel = signal(initialCheckoutModel); - toggleSameAsShipping(): void { - const checked = !this.form.controls.sameAsShipping.value; - this.form.controls.sameAsShipping.setValue(checked, { emitEvent: false }); - if (checked) { - this.billing.setValue(this.shipping.getRawValue(), { emitEvent: false }); - this.billing.disable({ emitEvent: false }); - } else { - this.billing.enable({ emitEvent: false }); - } - } + checkoutForm = form( + this.checkoutModel, + (schema) => { + required(schema.firstName, { message: 'This field is required' }); + required(schema.lastName, { message: 'This field is required' }); + required(schema.shipping.street, { message: 'This field is required' }); + required(schema.shipping.zipcode, { message: 'This field is required' }); + required(schema.shipping.city, { message: 'This field is required' }); + required(schema.billing.street, { message: 'This field is required' }); + required(schema.billing.zipcode, { message: 'This field is required' }); + required(schema.billing.city, { message: 'This field is required' }); - onSubmit(): void { - this.form.markAllAsTouched(); - if (this.form.invalid) { - return; - } - } + disabled(schema.billing, ({ valueOf }) => valueOf(schema.sameAsShipping)); + hidden(schema.billing, ({ valueOf }) => valueOf(schema.sameAsShipping)); + }, + { + submission: { + action: async () => { + console.log('Form submitted with value:', this.checkoutModel()); + }, + }, + }, + ); - showError(control: FormControl): boolean { - return control.invalid && (control.touched || control.dirty); - } + sameAsShipping = computed(() => this.checkoutModel().sameAsShipping); + shippingAddress = computed(() => this.checkoutModel().shipping); - private createAddressGroup(): AddressGroup { - return new FormGroup({ - street: new FormControl('', { - nonNullable: true, - validators: [Validators.required], - }), - zipcode: new FormControl('', { - nonNullable: true, - validators: [Validators.required], - }), - city: new FormControl('', { - nonNullable: true, - validators: [Validators.required], - }), + constructor() { + effect(() => { + if (this.sameAsShipping()) { + this.checkoutModel.update((model) => { + return { + ...model, + billing: { ...this.shippingAddress() }, + }; + }); + } }); } } diff --git a/apps/forms/63-child-forms/src/app/checkout-form.model.ts b/apps/forms/63-child-forms/src/app/checkout-form.model.ts new file mode 100644 index 000000000..611aee6fa --- /dev/null +++ b/apps/forms/63-child-forms/src/app/checkout-form.model.ts @@ -0,0 +1,29 @@ +export type AddressModel = { + street: string; + zipcode: string; + city: string; +}; + +export type CheckoutModel = { + firstName: string; + lastName: string; + shipping: AddressModel; + billing: AddressModel; + sameAsShipping: boolean; +}; + +export const initialCheckoutModel: CheckoutModel = { + firstName: '', + lastName: '', + shipping: { + street: '', + zipcode: '', + city: '', + }, + billing: { + street: '', + zipcode: '', + city: '', + }, + sameAsShipping: false, +}; diff --git a/apps/forms/63-child-forms/src/app/validation-message/validation-message.component.ts b/apps/forms/63-child-forms/src/app/validation-message/validation-message.component.ts new file mode 100644 index 000000000..615722dc8 --- /dev/null +++ b/apps/forms/63-child-forms/src/app/validation-message/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) { + {{ error.message }} + } + } + `, +}) +export class ValidationMessageComponent { + fieldState = input.required>(); +}