diff --git a/apps/forms/63-child-forms/src/app/account-form/account-form.component.html b/apps/forms/63-child-forms/src/app/account-form/account-form.component.html new file mode 100644 index 000000000..ac801916f --- /dev/null +++ b/apps/forms/63-child-forms/src/app/account-form/account-form.component.html @@ -0,0 +1,20 @@ +
+ + +
diff --git a/apps/forms/63-child-forms/src/app/account-form/account-form.component.ts b/apps/forms/63-child-forms/src/app/account-form/account-form.component.ts new file mode 100644 index 000000000..b72158478 --- /dev/null +++ b/apps/forms/63-child-forms/src/app/account-form/account-form.component.ts @@ -0,0 +1,14 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { FieldTree, FormField } from '@angular/forms/signals'; +import { ValidationComponent } from '../validation/validation.component'; +import { AccountData } from './account-form.model'; + +@Component({ + selector: 'account-form', + templateUrl: './account-form.component.html', + imports: [FormField, ValidationComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AccountFormComponent { + public readonly form = input.required>(); +} diff --git a/apps/forms/63-child-forms/src/app/account-form/account-form.model.ts b/apps/forms/63-child-forms/src/app/account-form/account-form.model.ts new file mode 100644 index 000000000..7d77491d8 --- /dev/null +++ b/apps/forms/63-child-forms/src/app/account-form/account-form.model.ts @@ -0,0 +1,21 @@ +import { signal } from '@angular/core'; +import { required, SchemaPathTree } from '@angular/forms/signals'; + +export type AccountData = { + firstName: string; + lastName: string; +}; + +export const createAccountModel = () => { + return signal({ + firstName: '', + lastName: '', + }); +}; + +export const buildAccountSection = ( + schemaPath: SchemaPathTree, +) => { + required(schemaPath.firstName, { message: 'First name is required' }); + required(schemaPath.lastName, { message: 'Last name is required' }); +}; 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..53e67b339 --- /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.ts b/apps/forms/63-child-forms/src/app/address-form/address-form.component.ts new file mode 100644 index 000000000..e06f206a3 --- /dev/null +++ b/apps/forms/63-child-forms/src/app/address-form/address-form.component.ts @@ -0,0 +1,14 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { FieldTree, FormField } from '@angular/forms/signals'; +import { ValidationComponent } from '../validation/validation.component'; +import { AddressData } from './address-form.model'; + +@Component({ + selector: 'address-form', + templateUrl: './address-form.component.html', + imports: [FormField, ValidationComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AddressFormComponent { + public readonly form = input.required>(); +} diff --git a/apps/forms/63-child-forms/src/app/address-form/address-form.model.ts b/apps/forms/63-child-forms/src/app/address-form/address-form.model.ts new file mode 100644 index 000000000..ea5533139 --- /dev/null +++ b/apps/forms/63-child-forms/src/app/address-form/address-form.model.ts @@ -0,0 +1,24 @@ +import { signal } from '@angular/core'; +import { required, SchemaPathTree } from '@angular/forms/signals'; + +export type AddressData = { + street: string; + zipcode: string; + city: string; +}; + +export const createAddressModel = () => { + return signal({ + street: '', + zipcode: '', + city: '', + }); +}; + +export const buildAddressSection = ( + schemaPath: SchemaPathTree, +) => { + required(schemaPath.street, { message: 'Street is required' }); + required(schemaPath.zipcode, { message: 'ZIP code is required' }); + required(schemaPath.city, { message: 'City is 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..f9953ab35 --- /dev/null +++ b/apps/forms/63-child-forms/src/app/app.component.html @@ -0,0 +1,62 @@ +
+
+

Order

+ +
+
+

Information

+ +
+ +
+

Shipping address

+ +
+ +
+
+

Billing address

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

Preview

+
{{ checkoutForm().value() | json }}
+
+
+
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 ff074abff..e75ae0521 100644 --- a/apps/forms/63-child-forms/src/app/app.component.ts +++ b/apps/forms/63-child-forms/src/app/app.component.ts @@ -1,288 +1,83 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component } from '@angular/core'; import { - FormControl, - FormGroup, - ReactiveFormsModule, - Validators, -} from '@angular/forms'; - -type AddressGroup = FormGroup<{ - street: FormControl; - zipcode: FormControl; - city: FormControl; -}>; + ChangeDetectionStrategy, + Component, + effect, + signal, + untracked, +} from '@angular/core'; +import { disabled, form, FormField, FormRoot } from '@angular/forms/signals'; +import { AccountFormComponent } from './account-form/account-form.component'; +import { + AccountData, + buildAccountSection, + createAccountModel, +} from './account-form/account-form.model'; +import { AddressFormComponent } from './address-form/address-form.component'; +import { + AddressData, + buildAddressSection, + createAddressModel, +} from './address-form/address-form.model'; -type CheckoutForm = { - firstName: FormControl; - lastName: FormControl; - shipping: AddressGroup; - billing: AddressGroup; - sameAsShipping: FormControl; +type CheckoutData = { + account: AccountData; + shipping: AddressData; + billing: AddressData; + sameAsShipping: boolean; }; @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: [ - ` - .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; - } - `, + imports: [ + CommonModule, + FormRoot, + FormField, + AccountFormComponent, + AddressFormComponent, + FormField, ], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './app.component.html', }) 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 }), - }); + private readonly _initialData: CheckoutData = { + account: createAccountModel()(), + shipping: createAddressModel()(), + billing: createAddressModel()(), + sameAsShipping: false, + }; + private _checkoutModel = signal(this._initialData); + private _onSameAsShippingChange = effect(() => { + const sameAsShipping = this.checkoutForm.sameAsShipping().value(); - 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 }); + if (sameAsShipping) { + untracked(() => { + this.checkoutForm + .billing() + .value.set(this.checkoutForm.shipping().value()); + }); } - } - - onSubmit(): void { - this.form.markAllAsTouched(); - if (this.form.invalid) { - return; - } - } - - showError(control: FormControl): boolean { - return control.invalid && (control.touched || control.dirty); - } + }); - 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], - }), - }); - } + protected checkoutForm = form( + this._checkoutModel, + (schemaPath) => { + buildAccountSection(schemaPath.account); + buildAddressSection(schemaPath.shipping); + buildAddressSection(schemaPath.billing); + disabled(schemaPath.billing, ({ valueOf }) => + valueOf(schemaPath.sameAsShipping), + ); + }, + { + submission: { + action: async () => { + if (this.checkoutForm().invalid()) { + return; + } + }, + }, + }, + ); } diff --git a/apps/forms/63-child-forms/src/app/validation/validation.component.html b/apps/forms/63-child-forms/src/app/validation/validation.component.html new file mode 100644 index 000000000..169a79b09 --- /dev/null +++ b/apps/forms/63-child-forms/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/63-child-forms/src/app/validation/validation.component.ts b/apps/forms/63-child-forms/src/app/validation/validation.component.ts new file mode 100644 index 000000000..0d760f47b --- /dev/null +++ b/apps/forms/63-child-forms/src/app/validation/validation.component.ts @@ -0,0 +1,23 @@ +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>(); + + protected readonly showError = computed( + () => + this.fieldState().invalid() && + (this.fieldState().touched() || this.fieldState().dirty()) && + this.fieldState().errors(), + ); +} diff --git a/apps/forms/63-child-forms/src/styles.scss b/apps/forms/63-child-forms/src/styles.scss index 77e408aa8..b83ece6c4 100644 --- a/apps/forms/63-child-forms/src/styles.scss +++ b/apps/forms/63-child-forms/src/styles.scss @@ -3,3 +3,9 @@ @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; +}