Angular 深入淺出三十天:表單與測試 Day11 - Reactive Forms 實作 - 動態表單初體驗

Day11

今天要來用 Reactive Forms 的方式再來實作一次昨天的表單。

具體的規格需求跟昨天差不多,如下所示:

  • 被保險人的欄位:
    • 姓名(文字輸入框)
      • 最少需要填寫兩個字,如驗證有誤則顯示錯誤訊息姓名至少需兩個字以上
      • 最多只能填寫十個字,如驗證有誤則顯示錯誤訊息姓名最多只能十個字
    • 性別(單選)
      • 選項:男性、女性
    • 年齡(下拉選單)
      • 選項: 18 歲、 20 歲、 70 歲、 75 歲
  • 以上欄位皆為必填,如驗證有誤則顯示錯誤訊息此欄位為必填
  • 以上驗證皆需在使用者輸入時動態檢查
  • 按下新增被保險人按鈕可以新增被保險人
  • 按下刪除被保險人按鈕可以刪除被保險人
  • 任一驗證有誤時,送出按鈕皆呈現不可被點選之狀態
  • 沒有被保險人時,送出按鈕皆呈現不可被點選之狀態

規格需求看清楚之後,我們就來開始實作吧!

實作開始

首先我們一樣先準備好基本的 HTML :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<form *ngIf="formGroup" [formGroup]="formGroup" (ngSubmit)="submit()">
<fieldset>
<legend>被保人</legend>
<p>
<label for="name">姓名:</label>
<input type="text" id="name" formControlName="name" />
<span class="error-message">{{ getErrorMessage("name") }}</span>
</p>
<p>
性別:
<input type="radio" id="male" value="male" formControlName="gender" />
<label for="male"></label>
<input type="radio" id="female" value="female" formControlName="gender" />
<label for="female"></label>
</p>
<p>
<label for="age">年齡:</label>
<select id="age" formControlName="age">
<option value="">請選擇</option>
<option value="18">18歲</option>
<option value="20">20歲</option>
<option value="70">70歲</option>
<option value="75">75歲</option>
</select>
<span class="error-message">{{ getErrorMessage("age") }}</span>
</p>
<p><button type="button">刪除</button></p>
</fieldset>
<p>
<button type="button">新增被保險人</button>
<button type="submit">送出</button>
</p>
</form>

未經美化的畫面跟昨天長得一樣:

Template view

接著跟昨天一樣先把它當成靜態表單來準備相關的屬性與方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';

@Component({
selector: 'app-template-driven-forms-async-insured',
templateUrl: './template-driven-forms-async-insured.component.html',
styleUrls: ['./template-driven-forms-async-insured.component.scss']
})
export class TemplateDrivenFormsAsyncInsuredComponent {

/**
* 綁定在表單上
*
* @type {(FormGroup | undefined)}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
formGroup: FormGroup | undefined;

/**
* 透過 DI 取得 FromBuilder 物件,用以建立表單
*
* @param {FormBuilder} formBuilder
* @memberof ReactiveFormsAsyncInsuredComponent
*/
constructor(private formBuilder: FormBuilder) {}

/**
* 當 Component 初始化的時候初始化表單
*
* @memberof ReactiveFormsAsyncInsuredComponent
*/
ngOnInit(): void {
this.formGroup = this.formBuilder.group({
name: [
'',
[Validators.required, Validators.minLength(2), Validators.maxLength(10)]
],
gender: ['', Validators.required],
age: ['', Validators.required]
});
}

/**
* 透過欄位的 Errors 來取得對應的錯誤訊息
*
* @param {string} key
* @param {number} index
* @return {*} {string}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
getErrorMessage(key: string): string {
const formControl = this.formGroup?.get(key);
let errorMessage: string;
if (!formControl || !formControl.errors || formControl.pristine) {
errorMessage = '';
} else if (formControl.errors.required) {
errorMessage = '此欄位必填';
} else if (formControl.errors.minlength) {
errorMessage = '姓名至少需兩個字以上';
} else if (formControl.errors.maxlength) {
errorMessage = '姓名至多只能輸入十個字';
}
return errorMessage!;
}

/**
* 綁定在表單上,當按下送出按鈕時會觸發此函式
*
* @memberof TemplateDrivenFormsAsyncInsuredComponent
*/
submit(): void {
// do submit...
}
}

準備好相關的屬性和方法之後,我們直接把他們跟 Template 綁定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<form *ngIf="formGroup" [formGroup]="formGroup" (ngSubmit)="submit()">
<fieldset>
<legend>被保人</legend>
<p>
<label for="name">姓名:</label>
<input
type="text"
id="name"
formControlName="name"
/>
<span class="error-message">{{ getErrorMessage('name') }}</span>
</p>
<p>
性別:
<input type="radio" id="male" value="male" formControlName="gender">
<label for="male"></label>
<input type="radio" id="female" value="female" formControlName="gender">
<label for="female"></label>
</p>
<p>
<label for="age">年齡:</label>
<select id="age" formControlName="age">
<option value="">請選擇</option>
<option value="18">18歲</option>
<option value="20">20歲</option>
<option value="70">70歲</option>
<option value="75">75歲</option>
</select>
<span class="error-message">{{ getErrorMessage('age') }}</span>
</p>
<p><button type="button">刪除</button></p>
</fieldset>
<p>
<button type="button">新增被保險人</button>
<button type="submit">送出</button>
</p>
</form>

