<br>
<br>
<br>

<p align="center">
  <img height="80px" src="https://github.com/LittleSound/slimeform/raw/main/example/public/favicon.svg">
</p>

<h1 align="center">SlimeForm</h1>

<p align="center">
  <a href="https://github.com/sponsors/LittleSound">
    <img src="https://cdn.jsdelivr.net/gh/littlesound/sponsors/sponsors.svg"/>
  </a>
</p>

<p align="center">
  这个项目得以实现，要感谢所有支持我的工作的 Sponsors<br>
  您可以在我的赞助商资料中加入他们：
</p>
<p align="center"><a href="https://github.com/sponsors/LittleSound"><img src="https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86&style=for-the-badge" /></a></p>

---

<p align="center"><a href="https://github.com/LittleSound/slimeform">English</a> | 简体中文</p>

<!-- 一些美丽的标签 -->
<p align="center">
  <a href="https://www.npmjs.com/package/slimeform">
    <img alt="Test" src="https://badgen.net/npm/v/slimeform">
  </a>
  <!-- <a href="https://github.com/LittleSound/slimeform/actions/workflows/test.yaml">
    <img alt="Test" src="https://github.com/LittleSound/slimeform/actions/workflows/test.yaml/badge.svg">
  </a> -->
  <a href="#在线尝试">
    <img alt="docs" src="https://img.shields.io/badge/-docs%20%26%20demos-1e8a7a">
  </a>
</p>

<br>

表单状态管理和数值校验

## 由来

在 Vue 项目中，我们经常会使用各种预制的表单组件，它可能是自己编写的，或者来自第三方 UI 库。对于第三方 UI 库而言，它们可能有自己的表单校验器，而自己的组件则需要自己编写表单校验器，很多时候这些表单的校验器是不统一的，尤其是当你在项目中混合使用第三方 UI 库 和自己的组件时，将它们结合在一起会十分困难。

基于现代 CSS 工具类和组件化，编写自己风格的 `<input>` 组件并将它们组合成表单十分容易，然而在涉及到需要整合所有输入相关的表单状态管理和规则校验时，问题就变得复杂起来了。

因此我开始为满足这一需求而对一个解决方案进行实验，并将其取名为 SlimeForm，含义为像史莱姆一样嵌入并粘粘所有表单 💙。

SlimeForm 是一个**无组件**、**无内置规则**的表单状态管理器和验证器，通过 `v-model` 绑定所有的原生或自定义组件，并响应式地管理以及验证它们。

## 待办事项

- [x] 改进功能
  - [x] 使用 `reactive` 类型返回表单
  - [x] 对于单个规则，可以省略数组
  - [x] 使用 `status[key].isDirty` 标记表单的值是否被修改
