iT邦幫忙

2021 iThome 鐵人賽

DAY 27
1
Modern Web

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

Angular 深入淺出三十天:表單與測試 Day27 - Reactive Forms 進階技巧 - 跨欄位驗證

Day27

今天想要跟大家分享的是跨欄位驗證的小技巧,這個小技巧其實沒有多厲害或多特別,只是可能滿多人剛好不知道原來可以這樣用。

而我們在 Day 23 - Reactive Forms 進階技巧 - 欄位連動檢核邏輯 所分享過欄位連動檢核邏輯的部份,就某方面來說,其實也可以使用這種方式來做,但究竟要適不適合、要不要使用,我覺得一切都還是要看需求、看想給使用者什麼樣的使用體驗來決定。

畢竟系統是為了服務需求而存在,至於能不能做到、能不能解決問題就看工程師的功力囉。

實作開始

言歸正傳,我們今天要做的功能是起迄日日期欄位檢核

感謝我的朋友 ─ Joseph 所提供的案例讓我多活了一天

規格需求

詳細規格需求如下:

  • 起日
    • 必填,驗證有誤時需顯示錯誤訊息: 此欄位必填
    • 格式需為 yyyy-MM-dd ,驗證有誤時需顯示錯誤訊息: 日期格式不正確
    • 需為確切存在的日期,驗證有誤時需顯示錯誤訊息: 此日期不存在
  • 迄日
    • 非必填
    • 格式需為 yyyy-MM-dd ,驗證有誤時需顯示錯誤訊息: 日期格式不正確
    • 需為確切存在的日期,驗證有誤時需顯示錯誤訊息: 此日期不存在
    • 迄日不可早於起日,驗證有誤時需顯示錯誤訊息: 迄日不可早於起日
    • 迄日不可晚於起日超過七天,驗證有誤時需顯示錯誤訊息: 迄日不可晚於起日超過七天
  • 以上驗證皆需在使用者輸入時動態檢查

準備畫面

接下來我們先把畫面準備好, HTML 如下:

<form>
  <p>
    <label for="start-date">起日:</label>
    <input type="text" id="start-date" placeholder="yyyy-mm-dd">
  </p>
  <p>
    <label for="end-date">迄日:</label>
    <input type="text" id="end-date" placeholder="yyyy-mm-dd">
  </p>
</form>

畫面應該會長這樣:

Template View

我知道一般大家在實作的時候會用漂亮的 UI 套件,不過我們現在主要聚焦在功能面,所以欄位的部份我只用簡單的 <input type="text"> 的方式實作。

其實我本來想至少用 <input type="date"> 來實作的,但它會害我們無法判斷使用者到底有沒有輸入值,所以最後還是放棄了使用它的打算。

準備 FormGroup

接著把 Reactive Forms 的 FormGroup 也準備好:

export class ReactiveFormsDateRangeComponent implements OnInit {

  formGroup: FormGroup | undefined;

  constructor(private formBuilder: FormBuilder) { }

  ngOnInit(): void {
    this.formGroup = this.formBuilder.group({
      startDate: [
        '', [
          Validators.required,
          Validators.pattern(/^\d{4}-\d{2}-\d{2}$/)
        ]
      ],
      endDate: ['', Validators.pattern(/^\d{4}-\d{2}-\d{2}$/)]
    });
  }

}

其實這邊的 Validators.requiredValidators.pattern(/^\d{4}-\d{2}-\d{2}$/) 可以不加,只不過後續就要把判斷寫在另一個地方,看大家想要稍稍彈性一點,還是直接寫死在另外一個地方都可以。

然後綁定到 Template 的表單上:

<form *ngIf="formGroup" [formGroup]="formGroup">
  <p>
    <label for="start-date">起日:</label>
    <input type="text" id="start-date" placeholder="yyyy-mm-dd" formControlName="startDate">
  </p>
  <p>
    <label for="end-date">迄日:</label>
    <input type="text" id="end-date" placeholder="yyyy-mm-dd" formControlName="endDate">
  </p>
</form>

再次提醒大家,在使用 Reactive Forms 的方式來開發表單時,請記得到 .module.ts 裡的引入 FormsModuleReactiveFormsModule

大家不要覺得我像老頭子一樣囉哩囉嗦的,都已經做了幾次的練習了還要一直提醒大家記得引入 FormsModuleReactiveFormsModule

