## 経緯
[[VeeValidate]]を[[shadcn-vue]]と一緒に使っていて、独自のUIを実装しようとしたら少しハマってしまったのでいくつかの実験をして挙動を把握しておく。基本的な使い方も含め。
## プロジェクト作成
[[toki]]で作成。せっかくなので[[Tailwind CSS]]も入れておく。[[shadcn-vue]]は入れない。
```console
toki tailwind vee-validate-zod-sandbox
cd $_
bun dev
```
`localhost:5173`にアクセスできればOK.
## VeeValidateのインストール
一緒に[[Zod]]も入れておく。
```console
bun add vee-validate @vee-validate/zod
```
## 超基本的なフォーム
Getting startedを参考に。
<div class="link-card-v2">
<div class="link-card-v2-site">
<img class="link-card-v2-site-icon" src="https://vee-validate.logaretm.com/v4/img/favicon.ico" />
<span class="link-card-v2-site-name">vee-validate.logaretm.com</span>
</div>
<div class="link-card-v2-title">
Getting started
</div>
<div class="link-card-v2-content">
Field-level and form-level validation and validation behavior and error messages with composition API
</div>
<img class="link-card-v2-image" src="https://res.cloudinary.com/logaretm/image/upload/w_1280,h_669,c_fill,q_auto,f_auto/w_760,c_fit,co_rgb:09a884,g_south_west,x_70,y_350,l_text:montserrat_64bold:Getting%20started/w_760,c_fit,co_rgb:ffffff,g_north_west,x_70,y_360,l_text:montserrat_48:Field-level%20and%20form-level%20validation%20and%20validation%20behavior%20and%20error%20messages%20with%20composition%20API/open-source/vee-validate-share.png" />
<a href="https://vee-validate.logaretm.com/v4/guide/composition-api/getting-started/"></a>
</div>
[[yup]]が[[Zod]]に変わってたり[[Tailwind CSS]]周りで変更が入っているけど本質は同じ。
`src/App.vue`
```html
<script setup lang="ts">
import { useForm } from "vee-validate";
import * as zod from "zod";
import { toTypedSchema } from "@vee-validate/zod";
// WARN: 粗い造りなのでproductionで使用しないこと
const mc = (...strs: string[]) => strs.join(" ");
const { values, errors, defineField } = useForm({
validationSchema: toTypedSchema(
zod.object({
email: zod.string().min(1).email(),
}),
),
});
const [email, emailAttrs] = defineField("email");
const inputClass = "border border-gray-700 px-3 py-1";
</script>
<template>
<div class="p-16 flex flex-col gap-4">
<input
:class="mc(inputClass, 'w-96')"
v-model="email"
v-bind="emailAttrs"
type="text"
/>
<pre>values: {{ values }}</pre>
<pre>errors: {{ errors }}</pre>
</div>
</template>
```
このコードは以下の動きをする。
| Step | Input | values | errors |
| ---- | ------------- | --------------- | --------------------------------------------- |
| 1 | 空 | | 空 |
| 2 | `a` | `"a"` | "Invalid email" |
| 3 | `
[email protected]` | `"
[email protected]"` | 空 |
| 4 | 空 | `""` | "String must contain at least 1 character(s)" |
- 初期状態でerrorsがないのは良い (いきなりエラーを出したくない)
- emailとして不正な文字列のときはエラーがちゃんと出る
- 1度入力後 空にするとエラーになる (実際は存在なしと空文字の違いあり)
## バリデーションボタンの追加
今回試したいユースケースとして以下の条件がある。
- 『バリデーションボタン』を押すまでは如何なる入力もエラーにしない
- 『バリデーションボタン』を押したらエラー箇所が浮き上がる
- エラーとなっている入力欄が正常に戻ったらエラーをリアルタイムに消す
- 正常に戻った入力欄が再び異常値になっても、『バリデーションボタン』を押すまではエラーにしない
これを実現するため、まずはバリデーションボタンを追加する。
### バリデーションボタンの追加
```html
<script setup lang="ts">
import { useForm } from "vee-validate";
import * as zod from "zod";
import { toTypedSchema } from "@vee-validate/zod";
// WARN: 粗い造りなのでproductionで使用しないこと
const mc = (...strs: string[]) => strs.join(" ");
const { values, errors, defineField, validate } = useForm({
validationSchema: toTypedSchema(
zod.object({
email: zod.string().min(1).email(),
}),
),
});
const [email, emailAttrs] = defineField("email");
const handleValidate = async () => {
await validate();
};
const inputClass = "border border-gray-700 px-3 py-1";
const buttonClass = "border border-gray-200 bg-green-500 text-white px-3 py-1";
</script>
<template>
<div class="p-16 flex flex-col gap-4">
<input
:class="mc(inputClass, 'w-96')"
v-model="email"
v-bind="emailAttrs"
type="text"
/>
<button :class="mc(buttonClass, 'w-48')" @click="handleValidate">
validate
</button>
<pre>values: {{ values }}</pre>
<pre>errors: {{ errors }}</pre>
</div>
</template>
```
これまでは一度値を入力しないとvalidateされなかったため、空文字列にはなれどvaluesがなくなることはなかった。しかし、バリデーションボタンを押すと `validate` が実行されるため `Required` というエラーが出る。
| Step | Input | values | errors |
| -------- | ----- | ------ | -------- |
| 1 | 空 | | 空 |
| validate | 空 | | Required |
### バリデーションボタンを押すまでvalidateしない
現状は入力が始まるとガンガンvalidateしてしまう。
| Step | Input | values | errors |
| ---- | ----- | ------ | --------------- |
| 1 | 空 | | 空 |
| 2 | `a` | `"a"` | "Invalid email" |
今回の要件にあわせてバリデーションボタンが押されるまではvalidateさせないようにする。[ドキュメントにあるvalidateOnModelUpdate](https://vee-validate.logaretm.com/v4/guide/composition-api/getting-started/#components)を使う。
```diff
- const [email, emailAttrs] = defineField("email");
+ const [email, emailAttrs] = defineField("email", {
+ validateOnModelUpdate: false,
+ });
```
これでバリデーションボタンを押すまでvalidateされなくなった。
### エラーの項目が元に戻った場合のみエラーを消す
先ほどの要件を確認してみよう。
> - 『バリデーションボタン』を押すまでは如何なる入力もエラーにしない
> - 『バリデーションボタン』を押したらエラー箇所が浮き上がる
> - エラーとなっている入力欄が正常に戻ったらエラーをリアルタイムに消す
> - 正常に戻った入力欄が再び異常値になっても、『バリデーションボタン』を押すまではエラーにしない
今の仕様では **エラーとなっている入力欄が正常に戻ったらエラーをリアルタイムに消す** の仕様を満たせない。心配はいらない。公式ドキュメントの[Dynamic configuration](https://vee-validate.logaretm.com/v4/guide/composition-api/getting-started/#dynamic-configuration)でこの解決策を提供している。
```diff
- const [email, emailAttrs] = defineField("email", {
- validateOnModelUpdate: false,
+ const [email, emailAttrs] = defineField("email", (state) => ({
+ validateOnModelUpdate: state.errors.length > 0,
}));
```
`useForm`から返却された`errors`ではなく、[[アロー関数 (JavaScript)|アロー関数]]の引数に渡される`state`を使うのがポイントだ。そして、エラーが存在するときのみ `validateOnModelUpdate` が有効になるようになっている。これは完璧ではないものの、限りなく現実的なフローにおいては完璧と言える。
| Step | Input | errors | validateOnmodelUpdate |
| ------------ | ------------- | -------- | --------------------- |
| 1 | 空 | なし | `false` |
| 2 | `a` | なし | `false` |
| **validate** | `a` | **あり** | `true` |
| 3 | `a@gmail` | **あり** | `true` |
| 4 | `
[email protected]` | なし | `false` |
| 5 | `a` | なし | `false` |
| **validate** | `a` | **あり** | `true` |
ツッコミどころはこの辺だろう。しかし、これらはあまり気にする価値のないものだと言える。
- 初回バリデーションボタン押下までの間に不正値があっても検出できない
- そもそも**今回はそういう要件**なのでOK (ベストとは言えないけど)
- validationエラーの項目を正したあとに不正な値に変更してもすぐ検知できない
- 一度正常値にすれば普通はそこからさらにいじらない
- 最悪バリデーションボタンで拾えるからよい
## 複雑なデータ型を扱う
ここまでは公式ドキュメントに導かれるままに書いたコードなので動いて当然だ。ここからが本題である。
### 扱うデータイメージ
フォームで複数のオーダーを設定でき、それぞれのオーダーにはキャラクターと個数が指定される感じ。制約はコメントの通り。
```ts
const orders = [
{
character: { // 必須. 初期値はnull
id: 1, // 数字. 必須
name: "タツヲ", // 20文字以内. 必須
},
num: 1, // 数字で1~99. 必須. 初期値は0(不正値)
},
{
character: {
id: 2,
name: "マサハル",
},
num: 2,
},
];
```
### FormのComposition APIを記述
項目が一気に増えるので、まずは`script`側から攻めていく。まずはFormの型から。
```ts
interface OrderForm {
character?: string;
// type=numberでも空文字列はくる
num?: number | "";
}
```
続いて[[Zod]]スキーマの定義。`OrderForm`のリストをvalidateすることになる。ほとんどのパラメーターはゼロ値と無効値のそれぞれに対して定義が必要なので注意。
```ts
const orderSchema = zod.object({
character: zod
.string({ required_error: "必須項目を指定してください" })
.min(1, "必須項目を指定してください")
.max(20, "1 ~ 20文字にしてください"),
num: zod
.number({ message: "必須項目を指定してください" })
.min(1, "1 ~ 99 にしてください")
.max(99, "1 ~ 99 にしてください"),
});
const { values, errors, validate } = useForm({
validationSchema: toTypedSchema(
zod.object({
orders: zod
.array(orderSchema, {
required_error: "オーダーを1つ以上指定してください",
})
.nonempty("オーダーを1つ以上指定してください"),
}),
),
});
```
今回は [useFieldArray](https://vee-validate.logaretm.com/v4/guide/composition-api/nested-objects-and-arrays/#field-arrays) を使う。
**ここで一旦保留**
```html
<script setup lang="ts">
import { useForm, useFieldArray } from "vee-validate";
import * as zod from "zod";
import { toTypedSchema } from "@vee-validate/zod";
// WARN: 粗い造りなのでproductionで使用しないこと
const mc = (...strs: string[]) => strs.join(" ");
const orderSchema = zod.object({
character: zod
.string({ required_error: "必須項目を指定してください" })
.min(1, "必須項目を指定してください")
.max(20, "1 ~ 20文字にしてください"),
num: zod
.number({ message: "必須項目を指定してください" })
.min(1, "1 ~ 99 にしてください")
.max(99, "1 ~ 99 にしてください"),
});
const { values, errors, validate } = useForm({
validationSchema: toTypedSchema(
zod.object({
orders: zod
.array(orderSchema, {
required_error: "オーダーを1つ以上指定してください",
})
.nonempty("オーダーを1つ以上指定してください"),
}),
),
});
interface OrderForm {
character?: string;
num?: number | "";
}
const { remove, push, fields } = useFieldArray<OrderForm>("orders");
const handleValidate = async () => {
await validate();
};
const inputClass = "border border-gray-700 px-3 py-1";
const primaryButtonClass =
"border border-gray-200 bg-gray-500 text-white px-3 py-1";
const actionButtonClass =
"border border-gray-200 bg-green-500 text-white px-3 py-1";
const destructedButtonClass =
"border border-red-200 bg-red-500 text-red-50 px-3 py-1";
</script>
<template>
<div class="p-16 flex flex-col gap-4">
<div v-for="(field, idx) in fields" :key="field.key" class="flex gap-2">
<input
v-model="field.value.character"
:class="mc(inputClass, 'w-48')"
type="text"
/>
<input
v-model="field.value.num"
:class="mc(inputClass, 'w-16')"
type="number"
/>
<button :class="mc(destructedButtonClass, 'w-16')" @click="remove(idx)">
X
</button>
</div>
<button :class="mc(primaryButtonClass, 'w-32')" @click="push({ num: 0 })">
追加
</button>
<button :class="mc(actionButtonClass, 'w-48')" @click="handleValidate">
validate
</button>
<pre>values: {{ values }}</pre>
<pre>errors: {{ errors }}</pre>
</div>
</template>
```