- [x] 文档
- [x] 更好的 Typescript 类型定义
- [x] 单元测试
- [x] 添加对  `object` 类型字段的支持
- [ ] 添加对异步规则的支持
- [x] 支持过滤未修改的条目，只留下已经修改的条目进行提交
- [ ] 支持第三方规则，比如 [yup](https://github.com/jquense/yup)
  - [x] 支持 `validateSync`
  - [ ] 支持 `validate`（异步）
- [ ] 💡 更多的点子

### 欢迎贡献

## 在线尝试

🚀 [slimeform-playground](https://stackblitz.com/edit/vitejs-vite-4eppne?file=package.json,src%2FApp.vue,src%2Fcomponents%2FHeader.vue,src%2Fcomponents%2FDemo.vue&terminal=dev)

## 安装

> ⚗️ **实验性**

```shell
npm i slimeform
```

> SlimeForm 仅支持 Vue 3

## 使用方式

### 表单状态管理

将 `form` 用 `v-model` 绑定到 `<input>` 或是其他组件。
值改变时 `status` 会产生对应的变化；使用 reset 方法重置表单的值到初始状态。

```vue
<script setup>
import { useForm } from 'slimeform'

const { form, status, reset, dirtyFields } = useForm({
  // 初始的 form 值
  form: () => ({
    username: '',
    password: '',
  }),
})
</script>

<template>
  <form @submit.prevent="mySubmit">
    <label>
      <!-- 这里 -->
      <input v-model="form.username" type="text">
      <input v-model="form.password" type="text">
    </label>
    <button type="submit">
      提交
    </button>
  </form>
</template>
```

#### 状态管理

```ts
const { form, status, reset, isDirty } = useForm(/* ... */)

// 表单是否已经被修改
isDirty.value
// username 是否已经被修改
status.username.isDirty
// password 是否已经被修改
status.password.isDirty

// 重置表单, 恢复到初始状态
reset()

// 重置指定字段
reset('username', 'password', /* ... */)
```

### 可变的初始 form 值

`useForm` 的表单初始值可以是其它变量或 pinia 的状态，初始值的改变将在表单重置时同步到 `form` 对象中

```ts
const userStore = useUserStore()

const { form, reset } = useForm({
  form: () => ({
    username: userStore.username,
    intro: userStore.intro,
  }),
})

// 假设我们调用 setInfo 函数更新了 username 和 intro 的值
userStore.setInfo(/* ... */)
// 在调用 reset 时，对 `userStore` 所做的更改将同步到 `form` 对象中
reset()

// 这些属性将是之前调用过 `setInfo` 之后 `userStore` 的值
form.username
form.intro
```

### 筛选出已修改的字段

假设你正在开发一个对现有数据进行编辑的表单，用户通常只会修改其中的部分字段，然后前端会通过 [HTTP  PATCH](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PATCH) 将用户修改过的那部分字段提交给后端，后端依据提交了哪些字段进行局部更新

此类需求可以使用  `dirtyFields` 计算函数，它的值是一个只会包含 `form` 当中被修改过字段的对象。

```ts
const { form: userInfo, status, dirtyFields } = useForm(/* ... */)

dirtyFields.value /* value: {} */

// 编辑用户简介
userInfo.intro = 'abcd'

dirtyFields.value /* value: { intro: 'abcd' } */

// 编辑用户简介为默认值
userInfo.intro = '' /* default value */

dirtyFields.value /* value: {} */
```

### 表单规则校验

使用 `rule` 定义表单字段的验证规则。当字段值发生更改时，验证过程将自动进行，验证结果将会存储和呈现在 `status[key].isError` 和 `status[key].message` 属性中。
如果一个字段需要多个规则，可以使用函数数组来声明。

> 你可以维护自己的规则集，并在需要使用的地方导入。

```ts
// formRules.ts
export function isRequired(value) {
  if (value && value.trim())
    return true

  return t('required') // i18n 支持
}
```

```vue
<script setup>
import { isRequired } from '~/util/formRules.ts'
const {
  form,
  status,
  submitter,
  clearErrors,
  isError,
  verify
} = useForm({
  // 初始 form 值
  form: () => ({
    name: '',
    age: '',
  }),
  // 进行校验
  rule: {
    name: isRequired,
    // 如果一个字段有多条规则，可以使用数组
    age: [
      isRequired,
      // 要求字段是数字 number
      val => !Number.isNaN(val) || 'Expected number',
      // 要求字段有最大值
      val => val.length < 3 || 'Length needs to be less than 3',
    ],
  },
})

const { submit } = submitter(() => {
  alert(`Age: ${form.age} \n Name: ${form.name}`)
})
</script>

<template>
  <form @submit.prevent="submit">
    <!-- ... -->
  </form>
</template>
```

此外，您可以在验证错误消息中使用任何响应式的值，例如如上所示，对 `vue-i18n` 库的多语言函数 `t('required')` 的调用。

<details><summary>手动触发校验</summary>
<p>

```ts
const { _, status, verify } = useForm(/* ... */)
// 表单校验
verify()
// 字段校验
status.username.verify()
```

</p>
</details>

<details><summary>手动指定错误</summary>
<p>

```ts
status.username.setError('username has been registered')
```

</p>
</details>

<details><summary>清除错误</summary>
<p>

```ts
const { _, status, clearErrors, reset } = useForm(/* ... */)
// 清除字段的错误
status.username.clearError()
// 清除全部错误
clearErrors()
// 重置表单也会清除错误
reset()
```

</p>
</details>

<details><summary>任何错误</summary>
<p>

`isError`: 是否有任何表单字段包含错误的验证结果

```ts
const { _, isError } = useForm(/* ... */)

isError /* true / false */
```

</p>
</details>

<details><summary>表单校验信息占位内容</summary>
<p>

使用 `defaultMessage` 定义表单字段校验信息的占位内容。默认值为 `''`，你可以将它设置为 `\u00A0`，在渲染时会被转义为 `&nbsp;`，以此来避免没有 message 时 `<p>` 出现高度坍塌问题。

```ts
const { form, status } = useForm({
  form: () => ({/* ... */}),
  rule: {/* ... */},
  // 没有错误消息时的占位内容
  defaultMessage: '\u00A0',
})
```

</p>
</details>

<details><summary>规则懒校验</summary>
<p>

将 `lazy` 设置为 `true` 可以阻止数据变化时规则自动校验。

此时, 可以考虑调用 `verify()` 或 `status[fieldName].verify()` 来手动校验字段。

```ts
const { form, status, verify } = useForm({
  form: () => ({
    userName: '',
    /* ... */
  }),

  rule: {
    userName: v => v.length < 3,
  },

  lazy: true,
})

form.userName = 'abc'
status.userName.isError // false

verify()

status.userName.isError // true
```

</p>
</details>

<details><summary><code>useForm()</code> 返回的 <code>rule</code></summary>
<p>

Slimeform 在 `useForm()` 的返回值中提供了 `rule`, 该字段可以校验表单外的数据。如果您想确保数据在传入表单之前一定是满足规则的，那么这个字段是很有用的。

```ts
const { form, rule } = useForm({
  form: () => ({
    userName: '',
    /* ... */
  }),

  rule: {
    userName: v => v.length < 3 || 'to many characters',
  },
})

const text = 'abcd'
const isValid = rule.userName.validate(text) // false
if (isValid)
  form.userName = text
```

您还可以获取到该字段的错误信息，方法是在第二个选项参数里传入 `fullResult: true`。此时该方法会返回一个带有错误信息的对象。

```ts
rule.userName.validate('abcd', { fullResult: true }) // { valid: false, message: "to many characters" }
rule.userName.validate('abc', { fullResult: true }) // { valid: true, message: null }
```

</p>
</details>

### 提交

`submitter` 接受一个回调函数参数，返回触发这个回调函数的函数和表示函数运行中的状态变量；传入 `submitter` 的回调函数可以拿到 `useForm` 函数返回的所有状态和函数，这样可以将回调函数放到单独的代码中，甚至编写通用的提交函数，方便组合使用。

```vue
<script setup>
import { useForm } from 'slimeform'

const { _, submitter } = useForm(/* ... */)

// Define the submit function
const {
  // trigger submit callback
  submit,
  // Indicates whether the asynchronous commit function is executing
  submitting,
} = submitter(async ({ form, status, isError, reset /* ... */ }) => {
  // Submission Code
  const res = await fetch(/* ... */)
  // ....
})
</script>

<template>
  <form @submit.prevent="submit">
    <!-- ... -->

    <!-- Use `submitting` to disable buttons and add loading indicator -->
    <button type="submit" :disabled="submitting">
      <icon v-if="submitting" name="loading" />
      Submit
    </button>
  </form>
</template>
```

默认情况下调用 `submit` 函数后会先进行表单规则验证，如果验证失败，将会直接结束函数执行，如果需要关闭此行为可以在 `submitter` 函数的第二个参数参数 `options` 中配置 `enableVerify: false` 来跳过验证。

#### 包装通用提交函数并使用

```ts
import { mySubmitForm } from './myFetch.ts'
const { _, submitter } = useForm(/* ... */)
// Wrap the generic submission code and use it later
const { submit, submitting } = submitter(mySubmitForm({ url: '/register', method: 'POST' }))
```

## 集成

### 使用 Yup 作为规则

如果你不想自己编写验证规则的细节，已经有一种非常简洁的方法可以使用 [Yup](https://github.com/jquense/yup) 作为规则。

SlimeForm 内置了 [Yup](https://github.com/jquense/yup) 同步规则的解析器：`yupFieldRule`，你可以从 `slimeform/resolvers` 导入它。`yupFieldRule` 函数在内部调用 `schema.validateSync` 方法，并处理结果为 SlimeForm 可接受的格式。

**首先，你要安装 [Yup](https://github.com/jquense/yup)**

```sh
npm install yup
```

然后在代码中导入 `yup` 和 `yupFieldRule` 就可以使用了

```ts
import { useForm } from 'slimeform'
import * as yup from 'yup'

/* 导入解析器 */
import { yupFieldRule } from 'slimeform/resolvers'

const { t } = useI18n()

const { form, status } = useForm({
  form: () => ({ age: '' }),
  rule: {
    /* 一些使用案例 */
    age: [
      yupFieldRule(yup.string()
        .required(),
      ),
      yupFieldRule(yup.number()
        .max(120, () => t('xxx_i18n_key'))
        .integer()
        .nullable(),
      ),
    ],
  },
})
```

## 建议

一些建议：

1. 使用 `@submit.prevent` 而不是 `@submit` 来屏蔽表单默认提交行为
2. 使用 `isError` 的值来动态地判断是否需要给表单输入框添加红色的描边

```vue
<template>
  <h3>请输入你的年龄</h3>
  <form @submit.prevent="submitFn">
    <label>
      <input
        v-model="form.age"
        type="text"
        :class="status.age.isError && '!border-red'"
      >
      <p>{{ status.age.message }}</p>
    </label>
    <button type="submit">
      提交
    </button>
  </form>
</template>
```