目前為止,大體上跟我們上次的實作差不多,應該沒有什麼難度。

不過這次綁定 FormControl 的方式,我改成用 formControlName="name" ,而不是上次的 [formControl]="nameControl" ,大家可以自行選用喜歡的方式。

如果大家在這邊有遇到問題,可以檢查看看自己有沒有引入 FormsModuleReactiveFormsModule ,我就不再贅述囉。

目前的結果:

result

有了基本的互動效果之後,我們就可以開始來思考怎麼樣把這個表單變成動態的。

跟昨天一樣的是,既然我們要讓被保人可以被新增或刪除,表示我們應該是會用陣列來表達這些被保人的資料,也就是說,我們現在的 FormGroup 要從 1 個變成 N 個。

之前曾經提到,我們如果從資料面來看, {} 代表表單,也就是 FormGroup'' 代表表單裡的子欄位,也就是 FormControl ;那 [] 呢?

答案是 ─ FormArray

不過 FormArray 不能直接跟 form 元素綁定,唯一可以跟 form 元素綁定的只有 FormGroup ,所以 FormArray 一定要在 FormGroup 裡面,就像這樣:

1
2
3
this.formGroup = this.formBuilder.group({
insuredList: this.formBuilder.array([])
});

這邊要注意的是, FormArray 一定要透過 FormBuilder 或是 FormArray 的建構式來建立,像上面示範的那樣,或是這樣:

1
2
3
this.formGroup = this.formBuilder.group({
insuredList: new FormArray([])
});

絕對不能偷懶寫成這樣:

1
2
3
this.formGroup = this.formBuilder.group({
insuredList: []
});

這樣的話,就會變成普通的 FormControl 囉!切記切記!

