iT邦幫忙

2021 iThome 鐵人賽

DAY 11
0
Modern Web

Angular 深入淺出三十天:表單與測試系列 第 11

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

Day11

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

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

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

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

實作開始

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

<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

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

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 綁定:

<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 裡面,就像這樣:

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

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

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

絕對不能偷懶寫成這樣:

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

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

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

@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 裡,把原本綁定的方式調整一下:

<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 都拿掉的話其實會清楚很多:

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

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

<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

<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 改成這樣其實也可以:

<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 上供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!

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


上一篇
Angular 深入淺出三十天:表單與測試 Day10 - Template Driven Forms 實作 - 動態表單初體驗
下一篇
Angular 深入淺出三十天:表單與測試 Day12 - 單元測試實作 - 被保人 by Template Driven Forms
系列文
Angular 深入淺出三十天:表單與測試30

2 則留言

0
tso1158687
iT邦新手 4 級 ‧ 2021-09-26 22:15:25

github 的連結好像錯了,沒有東西!

Leo iT邦新手 3 級 ‧ 2021-09-26 22:17:52 檢舉

感謝提醒!

0
TD
iT邦新手 5 級 ‧ 2021-09-28 23:02:40
  get formArray(): FormArray {
    return this.formGroup?.get('insuredList')! as FormArray;
  }

想請問這裡的 ! 是一定要加的嗎?

Leo iT邦新手 3 級 ‧ 2021-09-28 23:07:53 檢舉

如果你專案所採用的 Angular 版本高於或等於 v12 ,抑或者是你有啟用 TypeScript 的 strict mode 的話,建議是需要,不然你會需要在其他的地方加 ? 或者是 !

TD iT邦新手 5 級 ‧ 2021-09-28 23:11:24 檢舉

原來如此!我剛剛 fork 程式碼下來,刪掉 ! 好像沒有跳出提醒,我再檢查一下我的設定好了 :)

Leo iT邦新手 3 級 ‧ 2021-09-29 09:25:46 檢舉

我剛試了一下,沒有跳提醒的原因主要是因為我有強制轉型成 FormArray ,以這個例子來說倒是真的可以不用加沒錯

我要留言

立即登入留言