blog image

2022-09-08

Angular TypeForm - 強型別Form的心得

2024-11-10 更新:增加 FormBuild.group 和 class-validator 的程式說明


在 Angular v14 中最重要的 2 個功能,除了 Single Component 之外,就是 Typeform 了,而 Typeform 也就是在建立 Form 功能時,終於可以套用型別,方便開發及除錯,是期待很久的功能。 查詢官方文件可以發現,範列都是使用從建立 FormGroup,再建立 'FormControl' 的方式。

本人還是比較偏好用 FormBuilder 來建立 FormGroup,所以分享一下用 FromBuilder 建立的心得。

使用方式

import { Component, OnInit } from '@angular/core';
import { UntypedFormGroup, NgForm, Validators, UntypedFormBuilder } from '@angular/forms';

首先使用 Angular CLI 來將之前建立的小專案升級至 V.14,如上,升級後原本的程式會自動轉換成 UntypedFormGroupUntypedFormBuilder

import { FormGroup, NgForm, Validators, FormBuilder, FormControl } from '@angular/forms';

interface MemberForm {
  id?: FormControl<string>;
  name: FormControl<string>;
  email: FormControl<string>;
  mobile: FormControl<string>;
  birthday: FormControl<string>;
  account: FormControl<string>;
  password: FormControl<string>;
}

接著替換成 FormGroupFormBuilder,並且建立使用於 FormBuilder 上的 interface。

group: FormGroup<MemberForm>;

接著在 FormGroup 中使用已建立的 MemberForm

  constructor(
    private fb: FormBuilder,
  ) {}

  ngOnInit() {
    this.group = this.fb.nonNullable.group({
      name: new FormControl('', Validators.required),
      email: new FormControl('', Validators.required),
      mobile: new FormControl('', Validators.required),
      birthday: new FormControl('', Validators.required),
      account: new FormControl('', Validators.required),
      password: new FormControl('', Validators.required),
    });

  }

之後像之前 v.13 的一樣寫法,使用 'group' 方式來建立 FormGroup ,因為每個屬性都是必填,所以設定 Validators.required

class Member extends BaseModel {
  name = '';
  email = '';
  mobile = '';
  birthday = '';
  password = '';
}

interface MemberForm {
  id?: FormControl<string>;
  name: FormControl<string>;
  email: FormControl<string>;
  mobile: FormControl<string>;
  birthday: FormControl<string>;
  account: FormControl<string>;
  password: FormControl<string>;
}

一般說來,每個 Form 都會有相對應的 Model,也就是類型,比如上面程式碼中的 Member。而 FormGroup 也會建立都是相同屬性的類型,比如上面程式碼的 MemberForm ,如此就會重覆建立。

import { FormArray, FormControl, FormGroup } from '@angular/forms';

export type Unpacked<T> = T extends Array<infer U> ? U : T;

export type ToForm<OriginalType> = {
  [key in keyof OriginalType]: OriginalType[key] extends Array<any>
    ? FormArray<
        Unpacked<OriginalType[key]> extends object
          ? FormGroup<ToForm<Unpacked<OriginalType[key]>>>
          : FormControl<Unpacked<OriginalType[key]> | null>
      >
    : OriginalType[key] extends object
    ? FormGroup<ToForm<OriginalType[key]>>
    : FormControl<OriginalType[key] | null>;
};

可以參考文章搶先體驗強型別表單(Strict Typed Reactive Forms),就可以方便轉換。

import { ToForm } from '../utils/toForm';

group: FormGroup<ToForm<Member>>;

只需改成 FormGroup<ToForm<Member>> 即可。

class-validator

因為驗證是每個 Form 都是必須會需要做的行為,所以會使用 class-validator 來共同驗證,之前有接觸過 class-validator 這個套件,是用 decorator 的方式,在物件的屬性上設定所要驗證的格式,之前都是在 Node.js 上來使用,不過 Angular 上使用的話,需要整合一下。

import {
  IsNotEmpty,
  IsEmail,
  IsMobilePhone,
  ValidationOptions,
  Matches,
  MinLength,
  MaxLength,
} from 'class-validator';
import { plainToClassFromExist } from 'class-transformer';
import { BaseModel } from './baseModel';

const options: ValidationOptions = { message: '填寫正式資料' };

export class Member extends BaseModel {
  @IsNotEmpty({
    message: '姓名需填寫',
  })
  name = '';

  @IsNotEmpty({
    message: 'Email需填寫',
  })
  @IsEmail()
  email = '';

  @IsNotEmpty({
    message: '手機需填寫',
  })
  @IsMobilePhone(
    'zh-TW',
    {
      strictMode: false,
    },
    {
      message: '手機需填寫',
    }
  )
  mobile = '';

  birthday: string;

  @IsNotEmpty(options)
  @MinLength(6, options)
  @MaxLength(12, options)
  @Matches(/[a-zA-Z\d]/g, options)
  account = '';

  @IsNotEmpty(options)
  @MinLength(6, options)
  @MaxLength(12, options)
  @Matches(/[a-zA-Z\d]/g, options)
  password = '';

  constructor(data?: any) {
    super();
    plainToClassFromExist(this, data);
  }
}

class-validator 預設已經定義一些常用的驗證方式,例如是否必填、Email 或是手機驗證,直接套用在類別上的屬性即可,可以參考文件

import { ValidationErrors, ValidatorFn } from '@angular/forms';
import { validateSync } from 'class-validator';

export function utilValidator<T extends Record<string, any>>(
  model: T,
  prop: string,
): ValidatorFn {
  return (control): ValidationErrors | null => {
    let invalid = false;

    (model as any)[prop] = control.value;

    const errors = validateSync(model, {
      skipMissingProperties: true,
    });

    if (errors && errors.length) {
      const propError = errors.filter((e) => e.property == prop);

      if (propError.length > 0) {
        const message = propError.map(({ constraints }) =>
          Object.values(constraints || {}).join(', '),
        );
        invalid = true;

        return {
          hasError: invalid && (control.dirty || control.touched),
          message: message,
        };
      }
    }

    return null;
  };
}

為了要將 class-validatorFormGroupFormControl 整合,建立了共同方法 utilValidator,使用class-validator中的方法 validateSync 來驗證 model,並且取得特定屬性的錯誤訊息。

this.group = this.fb.group({
  name: new FormControl('', utilValidator(new Member(), 'name')),
  email: new FormControl('', utilValidator(new Member(), 'email')),
  mobile: new FormControl('', utilValidator(new Member(), 'mobile')),
  birthday: new FormControl('', utilValidator(new Member(), 'birthday')),
  account: new FormControl('', utilValidator(new Member(), 'account')),
  password: new FormControl('', utilValidator(new Member(), 'password')),
});

接著在 FormBuilderGroup 方法中,使用剛剛建立的 utilValidator,傳入 model 及屬性,即可將class-validatorFormGroup 整合在一起。

@if (group.controls.name.errors?.["hasError"]) {
<p class="help is-danger">{{ group.controls.name.errors?.["message"] }}</p>
}

而如果有錯誤的話,固定回傳格式為 { hasError: true, message },所以頁面上只需判斷 hasError === true即可。

並且因為是強型別的關系,在 html 上也有程式碼提示的幫助。

結論

一直以來,TypeForm 就是榜上有名的希望實作的功能,對於開發及除錯的幫助很大,我將以前的小專案改為 TypeForm 的方式嘗試看看,這是小小的心得分享。