相信我,如果我沒提醒,一定會有很多還不是很熟悉的朋友會卡住。

所以大家互相體諒包容一下,熟悉的朋友快速略過就好。

自訂驗證器 ─ dateRangeValidator

接著我們來用昨天分享過的自訂驗證器的的技巧來自訂一個名為 dateRangeValidator 的驗證器,程式碼如下:

export const dateRangeValidator: ValidatorFn = (formGroup) => {
  console.log(formGroup.value);
  return null
};

先把它掛在 FormGroup 上:

this.formGroup = this.formBuilder.group({
  startDate: [
    '', [
      Validators.required,
      Validators.pattern(/^\d{4}-\d{2}-\d{2}$/)
    ]
  ],
  endDate: ['', Validators.pattern(/^\d{4}-\d{2}-\d{2}$/)]
}, { validators: dateRangeValidator });

然後我們就可以在控制台裡看到 ─ 當 FormGroup 裡的欄位的值有變動時,就會觸發我們自訂的驗證器:

Template View

一開始的四個 { startDate: '', endDate: '' } 是 FormGroup 在初始化的時後所觸發的。

其實今天要做的這個功能最關鍵、最重要的兩件事情就是:

  1. 實作自訂驗證器
  2. 把它掛在 FormGroup 上

所以我們已經做完了,今天的文章就分享到這邊。

謎之音:喂!你給我回來!(抓回)

接下來在實作驗證器之前,我想先制定該驗證器在驗證有誤時,所要回傳的 ValidationErrors 格式。

制定驗證器的 ValidationErrors

之所以想要先制定 ValidationErrors 的格式,一方面是因為待會實作驗證邏輯的時候需要用到;另一方面則是因為這個格式如果訂得好,後續實作時會輕鬆許多。

我的預期是這樣:

{ 
  dateRange: {
    startDate: null | ValidationErrors;
    endDate: null | ValidationErrors;
  } 
}

這樣制定的意思是,如果起日欄位沒有錯誤,則 dateRange.startDate 的值會是 null ;而如果迄日欄位沒有錯誤,則 dateRange.endDate 的值會是 null ;又如果兩個欄位都沒錯誤,則該驗證器就會直接回傳 null

反之,如果起日欄位有錯誤,則 dateRange.startDate 的值會是我們接下來要制定的錯誤;迄日欄位亦然。

如此一來,如果驗證有誤時,我們比較能夠從驗證器所回傳的 ValidationErrors 來解析是哪個欄位有誤。

dateRange.startDatedateRange.endDate 究竟會有哪些錯誤呢?

先複習一下規格:

  • 起日
    • 必填,驗證有誤時需顯示錯誤訊息: 此欄位必填
    • 格式需為 yyyy-MM-dd ,驗證有誤時需顯示錯誤訊息: 日期格式不正確
    • 需為確切存在的日期,驗證有誤時需顯示錯誤訊息: 此日期不存在
  • 迄日
    • 非必填
    • 格式需為 yyyy-MM-dd ,驗證有誤時需顯示錯誤訊息: 日期格式不正確
    • 需為確切存在的日期,驗證有誤時需顯示錯誤訊息: 此日期不存在
    • 迄日不可早於起日,驗證有誤時需顯示錯誤訊息: 迄日不可早於起日
    • 迄日不可晚於起日超過七天,驗證有誤時需顯示錯誤訊息: 迄日不可晚於起日超過七天

除了必填與日期格式的部份已經用官方提供的 Validator 外,其他的錯誤應該就剩下:

  1. 不存在的日期:

    {
      inexistentDate: true
    }
    
  2. 迄日早於起日:

    {
      lessThanStartDate: true;
    }
    
  3. 迄日晚於起日七天

    {
      greaterThanStartDate: {
        actualGreater: 8
        requiredGreater: 7
      }
    }
    

以上格式是我自訂的,大家可以不用跟我一樣沒關係。

接著我們可以把錯誤訊息稍微訂個 type ,以便後續使用:

export type DateRangeValidationErrors = {
  dateRange: {
    startDate: null | DateErrors;
    endDate: null | DateErrors;
  }
};

export type DateErrors =
  | RequiredError
  | PatternError
  | InexistentDateError
  | LessThanStartDateError
  | GreaterThanStartDateError
  | ValidationErrors;

