iT邦幫忙

2021 iThome 鐵人賽

DAY 10
0
Modern Web

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

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

Day10

今天要來用 Template Driven Forms 的方式實作一個很簡易的動態表單,使用上有點像是保險業者的系統,可以新增多名被保人,也可以編輯與刪除被保人。

具體的規格需求如下:

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

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

開始實作

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

<form>
  <fieldset>
    <legend>被保人</legend>
    <p>
      <label for="name">姓名:</label>
      <input 
        type="text" 
        name="name" 
        id="name"
        required
        maxlength="10" 
        minlength="2"
      />
      <span class="error-message"></span>
    </p>
    <p>
      性別:
      <input type="radio" name="gender" id="male" value="male">
      <label for="male">男</label>
      <input type="radio" name="gender" id="female" value="female">
      <label for="female">女</label>
    </p>
    <p>
      <label for="age">年齡:</label>
      <select name="age" id="age" required>
        <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"></span>
    </p>
    <p><button type="button">刪除</button></p>
  </fieldset>
  <p>
    <button type="button">新增被保險人</button>
    <button type="submit">送出</button>
  </p>
</form>

未經美化的畫面應該會長這樣:

Template view

基本的 HTML 準備好之後,我建議對於 Angular 還沒那麼熟悉的朋友先不要一口氣就想要直接把它做成動態的,先把它當成靜態表單來做會比較簡單一些。

因此,我們先準備相關的屬性與方法:

import { Component } from '@angular/core';
import { ValidationErrors } 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 {

  // 綁在姓名欄位上
  name = '';

  // 綁在性別欄位上
  gender = '';

  // 綁在年齡欄位上
  age = '';

  // 姓名欄位的錯誤訊息
  nameErrorMessage = '';

  // 年齡欄位的錯誤訊息
  ageErrorMessage = '';

  /**
   * 綁定在姓名欄位上,當使用者改變被保險人的姓名時,會觸發此函式,並取得對應的錯誤訊息
   *
   * @param {string} name
   * @param {ValidationErrors | null} errors
   * @memberof TemplateDrivenFormsAsyncInsuredComponent
   */
  insuredNameChange(name: string, errors: ValidationErrors | null): void {
    this.name = name;
    this.nameErrorMessage = this.getErrorMessage(errors);
  }

  /**
   * 綁定在年齡欄位上,當使用者改變被保險人的年齡時,會觸發此函式,並取得對應的錯誤訊息
   *
   * @param {string} age
   * @param {ValidationErrors | null} errors
   * @memberof TemplateDrivenFormsAsyncInsuredComponent
   */
   insuredAgeChange(age: string, errors: ValidationErrors | null): void {
    this.age = age;
    this.ageErrorMessage = this.getErrorMessage(errors);
  }

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

  /**
   * 根據 FormControl 的 errors 屬性取得相應的錯誤訊息
   *
   * @private
   * @param {ValidationErrors | null} errors - FormControl 的 errors
   * @return {*}  {string}
   * @memberof TemplateDrivenFormsAsyncInsuredComponent
   */
  private getErrorMessage(errors: ValidationErrors | null): string {
    let errorMessage = '';
    if (errors?.required) {
      errorMessage = '此欄位必填';
    } else if (errors?.minlength) {
      errorMessage = '姓名至少需兩個字以上';
    }
    return errorMessage;
  }
}

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

<form (ngSubmit)="submit()">
  <fieldset>
    <legend>被保人</legend>
    <p>
      <label for="name">姓名:</label>
      <input
        type="text"
        name="name"
        id="name"
        required
        maxlength="10"
        minlength="2"
        #nameNgModel="ngModel"
        [ngModel]="name"
        (ngModelChange)="insuredNameChange(nameNgModel.value, nameNgModel.errors)"
      />
      <span class="error-message">{{ nameErrorMessage }}</span>
    </p>
    <p>
      性別:
      <input
        type="radio"
        name="gender"
        id="male"
        value="male"
        required
        [(ngModel)]="gender"
      >
      <label for="male">男</label>
      <input
        type="radio"
        name="gender"
        id="female"
        value="female"
        required
        [(ngModel)]="gender"
      >
      <label for="female">女</label>
    </p>
    <p>
      <label for="age">年齡:</label>
      <select
        name="age"
        id="age"
        required
        #ageNgModel="ngModel"
        [ngModel]="age"
        (ngModelChange)="insuredAgeChange(ageNgModel.value, ageNgModel.errors)"
      >
        <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">{{ ageErrorMessage }}</span>
    </p>
    <p><button type="button">刪除</button></p>
  </fieldset>
  <p>
    <button type="button">新增被保險人</button>
    <button type="submit">送出</button>
  </p>
