Vue × Vuetify × VeeValidate で入力バリデーション付きログイン画面のひな型作成

<img width="500" alt="validation.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1116978/f43dc241-616c-25fd-e944-02b984cebfba.gif">

## はじめに
<strong>「ひな型あれば開発の導入が楽になりそう！」</strong>
というわけで、ログイン画面のひな型を作ることにしました。

入力フォームとバリデーション機能を実装しつつ、心ばかりですが拡張性も意識してみました。

解説はVeeValidate最低限の使い方がメインです。
細かいパラメータはドキュメントを参照しつつコードをいじっていただければ幸いです。

#### やること
* Vue2 × Vuetify × VeeValidate による入力バリデーション
* VeeValidate導入手順
* バリデーションルールの動的な変更

#### やらないこと
* Vueプロジェクト作成手順やVuetify導入手順
* サインイン処理
* 画面デザイン
* Vuexによる状態管理...etc

#### 使った技術
* [Vue.js](https://jp.vuejs.org/index.html)：投稿時点で最新は Vue3ですがVuetifyやVuex対応まで日和見してます:whale2:
* [@vue/composition-api](https://github.com/vuejs/composition-api)：Vue3への移行を見据えて導入。
* [Vuetify](https://vuetifyjs.com/ja/)：マテリアルデザインに準拠したVue UIライブラリ。
* [VeeValidate](https://vee-validate.logaretm.com/v4/)：<strong>今回の主役。</strong>既存のバリデーションルールや任意のタイミングでバリデーションなど手軽さ＆高カスタマイズ性を兼ね備えている。:whale2:

## VeeValidate
個人的なメリットはこんな感じです。

* 基本的なバリデーションルール、エラーは既存のものを使える！
* 独自のルールや任意のタイミングが指定できる！
* 他プロジェクトにも使いまわせる！

これまで自前で実装してましたがコードの見通しも悪く、チェックの抜け漏れを引き起こしがちでした。
ライブラリを使って品質UP&作業削減！

#### VeeValidateをプロジェクトに取り入れる
```
# install with npm
npm install vee-validate@next --save
```

main.tsに追記して有効化します。

```javascript:main.ts
import Vue from 'vue';
import {
  configure,
  extend,
  localize,
  ValidationObserver,
  ValidationProvider,
} from 'vee-validate';
import ja from 'vee-validate/dist/locale/ja';

// 既存のバリデーションルールを取得
import { required, max, min, email } from 'vee-validate/dist/rules';

const config = {
  bails: false,
  mode: 'aggressive', //this is the default behavior and it validates whenever an input event is emitted.
};
configure(config);

extend('required', required);
extend('max', max);
extend('min', min);
extend('email', email);

// 日本語使用
localize('ja', ja);

// コンポーネント登録
Vue.component('ValidationObserver', ValidationObserver);
Vue.component('ValidationProvider', ValidationProvider);
```

## 画面作成
個人的にawsコンソールのサインイン画面が使いやすく好きなので寄せます。

* 最初はエラーメッセージを表示しない
* サインインが失敗した際にエラーメッセージでユーザーへフィードバック

実装するとこのような挙動になります。※条件に合わせてボタンの状態も切り替えています。
<img width="500" alt="validation.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1116978/f43dc241-616c-25fd-e944-02b984cebfba.gif">

#### ①入力値に適用するルールを決める
まずは、ユーザーネームとパスワードに適用するルールを決めます。
今回はmain.js内で取得した既存のバリデーションルールを用いて設定します。

```javascript:SignIn.vue
validationRules: computed(() => {
  // バリデーションルール
  return {
    base: {
      required: true,
    },
    // 必須＆emailアドレス形式
    username: {
      required: true,
      email: true,
    },
    // 必須＆8文字以上
    password: {
      required: true,
      min: 8,
    },
  };
})
```

#### ②入力フォームにルール適用
ルールを適用したい要素をバリデーション用コンポーネントで囲むのみです！

* `<ValidationObserver>`：invalidにバリデーションの結果が保持されています。
* `<ValidationProvider>`：rulesで適用したいルールを指定します。

細かいパラメータは[ドキュメント](https://vee-validate.logaretm.com/v2/guide/components/validation-provider.html)を参照ください。

```vue:SignIn.vue
<ValidationObserver v-slot="{ invalid }">
  <ValidationProvider
    v-slot="{ errors }"
    name="ユーザーネーム"
    rules="validationRules.username"
  >
    <v-text-field
      v-model="inputUsername"
      label="ユーザーネーム"
      :error-count="1"
      :error-messages="errors"
    />
  </ValidationProvider>
</ValidationObserver>
```
#### ③任意のタイミングでバリデーション
`validate()`を用いるとコードからバリデーションできます。
第一引数にはバリデーション対象、第二引数にはルール、第三引数には`name`を指定します。
※`name`はエラーメッセージの主語にあたります。Ex.) [name]は必須です。

```javascript:SignIn.vue
validate(state.inputPassword, state.validationRules.username, {
  name: "ユーザーネーム",
}).then((result) => {
  if (!result.valid) {
    isInvalid = true;
  }
  resolve(isInvalid);
});
```
#### ④ルールの動的な変更
条件を保持する変数を用意して`<ValidationProvider>`のrulesに三項演算子で指定するだけでOK!

```vue:SignIn.vue
<ValidationProvider
  v-slot="{ errors }"
  name="ユーザーネーム"
  :rules="
    showErrorMessages
      ? validationRules.username
      : validationRules.base
  "
>
```
#### ⑤がっちゃんこして出来上がり
細かいパラメータなどは[ドキュメント](https://vee-validate.logaretm.com/v4/)を参照ください。
表示するエラーメッセージの数やカスタムルールの設定が可能です。

```vue:SignIn.vue
<template>
  <v-container>
    <v-row justify="center">
      <v-col cols="6" align="center">
        <ValidationObserver v-slot="{ invalid }">
          <ValidationProvider
            v-slot="{ errors }"
            :name="vTEXT_FIELD_LABEL[0]"
            :rules="
              showErrorMessages
                ? validationRules.username
                : validationRules.base
            "
          >
            <v-text-field
              v-model="inputUsername"
              :label="vTEXT_FIELD_LABEL[0]"
              :error-count="1"
              :error-messages="showErrorMessages ? errors : null"
            />
          </ValidationProvider>
          <ValidationProvider
            v-slot="{ errors }"
            :name="vTEXT_FIELD_LABEL[1]"
            :rules="
              showErrorMessages
                ? validationRules.password
                : validationRules.base
            "
          >
            <v-text-field
              v-model="inputPassword"
              :type="showPassword ? 'text' : 'password'"
              :append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
              :label="vTEXT_FIELD_LABEL[1]"
              :error-count="1"
              :error-messages="showErrorMessages ? errors : null"
              @click:append="showPassword = !showPassword"
            />
          </ValidationProvider>
          <v-btn :disabled="invalid || isProcessing" @click="signIn()"
            >Go</v-btn
          >
        </ValidationObserver>
      </v-col>
    </v-row>
  </v-container>
</template>

<script lang="ts">
import {
  defineComponent,
  reactive,
  toRefs,
  computed,
} from '@vue/composition-api';
import { validate } from 'vee-validate';

const vTEXT_FIELD_LABEL = ['ユーザーネーム', 'パスワード'];

export default defineComponent({
  setup(props, context) {
    const state = reactive({
      inputUsername: '', // 入力されたユーザーネーム
      inputPassword: '', // 入力されたパスワード
      showPassword: false, // パスワードの表示制御
      showErrorMessages: false, // エラーメッセージの表示制御
      isProcessing: false, // サインイン処理の実行制御
      validationRules: computed(() => {
        // バリデーションルール
        return {
          base: {
            required: true,
          },
          username: {
            required: true,
            email: true,
          },
          password: {
            required: true,
            min: 8,
          },
        };
      }),
    });

    /**
     * ボタンによって呼び出されるサインイン処理
     */
    const signIn = async () => {
      // 状態を処理中にしてボタンを非活性化
      state.isProcessing = true;

      // 最初のエラーメッセージを表示していないときのみ実行
      if (!state.showErrorMessages) {
        // バリデーションルールに適しているかチェック
        if ((await isInvalidUsername()) || (await isInvalidPassword())) {
          // 適していなければ処理中断
          state.showErrorMessages = true;
          state.isProcessing = false;
          return;
        }
      }
      try {
        /* [TODO]サインイン処理 */
        context.root.$router.push(
          '/',
          () => {
            /* */
          },
          () => {
            /* */
          },
        );
      } catch (e) {
        // [TODO]ユーザーへのフィードバック
        state.isProcessing = false;
      }
    };

    /**
     * ユーザーネームのバリデーション
     * @return isInvalid 無効かどうか
     */
    const isInvalidUsername = () => {
      return new Promise((resolve) => {
        let isInvalid = false;
        validate(state.inputUsername, state.validationRules.username, {
          name: vTEXT_FIELD_LABEL[0],
        }).then((result) => {
          if (!result.valid) {
            isInvalid = true;
          }
          resolve(isInvalid);
        });
      });
    };

    /**
     * パスワードのバリデーション
     * @return isInvalid 無効かどうか
     */
    const isInvalidPassword = () => {
      return new Promise((resolve) => {
        let isInvalid = false;
        validate(state.inputPassword, state.validationRules.password, {
          name: vTEXT_FIELD_LABEL[1],
        }).then((result) => {
          if (!result.valid) {
            isInvalid = true;
          }
          resolve(isInvalid);
        });
      });
    };

    return {
      ...toRefs(state),
      vTEXT_FIELD_LABEL,
      signIn,
    };
  },
});
</script>
```

## おわり
最低限の機能でひな型作成しました。
開発の始めにコピペしてパラメータいじるだけ！工数削減！！

今後も再利用性を意識してコンポーネント化、関数切り出し...etc
頑張ります:whale2: