今天想要跟大家分享的是跨欄位驗證的小技巧,這個小技巧其實沒有多厲害或多特別,只是可能滿多人剛好不知道原來可以這樣用。
而我們在 Day 23 - Reactive Forms 進階技巧 - 欄位連動檢核邏輯 所分享過欄位連動檢核邏輯的部份,就某方面來說,其實也可以使用這種方式來做,但究竟要適不適合、要不要使用,我覺得一切都還是要看需求、看想給使用者什麼樣的使用體驗來決定。
畢竟系統是為了服務需求而存在,至於能不能做到、能不能解決問題就看工程師的功力囉。
實作開始
言歸正傳,我們今天要做的功能是起迄日的日期欄位檢核
感謝我的朋友 ─ Joseph 所提供的案例讓我多活了一天。
規格需求
詳細規格需求如下:
- 起日
- 必填,驗證有誤時需顯示錯誤訊息:
此欄位必填
- 格式需為
yyyy-MM-dd
,驗證有誤時需顯示錯誤訊息: 日期格式不正確
- 需為確切存在的日期,驗證有誤時需顯示錯誤訊息:
此日期不存在
- 迄日
- 非必填
- 格式需為
yyyy-MM-dd
,驗證有誤時需顯示錯誤訊息: 日期格式不正確
- 需為確切存在的日期,驗證有誤時需顯示錯誤訊息:
此日期不存在
- 迄日不可早於起日,驗證有誤時需顯示錯誤訊息:
迄日不可早於起日
- 迄日不可晚於起日超過七天,驗證有誤時需顯示錯誤訊息:
迄日不可晚於起日超過七天
- 以上驗證皆需在使用者輸入時動態檢查
準備畫面
接下來我們先把畫面準備好, HTML 如下:
1 2 3 4 5 6 7 8 9 10
| <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>
|
畫面應該會長這樣:
我知道一般大家在實作的時候會用漂亮的 UI 套件,不過我們現在主要聚焦在功能面,所以欄位的部份我只用簡單的 <input type="text">
的方式實作。
其實我本來想至少用 <input type="date">
來實作的,但它會害我們無法判斷使用者到底有沒有輸入值,所以最後還是放棄了使用它的打算。
接著把 Reactive Forms 的 FormGroup
也準備好:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| 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.required
與 Validators.pattern(/^\d{4}-\d{2}-\d{2}$/)
可以不加,只不過後續就要把判斷寫在另一個地方,看大家想要稍稍彈性一點,還是直接寫死在另外一個地方都可以。
然後綁定到 Template 的表單上:
1 2 3 4 5 6 7 8 9 10
| <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
裡的引入 FormsModule
與 ReactiveFormsModule
大家不要覺得我像老頭子一樣囉哩囉嗦的,都已經做了幾次的練習了還要一直提醒大家記得引入 FormsModule
和 ReactiveFormsModule
。
相信我,如果我沒提醒,一定會有很多還不是很熟悉的朋友會卡住。
所以大家互相體諒包容一下,熟悉的朋友快速略過就好。
自訂驗證器 ─ dateRangeValidator
接著我們來用昨天分享過的自訂驗證器的的技巧來自訂一個名為 dateRangeValidator
的驗證器,程式碼如下:
1 2 3 4
| export const dateRangeValidator: ValidatorFn = (formGroup) => { console.log(formGroup.value); return null };
|
先把它掛在 FormGroup 上:
1 2 3 4 5 6 7 8 9
| 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 裡的欄位的值有變動時,就會觸發我們自訂的驗證器:
一開始的四個 { startDate: '', endDate: '' }
是 FormGroup 在初始化的時後所觸發的。
其實今天要做的這個功能最關鍵、最重要的兩件事情就是:
- 實作自訂驗證器
- 把它掛在 FormGroup 上
所以我們已經做完了,今天的文章就分享到這邊。
謎之音:喂!你給我回來!(抓回)
接下來在實作驗證器之前,我想先制定該驗證器在驗證有誤時,所要回傳的 ValidationErrors
格式。
制定驗證器的 ValidationErrors
之所以想要先制定 ValidationErrors
的格式,一方面是因為待會實作驗證邏輯的時候需要用到;另一方面則是因為這個格式如果訂得好,後續實作時會輕鬆許多。
我的預期是這樣:
1 2 3 4 5 6
| { dateRange: { startDate: null | ValidationErrors; endDate: null | ValidationErrors; } }
|
這樣制定的意思是,如果起日欄位沒有錯誤,則 dateRange.startDate
的值會是 null
;而如果迄日欄位沒有錯誤,則 dateRange.endDate
的值會是 null
;又如果兩個欄位都沒錯誤,則該驗證器就會直接回傳 null
。
反之,如果起日欄位有錯誤,則 dateRange.startDate
的值會是我們接下來要制定的錯誤;迄日欄位亦然。
如此一來,如果驗證有誤時,我們比較能夠從驗證器所回傳的 ValidationErrors
來解析是哪個欄位有誤。
而 dateRange.startDate
與 dateRange.endDate
究竟會有哪些錯誤呢?
先複習一下規格:
- 起日
- 必填,驗證有誤時需顯示錯誤訊息:
此欄位必填
- 格式需為
yyyy-MM-dd
,驗證有誤時需顯示錯誤訊息: 日期格式不正確
- 需為確切存在的日期,驗證有誤時需顯示錯誤訊息:
此日期不存在
- 迄日
- 非必填
- 格式需為
yyyy-MM-dd
,驗證有誤時需顯示錯誤訊息: 日期格式不正確
- 需為確切存在的日期,驗證有誤時需顯示錯誤訊息:
此日期不存在
- 迄日不可早於起日,驗證有誤時需顯示錯誤訊息:
迄日不可早於起日
- 迄日不可晚於起日超過七天,驗證有誤時需顯示錯誤訊息:
迄日不可晚於起日超過七天
除了必填與日期格式的部份已經用官方提供的 Validator 外,其他的錯誤應該就剩下:
不存在的日期:
1 2 3
| { inexistentDate: true }
|
迄日早於起日:
1 2 3
| { lessThanStartDate: true; }
|
迄日晚於起日七天
1 2 3 4 5 6
| { greaterThanStartDate: { actualGreater: 8 requiredGreater: 7 } }
|
以上格式是我自訂的,大家可以不用跟我一樣沒關係。
接著我們可以把錯誤訊息稍微訂個 type
,以便後續使用:
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
| 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
來判斷,它其實可以算得出來:
1 2
| console.log(new Date('2021-02-29'));
|
那 2021-02-31
呢?
1 2
| console.log(new Date('2021-02-31'));
|
為什麼會這樣呢?
以上述例子來說, 用字串來建立 Date
的時候,它只會幫我們驗證兩件事情:
- 月份不可以超過
12
- 日期不可以超過
31
只要合乎上述這兩件事情,它就不會是 Invalid Date
。
那年份呢?我很無聊的幫大家試了一下,可以到 275759
年唷!
為了處理這件事情,我很偷懶的 Google 了一下大家的解法,最後借用了 Summer。桑莫。夏天的 JavaScript:檢查日期是否存在文中的程式碼,並且稍稍調整了一下以符合我的需求:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| export const isDateExist = (dateString: string) => { const dateObj = dateString.split('-');
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) { limitInMonth[1] = 29; }
return theMonth < 12 && theDay <= limitInMonth[theMonth - 1]; }
|
感謝每一個願意分享的朋友。
準備萬全之後,再來就是把驗證器的判斷邏輯補完:
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
| 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; };
|
結果:
看起來效果不錯,接下來就是把錯誤訊息接上囉!
ErrorMessagePipe
關於錯誤訊息的部份,今天就不把邏輯寫到 Component 的 .ts
裡了,來做個 ErrorMessagePipe 吧!
程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| @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 將其接上:
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
| <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>
|
最終成果:
本日小結
今天主要想告訴大家的是 FormGroup
、 FormArray
以及 FormControl
其實都可以設定 Validator
與 AsyncValidator
,不管是在初始化時就設定還是初始化後再動態設定都沒問題。
但可能是因為沒有遇過需要用到的場景,所以滿多對 Reactive Forms 還不太熟的朋友還是會不知道。
雖說今天主要想讓大家的知道的是 FormGroup
上也可以設定 Validator
與 AsyncValidator
,但寫著寫著又不知不覺寫了很多東西,希望這些東西都有幫助到大家。
此外, Template Driven Forms 當然也是可以跨欄位驗證,不過由於之前已經說過不會再分享 Template Driven Forms 的關係,所以有興趣的朋友可以參考官方的 Form Validation - Adding cross-validation to template-driven forms 的文件。
早知道就不要說不再分享,害自己少了好多篇可以寫,失策!
對了,測試大家可以練習寫寫看,我就不實作給大家看囉!
今天的程式碼會放在 Github - Branch: day27 上供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!
如果有任何的問題或是回饋,還請麻煩留言給我讓我知道!