</form>

從目前的程式碼應該不難發現,大體上跟我們第二天的實作內容差不多、結構也差不多,應該沒有什麼難度。

如果大家在這邊有遇到問題,大致上可以檢查看看自己有沒有引入 FormsModule ,抑或者是表單欄位上是否有 name 屬性,我就不再贅述囉。

目前的結果:

result

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

相信大家一定知道,既然我們要讓被保人可以被新增或刪除,表示我們應該是會用陣列來存放這些被保人的資料,所以我們可以先將這些我們需要的資料欄位定義一個型別以便後續使用。

像是這樣:

export type Insured = {
  name: string;
  gender: string;
  age: number;
  nameErrorMessage: string;
  ageErrorMessage: string;
};

或者是這樣:

export interface Insured {
  name: string;
  gender: string;
  age: number;
  nameErrorMessage: string;
  ageErrorMessage: string;
};

甚至是這樣:

export class Insured {
  name: string;
  gender: string;
  age: string;
  nameErrorMessage: string;
  ageErrorMessage: string;
};

這三種定義型別的方式基本上都可以,我就不多解釋他們之間的差異了,我個人近期是滿喜歡用第一種的。

接著我們就可以將原本那些單個的屬性拿掉,改成用陣列的方式,像是這樣:

// 以上省略...
import { Insured } from './insured.type';

@Component({
  // 省略...
})
export class TemplateDrivenFormsAsyncInsuredComponent {

  // 被保險人清單
  insuredList: Insured[] = [];

  // 以下這些都可以移除
  // name = '';
  // gender = '';
  // age = '';
  // nameErrorMessage = '';
  // ageErrorMessage = '';

  // 以下省略...
}

這些單個的屬性移除掉之後,原本有使用到它們的部分就會壞掉,所以我們要將它們改為使用傳進來的被保人的資料,像這樣:

// 以上省略...
import { Insured } from './insured.type';

@Component({
  // 省略...
})
export class TemplateDrivenFormsAsyncInsuredComponent {

  // 被保險人清單
  insuredList: Insured[] = [];

  /**
   * 綁定在姓名欄位上,當使用者改變被保險人的姓名時,會觸發此函式,並取得對應的錯誤訊息
   *
   * @param {string} name
   * @param {ValidationErrors | null} errors
   * @param {Insured} insured
   * @memberof TemplateDrivenFormsAsyncInsuredComponent
   */
  insuredNameChange(name: string, errors: ValidationErrors | null, insured: Insured): void {
    insured.name = name;
    insured.nameErrorMessage = this.getErrorMessage(errors);
  }

  /**
   * 綁定在年齡欄位上,當使用者改變被保險人的年齡時,會觸發此函式,並取得對應的錯誤訊息
   *
   * @param {string} age
   * @param {ValidationErrors | null} errors
   * @param {Insured} insured
   * @memberof TemplateDrivenFormsAsyncInsuredComponent
   */
   insuredAgeChange(age: string, errors: ValidationErrors | null, insured: Insured): void {
    insured.age = age;
    insured.ageErrorMessage = this.getErrorMessage(errors);
  }

  // 以下省略...
}

接著我們就可以到 Template 裡,將所有被保人的資料用 *ngFor 的方式迴圈出來,並將原本用單個屬性綁定的部份也改為綁定迴圈出來的被保人資料:

