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 上供大家参考,建议大家在看我的实作之前,先按照需求规格自己做一遍,之後再跟我的对照,看看自己的实作跟我的实作不同的地方在哪里、有什麽好处与坏处,如此反覆咀嚼消化後,我相信你一定可以进步地非常快!

如果有任何的问题或是回馈,还请麻烦留言给我让我知道!


<<:  在Edge上进行部署(Serving)

>>:  Proxmox VE 设定客体机高可用性

30天程序语言研究

今天是30天程序语言研究的第十五天,由於深度学习老师多让我们上了python的进阶课程里面包括之前没...

第49天-学习 crontab 工作排程 Part 2 - 认真学 crontab 表达式

今天进度藉由 Crontab.guru - The cron schedule expression...

Day 7 Dart语言-资料型态

资料型态 内建资料型态是构成整个程序的最小型态单位,是程序中不可或缺的元素,而Dart的内建类型主要...

13 | WordPress 清单区块 List Block

如果你平常要处理大量文书工作,应该对我们这次介绍的「项目符号和编号」并不陌生,这又另称为清单区块 ...

[Android Studio] 每日小技巧 - 增加 Editor 中可开启 File 的最大数量

有经验的大家都知道 Android Studio 开太多 File 的话 似乎超过 10 个,多的就...