[UI畫面操作,網站演示要出現Form.value]
在上一篇(篇三),我們透過購物車、報名表,甚至戲院座位表的案例,體驗了 FormArray 在動態集合中的效果。
不過,實際專案往往更複雜:
👉 這時候,就需要 跨元件巢狀 的設計,把大表單切分成多個小表單元件,各自專注一塊,再由父元件統一組合。
ControlContainer
注入父群組程式碼
子元件:
@Component({
selector: 'app-personal-form',
template: `
<div formGroupName="personal">
<input formControlName="name" placeholder="姓名">
<input formControlName="birthday" type="date">
</div>
`,
viewProviders: [{ provide: ControlContainer, useExisting: FormGroupDirective }]
})
export class PersonalFormComponent {}
父元件模板:
<form [formGroup]="form">
<div formGroupName="personal">
<app-personal-form></app-personal-form>
</div>
</form>
參考網址:Day24
操作完後可直接觀察form的值跟結構。
一個報名表單包含:
其中姓名、生日、信用卡號、有效期限為必填,Email、手機為擇一必填。
PersonalFormComponent
→ 管理個人資訊ContactFormComponent
→ 管理聯絡方式PaymentFormComponent
→ 管理付款資訊RegisterFormComponent
→ 組合整體// 跳過 import 的東西
type PersonalGroup = FormGroup<{
name: FormControl<string>;
birthday: FormControl<string>;
}>;
type ContactGroup = FormGroup<{
email: FormControl<string | null>;
phone: FormControl<string | null>;
}>;
type PaymentGroup = FormGroup<{
cardNumber: FormControl<string>;
expireDate: FormControl<string>;
}>;
type RootForm = FormGroup<{
personal: PersonalGroup;
contact: ContactGroup;
payment: PaymentGroup;
}>;
@Component({
selector: 'app-day24',
standalone: true,
imports: [
ReactiveFormsModule,
CommonModule,
NzFormModule,
NzButtonModule,
NzGridModule,
NzStepsModule,
NzResultModule,
NzSpinModule,
PersonalFormComponent,
ContactForm1Component,
PaymentFormComponent
],
templateUrl: './day24.component.html',
styleUrl: './day24.component.scss'
})
export class Day24Component {
step = signal(0);
form: RootForm;
private readonly groupOrder: Array<keyof RootForm['controls']> = ['personal', 'contact', 'payment'];
constructor(private fb: FormBuilder, private msg: NzMessageService) {
this.form = this.fb.group({
personal: this.fb.group({
name: this.fb.nonNullable.control('', [Validators.required, Validators.maxLength(50)]),
birthday: this.fb.nonNullable.control('', [Validators.required])
}),
contact: this.fb.group({
email: this.fb.control<string | null>('', [Validators.email]),
phone: this.fb.control<string | null>('', [Validators.pattern(/^09\d{8}$/)])
}),
payment: this.fb.group({
cardNumber: this.fb.nonNullable.control('', [Validators.required, Validators.pattern(/^\d{16}$/)]),
expireDate: this.fb.nonNullable.control('', [Validators.required])
})
});
// ✅ 把跨群組驗證掛到 root(確保會被觸發)
this.form.addValidators(this.emailOrPhoneRequired('contact.email', 'contact.phone'));
// ✅ 依目前步驟先設定啟用/停用(預設啟用 step 0)
this.updateGroupStates();
}
// 父層跨群組驗證:Email 或手機至少一個
emailOrPhoneRequired(emailPath: string, phonePath: string) {
return (group: AbstractControl) => {
const email = group.get(emailPath)?.value?.trim();
const phone = group.get(phonePath)?.value?.trim();
const valid = !!email || !!phone;
const contactGroup = group.get('contact');
if (!valid) {
contactGroup?.setErrors({ ...(contactGroup?.errors ?? {}), atLeastOne: true });
} else if (contactGroup?.errors) {
const { atLeastOne, ...others } = contactGroup.errors;
contactGroup.setErrors(Object.keys(others).length ? others : null);
}
return valid ? null : { atLeastOne: true };
};
}
// MM/YY 基本格式驗證(原樣)
mmYYValidator(control: AbstractControl) { /* ...保持不變... */ }
// ✅ 依目前 step 啟用/停用群組;step === 3 全部停用
private updateGroupStates(): void {
const current = this.step();
this.groupOrder.forEach((name, idx) => {
const g = this.form.get(name) as FormGroup;
if (current === 3) {
g.disable({ emitEvent: false });
} else if (idx === current) {
g.enable({ emitEvent: false });
} else {
g.disable({ emitEvent: false });
}
});
}
// ✅ 統一切步驟並更新啟用狀態
private goToStep(index: number): void {
this.step.set(index);
this.updateGroupStates();
}
// 換步驟前驗證目前步驟
next(): void {
const current = this.step();
const groupMap: Record<number, string> = { 0: 'personal', 1: 'contact', 2: 'payment' };
if (current <= 2) {
const groupName = groupMap[current];
const g = this.form.get(groupName) as FormGroup;
g.markAllAsTouched();
// 觸發所有驗證(含 root 的跨群組驗證)
this.form.updateValueAndValidity({ onlySelf: false, emitEvent: true });
if (g.invalid || (current === 1 && this.form.get('contact')?.errors?.['atLeastOne'])) {
this.msg.error('請先完成本步驟的必填欄位與驗證。');
return;
}
}
this.goToStep(Math.min(3, current + 1));
}
prev(): void {
this.goToStep(Math.max(0, this.step() - 1));
}
submit(): void {
this.form.markAllAsTouched();
this.form.updateValueAndValidity();
if (this.form.invalid) {
this.msg.error('表單驗證未通過,請檢查各步驟欄位。');
return;
}
console.log('submit payload', this.form.value);
this.msg.success('送出成功!(請開 console 檢視 payload)');
}
}
<div nz-row nzJustify="center" class="container" style="padding: 24px;">
<div nz-col nzSpan="24" nzMd="18" nzLg="12">
<h2>報名表單(分步驟)</h2>
<nz-steps [nzCurrent]="step()">
<nz-step nzTitle="個人資訊"></nz-step>
<nz-step nzTitle="聯絡方式"></nz-step>
<nz-step nzTitle="付款資訊"></nz-step>
<nz-step nzTitle="確認送出"></nz-step>
</nz-steps>
<form nz-form [formGroup]="form" style="margin-top: 16px;">
<!-- Step 0:個人資訊 (子元件內的 formControlName 會依附到「最近的 FormGroupName 容器」 (personal 群組)) -->
@defer (when step() === 0) {
<div formGroupName="personal">
<app-personal-form></app-personal-form>
</div>
}
<!-- Step 1:聯絡方式(Lazy Load) -->
@defer (when step() === 1) {
<div formGroupName="contact">
<app-contact-form>
</app-contact-form>
</div>
}
<!-- Step 2:付款資訊(Lazy Load) -->
@defer (when step() === 2) {
<div formGroupName="payment">
<app-payment-form></app-payment-form>
</div>
}
<!-- Step 3:確認送出 -->
@if (step() === 3) {
<div style="border:1px solid #eee;padding:16px;margin-bottom:16px;border-radius:8px;">
<h3>確認資料</h3>
<pre style="white-space: pre-wrap; margin: 0;">{{ form.value | json }}</pre>
</div>
}
<!-- 導覽按鈕 -->
<div style="display:flex; gap:8px; margin-top: 8px;">
<button nz-button (click)="prev()" [disabled]="step() === 0">上一步</button>
<button nz-button nzType="default" *ngIf="step() < 3" (click)="next()">下一步</button>
<button nz-button nzType="primary" *ngIf="step() === 3" (click)="submit()">送出報名</button>
</div>
</form>
</div>
</div>
個人資料
@Component({
selector: 'app-personal-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, NzFormModule, NzInputModule],
template:
`
<section style="border:1px solid #eee;padding:16px;margin-bottom:16px;border-radius:8px;">
<legend>個人資訊</legend>
<nz-form-item>
<nz-form-label [nzSpan]="6" nzRequired>姓名</nz-form-label>
<nz-form-control [nzSpan]="18" [nzErrorTip]="'姓名為必填,且不得超過 50 字。'">
<input nz-input formControlName="name" placeholder="請輸入姓名" />
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-label [nzSpan]="6" nzRequired>生日</nz-form-label>
<nz-form-control [nzSpan]="18" [nzErrorTip]="'請選擇生日。'">
<input nz-input type="date" formControlName="birthday" />
</nz-form-control>
</nz-form-item>
</section>
`
,
viewProviders: [{ provide: ControlContainer, useExisting: FormGroupName }]
})
export class PersonalFormComponent {}
聯絡方式
import { CommonModule } from '@angular/common';
import { Component, Host } from '@angular/core';
import { ControlContainer, FormGroup, FormGroupName, ReactiveFormsModule } from '@angular/forms';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzInputModule } from 'ng-zorro-antd/input';
@Component({
selector: 'app-contact-form-1',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, NzFormModule, NzInputModule],
template:
`
<div style="border:1px solid #eee;padding:16px;margin-bottom:16px;border-radius:8px;">
<legend>聯絡方式</legend>
<nz-form-item>
<nz-form-label [nzSpan]="6">Email</nz-form-label>
<nz-form-control [nzSpan]="18" [nzErrorTip]="'Email 格式不正確。'">
<input nz-input formControlName="email" placeholder="example@domain.com" />
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-label [nzSpan]="6">手機</nz-form-label>
<nz-form-control [nzSpan]="18" [nzErrorTip]="'手機格式需為 09 開頭的 10 碼數字。'">
<input nz-input formControlName="phone" placeholder="09xxxxxxxx" />
</nz-form-control>
</nz-form-item>
<div
*ngIf="showGroupError"
class="ant-form-item-explain-error"
style="margin-left: calc(6/24*100%);"
aria-live="polite">
請至少填寫 Email 或手機
</div>
</div>
`,
viewProviders: [{ provide: ControlContainer, useExisting: FormGroupName }],
})
export class ContactFormComponent {
constructor(@Host() private readonly cc: ControlContainer) {}
private get group(): FormGroup {
return this.cc.control as FormGroup; // 這就是 contact 群組
}
get showGroupError(): boolean {
const g = this.group;
// 你也可以改成 g.invalid && (g.touched || g.dirty) 視 UX 而定
return !!g.errors?.['atLeastOne'] && (g.touched || g.dirty);
}
}
付款方式
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
import { ReactiveFormsModule, FormGroup, FormGroupName, ControlContainer } from '@angular/forms';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzInputModule } from 'ng-zorro-antd/input';
@Component({
selector: 'app-payment-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, NzFormModule, NzInputModule],
template:
`
<div style="border:1px solid #eee;padding:16px;margin-bottom:16px;border-radius:8px;">
<legend>付款資訊</legend>
<nz-form-item>
<nz-form-label [nzSpan]="6" nzRequired>信用卡號</nz-form-label>
<nz-form-control [nzSpan]="18" [nzErrorTip]="'請填寫 16 碼數字的卡號。'">
<input nz-input maxlength="16" formControlName="cardNumber" placeholder="16 碼數字,不含空白" />
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-label [nzSpan]="6" nzRequired>有效期限</nz-form-label>
<nz-form-control [nzSpan]="18" [nzErrorTip]="'請輸入正確的 MM/YY。'">
<input nz-input formControlName="expireDate" placeholder="MM/YY,如 08/28" />
</nz-form-control>
</nz-form-item>
</div>
`,
viewProviders: [{ provide: ControlContainer, useExisting: FormGroupName }],
})
export class PaymentFormComponent {
}
大表單若全部塞在一個元件,維護上會比較困難。雖然也可以利用 input 控制子元件,或是不在父層定義表單名稱,直接在子元件寫上對應的 formGroupName,並把 useExisting 改成利用 FormGroupDirective 的方式也都可以拆成子元件,但目前覺得彈性最好的方式還是剛剛內文介紹的方式,看起來最乾淨、可讀性最高。
👉 下一篇(篇五),我們將探討 動態表單 (Schema-driven Forms),讓表單不需要手動定義,而是根據一份 Schema 自動生成。
https://github.com/angular/angular/pull/63408
Angular 團隊正在開發 signal form prototype。我還沒看過,但值得一看。
喔!我也來看看
我們一起學習吧。