<form (ngSubmit)="submit()">
  <!-- 將所有被保人的資料迴圈出來 -->
  <fieldset *ngFor="let insured of insuredList">
    <legend>被保人</legend>
    <p>
      <label for="name">姓名:</label>
      <!-- 改為綁定被迴圈出來的被保人資料,並將其傳入函式內 -->
      <input
        type="text"
        name="name"
        id="name"
        required
        maxlength="10"
        minlength="2"
        #nameNgModel="ngModel"
        [ngModel]="insured.name" 
        (ngModelChange)="insuredNameChange(nameNgModel.value, nameNgModel.errors, insured)"
      />
      <span class="error-message">{{ insured.nameErrorMessage }}</span>
    </p>
    <p>
      性別:
      <!-- 改為綁定被迴圈出來的被保人資料 -->
      <input
        type="radio"
        name="gender"
        id="male"
        value="male"
        required
        [(ngModel)]="insured.gender"
      >
      <label for="male">男</label>
      <input
        type="radio"
        name="gender"
        id="female"
        value="female"
        required
        [(ngModel)]="insured.gender"
      >
      <label for="female">女</label>
    </p>
    <p>
      <label for="age">年齡:</label>
      <!-- 改為綁定被迴圈出來的被保人資料,並將其傳入函式內 -->
      <select
        name="age"
        id="age"
        required
        #ageNgModel="ngModel"
        [ngModel]="insured.age"
        (ngModelChange)="insuredAgeChange(ageNgModel.value, ageNgModel.errors, insured)"
      >
        <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">{{ insured.ageErrorMessage }}</span>
    </p>
    <p><button type="button">刪除</button></p>
  </fieldset>
  <p>
    <button type="button">新增被保險人</button>
    <button type="submit">送出</button>
  </p>
</form>

接著我們就可以儲存以查看目前的結果:

result

咦?!怎麼表單欄位不見了?!

別緊張,這是因為 insuredList 現在是個空陣列呀!

接下來我們再加個新增被保險人與刪除被保險人的函式:

/**
  * 新增被保險人
  *
  * @memberof TemplateDrivenFormsAsyncInsuredComponent
  */
addInsured(): void {
  const insured: Insured = {
    name: '',
    gender: '',
    age: '',
    nameErrorMessage: '',
    ageErrorMessage: ''
  };
  this.insuredList.push(insured);
}

/**
  * 刪除被保險人
  *
  * @param {number} index
  * @memberof TemplateDrivenFormsAsyncInsuredComponent
  */
deleteInsured(index: number): void {
  this.insuredList.splice(index, 1);
}

然後把它們綁定到按鈕上,並且在 *ngFor 裡新增索引的宣告,以供刪除時使用 :

<form (ngSubmit)="submit()">
  <fieldset *ngFor="let insured of insuredList; let index = index">
    <!-- 中間省略... -->
    <p><button type="button" (click)="deleteInsured(index)">刪除</button></p>
  </fieldset>
  <p>
    <button type="button" (click)="addInsured()">新增被保險人</button>
    <button type="submit">送出</button>
  </p>
</form>

結果:

result

雖然我們的表單就差不多快完成了,但其實我們的表單目前有兩個問題,不曉得大家有沒有發現?

問題一

thinking

專業的前端工程師來說,我們做出來的表單一定要讓人家有良好的使用者體驗。

為此,我們通常會使用一些 HTML 的屬性來讓我們的表單更為人性化,像是在 label 上加 for

但問題來了, for 要跟 id 搭配使用,但 id 一整頁只會有一個,而我們可能會有 N 個被保險人,怎辦?

這時候我們可以善用陣列的索引值來幫我們達成這個目的,像是這樣:

<label [for]="'name-' + index">姓名:</label>
<input
  type="text"
  [name]="'name-' + index"
  [id]="'name-' + index"
  required
  maxlength="10"
  minlength="2"
  #nameNgModel="ngModel"
  [ngModel]="insured.name"
  (ngModelChange)="insuredNameChange(nameNgModel.value, nameNgModel.errors, insured)"
/>

我知道很醜,但沒辦法,這是天生的侷限。

對了, name 屬性也要噢!因為表單裡的 name 也是唯一性的。

