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