export type RequiredError = {
  required: true;
};

export type PatternError = {
  pattern: {
    actualValue: string;
    requiredPattern: string;
  }
};

export type InexistentDateError = {
  inexistentDate: true;
};

export type LessThanStartDateError = {
  lessThanStartDate: true;
};

export type GreaterThanStartDateError = {
  greaterThanStartDate: {
    actualGreater: number;
    requiredGreater: number;
  }
};

如此一來,我們差不多就可以開始來寫驗證器的邏輯囉!

實作驗證器的邏輯

首先,我們先處理判斷使用者所輸入的日期是否真實存在的邏輯。

舉例來說,大家覺得 2021-02-29 這個日期是存在的嗎?大家應該翻一下年曆就會知道,今年不是閏年,所以二月不會有第二十九天。

但是如果單純用 Date 來判斷,它其實可以算得出來:

console.log(new Date('2021-02-29'));
// Mon Mar 01 2021 08:00:00 GMT+0800 (Taipei Standard Time)

2021-02-31 呢?

console.log(new Date('2021-02-31'));
// Mon Mar 03 2021 08:00:00 GMT+0800 (Taipei Standard Time)

為什麼會這樣呢?

以上述例子來說, 用字串來建立 Date 的時候,它只會幫我們驗證兩件事情:

  1. 月份不可以超過 12
  2. 日期不可以超過 31

只要合乎上述這兩件事情,它就不會是 Invalid Date

那年份呢?我很無聊的幫大家試了一下,可以到 275759 年唷!

為了處理這件事情,我很偷懶的 Google 了一下大家的解法,最後借用了 Summer。桑莫。夏天JavaScript:檢查日期是否存在文中的程式碼,並且稍稍調整了一下以符合我的需求:

export const isDateExist = (dateString: string) => {
  const dateObj = dateString.split('-'); // yyyy-mm-dd

  //列出12個月,每月最大日期限制
  const limitInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];

  const theYear = parseInt(dateObj[0]);
  const theMonth = parseInt(dateObj[1]);
  const theDay = parseInt(dateObj[2]);
  const isLeap = new Date(theYear, 2, 0).getDate() === 29; // 是否為閏年?

  if (isLeap) {
    // 若為閏年,最大日期限制改為 29
    limitInMonth[1] = 29;
  }

  // 月份不可以大於 12, 並比對該日是否超過每個月份最大日期限制
  return theMonth < 12 && theDay <= limitInMonth[theMonth - 1];
}

感謝每一個願意分享的朋友。

準備萬全之後,再來就是把驗證器的判斷邏輯補完:

export const dateRangeValidator: ValidatorFn = (formGroup) => {
  const startDateControl = formGroup.get('startDate')!;
  const endDateControl = formGroup.get('endDate')!;

  let errors: DateRangeValidationErrors = {
    dateRange: {
      startDate: null,
      endDate: null,
    }
  };
  
  if (startDateControl.errors) {
    errors.dateRange.startDate = startDateControl.errors;
  } else if (!isDateExist(startDateControl.value)) {
    errors.dateRange.startDate = { inexistentDate: true };
  }

  if (endDateControl.errors) {
    errors.dateRange.endDate = endDateControl.errors;
  } else if (endDateControl.value) {
    if (!isDateExist(endDateControl.value)) {
      errors.dateRange.endDate = { inexistentDate: true };
    } else if (!errors.dateRange.startDate) {
      const startDateTimeStamp = new Date(startDateControl.value).getTime();
      const endDateTimeStamp = new Date(endDateControl.value).getTime();
      const dayInMilliseconds = 24 * 60 * 60 * 1000;
      const duration = 7 * dayInMilliseconds;
      if (endDateTimeStamp < startDateTimeStamp) {
        errors.dateRange.endDate = { lessThanStartDate: true };
      } else if (endDateTimeStamp - duration > startDateTimeStamp) {
        errors.dateRange.endDate = {
          greaterThanStartDate: {
            actualGreater: (endDateTimeStamp - startDateTimeStamp) / dayInMilliseconds,
            requiredGreater: 7
          }
        }
      }
    }
  }

  if (!errors.dateRange.startDate && !errors.dateRange.endDate) {
    return null;
  }
  return errors;
};

結果:

Template View

看起來效果不錯,接下來就是把錯誤訊息接上囉!