問題二

thinking

這個問題是因為在畫面重新渲染完之後, NgForm 裡面 Key 值為 xxx-0NgModel 們就不見了,只留下 xxx-1NgModel 們。在這之後如果再按新增被保人時,由於新增的那一筆的索引是 1 ,就又會把原本留下的 Key 值為 xxx-1NgModel 們蓋掉,導致大家現在所看到的情況。

thinking

解決方式其實說難不難,因為其實 *ngFor 有個 trackBy 的參數,只要傳入這個參數就可以解決這個問題。但說簡單也不簡單,不知道原因跟解法的人就會卡上一段時間。

其實我一開始也卡住,還跟社群的人求救,進而引出一大串的討論(笑)。

方式是先在 .ts 裡加一個函式:

/**
  * 根據索引來重新渲染有更改的節點
  *
  * @param {string} index
  * @return {*}  {number}
  * @memberof AppComponent
  */
trackByIndex(index: number): number {
  return index;
}

然後在 *ngFor 的後面加上:

<fieldset *ngFor="let insured of insuredList; let index = index; trackBy: trackByIndex">

這樣就可以解決我們的問題了!

最後,我們就剩以下兩項事情還沒做:

  • 任一驗證有誤時,送出按鈕皆呈現不可被點選之狀態
  • 沒有被保險人時,送出按鈕皆呈現不可被點選之狀態

這兩件事情基本上可以看成同一件事情 ─ 判斷表單是否無效。

怎麼判斷呢?

大家記不記得上次有用到一個類別叫做 NgForm ,當表單內的驗證有誤時, NgForm 的屬性 invalid 就會為 true

所以我們一樣可以利用它來幫我們判斷,像這樣:

<form #form="ngForm" (ngSubmit)="submit()">
  <fieldset *ngFor="let insured of insuredList; let index = index">
    <!-- 中間省略... -->
  </fieldset>
  <p>
    <button type="button" (click)="addInsured()">新增被保險人</button>
    <button type="submit" [disabled]="insuredList.length === 0 || form.invalid">送出</button>
  </p>
</form>

結果:

result

本日小結

今天的學習重點主要是在練習如何讓靜態的表單變成動態,雖然沒有多複雜,但可能也是會難倒大部分的初學者。

其實大體上的邏輯跟實作登入時是差不多的,大家之所以會卡住主要可能會是因為不知道如何讓靜態表單變成動態,而以 Template Driven Forms 的方式來說,滿多程式碼都會綁在 Template 上,大家在實作時要看清楚才不會出錯。

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

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


上一篇
Angular 深入淺出三十天:表單與測試 Day09 - 整合測試實作 - 登入系統 by Reactive Forms
下一篇
Angular 深入淺出三十天:表單與測試 Day11 - Reactive Forms 實作 - 動態表單初體驗
系列文
Angular 深入淺出三十天:表單與測試30

1 則留言

0
TD
iT邦新手 4 級 ‧ 2021-09-28 22:46:54
  /**
   * 根據索引來重新渲染有更改的節點
   * 詳情請參考官方文件:https://angular.tw/api/common/NgForOf
   *
   * @param {string} index
   * @return {*}  {number}
   * @memberof AppComponent
   */

想請問這裡的 @memberof 是 AppComponent 還是 TemplateDrivenFormsAsyncInsuredComponent 呢?

另外想問這些註解是手動寫出來的,還是自動生成的 XD

Leo iT邦新手 3 級 ‧ 2021-09-28 22:51:35 檢舉

HI TD,

其實我這邊的 @memberof 不用太關注,因為我可能會因為貼來貼去的關係導致沒有改到XDD

這是自動產生的噢!我是使用 Document This 這個套件,還滿方便的!

TD iT邦新手 4 級 ‧ 2021-09-28 23:08:14 檢舉

感謝介紹!我自己因為好奇所以特別注意裡面的內容 XD

Leo iT邦新手 3 級 ‧ 2021-09-29 09:49:03 檢舉

這算是標準 JSDoc 的格式,有興趣的話可以研究看看

我要留言

立即登入留言