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
+
+
+
+
+ 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
-
-
-
- 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;
+}