ErrorMessagePipe

關於錯誤訊息的部份,今天就不把邏輯寫到 Component 的 .ts 裡了,來做個 ErrorMessagePipe 吧!

程式碼如下:

@Pipe({
  name: 'errorMessage',
})
export class ErrorMessagePipe implements PipeTransform {
  transform(errors: null | DateErrors, ...args: unknown[]): string {
    if (errors) {
      if ((errors as RequiredError).required) {
        return '此欄位必填';
      } else if ((errors as PatternError).pattern) {
        return '日期格式不正確';
      } else if ((errors as InexistentDateError).inexistentDate) {
        return '此日期不存在';
      } else if ((errors as LessThanStartDateError).lessThanStartDate) {
        return '迄日不可早於起日';
      } else if ((errors as GreaterThanStartDateError).greaterThanStartDate) {
        return '迄日不可晚於起日超過七天';
      }
    }
    return '';
  }
}

接著再到 Template 將其接上:

<h1>Reactive Forms 進階技巧 ─ 跨欄位驗證</h1>
<form *ngIf="formGroup" [formGroup]="formGroup">
  <p>
    <label for="start-date">起日:</label>
    <input
      type="text"
      id="start-date"
      placeholder="yyyy-mm-dd"
      formControlName="startDate"
    />
    <span
      class="error-message"
      *ngIf="formGroup.errors && formGroup.dirty"
    >
      {{ formGroup.errors.dateRange.startDate | errorMessage }}
    </span>
  </p>
  <p>
    <label for="end-date">迄日:</label>
    <input
      type="text"
      id="end-date"
      placeholder="yyyy-mm-dd"
      formControlName="endDate"
    />
    <span
      class="error-message"
      *ngIf="formGroup.errors && formGroup.dirty"
    >
      {{ formGroup.errors.dateRange.endDate | errorMessage }}
    </span>
  </p>
</form>

最終成果:

Template View

本日小結

今天主要想告訴大家的是 FormGroupFormArray 以及 FormControl 其實都可以設定 ValidatorAsyncValidator ,不管是在初始化時就設定還是初始化後再動態設定都沒問題。

但可能是因為沒有遇過需要用到的場景,所以滿多對 Reactive Forms 還不太熟的朋友還是會不知道。

雖說今天主要想讓大家的知道的是 FormGroup 上也可以設定 ValidatorAsyncValidator ,但寫著寫著又不知不覺寫了很多東西,希望這些東西都有幫助到大家。

此外, Template Driven Forms 當然也是可以跨欄位驗證,不過由於之前已經說過不會再分享 Template Driven Forms 的關係,所以有興趣的朋友可以參考官方的 Form Validation - Adding cross-validation to template-driven forms 的文件。

早知道就不要說不再分享,害自己少了好多篇可以寫,失策!

對了,測試大家可以練習寫寫看,我就不實作給大家看囉!

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

如果有任何的問題或是回饋,還請麻煩留言給我讓我知道!


上一篇
Angular 深入淺出三十天:表單與測試 Day26 - 進階表單開發技巧 - 自訂驗證器
下一篇
Angular 深入淺出三十天:表單與測試 Day28 - 自訂表單元件
系列文
Angular 深入淺出三十天:表單與測試30

2 則留言

0
cheerupche
iT邦新手 5 級 ‧ 2021-10-12 09:31:12

一路有在看大大的文章,雖然進度還沒有這麼前面,但很希望這次出書也有大大的份。XD
現在不管職場還是教學都React和Vue居多,比較介於初階到進階,又能循序漸進的Angular教學文就不是那麼好找(話雖如此,在IT邦幫忙還是能搜到好幾篇,真的很讚)
非常感謝大大的付出!

Leo iT邦新手 3 級 ‧ 2021-10-12 09:32:44 檢舉

Hi cheerupche,

很高興我的文能夠幫助到你,出書的部份我會再多加努力的!

/images/emoticon/emoticon12.gif

0
juck30808
iT邦新手 3 級 ‧ 2021-10-12 18:39:20

恭喜大大即將完賽XD !!!

Leo iT邦新手 3 級 ‧ 2021-10-12 19:58:23 檢舉

Hi juck30808

還有最後三天呢,這三天是最硬的...QQ

/images/emoticon/emoticon02.gif

我要留言

立即登入留言