接著我們就可以將原本的程式碼修改成用陣列的方式,並把新增被保人、刪除被保人與判斷表單是否有效的函式都補上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
@Component({
// 省略...
})
export class AppComponent implements OnInit {

/**
* 綁定在表單上
*
* @type {(FormGroup | undefined)}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
formGroup: FormGroup | undefined;

/**
* 用以取得 FormArray
*
* @readonly
* @type {FormArray}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
get formArray(): FormArray {
return this.formGroup?.get('insuredList')! as FormArray;
}

/**
* 綁定在送出按鈕上,判斷表單是不是無效
*
* @readonly
* @type {boolean}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
get isFormInvalid(): boolean {
return this.formArray.controls.length === 0 || this.formGroup!.invalid;
}

/**
* 透過 DI 取得 FromBuilder 物件,用以建立表單
*
* @param {FormBuilder} formBuilder
* @memberof ReactiveFormsAsyncInsuredComponent
*/
constructor(private formBuilder: FormBuilder) {}

/**
* 當 Component 初始化的時候初始化表單
*
* @memberof ReactiveFormsAsyncInsuredComponent
*/
ngOnInit(): void {
this.formGroup = this.formBuilder.group({
insuredList: this.formBuilder.array([])
});
}

/**
* 新增被保人
*
* @memberof ReactiveFormsAsyncInsuredComponent
*/
addInsured(): void {
const formGroup = this.createInsuredFormGroup();
this.formArray.push(formGroup);
}

/**
* 刪除被保人
*
* @param {number} index
* @memberof ReactiveFormsAsyncInsuredComponent
*/
deleteInsured(index: number): void {
this.formArray.controls.splice(index, 1);
this.formArray.updateValueAndValidity();
}

/**
* 送出表單
*
* @memberof ReactiveFormsAsyncInsuredComponent
*/
submit(): void {
// do login...
}

/**
* 透過欄位的 Errors 來取得對應的錯誤訊息
*
* @param {string} key
* @param {number} index
* @return {*} {string}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
getErrorMessage(key: string, index: number): string {
const formGroup = this.formArray.controls[index];
const formControl = formGroup.get(key);
let errorMessage: string;
if (!formControl || !formControl.errors || formControl.pristine) {
errorMessage = '';
} else if (formControl.errors.required) {
errorMessage = '此欄位必填';
} else if (formControl.errors.minlength) {
errorMessage = '姓名至少需兩個字以上';
} else if (formControl.errors.maxlength) {
errorMessage = '姓名至多只能輸入十個字';
}
return errorMessage!;
}

/**
* 建立被保人的表單
*
* @private
* @return {*} {FormGroup}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
private createInsuredFormGroup(): FormGroup {
return this.formBuilder.group({
name: [
'',
[Validators.required, Validators.minLength(2), Validators.maxLength(10)]
],
gender: ['', Validators.required],
age: ['', Validators.required]
});
}
}

接著我們到 Template 裡,把原本綁定的方式調整一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<form *ngIf="formGroup" [formGroup]="formGroup" (submit)="submit()">
<ng-container
formArrayName="insuredList"
*ngFor="let control of formArray.controls; let index = index"
>
<fieldset [formGroupName]="index">
<legend>被保人</legend>
<p>
<label [for]="'name-' + index">姓名:</label>
<input type="text" [id]="'name-' + index" formControlName="name" />
<span class="error">{{ getErrorMessage("name", index) }}</span>
</p>
<p>
性別:
<input
type="radio"
[id]="'male-' + index"
value="male"
formControlName="gender"
/>
<label [for]="'male-' + index"></label>
<input
type="radio"
[id]="'female-' + index"
value="female"
formControlName="gender"
/>
<label [for]="'female-' + index"></label>
</p>
<p>
<label [for]="'age-' + index">年齡:</label>
<select name="age" [id]="'age-' + index" formControlName="age">
<option value="">請選擇</option>
<option value="18">18歲</option>
<option value="20">20歲</option>
<option value="70">70歲</option>
<option value="75">75歲</option>
</select>
<span class="error">{{ getErrorMessage("age", index) }}</span>
</p>
<p><button type="button" (click)="deleteInsured(index)">刪除</button></p>
</fieldset>
</ng-container>
<p>
<button type="button" (click)="addInsured()">新增被保險人</button>
<button type="submit" [disabled]="isFormInvalid">送出</button>
</p>
</form>

初次看到這種綁定方式的 Angular 初學者可能會傻眼,不過靜下心來看之後你會發現,其實這只是我們所建立的 FormGroup 裡的階層關係,這樣綁定 Angular 才能從一層層的表單之中開始往下找。

如果我們把其他的 HTML 都拿掉的話其實會清楚很多:

1
2
3
<form *ngIf="formGroup" [formGroup]="formGroup" (submit)="submit()">
<!-- 其他省略 -->
</form>

最外層的這個大家應該都知道,就是我們在 .ts 裡的 formGroup

1
2
3
4
5
6
7
8
<form *ngIf="formGroup" [formGroup]="formGroup" (submit)="submit()">
<ng-container
formArrayName="insuredList"
*ngFor="let control of formArray.controls; let index = index"
>
<!-- 其他省略 -->
</ng-container>
</form>

而這裡呢,就像我們寫靜態表單的時候,會從 FormGroup 裡根據對應的 key 值找到對應的 FormControl 一樣,這裡則是把對應的 FormArray 找出來。

然後再用 *ngFor 的方式,把 FormArray 底下的 AbstractControl 都迴圈出來。

關於 AbstractControl ,它其實是一個抽象類別,而 FormGroupFormArrayFormControl 這三種類型其實都繼承於這個類別,所以大家不知道有沒有注意到,一般我們在 .ts 裡使用的時候,我們會特別用 as FormControl 或是 as FormArray 的方式來讓編譯器知道現在取得的物件實體是什麼型別,以便後續使用。

想知道更多 AbstractControl 的資訊的話,請參考官方 API 文件: https://angular.io/api/forms/AbstractControl

1
2
3
4
5
6
7
8
9
<form *ngIf="formGroup" [formGroup]="formGroup" (submit)="submit()">
<ng-container
formArrayName="insuredList"
*ngFor="let control of formArray.controls; let index = index"
>
<fieldset [formGroupName]="index">
</fieldset>
</ng-container>
</form>

最後再用索引值 index 找出對應的 FormGroup

而要做這件事情其實要有相對應的階層關係的 HTML 來幫忙,但因為我的 HTML 的階層關係少一層,所以我才會用 ng-container 多做一層階層,好讓我的表單可以順利綁上去。

如果今天你做的 HTML 的階層數是足夠的,就可以不用用 ng-container 多做一層階層,例如把上面的 HTML 改成這樣其實也可以:

1
2
3
4
5
6
7
8
9
<form *ngIf="formGroup" [formGroup]="formGroup" (submit)="submit()">
<div
formArrayName="insuredList"
*ngFor="let control of formArray.controls; let index = index"
>
<fieldset [formGroupName]="index">
</fieldset>
</div>
</form>

不過用 ng-container 的好處是這個元素並不會真的出現在畫面上,大家可以視情況斟酌使用。

改完之後就大功告成囉!來看看最後的結果:

result

本日小結

今天的學習重點主要是在圍繞在 FormArray 上,因為多了這個階層的關係,所以在與 Template 的綁定上看起來會較為複雜一點點。

話雖如此,大家可以拿今天的 template 與昨天的 template 互相比較一下,除了 forid 這兩個屬性因為天生侷限的關係真的沒辦法之外,但 name 的部份就不用再去處理了,還是很方便的。

今天的程式碼我會放在 Github - Branch: day11 上供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!

如果有任何的問題或是回饋,也都非常歡迎留言給我讓我知道噢!

評論

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×