diff --git a/apps/forms/63-custom-forms/src/app/address-form.component.ts b/apps/forms/63-custom-forms/src/app/address-form.component.ts new file mode 100644 index 000000000..ab03ef871 --- /dev/null +++ b/apps/forms/63-custom-forms/src/app/address-form.component.ts @@ -0,0 +1,98 @@ +import { Component, input, model } from '@angular/core'; +import { + FieldState, + FieldTree, + FormValueControl, +} from '@angular/forms/signals'; + +interface AddressFieldGroup { + street: string; + zipcode: string; + city: string; +} + +@Component({ + selector: 'app-address-form', + template: ` +
+ + + +
+ `, + 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; + } + `, + ], +}) +export class AddressFormComponent + implements FormValueControl +{ + value = model({ + street: '', + zipcode: '', + city: '', + }); + + fieldTree = input.required>(); + + updateField(field: keyof AddressFieldGroup, value: string) { + this.value.update((state) => ({ + ...state, + [field]: value, + })); + } + + showError(field: FieldState): boolean { + return field.invalid() && (field.touched() || field.dirty()); + } +} diff --git a/apps/forms/63-custom-forms/src/app/app.component.ts b/apps/forms/63-custom-forms/src/app/app.component.ts index ff074abff..ff568f341 100644 --- a/apps/forms/63-custom-forms/src/app/app.component.ts +++ b/apps/forms/63-custom-forms/src/app/app.component.ts @@ -1,38 +1,49 @@ -import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { JsonPipe } from '@angular/common'; import { - FormControl, - FormGroup, - ReactiveFormsModule, - Validators, -} from '@angular/forms'; + ChangeDetectionStrategy, + Component, + effect, + signal, + untracked, +} from '@angular/core'; +import { + apply, + disabled, + FieldState, + form, + FormField, + required, + SchemaPathTree, + submit, +} from '@angular/forms/signals'; +import { AddressFormComponent } from './address-form.component'; -type AddressGroup = FormGroup<{ - street: FormControl; - zipcode: FormControl; - city: FormControl; -}>; +type AddressGroup = { + street: string; + zipcode: string; + city: string; +}; type CheckoutForm = { - firstName: FormControl; - lastName: FormControl; + firstName: string; + lastName: string; shipping: AddressGroup; billing: AddressGroup; - sameAsShipping: FormControl; + sameAsShipping: boolean; }; @Component({ selector: 'app-root', standalone: true, - imports: [CommonModule, ReactiveFormsModule], + imports: [FormField, AddressFormComponent, JsonPipe], changeDetection: ChangeDetectionStrategy.OnPush, template: `

Order

Information

@@ -43,11 +54,10 @@ type CheckoutForm = { - @if (showError(form.controls.lastName)) { + @if (showError(form.lastName())) { This field is required } @@ -58,11 +68,10 @@ type CheckoutForm = { - @if (showError(form.controls.firstName)) { + @if (showError(form.firstName())) { This field is required } @@ -72,56 +81,9 @@ type CheckoutForm = {

Shipping address

-
- - - -
+
@@ -132,71 +94,23 @@ type CheckoutForm = { + [formField]="form.sameAsShipping" /> Billing address same as shipping
- @if (!form.controls.sameAsShipping.value) { -
- - - -
+ @if (!form.sameAsShipping().value()) { + }
- - {{ form.invalid ? 'Form incomplete' : 'Ready to submit' }} + + {{ form().invalid() ? 'Form incomplete' : 'Ready to submit' }}
@@ -230,59 +144,61 @@ type CheckoutForm = { ], }) export class AppComponent { - readonly shipping: AddressGroup = this.createAddressGroup(); - readonly billing: AddressGroup = this.createAddressGroup(); + model = signal({ + firstName: '', + lastName: '', + shipping: { + street: '', + zipcode: '', + city: '', + }, + billing: { + street: '', + zipcode: '', + city: '', + }, + sameAsShipping: false, + }); - 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 }), + form = form(this.model, (schema) => { + required(schema.firstName); + required(schema.lastName); + required(schema.billing, { + when: ({ valueOf }) => valueOf(schema.sameAsShipping), + }); + apply(schema.shipping, this.addressSchema); + apply(schema.billing, this.addressSchema); + disabled(schema.billing, ({ valueOf }) => valueOf(schema.sameAsShipping)); }); - 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 }); - } - } + constructor() { + effect(() => { + const checked = this.form.sameAsShipping().value(); - onSubmit(): void { - this.form.markAllAsTouched(); - if (this.form.invalid) { - return; - } + if (checked) { + untracked(() => { + this.form.billing().value.set(this.form.shipping().value()); + }); + } + }); } - showError(control: FormControl): boolean { - return control.invalid && (control.touched || control.dirty); + addressSchema(item: SchemaPathTree) { + required(item.street); + required(item.zipcode); + required(item.city); } - 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], - }), + async onSubmit($event: Event): Promise { + // TODO: to be removed in 21.2 and replaced by formRoot directive + $event.preventDefault(); + + await submit(this.form, async () => { + // TODO: add submit logic }); } + + showError(field: FieldState): boolean { + return field.invalid() && (field.touched() || field.dirty()); + } }