refactor(@vben-core/form-ui): migrate to Zod 4 and TanStack Form (#8176)

* build(@vben-core/form-ui): update form validation dependencies

* refactor(@vben-core/form-ui): replace vee-validate with TanStack Form

* test(@vben-core/form-ui): cover TanStack Form migration

* docs(@vben/docs): document Zod 4 form migration

* refactor(project): update form adapters

* refactor(project): migrate application form consumers

* refactor(@vben/playground): migrate form examples

* docs(@vben/docs): fix validate return type and required rule empty check in form docs

* fix(@vben-core/form-ui): resolve oxlint and eslint errors

- rewrite nested ternary expressions as if/else in form-field-array.vue and form-runtime.ts
- sort @tanstack/vue-form before @vben-core/composables in package.json

* chore: fix merge artifacts and formatting

- move dependencies before devDependencies in root package.json
- update preferences snapshot for widget positioning fields
- format form-array demo README

* refactor(@vben-core/form-ui): optimize form runtime and value flow

- add fine-grained field selectors and atomic dependency resolution
- expose raw and formatted value snapshots with focused regression coverage

* fix(@vben/layouts): dispose sortable instance on unmount

* docs(@vben/docs): document form runtime API changes

- list added, changed, removed, and deprecated form APIs
- document atomic dependencies and raw versus formatted values
This commit is contained in:
dream-weave
2026-07-22 19:47:07 +08:00
committed by GitHub
parent 6b6708bcf2
commit 28757fb9c8
81 changed files with 4683 additions and 1435 deletions

View File

@@ -1,6 +1,7 @@
import type {
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
FormValues,
} from '@vben/common-ui';
import type { ComponentPropsMap, ComponentType } from './component';
@@ -22,7 +23,7 @@ async function initSetupVbenForm() {
Upload: 'fileList',
},
},
defineRules: {
rules: {
// 输入项目必填国际化适配
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
@@ -41,9 +42,18 @@ async function initSetupVbenForm() {
});
}
const useVbenForm = useForm<ComponentType, ComponentPropsMap>;
function useVbenForm<TValues extends FormValues = FormValues>(
options: FormProps<ComponentType, ComponentPropsMap, TValues>,
) {
return useForm<TValues, ComponentType, ComponentPropsMap>(options);
}
export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType, ComponentPropsMap>;
export type VbenFormProps = FormProps<ComponentType, ComponentPropsMap>;
export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps<
ComponentType,
ComponentPropsMap,
TValues
>;

View File

@@ -46,7 +46,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { password } = values;
return z
.string({ required_error: $t('authentication.passwordTip') })
.string({ error: $t('authentication.passwordTip') })
.min(1, { message: $t('authentication.passwordTip') })
.refine((value) => value === password, {
message: $t('authentication.confirmPasswordTip'),

View File

@@ -38,7 +38,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { newPassword } = values;
return z
.string({ required_error: '请再次输入新密码' })
.string({ error: '请再次输入新密码' })
.min(1, { message: '请再次输入新密码' })
.refine((value) => value === newPassword, {
message: '两次输入的密码不一致',

View File

@@ -1,6 +1,7 @@
import type {
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
FormValues,
} from '@vben/common-ui';
import type { ComponentPropsMap, ComponentType } from './component';
@@ -22,7 +23,7 @@ async function initSetupVbenForm() {
Upload: 'fileList',
},
},
defineRules: {
rules: {
// 输入项目必填国际化适配
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
@@ -40,9 +41,18 @@ async function initSetupVbenForm() {
},
});
}
const useVbenForm = useForm<ComponentType, ComponentPropsMap>;
function useVbenForm<TValues extends FormValues = FormValues>(
options: FormProps<ComponentType, ComponentPropsMap, TValues>,
) {
return useForm<TValues, ComponentType, ComponentPropsMap>(options);
}
export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType, ComponentPropsMap>;
export type VbenFormProps = FormProps<ComponentType, ComponentPropsMap>;
export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps<
ComponentType,
ComponentPropsMap,
TValues
>;

View File

@@ -46,7 +46,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { password } = values;
return z
.string({ required_error: $t('authentication.passwordTip') })
.string({ error: $t('authentication.passwordTip') })
.min(1, { message: $t('authentication.passwordTip') })
.refine((value) => value === password, {
message: $t('authentication.confirmPasswordTip'),

View File

@@ -38,7 +38,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { newPassword } = values;
return z
.string({ required_error: '请再次输入新密码' })
.string({ error: '请再次输入新密码' })
.min(1, { message: '请再次输入新密码' })
.refine((value) => value === newPassword, {
message: '两次输入的密码不一致',

View File

@@ -1,6 +1,7 @@
import type {
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
FormValues,
} from '@vben/common-ui';
import type { ComponentPropsMap, ComponentType } from './component';
@@ -16,7 +17,7 @@ async function initSetupVbenForm() {
CheckboxGroup: 'model-value',
},
},
defineRules: {
rules: {
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
@@ -33,9 +34,18 @@ async function initSetupVbenForm() {
});
}
const useVbenForm = useForm<ComponentType, ComponentPropsMap>;
function useVbenForm<TValues extends FormValues = FormValues>(
options: FormProps<ComponentType, ComponentPropsMap, TValues>,
) {
return useForm<TValues, ComponentType, ComponentPropsMap>(options);
}
export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType, ComponentPropsMap>;
export type VbenFormProps = FormProps<ComponentType, ComponentPropsMap>;
export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps<
ComponentType,
ComponentPropsMap,
TValues
>;

View File

@@ -46,7 +46,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { password } = values;
return z
.string({ required_error: $t('authentication.passwordTip') })
.string({ error: $t('authentication.passwordTip') })
.min(1, { message: $t('authentication.passwordTip') })
.refine((value) => value === password, {
message: $t('authentication.confirmPasswordTip'),

View File

@@ -38,7 +38,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { newPassword } = values;
return z
.string({ required_error: '请再次输入新密码' })
.string({ error: '请再次输入新密码' })
.min(1, { message: '请再次输入新密码' })
.refine((value) => value === newPassword, {
message: '两次输入的密码不一致',

View File

@@ -1,6 +1,7 @@
import type {
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
FormValues,
} from '@vben/common-ui';
import type { ComponentPropsMap, ComponentType } from './component';
@@ -20,7 +21,7 @@ async function initSetupVbenForm() {
Upload: 'fileList',
},
},
defineRules: {
rules: {
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
@@ -37,9 +38,18 @@ async function initSetupVbenForm() {
});
}
const useVbenForm = useForm<ComponentType, ComponentPropsMap>;
function useVbenForm<TValues extends FormValues = FormValues>(
options: FormProps<ComponentType, ComponentPropsMap, TValues>,
) {
return useForm<TValues, ComponentType, ComponentPropsMap>(options);
}
export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType, ComponentPropsMap>;
export type VbenFormProps = FormProps<ComponentType, ComponentPropsMap>;
export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps<
ComponentType,
ComponentPropsMap,
TValues
>;

View File

@@ -46,7 +46,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { password } = values;
return z
.string({ required_error: $t('authentication.passwordTip') })
.string({ error: $t('authentication.passwordTip') })
.min(1, { message: $t('authentication.passwordTip') })
.refine((value) => value === password, {
message: $t('authentication.confirmPasswordTip'),

View File

@@ -38,7 +38,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { newPassword } = values;
return z
.string({ required_error: '请再次输入新密码' })
.string({ error: '请再次输入新密码' })
.min(1, { message: '请再次输入新密码' })
.refine((value) => value === newPassword, {
message: '两次输入的密码不一致',

View File

@@ -50,7 +50,7 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
await formApi.validateAndSubmitForm();
await formApi.validateAndSubmit();
// modalApi.close();
},
onOpenChange(isOpen: boolean) {

View File

@@ -24,7 +24,7 @@ const [Form, formApi] = useVbenForm({
component: 'VbenFormFieldArray',
fieldName: 'members',
label: '项目成员',
// 初始化为空数组,供内部 useFieldArray 使用
// 初始化为空数组,供数组编辑器使用
defaultValue: [],
componentProps: {
min: 1,
@@ -113,9 +113,7 @@ async function getFormValues() {
<template #header-extra>
<NButton class="mr-2" @click="setFormValues">设置表单值</NButton>
<NButton class="mr-2" @click="getFormValues">获取表单值</NButton>
<NButton type="primary" @click="formApi.submitForm()">
提交校验
</NButton>
<NButton type="primary" @click="formApi.submit()"> 提交校验 </NButton>
</template>
<Form />
</NCard>

View File

@@ -1,6 +1,7 @@
import type {
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
FormValues,
} from '@vben/common-ui';
import type { ComponentPropsMap, ComponentType } from './component';
@@ -22,7 +23,7 @@ async function initSetupVbenForm() {
Upload: 'fileList',
},
},
defineRules: {
rules: {
// 输入项目必填国际化适配
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
@@ -41,9 +42,18 @@ async function initSetupVbenForm() {
});
}
const useVbenForm = useForm<ComponentType, ComponentPropsMap>;
function useVbenForm<TValues extends FormValues = FormValues>(
options: FormProps<ComponentType, ComponentPropsMap, TValues>,
) {
return useForm<TValues, ComponentType, ComponentPropsMap>(options);
}
export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType, ComponentPropsMap>;
export type VbenFormProps = FormProps<ComponentType, ComponentPropsMap>;
export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps<
ComponentType,
ComponentPropsMap,
TValues
>;

View File

@@ -46,7 +46,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { password } = values;
return z
.string({ required_error: $t('authentication.passwordTip') })
.string({ error: $t('authentication.passwordTip') })
.min(1, { message: $t('authentication.passwordTip') })
.refine((value) => value === password, {
message: $t('authentication.confirmPasswordTip'),

View File

@@ -38,7 +38,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { newPassword } = values;
return z
.string({ required_error: '请再次输入新密码' })
.string({ error: '请再次输入新密码' })
.min(1, { message: '请再次输入新密码' })
.refine((value) => value === newPassword, {
message: '两次输入的密码不一致',

View File

@@ -1,6 +1,7 @@
import type {
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
VbenFormProps,
FormValues,
} from '@vben/common-ui';
import type { ComponentType } from './component';
@@ -24,7 +25,7 @@ setupVbenForm<ComponentType>({
Upload: 'fileList',
},
},
defineRules: {
rules: {
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
@@ -40,9 +41,18 @@ setupVbenForm<ComponentType>({
},
});
const useVbenForm = useForm<ComponentType>;
function useVbenForm<TValues extends FormValues = FormValues>(
options: FormProps<ComponentType, Record<never, never>, TValues>,
) {
return useForm<TValues, ComponentType, Record<never, never>>(options);
}
export { useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType>;
export type { VbenFormProps };
export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, Record<never, never>, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps<
ComponentType,
Record<never, never>,
TValues
>;

View File

@@ -16,7 +16,9 @@ outline: deep
## 适配器
表单底层使用 [vee-validate](https://vee-validate.logaretm.com/v4/) 进行表单验证,所以你可以使用 `vee-validate` 的所有功能。对于不同的 UI 框架,我们提供了适配器,以便更好的适配不同的 UI 框架
表单内部使用 [TanStack Form](https://tanstack.com/form/latest/docs/framework/vue/overview) 管理状态与校验生命周期,并使用 [Zod 4](https://zod.dev/v4) 描述 schema。业务侧仍通过 `useVbenForm``FormApi` 和组件适配器使用表单,不应直接依赖底层 TanStack 实例
从 Zod 3 或旧表单引擎升级时,请先阅读 [Zod 4 与 TanStack Form 迁移指南](/guide/in-depth/zod-v4-form-migration)。
### 适配器说明
@@ -26,8 +28,9 @@ outline: deep
```ts
import type {
FormValues,
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
VbenFormProps,
} from '@vben/common-ui';
import type { ComponentType } from './component';
@@ -42,6 +45,8 @@ setupVbenForm<ComponentType>({
config: {
// ant design vue组件库默认都是 v-model:value
baseModelPropName: 'value',
// 仅当组件不发送 update:*、只发送 change 时启用
changeEventFallback: false,
// 一些组件库空值为 null重置表单时需要和实际组件行为保持一致
emptyStateValue: null,
// 一些组件是 v-model:checked 或者 v-model:fileList
@@ -52,7 +57,7 @@ setupVbenForm<ComponentType>({
Upload: 'fileList',
},
},
defineRules: {
rules: {
// 输入项目必填国际化适配
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
@@ -70,11 +75,20 @@ setupVbenForm<ComponentType>({
},
});
const useVbenForm = useForm<ComponentType>;
function useVbenForm<TValues extends FormValues = FormValues>(
options: FormProps<ComponentType, Record<never, never>, TValues>,
) {
return useForm<TValues, ComponentType, Record<never, never>>(options);
}
export { useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType>;
export type { VbenFormProps };
export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, Record<never, never>, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps<
ComponentType,
Record<never, never>,
TValues
>;
```
:::
@@ -252,6 +266,8 @@ export { initComponentAdapter };
_注意_ 需要指定 `dependencies``triggerFields` 属性,设置由谁的改动来触发,以便表单组件能够正确的联动。
新代码推荐使用 `dependencies.resolve(context)` 一次返回完整动态状态。它只在 `triggerFields` 变化时执行,并原子更新 `if``show``disabled``required``rules``componentProps``help``renderComponentContent`,避免多个异步回调产生中间状态。原有多回调结构继续兼容。
<DemoPreview dir="demos/vben-form/dynamic" />
## 自定义组件
@@ -287,29 +303,105 @@ const [Form, formApi] = useVbenForm({
</template>
```
### 类型传递与插槽
通过 `useVbenForm<TValues>` 定义一次表单值类型后,`getValues``setValues``setFieldValue``handleSubmit``handleValuesChange``formApi.form.values` 和 selector 都会沿用该类型,可直接作为 API 请求参数:
```vue
<script setup lang="ts">
import { useVbenForm } from '#/adapter/form';
interface AccountFormValues {
email: string;
nickname: string;
}
const [Form, formApi] = useVbenForm<AccountFormValues>({
handleSubmit(values, rawValues) {
// values: AccountFormValues
// rawValues: Readonly<AccountFormValues>
return addAccount(values);
},
schema: [
{ component: 'Input', fieldName: 'email', label: 'Email' },
{ component: 'Input', fieldName: 'nickname', label: 'Nickname' },
],
});
async function fillForm() {
await formApi.setValues({ email: 'user@example.com' });
const values = await formApi.getValues(); // AccountFormValues
return values;
}
</script>
<template>
<Form>
<template #email="{ componentField, field, formApi, values }">
<!-- field.state.valuecomponentField.modelValue 均为 string -->
<input v-bind="componentField" :data-email="values.email" />
<button type="button" @click="formApi.clearValidation('email')">
Clear
</button>
</template>
<template #default="{ formApi, shapes, values }">
<!-- values: AccountFormValues -->
<button type="button" @click="formApi.submit()">
Submit {{ shapes.length }} fields for {{ values.email }}
</button>
</template>
</Form>
</template>
```
字段命名插槽提供 `field``componentField``modelValue``name``disabled``isInValid``values``formApi`。默认插槽提供 `shapes``values``formApi``reset-before``submit-before``expand-before``expand-after` 提供 `values``formApi`。未声明 `TValues` 时仍兼容任意字段名,但 slot props 会回退为宽泛类型。
### FormApi
useVbenForm 返回的第二个参数,是一个对象,包含了一些表单的方法。
| 方法名 | 描述 | 类型 | 版本号 |
| --- | --- | --- | --- |
| submitForm | 提交表单 | `(e:Event)=>Promise<Record<string,any>>` | - |
| validateAndSubmitForm | 提交并校验表单 | `(e:Event)=>Promise<Record<string,any>>` | - |
| resetForm | 重置表单 | `()=>Promise<void>` | - |
| setValues | 设置表单值, 默认会过滤不在schema中定义的field, 可通过filterFields形参关闭过滤 | `(fields: Record<string, any>, filterFields?: boolean, shouldValidate?: boolean) => Promise<void>` | - |
| getValues | 获取表单值 | `(fields:Record<string, any>,shouldValidate: boolean = false)=>Promise<void>` | - |
| validate | 表单校验 | `()=>Promise<void>` | - |
| validateField | 校验指定字段 | `(fieldName: string)=>Promise<ValidationResult<unknown>>` | - |
| submit | 提交表单 | `(e?: Event) => Promise<TValues>` | - |
| validateAndSubmit | 校验通过后提交表单 | `() => Promise<TValues \| undefined>` | - |
| reset | 重置表单 | `(state?: FormResetState<TValues>, options?: FormResetOptions) => Promise<void>` | - |
| clearValidation | 清空指定字段或全部校验,并取消进行中的异步校验 | `(fieldNames?: FormFieldName<TValues> \| FormFieldName<TValues>[]) => Promise<void>` | - |
| setValues | 设置表单值,默认会过滤不在 schema 中定义的字段 | `(fields: Partial<TValues>, filterFields?: boolean, shouldValidate?: boolean) => Promise<void>` | - |
| getValues | 获取经过字段映射和 valueFormat 的值 | `() => Promise<TValues>` | - |
| getRawValues | 获取未格式化的独立值快照 | `() => Promise<TValues>` | - |
| getValueSnapshot | 一次获取原始值和格式化值 | `() => Promise<FormValueSnapshot<TValues>>` | - |
| formatValues | 格式化指定的原始值快照 | `(rawValues: Readonly<TValues>) => TValues` | - |
| validate | 表单校验 | `() => Promise<FormValidationResult>` | - |
| validateField | 校验指定字段 | `(fieldName: string) => Promise<FormValidationResult>` | - |
| isFieldValid | 检查某个字段是否已通过校验 | `(fieldName: string)=>Promise<boolean>` | - |
| resetValidate | 重置表单校验 | `()=>Promise<void>` | - |
| updateSchema | 更新formSchema | `(schema:FormSchema[])=>void` | - |
| setFieldValue | 设置字段值 | `(field: string, value: any, shouldValidate?: boolean)=>Promise<void>` | - |
| setState | 设置组件状态props | `(stateOrFn:\| ((prev: VbenFormProps) => Partial<VbenFormProps>)\| Partial<VbenFormProps>)=>Promise<void>` | - |
| getState | 获取组件状态props | `()=>Promise<VbenFormProps>` | - |
| form | 表单对象实例,可以操作表单,见 [useForm](https://vee-validate.logaretm.com/v4/api/use-form/) | - | - |
| form | 稳定的 `FormContextApi`,提供 values、errors、set/reset/validate/submit 与数组字段操作,不暴露底层 TanStack 泛型 | `FormContextApi` | - |
| getFieldComponentRef | 获取指定字段的组件实例 | `<T=unknown>(fieldName: string)=>T` | >5.5.3 |
| getFocusedField | 获取当前已获得焦点的字段 | `()=>string\|undefined` | >5.5.3 |
旧命名 `submitForm``validateAndSubmitForm``resetForm``resetValidate` 分别对应 `submit``validateAndSubmit``reset``clearValidation`。它们仍可调用,但已标记 `@deprecated`,开发环境每个旧名称只警告一次,生产环境静默。
### FormContextApi 响应式读取
`formApi.form` 提供细粒度 selector。字段组件应优先使用字段级方法避免订阅整份 values 或 errors
| 方法 | 返回值 | 用途 |
| --- | --- | --- |
| `useFieldValue(fieldName)` | `Readonly<Ref<FormFieldValue>>` | 订阅一个字段值。 |
| `useFieldValues(fieldNames)` | `Readonly<Ref<FormFieldValue[]>>` | 订阅一组声明字段值。 |
| `useFieldError(fieldName)` | `Readonly<Ref<string \| undefined>>` | 订阅一个字段错误。 |
| `useValues()` | `Readonly<Ref<TValues>>` | 订阅整份表单值。 |
| `useSelector(selector)` | `Readonly<Ref<TResult>>` | 兼容入口,可从 `{ values, errors, meta }` 组合选择状态。 |
```ts
const email = formApi.form.useFieldValue('email');
const emailError = formApi.form.useFieldError('email');
const submitting = formApi.form.useSelector((state) => state.meta.submitting);
```
## Props
所有属性都可以传入 `useVbenForm` 的第一个参数中。
@@ -323,8 +415,8 @@ useVbenForm 返回的第二个参数,是一个对象,包含了一些表单
| actionLayout | 表单操作按钮位置 | `'newLine' \| 'rowEnd' \| 'inline'` | `rowEnd` |
| actionPosition | 表单操作按钮对齐方式 | `'left' \| 'center' \| 'right'` | `right` |
| handleReset | 表单重置回调 | `(values: Record<string, any>,) => Promise<void> \| void` | - |
| handleSubmit | 表单提交回调 | `(values: Record<string, any>,) => Promise<void> \| void` | - |
| handleValuesChange | 表单值变化回调 | `(values: Record<string, any>, fieldsChanged: string[]) => void` | - |
| handleSubmit | 表单提交回调 | `(values: TValues, rawValues: Readonly<TValues>) => Promise<void> \| void` | - |
| handleValuesChange | 表单值变化回调 | `(rawValues: Readonly<TValues>, fieldsChanged: string[], getFormattedValues: () => TValues) => void` | - |
| handleCollapsedChange | 表单收起展开状态变化回调 | `(collapsed: boolean) => void` | - |
| actionButtonsReverse | 调换操作按钮位置 | `boolean` | `false` |
| resetButtonOptions | 重置按钮组件参数 | `ActionButtonOptions` | - |
@@ -343,7 +435,9 @@ useVbenForm 返回的第二个参数,是一个对象,包含了一些表单
::: tip handleValuesChange
`handleValuesChange` 回调函数的第一个参数`values`装载了表单改变后的当前值对象,第二个参数`fieldsChanged`是一个数组包含了所有被改变的字段名。注意第二个参数仅在v5.5.4(不含)以上版本可用并且传递的是已在schema中定义的字段名。如果你使用了字段映射并且需要检查是哪些字段发生了变化的话请注意该参数并不会包含映射后的字段名
`handleValuesChange` 的第一个参数是未经过 `valueFormat``fieldMappingTime` 或 array-to-string 转换的只读当前值,第二个参数是本次发生变化的 schema 字段名。第三个参数 `getFormattedValues` 是惰性函数:不调用就不会执行深拷贝和格式化,适合只在少数变化场景读取提交结构。字段映射生成的目标字段不会出现在 `fieldsChanged`
`getRawValues()``getValues()` 分别只生成一份目标快照;确实需要同时比较两种结构时再调用 `getValueSnapshot()``handleSubmit(values, rawValues)` 会在提交边界同时提供格式化结果和对应的原始快照。
:::
@@ -410,6 +504,11 @@ export interface ActionButtonOptions {
```ts
export interface FormCommonConfig {
/**
* 仅当组件不发送 update:*、只发送 change 时启用兼容回退
* @default false
*/
changeEventFallback?: boolean;
/**
* 所有表单项的props
*/
@@ -431,7 +530,7 @@ export interface FormCommonConfig {
* 所有表单项的控件样式
* @default {}
*/
formFieldProps?: Partial<typeof Field>;
formFieldProps?: FormFieldOptions;
/**
* 所有表单项的栅格布局
* @default ""
@@ -475,11 +574,14 @@ export interface FormCommonConfig {
```ts
export interface FormSchema<
T extends BaseFormComponentType = BaseFormComponentType,
TValues extends FormValues = FormValues,
> extends FormCommonConfig {
/** 组件 */
component: Component | T;
/** 组件参数 */
componentProps?: ComponentProps;
componentProps?:
| MaybeComponentProps
| ((ctx: FormSchemaContext<TValues>) => MaybeComponentProps);
/** 默认值 */
defaultValue?: any;
/** 依赖 */
@@ -489,13 +591,15 @@ export interface FormSchema<
/** 字段名,也作为自定义插槽的名称 */
fieldName: string;
/** 帮助信息 */
help?: CustomRenderType;
help?: string | ((ctx: FormSchemaContext<TValues>) => Component | string);
/** 是否隐藏表单项 */
hide?: boolean;
/** 表单的标签如果是一个string会用于默认必选规则的消息提示 */
label?: CustomRenderType;
/** 自定义组件内部渲染 */
renderComponentContent?: RenderComponentContentType;
renderComponentContent?: (
ctx: FormSchemaContext<TValues>,
) => Record<string, any>;
/** 字段规则 */
rules?: FormSchemaRuleType;
/** 后缀 */
@@ -505,6 +609,8 @@ export interface FormSchema<
}
```
顶层 `componentProps``help``renderComponentContent` 函数只接收轻量 `FormSchemaContext`,适合数组行索引、字段路径等 schema 信息。需要读取表单值时,使用 `dependencies.resolve({ values, ... })`,避免每个字段订阅整份 values。
:::
::: details FormValueFormat
@@ -529,29 +635,37 @@ type FormValueFormat = (
```ts
dependencies: {
// 触发字段。只有这些字段值变动时,联动才会触发
triggerFields: ['name'],
// 动态判断当前字段是否需要显示,不显示则直接销毁
if(values,formApi){},
// 动态判断当前字段是否需要显示不显示用css隐藏
show(values,formApi){},
// 动态判断当前字段是否需要禁用
disabled(values,formApi){},
// 字段变更时,都会触发该函数
trigger(values,formApi){},
// 动态rules
rules(values,formApi){},
// 动态必填
required(values,formApi){},
// 动态组件参数
componentProps(values,formApi){},
triggerFields: ['type', 'role'],
resolve({ values, actions, controller, schema }) {
const editable = values.type === 'editable';
return {
componentProps: { placeholder: schema.fieldName },
disabled: !editable,
required: values.role === 'owner',
rules: editable ? 'required' : null,
show: values.type !== 'hidden',
};
},
}
```
`resolve` 返回的字段会一次性提交;支持 `if``show``disabled``required``rules``componentProps``help``renderComponentContent`。未返回 `rules` 时继续使用静态规则,显式返回 `rules: null` 时关闭静态规则。`actions` 是稳定的 `FormContextApi``controller` 是高层 FormApi`schema` 包含字段名和数组行上下文。
旧的 `if/show/disabled/required/rules/componentProps/trigger` 回调语法仍完整兼容并保持原求值顺序,但已标记为 `@deprecated`,开发环境首次使用时会提示迁移。新旧语法在同一个 dependencies 对象中互斥;绕过类型同时传入时以 `resolve` 为准。
### 表单校验
表单校验需要通过 schema 内的 `rules` 属性进行配置。
字段默认在 blur、change 和 submit 时校验。使用 `formFieldProps.validateOn` 限制交互触发时机submit 始终校验;异步校验可通过 `asyncDebounceMs` 防抖:
```ts
formFieldProps: {
asyncDebounceMs: 300,
validateOn: ['blur'],
}
```
rules的值可以是字符串预定义的校验规则名称也可以是一个zod的schema。
#### 预定义的校验规则

View File

@@ -124,26 +124,28 @@ const [Form] = useVbenForm({
showSearch: true,
},
dependencies: {
componentProps(values) {
resolve({ values }) {
if (values.field2 === '123') {
return {
options: [
{
label: '选项1',
value: '1',
},
{
label: '选项2',
value: '2',
},
{
label: '选项3',
value: '3',
},
],
componentProps: {
options: [
{
label: '选项1',
value: '1',
},
{
label: '选项2',
value: '2',
},
{
label: '选项3',
value: '3',
},
],
},
};
}
return {};
return { componentProps: {} };
},
triggerFields: ['field2'],
},

View File

@@ -69,7 +69,7 @@ const [Form] = useVbenForm({
fieldName: 'field4',
// 界面显示的label
label: '邮箱',
rules: z.string().email('请输入正确的邮箱'),
rules: z.email('请输入正确的邮箱'),
},
{
component: 'InputNumber',

View File

@@ -6,6 +6,10 @@ outline: deep
`Vben Form` is the shared form abstraction used across different UI-library variants such as `Ant Design Vue`, `Element Plus`, `Naive UI`, and other adapters added inside this repository.
It uses [TanStack Form](https://tanstack.com/form/latest/docs/framework/vue/overview) internally for state and validation lifecycles, with [Zod 4](https://zod.dev/v4) schemas. Application code should continue using `useVbenForm`, `FormApi`, and the adapter layer instead of depending on the raw TanStack instance.
Read the [Zod 4 and TanStack Form migration guide](/en/guide/in-depth/zod-v4-form-migration) before upgrading an existing project.
> If some details are not obvious from the docs, check the live demos as well.
## Adapter Setup
@@ -23,8 +27,9 @@ The current adapter pattern is:
```ts
import type {
FormValues,
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
VbenFormProps,
} from '@vben/common-ui';
import type { ComponentType } from './component';
@@ -46,7 +51,7 @@ setupVbenForm<ComponentType>({
Upload: 'fileList',
},
},
defineRules: {
rules: {
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
@@ -62,11 +67,20 @@ setupVbenForm<ComponentType>({
},
});
const useVbenForm = useForm<ComponentType>;
function useVbenForm<TValues extends FormValues = FormValues>(
options: FormProps<ComponentType, Record<never, never>, TValues>,
) {
return useForm<TValues, ComponentType, Record<never, never>>(options);
}
export { useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType>;
export type { VbenFormProps };
export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, Record<never, never>, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps<
ComponentType,
Record<never, never>,
TValues
>;
```
### Component Adapter Example
@@ -191,6 +205,55 @@ Create the form through `useVbenForm`:
<DemoPreview dir="demos/vben-form/basic" />
## Typed Values and Slots
Declare the value shape once with `useVbenForm<TValues>`. The same type flows through value APIs, callbacks, selectors, and field/default/action slots:
```vue
<script setup lang="ts">
import { useVbenForm } from '#/adapter/form';
interface AccountFormValues {
email: string;
nickname: string;
}
const [Form, formApi] = useVbenForm<AccountFormValues>({
handleSubmit(values) {
return addAccount(values); // AccountFormValues
},
schema: [
{ component: 'Input', fieldName: 'email', label: 'Email' },
{ component: 'Input', fieldName: 'nickname', label: 'Nickname' },
],
});
async function fillForm() {
await formApi.setValues({ email: 'user@example.com' });
return formApi.getValues(); // Promise<AccountFormValues>
}
</script>
<template>
<Form>
<template #email="{ componentField, field, formApi, values }">
<!-- field.state.value and componentField.modelValue are strings -->
<input v-bind="componentField" :data-email="values.email" />
<button type="button" @click="formApi.clearValidation('email')">
Clear
</button>
</template>
<template #default="{ formApi, shapes, values }">
<button type="button" @click="formApi.submit()">
Submit {{ shapes.length }} fields for {{ values.email }}
</button>
</template>
</Form>
</template>
```
Named field slots expose `field`, `componentField`, `modelValue`, `name`, `disabled`, `isInValid`, `values`, and `formApi`. The default slot exposes `shapes`, `values`, and `formApi`; action slots expose `values` and `formApi`. Forms without an explicit `TValues` remain compatible with arbitrary slot names and broad props.
## Value Formatting
Use `schema.valueFormat` when the component value is convenient for the UI but the final payload returned by `getValues()` should use a different shape.
@@ -204,10 +267,25 @@ Use `schema.valueFormat` when the component value is convenient for the UI but t
## Key API Notes
- `useVbenForm` returns `[Form, formApi]`
- `useVbenForm<TValues>` propagates values through APIs, callbacks, schema callbacks, and slots
- prefer `reset`, `submit`, `validateAndSubmit`, and `clearValidation`
- `resetForm`, `submitForm`, `validateAndSubmitForm`, and `resetValidate` remain deprecated aliases that warn once in development
- `clearValidation` invalidates in-flight async results before clearing errors
- `formApi.getFieldComponentRef()` and `formApi.getFocusedField()` are available in current versions
- `handleValuesChange(values, fieldsChanged)` includes the second parameter in newer versions
- `handleValuesChange(values, fieldsChanged)` receives readonly raw form state before `valueFormat`, `fieldMappingTime`, or array-to-string conversion
- its third `getFormattedValues` argument formats lazily, so raw-only change handlers avoid clone and transform work
- `getRawValues()` returns only an independent raw snapshot, `getValues()` returns only the formatted payload, and `getValueSnapshot()` returns both
- `handleSubmit(values, rawValues)` receives the formatted payload and its corresponding raw snapshot
- `fieldMappingTime` and `scrollToFirstError` are part of the current form props
- `schema.valueFormat` lets `getValues()` transform UI values into backend-friendly payloads
- `formApi.form` is the stable `FormContextApi`; raw TanStack generics are intentionally not exposed
- prefer `formApi.form.useFieldValue`, `useFieldValues`, and `useFieldError` for fine-grained subscriptions; use `useValues` only when the whole form is required
- `useSelector` remains the compatibility selector for combined `{ values, errors, meta }` state
- legacy `setupVbenForm({ defineRules })` still works, warns once in development, and is silent in production; use `rules` for new code
- prefer `dependencies: { triggerFields, resolve(context) }` for one atomic dynamic-state patch; legacy dependency callbacks remain supported but are deprecated and warn once in development
- top-level `componentProps`, `help`, and `renderComponentContent` functions receive `FormSchemaContext`; value-dependent rendering belongs in `dependencies.resolve`
- use `formFieldProps.validateOn` with `blur` and/or `change`; submit always validates, and `asyncDebounceMs` debounces async validators
- use `changeEventFallback: true` only for components that emit `change` without an `update:*` event
## Reference

View File

@@ -0,0 +1,242 @@
---
outline: deep
---
# Zod 4 and TanStack Form Migration
This migration upgrades form schemas from Zod 3 to Zod 4 and replaces vee-validate with TanStack Form internally. The Vben business API remains stable while implementation-specific form APIs are removed from the public boundary.
## Dependency Changes
| Area | Before | After |
| --- | --- | --- |
| Schema | `zod@^3.25.76` | `zod@^4.4.3` |
| Defaults | `zod-defaults@0.1.3` | `zod-defaults@^0.2.3` |
| Form engine | `vee-validate@^4.15.1` | `@tanstack/vue-form@^1.33.2` |
| Zod adapter | `@vee-validate/zod@^4.15.1` | Removed; TanStack Form supports Standard Schema |
Source files, package manifests, and the lockfile must no longer depend on `vee-validate` or `@vee-validate/zod`.
## Compatibility Boundary
The following Vben APIs remain supported:
- `useVbenForm(options)` returning `[Form, formApi]`
- existing `FormApi` methods for values, reset, validation, submission, schema updates, and component refs
- existing `FormSchema` fields, dependencies, `valueFormat`, and array schema structure
- application adapters and the re-exported `z` namespace
- the existing `componentField` slot and binding shape
`formApi.form` is now the library-independent `FormContextApi`. It exposes values, errors, meta, set/reset/validate/submit methods, and array operations without leaking vee or raw TanStack generics.
New code uses `reset`, `submit`, `validateAndSubmit`, and `clearValidation`. The former `resetForm`, `submitForm`, `validateAndSubmitForm`, and `resetValidate` names remain deprecated forwarding aliases. They emit one warning per name in development and stay silent in production.
## Form UI API Changes in This Refactor
### Added APIs
| API | Type/Location | Description |
| --- | --- | --- |
| `dependencies.resolve(context)` | `FormItemDependenciesResolve` | Evaluates one complete dynamic patch from declared `triggerFields` and commits it atomically. Context contains readonly `values`, `actions`, `controller`, and row-aware `schema`. |
| `useValues()` | `FormContextApi` | Subscribes to all form values. Use only when full-form reactivity is required. |
| `useFieldValue(fieldName)` | `FormContextApi` | Subscribes to one field value without reacting to unrelated fields. |
| `useFieldValues(fieldNames)` | `FormContextApi` | Subscribes to a declared group of field values. |
| `useFieldError(fieldName)` | `FormContextApi` | Subscribes to one field error without consuming the full error object. |
| `getRawValues()` | `FormApi` | Returns an independent raw snapshot before field mapping and `valueFormat`. |
| `formatValues(rawValues)` | `FormApi` | Runs the unified formatting pipeline on a supplied raw snapshot. |
| `getValueSnapshot()` | `FormApi` | Returns `{ rawValues, values }`, where `values` is the formatted payload. |
| `asyncDebounceMs` | `FormFieldOptions` | Configures TanStack Field async validation debounce. |
| `changeEventFallback` | `FormCommonConfig` / adapter config | Enables fallback for legacy components that emit `change` without `update:*`; defaults to `false`. |
`dependencies.resolve` may return `if`, `show`, `disabled`, `required`, `rules`, `componentProps`, `help`, and `renderComponentContent`. Omitting `rules` keeps the static rule; returning `rules: null` disables it.
### Changed APIs
| API | Before | After |
| --- | --- | --- |
| Submit callback | `handleSubmit(values)` | `handleSubmit(values, rawValues)`; the first argument is formatted and the second is the matching readonly raw snapshot. Existing single-argument functions remain valid. |
| Values change callback | `handleValuesChange(values, fieldsChanged)` | `handleValuesChange(rawValues, fieldsChanged, getFormattedValues)`; formatting is lazy and incurs no clone/transform cost unless requested. |
| Field validation triggers | Four `validateOn*` booleans | `validateOn?: readonly ('blur' \| 'change')[]`; submit always validates. |
| Change-event compatibility | `disabledOnChangeListener: false` enabled fallback | `changeEventFallback: true` enables fallback with positive semantics. |
| Top-level render callbacks | `componentProps(values, actions, ctx)`, `help(values, actions, ctx)`, `renderComponentContent(values, actions, ctx)` | Receive only lightweight `FormSchemaContext`; value-dependent behavior moves to `dependencies.resolve`. |
| `validateAndSubmit()` | Repeated low-level validation/scroll handling and could validate again during submit | Delegates to canonical `validate()` and shared submission logic; invalid forms do not submit. |
| `getValues()` | Implicitly returned transformed values | Still returns the formatted payload; use `getRawValues()` for raw state. |
### Removed APIs
| Removed API | Replacement |
| --- | --- |
| `FormValidationOptions` | `validate()` and `validateField(fieldName)` no longer accept options. |
| `force` / `silent` / `validated-only` validation modes | Removed because these vee modes have no TanStack runtime semantics. |
| `validateOnBlur` / `validateOnChange` / `validateOnInput` / `validateOnModelUpdate` | Use `formFieldProps.validateOn`; input and model updates are represented by `change`. |
| `disabledOnChangeListener` | Use positive `changeEventFallback`. |
| `disabledOnInputListener` | Input listeners are no longer bound automatically; provide `componentProps.onInput` explicitly when required. |
| `values/actions` parameters from top-level schema render functions | Use `FormSchemaContext`; move value-dependent behavior to `dependencies.resolve`. |
### Deprecated but Supported
- `dependencies.if/show/disabled/required/rules/componentProps/trigger` remain compatible for this release, but every callback is marked `@deprecated` and emits one development warning. If both syntaxes bypass the type union, `resolve` wins.
- `resetForm`, `submitForm`, `resetValidate`, and `validateAndSubmitForm` continue forwarding to canonical methods.
- `FormActions` remains as a deprecated alias of `FormContextApi`.
- `setupVbenForm({ defineRules })` remains supported; `rules` wins for duplicate names.
- The re-exported `z`, `componentField` slots, and `emptyStateValue` remain unchanged.
### Internal Behavior Changes
- Field components use fine-grained value/error selectors; full error aggregation is no longer on the normal input path.
- Async validators discard stale Promises through a Vben generation without reading private TanStack AbortController or meta fields.
- New and legacy dependencies share one atomic executor, so stale async results cannot overwrite newer state.
- Formatting runs in a fixed array-to-string, range mapping, schema `valueFormat` order and performs one deep clone per formatted snapshot.
## Typed Values and Slots
Application adapters keep the UI component mapping fixed and expose the business value shape as the only generic:
```ts
interface AccountFormValues {
email: string;
nickname: string;
}
const [Form, formApi] = useVbenForm<AccountFormValues>({
handleSubmit(values) {
return addAccount(values);
},
schema: [
{ component: 'Input', fieldName: 'email' },
{ component: 'Input', fieldName: 'nickname' },
],
});
```
`TValues` flows through `VbenFormProps`, `FormSchema`, `FormApi`, `FormContextApi`, value APIs, submit/change callbacks, selectors, and dynamic schema callbacks. The returned `Form` component also exposes typed slots: known field slots use the matching value type for `field.state.value` and `componentField.modelValue`, while all field/default/action slots receive the complete `values` and matching `formApi`. Legacy forms without `TValues` retain arbitrary slot names and broad props.
## New and Legacy Rule Registration
Use `rules` in new code:
```ts
setupVbenForm({
rules: {
required(value, _params, context) {
const isEmpty =
value === undefined ||
value === null ||
value === '' ||
(Array.isArray(value) && value.length === 0);
return isEmpty ? `${context.label} is required` : true;
},
},
});
```
The legacy `defineRules` option forwards to the same registry:
```ts
setupVbenForm({
defineRules: {
required: legacyRequiredRule,
},
});
```
Legacy runtime usage emits one warning per deprecation key in development and no warnings in production. If both options define the same rule, `rules` wins. The `FormActions` type remains as a deprecated alias of `FormContextApi`; editors report the type deprecation because type-only usage cannot emit runtime warnings.
## Running the Codemod
Run the pinned tool against each affected tsconfig from a clean Git worktree:
```bash
npx --yes zod-v3-to-v4@1.21.3 path/to/tsconfig.json
```
The tool edits `.ts`, `.tsx`, and `.vue` files in place and has no dry-run mode. Always review `git diff` afterward.
The codemod primarily recognizes direct `zod` imports. Schemas that obtain `z` through `@vben/common-ui` or an application adapter need manual review, especially constructor errors, string formats, and dynamic refinement messages.
## Zod 4 Changes
### Unified Error Parameters
Replace `required_error` and `invalid_type_error` with `error`:
```ts
const count = z.number({
error: (issue) =>
issue.input === undefined ? 'Count is required' : 'Count must be a number',
});
```
Use an `error(issue)` callback for dynamic refinement messages instead of passing a function that returns params as the second argument to `.refine()`.
### String Formats and Errors
Prefer top-level format schemas:
```ts
z.email('Invalid email');
z.url('Invalid URL');
z.uuid('Invalid UUID');
```
Read validation details from `ZodError.issues`; the old `.errors` property is removed.
### Defaults and Optionality
Zod 4 defaults may return immediately when the input is `undefined`. Review `.default().optional()` using actual parse behavior instead of internal type names.
Vben initial values use this precedence:
1. explicit schema `defaultValue`
2. Zod `.default()`
3. Zod 4-compatible `zod-defaults`
4. component empty-state conventions
Required markers are derived from whether the schema accepts `undefined`.
### Wrappers, Refine, Transform, and Coerce
Do not read `_def`, `_zod.def`, or `typeName`. Use public `.unwrap()` APIs and public pipe inputs. Delegate intersection defaults to the Zod 4-compatible `zod-defaults` package.
Standard Schema validation does not write transform/coerce output back into TanStack Form state. Keep using `valueFormat` for submission payload conversion, or explicitly call `parseAsync` at the submission boundary when transformed schema output is required.
Also review these changes:
- `z.record()` should specify key and value schemas
- `z.enum()` replaces former `nativeEnum` use cases
- number integer, Infinity, and finite behavior
- object strictness, merge, and unknown keys
- intersection merge conflicts
- coerce input types defaulting to `unknown`
- removal of Zod 3 types such as `ZodEffects`, `ZodTypeAny`, and `AnyZodObject`
## Form Engine Behavior
`formFieldProps.validateOn` accepts `blur` and `change`, with both enabled by default; submit always validates fields. `asyncDebounceMs` configures TanStack Field async debounce. The four vee-style `validateOn*` booleans and `force/silent/validated-only` modes have been removed.
The shadcn form primitives now use a Vben-owned field context. Labels, controls, descriptions, and messages continue to provide ids, `aria-invalid`, `aria-describedby`, touched, dirty, valid, and error states.
`clearValidation(fieldNames?)` advances Vben's validator generation and clears public error state without relying on a private TanStack AbortController. A Promise that finishes later is discarded as stale. Omitting `fieldNames` covers every registered field and every field with an existing error.
`dependencies.resolve(context)` is the recommended API: it evaluates once and atomically commits one dynamic-state patch, while stale async results are discarded as a unit. Legacy `if/show/disabled/required/rules/componentProps/trigger` callbacks remain supported through the same normalized executor, but are marked `@deprecated` and emit one development warning. Both APIs react only to declared `triggerFields`.
`handleValuesChange(rawValues, fieldsChanged, getFormattedValues)` receives readonly raw values and formats only when its third argument is called. `getRawValues()` and `getValues()` each create only the requested snapshot; use `getValueSnapshot()` when both are required. `handleSubmit(values, rawValues)` receives both forms at submission. The formatter performs one deep clone, then applies array-to-string, range mapping, and schema `valueFormat` in order. Array fields keep using TanStack push/remove operations and stable row identity.
## Test and Acceptance Matrix
Required coverage includes:
- Zod defaults, optional, nullable, intersection, pipe, transform, coerce, and errors
- runtime values, selectors, reset, manual errors, validation, and async validation
- field binding, blur/change triggers, error messages, ARIA, dependencies, and arrays
- new/legacy API equivalence, warning deduplication, production silence, and type aliases
- complete `useVbenForm` lifecycle, submission, `handleValuesChange`, submit-on-change, and async race handling
Acceptance requires zero TypeScript errors, zero build errors, all tests passing, no unhandled browser errors, modified-file formatting and lint passing, and no source dependency on vee or Zod private structures.
## References
- [Zod 4 release notes](https://zod.dev/v4)
- [Zod migration guide](https://zod.dev/v4/changelog)
- [TanStack Form Vue overview](https://tanstack.com/form/latest/docs/framework/vue/overview)
- [TanStack Form validation](https://tanstack.com/form/latest/docs/framework/vue/guides/validation)

View File

@@ -0,0 +1,274 @@
---
outline: deep
---
# Zod 4 与 TanStack Form 迁移指南
本次迁移将表单校验 schema 从 Zod 3 升级到 Zod 4并将内部表单引擎从 vee-validate 替换为 TanStack Form。迁移目标是保持 Vben 业务 API 稳定,同时移除业务代码对具体表单引擎的耦合。
## 依赖变化
| 类型 | 迁移前 | 迁移后 |
| --- | --- | --- |
| Schema | `zod@^3.25.76` | `zod@^4.4.3` |
| 默认值 | `zod-defaults@0.1.3` | `zod-defaults@^0.2.3` |
| 表单引擎 | `vee-validate@^4.15.1` | `@tanstack/vue-form@^1.33.2` |
| Zod 适配器 | `@vee-validate/zod@^4.15.1` | 不再需要TanStack Form 支持 Standard Schema |
迁移后源码、package manifest 和锁文件中都不应再依赖 `vee-validate``@vee-validate/zod`
## 上层兼容范围
以下 Vben API 保持兼容:
- `useVbenForm(options)` 仍返回 `[Form, formApi]`
- `FormApi` 的值、校验、提交、重置、schema 更新和组件引用能力
- `FormSchema``fieldName``component``componentProps``rules``dependencies``defaultValue``valueFormat` 和数组字段结构
- `dependencies.triggerFields` 与回调参数
- 组件适配器和 `z` 重导出路径
- 自定义 slot 中原有的 `componentField` 绑定对象
`formApi.form` 现在是库无关的 `FormContextApi`。它提供 values、errors、meta、字段读写、验证、提交、重置和数组操作但不再暴露 vee `FormContext` 或原始 TanStack 实例。
新代码使用 `reset``submit``validateAndSubmit``clearValidation`。旧的 `resetForm``submitForm``validateAndSubmitForm``resetValidate` 仍会委托给新实现,并通过 `@deprecated` 与开发环境一次性 warning 提示迁移;生产环境不输出 warning。
## 本轮 Form UI API 变更
### 新增 API
| API | 类型/位置 | 说明 |
| --- | --- | --- |
| `dependencies.resolve(context)` | `FormItemDependenciesResolve` | 根据声明的 `triggerFields` 一次计算完整动态 patch并原子更新字段状态。context 包含只读 `values``actions``controller` 和数组行感知的 `schema`。 |
| `useValues()` | `FormContextApi` | 订阅完整表单值。仅在确实需要整表响应式值时使用。 |
| `useFieldValue(fieldName)` | `FormContextApi` | 订阅单字段值,避免无关字段变化触发组件更新。 |
| `useFieldValues(fieldNames)` | `FormContextApi` | 订阅一组字段值,主要用于声明式依赖计算。 |
| `useFieldError(fieldName)` | `FormContextApi` | 订阅单字段错误,不再依赖全量错误对象。 |
| `getRawValues()` | `FormApi` | 返回未执行字段映射与 `valueFormat` 的独立原始值快照。 |
| `formatValues(rawValues)` | `FormApi` | 对指定原始值执行统一格式化流水线。 |
| `getValueSnapshot()` | `FormApi` | 同时返回 `{ rawValues, values }`,其中 `values` 为格式化结果。 |
| `asyncDebounceMs` | `FormFieldOptions` | 设置 TanStack Field 异步校验防抖时间。 |
| `changeEventFallback` | `FormCommonConfig` / adapter config | 为只发送 `change`、不发送 `update:*` 的旧组件启用事件回退,默认 `false`。 |
`dependencies.resolve` 可以返回 `if``show``disabled``required``rules``componentProps``help``renderComponentContent`。未返回 `rules` 时继续使用静态规则;显式返回 `rules: null` 时关闭静态规则。
### 变更的 API
| API | 迁移前 | 迁移后 |
| --- | --- | --- |
| 提交回调 | `handleSubmit(values)` | `handleSubmit(values, rawValues)`;首参为格式化值,次参为同一次提交对应的只读原始快照。旧单参数函数仍可直接使用。 |
| 值变化回调 | `handleValuesChange(values, fieldsChanged)` | `handleValuesChange(rawValues, fieldsChanged, getFormattedValues)`;第三个参数为惰性格式化函数,不调用时不产生深拷贝和转换开销。 |
| 字段校验触发 | 四个 `validateOn*` 布尔项 | `validateOn?: readonly ('blur' \| 'change')[]`submit 始终校验。 |
| change 事件兼容 | `disabledOnChangeListener: false` 表示启用 | `changeEventFallback: true` 表示启用,改为正向语义。 |
| 顶层动态渲染回调 | `componentProps(values, actions, ctx)``help(values, actions, ctx)``renderComponentContent(values, actions, ctx)` | 仅接收轻量 `FormSchemaContext`。依赖表单值的动态逻辑迁移到 `dependencies.resolve`。 |
| `validateAndSubmit()` | 自行调用底层校验并重复实现错误滚动,提交阶段可能再次校验 | 委托统一 `validate()` 与共享提交逻辑,无效时不提交,错误滚动只有一个实现。 |
| `getValues()` | 隐式完成所有字段转换 | 语义保持为“返回格式化值”;需要原始值时显式使用 `getRawValues()`。 |
### 删除的 API
| 已删除 API | 替代方式 |
| --- | --- |
| `FormValidationOptions` | `validate()``validateField(fieldName)` 不再接收 options。 |
| `force` / `silent` / `validated-only` validation mode | 这些 vee mode 在 TanStack runtime 中没有对应语义,直接删除。 |
| `validateOnBlur` / `validateOnChange` / `validateOnInput` / `validateOnModelUpdate` | 使用 `formFieldProps.validateOn`input 与 model update 统一归入 `change`。 |
| `disabledOnChangeListener` | 使用正向语义的 `changeEventFallback`。 |
| `disabledOnInputListener` | 不再自动绑定 input listener确需自定义 input 处理时在 `componentProps.onInput` 中显式提供。 |
| 顶层 schema 渲染函数中的 `values/actions` 参数 | 使用 `FormSchemaContext`;值相关联动使用 `dependencies.resolve`。 |
### 已弃用但保留兼容
- `dependencies.if/show/disabled/required/rules/componentProps/trigger` 本轮仍完整兼容,但均已标记 `@deprecated`。开发环境首次使用时警告一次;新旧语法绕过类型同时存在时以 `resolve` 为准。
- `resetForm``submitForm``resetValidate``validateAndSubmitForm` 继续转发到新方法。
- `FormActions` 继续作为 `FormContextApi` 的弃用类型别名。
- `setupVbenForm({ defineRules })` 继续兼容;与 `rules` 同名时新 API 优先。
- `z` 重导出、`componentField` slot 和 `emptyStateValue` 保持不变。
### 非 API 行为调整
- 字段组件改用细粒度 value/error selector全量错误聚合退出普通输入热路径。
- async validator 通过 Vben generation 丢弃过期 Promise不读取 TanStack 私有 AbortController 或 meta 字段。
- dependencies 新旧语法共用一个原子执行器,异步旧结果不会覆盖新状态。
- 值格式化按 array-to-string、时间范围映射、schema `valueFormat` 的固定顺序执行,并且每次格式化只深拷贝一次。
## 值类型与插槽类型
应用 adapter 保留 UI 组件类型,只把业务值类型作为泛型暴露:
```ts
interface AccountFormValues {
email: string;
nickname: string;
}
const [Form, formApi] = useVbenForm<AccountFormValues>({
handleSubmit(values) {
return addAccount(values);
},
schema: [
{ component: 'Input', fieldName: 'email' },
{ component: 'Input', fieldName: 'nickname' },
],
});
```
`TValues` 会传递给 `VbenFormProps``FormSchema``FormApi``FormContextApi`、值读写 API、提交/变化回调、selector 和 schema 动态回调。返回的 `Form` 组件同时提供 typed slots已知字段插槽的 `field.state.value``componentField.modelValue` 使用对应字段类型,并额外提供完整 `values` 与同型 `formApi`;默认和操作插槽也提供 `values/formApi`。未声明 `TValues` 的旧表单仍允许任意字段插槽并回退为宽泛类型。
## 新旧规则注册 API
新代码使用 `rules`
```ts
setupVbenForm({
rules: {
required(value, _params, context) {
const isEmpty =
value === undefined ||
value === null ||
value === '' ||
(Array.isArray(value) && value.length === 0);
return isEmpty ? `${context.label} is required` : true;
},
},
});
```
旧的 `defineRules` 仍会转发到同一个规则注册表:
```ts
setupVbenForm({
defineRules: {
required: legacyRequiredRule,
},
});
```
使用旧入口时,开发环境针对该弃用项只输出一次警告;生产环境不输出。若同时提供 `rules``defineRules` 的同名规则,`rules` 优先。`FormActions` 类型保留为 `FormContextApi` 的弃用别名,类型别名本身无法触发运行时警告,编辑器会通过 `@deprecated` 提示迁移。
## 使用迁移工具
建议在干净的 Git 工作树中按项目 tsconfig 执行固定版本工具:
```bash
npx --yes zod-v3-to-v4@1.21.3 path/to/tsconfig.json
```
工具会原地修改 `.ts``.tsx``.vue` 文件,没有 dry-run 模式。执行后必须检查 `git diff`
工具只能可靠识别直接从 `zod` 导入的调用。通过 `@vben/common-ui` 或应用 adapter 间接取得 `z` 的 schema 需要人工审计,尤其是构造器错误参数、字符串格式和动态 refine 参数。
## Zod 4 代码变更
### 错误参数
构造器中的 `required_error``invalid_type_error` 合并为 `error`
```ts
const count = z.number({
error: (issue) =>
issue.input === undefined ? 'Count is required' : 'Count must be a number',
});
```
refinement 继续支持字符串或对象参数。需要根据输入动态生成消息时,使用 `error(issue)`,不再传入返回 params 的第二个函数。
### 字符串格式
优先使用顶层格式 API
```ts
z.email('Invalid email');
z.url('Invalid URL');
z.uuid('Invalid UUID');
```
旧的 `z.string().email()` 等形式不应继续新增。
### 错误列表
ZodError 使用 `issues`
```ts
const result = schema.safeParse(value);
if (!result.success) {
console.log(result.error.issues);
}
```
不要读取已移除的 `.errors`
### 默认值与 optional
Zod 4 的 default 在输入为 `undefined` 时可以直接返回默认值。`.default().optional()` 的结果必须按实际 parse 语义复核,而不是通过类型名称猜测。
Vben 表单按以下优先级生成初值:
1. schema 中显式 `defaultValue`
2. Zod schema 中的 `.default()`
3. `zod-defaults` 生成的对象、intersection 和基础空值
4. Vben 组件约定的空字符串、空数组或空状态值
必填标记以 schema 是否接受 `undefined` 为准。
### 包装器、refine 与 transform
不要读取 `_def``_zod.def``typeName`。公共包装器使用 `.unwrap()`Zod 4 的 transform/pipe 使用公开的输入 schema。intersection 的默认值交给支持 Zod 4 的 `zod-defaults` 处理。
TanStack Form 使用 Standard Schema 校验时不会自动把 transform/coerce 的输出写回当前表单 state。提交 payload 需要转换时,继续使用 `valueFormat`;如果必须提交 schema transform 后的结果,应在提交边界显式调用 `parseAsync`
### 其他需要复核的 API
- `z.record()` 需要明确 key schema 与 value schema
- `z.enum()` 已覆盖原 `nativeEnum` 用法
- number 的 `int`、Infinity 和 finite 约束需按 Zod 4 语义复核
- object 的 strict、merge、unknown keys 行为需要通过测试确认
- intersection 合并冲突现在可能直接抛出错误
- coerce schema 的 input 类型默认为 `unknown`
- `ZodEffects``ZodTypeAny``AnyZodObject` 等 Zod 3 类型不应继续使用
## 表单引擎行为
### 验证触发
`formFieldProps.validateOn` 接收 `blur``change` 数组,默认两者都启用;所有字段仍会在 submit 时验证。`asyncDebounceMs` 映射到 TanStack Field 的异步防抖配置。原 vee 风格的四个 `validateOn*` 布尔项和 `force/silent/validated-only` mode 已删除。
### 错误与可访问性
shadcn form primitive 使用 Vben 自有字段上下文,不再注入 vee 的 `FieldContextKey``FormLabel``FormControl``FormDescription``FormMessage` 继续维护:
- `for` 与 control id
- `aria-invalid`
- `aria-describedby`
- touched、dirty、valid 和错误消息
`clearValidation(fieldNames?)` 会递增 Vben validator generation 并清空公开错误状态,不依赖 TanStack 私有 AbortController。异步 Promise 即使随后完成也会因代次过期而被丢弃;省略字段参数时会处理全部已注册或已有错误的字段。
### 依赖与数组
`dependencies.resolve(context)` 是推荐语法:一次求值并原子提交完整动态 patch过期异步结果整体丢弃。旧的 `if/show/disabled/required/rules/componentProps/trigger` 语法仍兼容,但已标记为 `@deprecated` 并在开发环境首次使用时提示迁移;内部仍归一到同一个执行器。两种语法都只根据 `triggerFields` 重算,无关字段变化不会执行回调。
`handleValuesChange(rawValues, fieldsChanged, getFormattedValues)` 接收未格式化的只读当前值,第三个参数仅在调用时执行格式化。`getRawValues()``getValues()` 分别只生成原始或格式化快照;需要同时比较时使用 `getValueSnapshot()`。提交回调通过 `handleSubmit(values, rawValues)` 同时取得两种结构。格式化流水线只深拷贝一次,并按 array-to-string、时间范围映射、schema `valueFormat` 的顺序执行。数组字段继续使用 TanStack push/remove 操作和稳定行身份。
## 测试与验收
迁移至少需要覆盖以下层级:
- Zod 4 helperdefault、optional、nullable、intersection、pipe、transform、coerce 与错误参数
- runtime值读写、selector、reset、字段错误、validate 和异步校验
- 组件输入绑定、blur/change 触发、错误消息、ARIA、dependencies 和数组增删
- 兼容:`rules`/`defineRules` 结果一致、开发 warning 去重、生产静默、类型别名
- 集成:`useVbenForm` 生命周期、提交、`handleValuesChange`、submit-on-change 和 async race
验收标准:
1. 受影响 package、应用、playground 和 docs 无 TypeScript 错误
2. form-ui 与所有应用构建成功
3. 单元、组件和集成测试全部通过
4. 浏览器 smoke 流程无 `pageerror``console.error` 或未处理 Promise
5. 修改文件通过 oxfmt 与 ESLint
6. 静态搜索中不再出现 vee 依赖、Zod 私有结构或 Zod 3 错误参数
## 参考资料
- [Zod 4 release notes](https://zod.dev/v4)
- [Zod migration guide](https://zod.dev/v4/changelog)
- [TanStack Form Vue overview](https://tanstack.com/form/latest/docs/framework/vue/overview)
- [TanStack Form validation](https://tanstack.com/form/latest/docs/framework/vue/guides/validation)

View File

@@ -65,6 +65,9 @@
"version": "pnpm exec changeset version && pnpm install --no-frozen-lockfile",
"catalog": "pnpm dlx codemod pnpm/catalog"
},
"dependencies": {
"sortablejs": "catalog:"
},
"devDependencies": {
"@changesets/changelog-github": "catalog:",
"@changesets/cli": "catalog:",

View File

@@ -98,6 +98,11 @@ describe('formApi', () => {
startTime: 1_710_000_000_000,
},
});
expect(await formApi.getRawValues()).toEqual(originalValuesSnapshot);
expect(await formApi.getValueSnapshot()).toEqual({
rawValues: originalValuesSnapshot,
values,
});
expect(formActions.values).toEqual(originalValuesSnapshot);
});
@@ -159,24 +164,59 @@ describe('formApi', () => {
);
});
it('should reset form', async () => {
const resetFormMock = vi.fn();
it('should set only known fields without losing provided values', async () => {
const setValuesMock = vi.fn();
formApi.setState({
schema: [
{ component: 'text', fieldName: 'name' },
{ component: 'text', fieldName: 'profile.email' },
],
});
const formActions: any = {
meta: {},
resetForm: resetFormMock,
setValues: setValuesMock,
values: {},
};
await formApi.mount(formActions, new Map());
await formApi.setValues({
name: 'Ada',
profile: {
email: 'ada@example.com',
ignored: true,
},
unknown: 'ignored',
});
expect(setValuesMock).toHaveBeenCalledWith(
{
name: 'Ada',
profile: {
email: 'ada@example.com',
},
},
false,
);
});
it('should reset form', async () => {
const resetMock = vi.fn();
const formActions: any = {
meta: {},
reset: resetMock,
values: { name: 'test' },
};
await formApi.mount(formActions, new Map());
await formApi.resetForm();
expect(resetFormMock).toHaveBeenCalled();
await formApi.reset();
expect(resetMock).toHaveBeenCalled();
});
it('should call handleSubmit on submit', async () => {
const handleSubmitMock = vi.fn();
const formActions: any = {
meta: {},
submitForm: vi.fn().mockResolvedValue(true),
submit: vi.fn().mockResolvedValue(true),
values: { name: 'test' },
};
@@ -187,9 +227,12 @@ describe('formApi', () => {
formApi.setState(state);
await formApi.mount(formActions, new Map());
const result = await formApi.submitForm();
expect(formActions.submitForm).toHaveBeenCalled();
expect(handleSubmitMock).toHaveBeenCalledWith({ name: 'test' });
const result = await formApi.submit();
expect(formActions.submit).toHaveBeenCalled();
expect(handleSubmitMock).toHaveBeenCalledWith(
{ name: 'test' },
{ name: 'test' },
);
expect(result).toEqual({ name: 'test' });
});
@@ -243,6 +286,48 @@ describe('formApi', () => {
expect(validateMock).toHaveBeenCalled();
expect(isValid).toBe(true);
});
it('should validate only once before submitting valid values', async () => {
const handleSubmit = vi.fn();
const formActions: any = {
meta: {},
submit: vi.fn(),
validate: vi.fn().mockResolvedValue({ errors: {}, valid: true }),
values: { name: 'Ada' },
};
formApi.setState({ handleSubmit });
await formApi.mount(formActions, new Map());
await expect(formApi.validateAndSubmit()).resolves.toEqual({ name: 'Ada' });
expect(formActions.validate).toHaveBeenCalledOnce();
expect(formActions.submit).not.toHaveBeenCalled();
expect(handleSubmit).toHaveBeenCalledOnce();
});
it('should not submit invalid values', async () => {
const handleSubmit = vi.fn();
const errors = { name: 'Name is required' };
const formActions: any = {
meta: {},
submit: vi.fn(),
validate: vi.fn().mockResolvedValue({ errors, valid: false }),
values: { name: '' },
};
const scrollToFirstError = vi
.spyOn(formApi as any, 'scrollToFirstError')
.mockImplementation(() => {});
formApi.setState({ handleSubmit, scrollToFirstError: true });
await formApi.mount(formActions, new Map());
await expect(formApi.validateAndSubmit()).resolves.toBeUndefined();
expect(formActions.validate).toHaveBeenCalledOnce();
expect(formActions.submit).not.toHaveBeenCalled();
expect(handleSubmit).not.toHaveBeenCalled();
expect(scrollToFirstError).toHaveBeenCalledOnce();
expect(scrollToFirstError).toHaveBeenCalledWith(errors);
});
});
describe('updateSchema', () => {

View File

@@ -0,0 +1,101 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { setupVbenForm } from '../src/config';
import {
resetDeprecationWarnings,
warnDeprecatedOnce,
} from '../src/deprecation';
import { FormApi } from '../src/form-api';
import { getFormRule } from '../src/rule-registry';
afterEach(() => {
resetDeprecationWarnings();
vi.restoreAllMocks();
});
describe('form api compatibility', () => {
it('forwards defineRules and warns only once in development', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
const legacyRule = () => 'legacy error';
setupVbenForm({ defineRules: { legacy: legacyRule } });
setupVbenForm({ defineRules: { legacy: legacyRule } });
expect(warning).toHaveBeenCalledOnce();
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `setupVbenForm({ defineRules })` is deprecated. Use `setupVbenForm({ rules })` instead.',
);
const registeredRule = getFormRule('legacy');
expect(registeredRule).toBeDefined();
if (!registeredRule) return;
expect(
await registeredRule('', [], {
field: { name: 'legacy' },
name: 'legacy',
}),
).toBe('legacy error');
});
it('prefers the new rules option when both APIs define the same rule', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
setupVbenForm({
defineRules: { required: () => 'legacy error' },
rules: { required: () => 'new error' },
});
const registeredRule = getFormRule('required');
expect(registeredRule).toBeDefined();
if (!registeredRule) return;
expect(
await registeredRule('', [], {
field: { name: 'required' },
name: 'required',
}),
).toBe('new error');
});
it('does not emit deprecation warnings in production', () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
warnDeprecatedOnce('legacy-api', 'deprecated', { production: true });
expect(warning).not.toHaveBeenCalled();
});
it('keeps legacy form methods and warns once for each name', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
const formApi = new FormApi();
const form = {
clearValidation: vi.fn(),
meta: {},
reset: vi.fn(),
submit: vi.fn(),
validate: vi.fn().mockResolvedValue({ errors: {}, valid: true }),
values: { name: 'Ada' },
} as any;
formApi.mount(form);
await formApi.resetForm();
await formApi.resetForm();
await formApi.resetValidate();
await formApi.submitForm();
await formApi.validateAndSubmitForm();
expect(form.reset).toHaveBeenCalledTimes(2);
expect(form.clearValidation).toHaveBeenCalledOnce();
expect(form.submit).toHaveBeenCalledOnce();
expect(warning).toHaveBeenCalledTimes(4);
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `formApi.resetForm()` is deprecated. Use `formApi.reset()` instead.',
);
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `formApi.resetValidate()` is deprecated. Use `formApi.clearValidation()` instead.',
);
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `formApi.submitForm()` is deprecated. Use `formApi.submit()` instead.',
);
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `formApi.validateAndSubmitForm()` is deprecated. Use `formApi.validateAndSubmit()` instead.',
);
});
});

View File

@@ -0,0 +1,656 @@
import type { VueWrapper } from '@vue/test-utils';
import type { FormSchemaRuleType } from '../src/types';
import { flushPromises, mount } from '@vue/test-utils';
import { defineComponent, h, nextTick } from 'vue';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { z } from 'zod';
import { setupVbenForm } from '../src/config';
import { resetDeprecationWarnings } from '../src/deprecation';
import { useVbenForm } from '../src/use-vben-form';
const wrappers: VueWrapper[] = [];
function createDeferred<T>() {
let resolvePromise: (value: T) => void = () => {};
const promise = new Promise<T>((resolve) => {
resolvePromise = resolve;
});
return { promise, resolve: resolvePromise };
}
const TestInput = defineComponent({
inheritAttrs: false,
props: {
eventMode: {
default: 'model-value',
type: String,
},
},
emits: ['change', 'update:modelValue', 'update:value'],
setup(props, { attrs, emit }) {
function handleInput(event: Event) {
const target = event.target;
if (!(target instanceof HTMLInputElement)) {
return;
}
if (props.eventMode === 'change-only') {
emit('change', event);
return;
}
if (props.eventMode === 'value-and-change') {
emit('update:value', target.value);
emit('change', event);
return;
}
emit('update:modelValue', target.value);
}
return () =>
h('input', {
...attrs,
onInput: handleInput,
value: attrs.modelValue ?? '',
});
},
});
beforeAll(() => {
setupVbenForm({
config: {},
rules: {
required(value, _params, context) {
return value ? true : `${context.label} is required`;
},
},
});
});
afterEach(() => {
for (const wrapper of wrappers.splice(0)) {
wrapper.unmount();
}
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('useVbenForm integration', () => {
it('uses model updates as the primary channel and preserves empty strings', async () => {
const validateValue = vi.fn();
const [Form, formApi] = useVbenForm({
schema: [
{
component: TestInput,
componentProps: { eventMode: 'value-and-change' },
defaultValue: 'initial',
fieldName: 'name',
modelPropName: 'value',
rules: z.string().superRefine((value) => validateValue(value)),
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
const initialValidationCount = validateValue.mock.calls.length;
await wrapper.get('input').setValue('');
await flushPromises();
expect(await formApi.getValues()).toEqual({ name: '' });
expect(validateValue).toHaveBeenCalledTimes(initialValidationCount + 1);
});
it('supports a field-level change event fallback for legacy components', async () => {
const [Form, formApi] = useVbenForm({
schema: [
{
component: TestInput,
componentProps: { eventMode: 'change-only' },
changeEventFallback: true,
fieldName: 'name',
modelPropName: 'value',
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
await wrapper.get('input').setValue('fallback');
await flushPromises();
expect(await formApi.getValues()).toEqual({ name: 'fallback' });
});
it('warns once for legacy dependency callbacks', async () => {
resetDeprecationWarnings();
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
const [Form] = useVbenForm({
schema: [
{
component: TestInput,
dependencies: {
show: true,
triggerFields: ['toggle'],
},
fieldName: 'first',
},
{
component: TestInput,
dependencies: {
disabled: false,
triggerFields: ['toggle'],
},
fieldName: 'second',
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
expect(warning).toHaveBeenCalledOnce();
expect(warning).toHaveBeenCalledWith(
'[Vben Form] Legacy dependency callbacks are deprecated. Use `dependencies.resolve(context)` instead.',
);
});
it('binds fields, renders accessible errors, and submits valid values', async () => {
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const handleSubmit = vi.fn();
const [Form, formApi] = useVbenForm({
handleSubmit,
schema: [
{
component: TestInput,
fieldName: 'name',
label: 'Name',
rules: z.string().min(1, 'Name is required'),
valueFormat: (value) => value.trim(),
},
{
component: TestInput,
fieldName: 'alias',
label: 'Alias',
rules: 'required',
},
],
});
const wrapper = mount(Form, { attachTo: document.body });
wrappers.push(wrapper);
await flushPromises();
expect(await formApi.validate()).toEqual({
errors: {
alias: 'Alias is required',
name: 'Name is required',
},
valid: false,
});
await flushPromises();
const inputs = wrapper.findAll('input');
expect(inputs).toHaveLength(2);
expect(inputs[0]?.attributes('aria-invalid')).toBe('true');
expect(wrapper.text()).toContain('Name is required');
expect(wrapper.text()).toContain('Alias is required');
await inputs[0]?.setValue('Ada');
await formApi.setFieldValue('alias', 'Countess', true);
await flushPromises();
expect(wrapper.text()).not.toContain('Name is required');
expect(await formApi.validateField('name')).toEqual({
errors: {},
valid: true,
});
expect(await formApi.validateAndSubmit()).toEqual({
alias: 'Countess',
name: 'Ada',
});
expect(handleSubmit).toHaveBeenCalledOnce();
expect(handleSubmit).toHaveBeenCalledWith(
{
alias: 'Countess',
name: 'Ada',
},
{
alias: 'Countess',
name: 'Ada',
},
);
expect(consoleError).not.toHaveBeenCalled();
});
it('recomputes dependencies only from declared trigger fields', async () => {
const dependency = vi.fn((values: Record<string, any>) => {
return values.toggle === 'show';
});
const [Form, formApi] = useVbenForm({
schema: [
{
component: TestInput,
fieldName: 'toggle',
label: 'Toggle',
},
{
component: TestInput,
dependencies: {
if: dependency,
triggerFields: ['toggle'],
},
fieldName: 'details',
label: 'Details',
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
expect(wrapper.find('input[name="details"]').exists()).toBe(false);
const initialCalls = dependency.mock.calls.length;
await formApi.setFieldValue('unrelated', 'value');
await flushPromises();
expect(dependency).toHaveBeenCalledTimes(initialCalls);
await formApi.setFieldValue('toggle', 'show');
await flushPromises();
expect(wrapper.find('input[name="details"]').exists()).toBe(true);
expect(dependency.mock.calls.length).toBeGreaterThan(initialCalls);
});
it('resolves dependency patches atomically from declared fields', async () => {
const pendingPatch = createDeferred<{
componentProps: { placeholder: string };
if: boolean;
}>();
const resolve = vi.fn(({ values }: { values: Record<string, any> }) => {
if (values.toggle === 'pending') {
return pendingPatch.promise;
}
return {
componentProps: { placeholder: 'initial' },
if: false,
};
});
const [Form, formApi] = useVbenForm({
schema: [
{
component: TestInput,
fieldName: 'toggle',
label: 'Toggle',
},
{
component: TestInput,
dependencies: {
resolve,
triggerFields: ['toggle'],
},
fieldName: 'details',
label: 'Details',
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
expect(wrapper.find('input[name="details"]').exists()).toBe(false);
const initialCalls = resolve.mock.calls.length;
await formApi.setFieldValue('unrelated', 'value');
await flushPromises();
expect(resolve).toHaveBeenCalledTimes(initialCalls);
await formApi.setFieldValue('toggle', 'pending');
await flushPromises();
expect(wrapper.find('input[name="details"]').exists()).toBe(false);
pendingPatch.resolve({
componentProps: { placeholder: 'resolved' },
if: true,
});
await flushPromises();
const details = wrapper.find('input[name="details"]');
expect(details.exists()).toBe(true);
expect(details.attributes('placeholder')).toBe('resolved');
});
it('applies required rules enabled by dependencies after mount', async () => {
const [Form, formApi] = useVbenForm({
schema: [
{
component: TestInput,
fieldName: 'toggle',
label: 'Toggle',
},
{
component: TestInput,
dependencies: {
required(values) {
return values.toggle === true;
},
triggerFields: ['toggle'],
},
fieldName: 'details',
label: 'Details',
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
expect(await formApi.validate()).toEqual({ errors: {}, valid: true });
await formApi.setFieldValue('toggle', true);
await flushPromises();
expect(await formApi.validate()).toEqual({
errors: { details: 'Details is required' },
valid: false,
});
await formApi.setFieldValue('details', 'ready');
expect(await formApi.validate()).toEqual({ errors: {}, valid: true });
});
it('allows dependencies to disable static rules with null', async () => {
const [Form, formApi] = useVbenForm({
schema: [
{
component: TestInput,
fieldName: 'toggle',
label: 'Toggle',
},
{
component: TestInput,
dependencies: {
rules(values) {
return values.toggle === true
? z.string().min(1, 'Details is required')
: null;
},
triggerFields: ['toggle'],
},
fieldName: 'details',
label: 'Details',
rules: z.string().min(1, 'Static details rule'),
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
expect(await formApi.validate()).toEqual({ errors: {}, valid: true });
await formApi.setFieldValue('toggle', true);
await flushPromises();
expect(await formApi.validate()).toEqual({
errors: { details: 'Details is required' },
valid: false,
});
});
it('ignores stale async dependency rule results', async () => {
const requiredRules = createDeferred<FormSchemaRuleType>();
const optionalRules = createDeferred<FormSchemaRuleType>();
const [Form, formApi] = useVbenForm({
schema: [
{
component: TestInput,
fieldName: 'mode',
label: 'Mode',
},
{
component: TestInput,
dependencies: {
rules(values) {
if (values.mode === 'required') {
return requiredRules.promise;
}
if (values.mode === 'optional') {
return optionalRules.promise;
}
return null;
},
triggerFields: ['mode'],
},
fieldName: 'details',
label: 'Details',
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
await formApi.setFieldValue('mode', 'required');
await flushPromises();
await formApi.setFieldValue('mode', 'optional');
await flushPromises();
optionalRules.resolve(null);
await flushPromises();
requiredRules.resolve(z.string().min(1, 'Stale required rule'));
await flushPromises();
expect(await formApi.validate()).toEqual({ errors: {}, valid: true });
});
it('keeps array values and rendered rows aligned after mutations', async () => {
const [Form, formApi] = useVbenForm({
schema: [
{
children: [
{
component: TestInput,
fieldName: 'name',
label: 'Name',
rules: z.string().min(1, 'Name is required'),
},
],
defaultValue: [{ name: 'Ada' }],
fieldName: 'contacts',
type: 'array',
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
expect(wrapper.findAll('input')).toHaveLength(1);
formApi.form.pushFieldValue('contacts', { name: 'Grace' });
await flushPromises();
expect(wrapper.findAll('input')).toHaveLength(2);
expect(await formApi.getValues()).toEqual({
contacts: [{ name: 'Ada' }, { name: 'Grace' }],
});
await formApi.form.removeFieldValue('contacts', 0);
await flushPromises();
expect(wrapper.findAll('input')).toHaveLength(1);
expect(await formApi.getValues()).toEqual({
contacts: [{ name: 'Grace' }],
});
});
it('scopes resolve dependencies to array rows', async () => {
const resolve = vi.fn(({ schema }: Record<string, any>) => ({
componentProps: {
disabled: schema.row?.role === 'viewer',
},
}));
const [Form] = useVbenForm({
schema: [
{
children: [
{
component: TestInput,
fieldName: 'role',
label: 'Role',
},
{
component: TestInput,
dependencies: {
resolve,
triggerFields: ['role'],
},
fieldName: 'phone',
label: 'Phone',
},
],
defaultValue: [{ phone: '', role: 'viewer' }],
fieldName: 'contacts',
type: 'array',
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
expect(resolve).toHaveBeenCalledWith(
expect.objectContaining({
schema: expect.objectContaining({
fieldName: 'contacts[0].phone',
row: { phone: '', role: 'viewer' },
rowIndex: 0,
rowPath: 'contacts[0]',
}),
}),
);
expect(
wrapper.get('input[name="contacts[0].phone"]').attributes('disabled'),
).toBeDefined();
});
it('reports changed fields and submits valid changes', async () => {
vi.useFakeTimers();
const handleSubmit = vi.fn();
const handleValuesChange = vi.fn();
const [Form, formApi] = useVbenForm({
changeDebouncedTime: 0,
handleSubmit,
handleValuesChange,
schema: [
{
component: TestInput,
fieldName: 'name',
label: 'Name',
rules: z.string().min(1, 'Name is required'),
valueFormat: (value) => value.trim(),
},
],
submitOnChange: true,
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
await formApi.setFieldValue('name', ' Ada ');
await nextTick();
await vi.runAllTimersAsync();
await flushPromises();
expect(handleValuesChange).toHaveBeenCalledWith(
{ name: ' Ada ' },
['name'],
expect.any(Function),
);
const valuesChangeCall = handleValuesChange.mock.calls.at(0);
expect(valuesChangeCall).toBeDefined();
if (!valuesChangeCall) return;
expect(valuesChangeCall[2]()).toEqual({ name: 'Ada' });
expect(handleSubmit).toHaveBeenCalledWith(
{ name: 'Ada' },
{ name: ' Ada ' },
);
});
it('respects blur and change validation triggers', async () => {
const [Form] = useVbenForm({
schema: [
{
component: TestInput,
defaultValue: 'valid',
fieldName: 'name',
formFieldProps: {
validateOn: ['blur'],
},
label: 'Name',
rules: z.string().min(1, 'Name is required'),
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
const input = wrapper.get('input');
await input.setValue('');
await flushPromises();
expect(wrapper.text()).not.toContain('Name is required');
await input.trigger('blur');
await flushPromises();
expect(wrapper.text()).toContain('Name is required');
await input.setValue('Ada');
await flushPromises();
expect(wrapper.text()).not.toContain('Name is required');
await input.trigger('blur');
await flushPromises();
expect(wrapper.text()).not.toContain('Name is required');
});
it('ignores stale asynchronous validation results', async () => {
let resolveTaken: (() => void) | undefined;
const usernameRule = z.string().refine(async (value) => {
if (value === 'taken') {
await new Promise<void>((resolve) => {
resolveTaken = resolve;
});
}
return value !== 'taken';
}, 'Username is already taken');
const [Form, formApi] = useVbenForm({
schema: [
{
component: TestInput,
fieldName: 'username',
label: 'Username',
rules: usernameRule,
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
const input = wrapper.get('input');
await input.setValue('taken');
await vi.waitFor(() => {
expect(resolveTaken).toBeDefined();
});
if (!resolveTaken) return;
await input.setValue('available');
await flushPromises();
resolveTaken();
await flushPromises();
expect(formApi.form.getFieldError('username')).toBeUndefined();
});
});

View File

@@ -0,0 +1,236 @@
import type { FormActions } from '../src/types';
import { flushPromises, mount } from '@vue/test-utils';
import { defineComponent, h, nextTick, watch } from 'vue';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useFormRuntime } from '../src/form-runtime';
const wrappers: ReturnType<typeof mount>[] = [];
function mountRuntime(
defaultValues: Record<string, any>,
validator?: (input: { value: any }) => Promise<string | undefined>,
) {
let form: FormActions | undefined;
const RuntimeHarness = defineComponent({
setup() {
const runtime = useFormRuntime(defaultValues);
form = runtime;
return () => {
if (!validator) {
return h('div');
}
return h(
runtime.fieldComponent,
{
name: 'name',
validators: {
onSubmitAsync: validator,
},
},
{
default: ({ field }: Record<string, any>) =>
h('input', {
name: 'name',
onBlur: field.handleBlur,
onInput: (event: Event) => {
const target = event.target;
if (target instanceof HTMLInputElement) {
field.handleChange(target.value);
}
},
value: field.state.value,
}),
},
);
};
},
});
const wrapper = mount(RuntimeHarness);
wrappers.push(wrapper);
return { form, wrapper };
}
afterEach(() => {
for (const wrapper of wrappers.splice(0)) {
wrapper.unmount();
}
});
describe('form runtime', () => {
it('updates values and resets to defaults', async () => {
const { form } = mountRuntime({ name: 'initial' });
expect(form).toBeDefined();
if (!form) return;
await form.setFieldValue('name', 'updated');
await nextTick();
expect(form.values).toEqual({ name: 'updated' });
await form.reset();
await nextTick();
expect(form.values).toEqual({ name: 'initial' });
});
it('preserves empty string field updates', async () => {
const { form, wrapper } = mountRuntime(
{ name: 'initial' },
async () => undefined,
);
expect(form).toBeDefined();
if (!form) return;
await wrapper.find('input').setValue('');
await nextTick();
expect(form.values).toEqual({ name: '' });
});
it('exposes reactive selectors', async () => {
const { form } = mountRuntime({ name: 'initial' });
expect(form).toBeDefined();
if (!form) return;
const name = form.useSelector((state) => state.values.name);
await form.setFieldValue('name', 'updated');
await nextTick();
expect(name.value).toBe('updated');
});
it('updates only changed field value selectors', async () => {
const { form } = mountRuntime({ email: '', name: 'initial' });
expect(form).toBeDefined();
if (!form) return;
const name = form.useFieldValue('name');
const selectedValues = form.useFieldValues(['name'] as const);
const onNameChange = vi.fn();
const stop = watch(name, onNameChange);
await form.setFieldValue('email', 'ada@example.com');
await nextTick();
expect(onNameChange).not.toHaveBeenCalled();
expect(selectedValues.value).toEqual(['initial']);
await form.setFieldValue('name', 'Ada');
await nextTick();
expect(onNameChange).toHaveBeenCalledOnce();
expect(name.value).toBe('Ada');
expect(selectedValues.value).toEqual(['Ada']);
stop();
});
it('exposes reactive field error selectors', async () => {
const { form } = mountRuntime({ email: '', name: '' });
expect(form).toBeDefined();
if (!form) return;
const nameError = form.useFieldError('name');
const onNameErrorChange = vi.fn();
const stop = watch(nameError, onNameErrorChange);
form.setFieldError('email', 'Email error');
await nextTick();
expect(onNameErrorChange).not.toHaveBeenCalled();
form.setFieldError('name', 'Name error');
await nextTick();
expect(nameError.value).toBe('Name error');
expect(onNameErrorChange).toHaveBeenCalledOnce();
stop();
});
it('validates mounted fields and clears stale errors', async () => {
const { form } = mountRuntime({ name: '' }, async ({ value }) => {
return value ? undefined : 'Name is required';
});
expect(form).toBeDefined();
if (!form) return;
expect(await form.validate()).toEqual({
errors: { name: 'Name is required' },
valid: false,
});
await form.setFieldValue('name', 'Ada');
await flushPromises();
expect(await form.validateField('name')).toEqual({
errors: {},
valid: true,
});
expect(form.isFieldValid('name')).toBe(true);
});
it('sets and clears manual field errors', async () => {
const { form } = mountRuntime({ name: '' }, async () => undefined);
expect(form).toBeDefined();
if (!form) return;
form.setFieldError('name', 'Server error');
await nextTick();
expect(form.getFieldError('name')).toBe('Server error');
expect(form.meta.valid).toBe(false);
form.setFieldError('name');
await nextTick();
expect(form.getFieldError('name')).toBeUndefined();
expect(form.meta.valid).toBe(true);
});
it('clears manual errors when resetting the form', async () => {
const { form } = mountRuntime({ name: '' });
expect(form).toBeDefined();
if (!form) return;
form.setFieldError('name', 'Server error');
await nextTick();
expect(form.errors).toEqual({ name: 'Server error' });
await form.reset();
await nextTick();
expect(form.errors).toEqual({});
expect(form.meta.valid).toBe(true);
});
it('invalidates in-flight async validation when clearing validation', async () => {
let resolveValidation: ((error: string | undefined) => void) | undefined;
let notifyValidationStarted: (() => void) | undefined;
const validationStarted = new Promise<void>((resolve) => {
notifyValidationStarted = resolve;
});
const validator = vi.fn(() => {
notifyValidationStarted?.();
return new Promise<string | undefined>((resolve) => {
resolveValidation = resolve;
});
});
const { form } = mountRuntime({ name: '' }, validator);
expect(form).toBeDefined();
if (!form) return;
const pendingValidation = form.validateField('name');
await validationStarted;
form.clearValidation();
resolveValidation?.('Name is already used');
await pendingValidation;
await flushPromises();
expect(form.errors).toEqual({});
expect(form.meta.validating).toBe(false);
});
it('clears only the requested field validation state', async () => {
const { form } = mountRuntime({ email: '', name: '' });
expect(form).toBeDefined();
if (!form) return;
form.setFieldError('name', 'Name error');
form.setFieldError('email', 'Email error');
await nextTick();
form.clearValidation('name');
await nextTick();
expect(form.errors).toEqual({ email: 'Email error' });
});
});

View File

@@ -0,0 +1,153 @@
import type {
BaseFormComponentType,
ExtendedFormApi,
FormActions,
FormContextApi,
FormFieldOptions,
FormItemDependencies,
FormValidationResult,
VbenFormAdapterOptions,
VbenFormProps,
} from '../src/types';
import { describe, expectTypeOf, it } from 'vitest';
import { useVbenForm } from '../src/use-vben-form';
interface AccountFormValues {
email: string;
profile: {
nickname: string;
};
roles: string[];
}
describe('form public types', () => {
it('keeps the compatibility alias and stable method signatures', () => {
expectTypeOf<FormActions>().toEqualTypeOf<FormContextApi>();
expectTypeOf<FormActions['setFieldValue']>()
.parameter(0)
.toMatchTypeOf<string>();
expectTypeOf<
FormActions['validate']
>().returns.resolves.toEqualTypeOf<FormValidationResult>();
expectTypeOf<Parameters<FormActions['validate']>>().toEqualTypeOf<[]>();
expectTypeOf<Parameters<FormActions['validateField']>>().toEqualTypeOf<
[fieldName: string]
>();
});
it('accepts both new and deprecated rule registration options', () => {
expectTypeOf<VbenFormAdapterOptions>().toMatchTypeOf<{
defineRules?: Record<string, unknown>;
rules?: Record<string, unknown>;
}>();
});
it('supports resolve and legacy dependency contracts', () => {
const resolveDependencies: FormItemDependencies<AccountFormValues> = {
resolve({ actions, controller, schema, values }) {
expectTypeOf(values).toEqualTypeOf<Readonly<AccountFormValues>>();
expectTypeOf(actions).toEqualTypeOf<FormActions<AccountFormValues>>();
expectTypeOf(controller).toEqualTypeOf<
ExtendedFormApi<AccountFormValues>
>();
expectTypeOf(schema.fieldName).toEqualTypeOf<string | undefined>();
return { disabled: !values.email, rules: null };
},
triggerFields: ['email'],
};
const legacyDependencies: FormItemDependencies<AccountFormValues> = {
show(values) {
expectTypeOf(values).toEqualTypeOf<Partial<AccountFormValues>>();
return Boolean(values.email);
},
triggerFields: ['email'],
};
const fieldOptions: FormFieldOptions = {
asyncDebounceMs: 200,
validateOn: ['blur', 'change'],
};
expectTypeOf(resolveDependencies).toMatchTypeOf<
FormItemDependencies<AccountFormValues>
>();
expectTypeOf(legacyDependencies).toMatchTypeOf<
FormItemDependencies<AccountFormValues>
>();
expectTypeOf(fieldOptions).toMatchTypeOf<FormFieldOptions>();
});
it('propagates form value types through public APIs and callbacks', () => {
const options: VbenFormProps<
BaseFormComponentType,
Record<never, never>,
AccountFormValues
> = {
handleSubmit(values, rawValues) {
expectTypeOf(values).toEqualTypeOf<AccountFormValues>();
expectTypeOf(rawValues).toEqualTypeOf<Readonly<AccountFormValues>>();
},
handleValuesChange(values, _fieldsChanged, getFormattedValues) {
expectTypeOf(values).toEqualTypeOf<Readonly<AccountFormValues>>();
expectTypeOf(getFormattedValues()).toEqualTypeOf<AccountFormValues>();
},
schema: [],
};
const [Form, formApi] = useVbenForm<AccountFormValues>(options);
expectTypeOf(formApi).toEqualTypeOf<ExtendedFormApi<AccountFormValues>>();
function assertContextApi(
contextApi: FormContextApi<AccountFormValues>,
typedFormApi: ExtendedFormApi<AccountFormValues>,
) {
expectTypeOf(
typedFormApi.getValues(),
).resolves.toEqualTypeOf<AccountFormValues>();
expectTypeOf(
typedFormApi.getRawValues(),
).resolves.toEqualTypeOf<AccountFormValues>();
expectTypeOf(typedFormApi.getValueSnapshot()).resolves.toEqualTypeOf<{
rawValues: Readonly<AccountFormValues>;
values: AccountFormValues;
}>();
expectTypeOf(typedFormApi.setValues)
.parameter(0)
.toEqualTypeOf<Partial<AccountFormValues>>();
expectTypeOf(typedFormApi.form.values).toEqualTypeOf<AccountFormValues>();
expectTypeOf(contextApi.getFieldValue('email')).toEqualTypeOf<string>();
expectTypeOf(
contextApi.useSelector((state) => state.values.profile.nickname),
).toEqualTypeOf<Readonly<import('vue').Ref<string>>>();
}
expectTypeOf(assertContextApi).toBeFunction();
type FormSlots = InstanceType<typeof Form>['$slots'];
type EmailSlot = NonNullable<FormSlots['email']>;
type EmailSlotProps = Parameters<EmailSlot>[0];
type DefaultSlot = NonNullable<FormSlots['default']>;
type DefaultSlotProps = Parameters<DefaultSlot>[0];
expectTypeOf<
EmailSlotProps['field']['state']['value']
>().toEqualTypeOf<string>();
expectTypeOf<EmailSlotProps['values']>().toEqualTypeOf<AccountFormValues>();
expectTypeOf<EmailSlotProps['formApi']>().toEqualTypeOf<
ExtendedFormApi<AccountFormValues>
>();
expectTypeOf<
DefaultSlotProps['values']
>().toEqualTypeOf<AccountFormValues>();
});
it('exposes canonical names alongside deprecated aliases', () => {
expectTypeOf<FormContextApi['reset']>().toEqualTypeOf<
FormContextApi['resetForm']
>();
expectTypeOf<FormContextApi['submit']>().toEqualTypeOf<
FormContextApi['submitForm']
>();
});
});

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import {
applyFormValueFormats,
formatFormValues,
transformRangeTimeValues,
} from '../src/form-value-transform';
describe('form value transforms', () => {
it('maps array and range fields without mutating input values', () => {
const input = {
period: [1_710_000_000_000, 1_720_000_000_000],
tags: ['admin', 'editor'],
};
const result = transformRangeTimeValues(
input,
[['period', ['startTime', 'endTime'], null]],
['tags'],
);
expect(result).toEqual({
endTime: 1_720_000_000_000,
startTime: 1_710_000_000_000,
tags: 'admin,editor',
});
expect(input).toEqual({
period: [1_710_000_000_000, 1_720_000_000_000],
tags: ['admin', 'editor'],
});
});
it('formats array children with row and root paths', () => {
const values = {
contacts: [{ name: ' Ada ' }, { name: ' Grace ' }],
};
const schema = [
{
children: [
{
component: 'text',
fieldName: 'name',
valueFormat(value: string, setValue: any, _values: any, ctx: any) {
setValue('$row.normalizedName', value.trim());
setValue('$root.lastRow', ctx.rowIndex);
},
},
],
fieldName: 'contacts',
type: 'array',
},
] as any;
const result = applyFormValueFormats(values, schema);
expect(result).toEqual({
contacts: [{ normalizedName: 'Ada' }, { normalizedName: 'Grace' }],
lastRow: 1,
});
expect(values).toEqual({
contacts: [{ name: ' Ada ' }, { name: ' Grace ' }],
});
});
it('runs the unified formatting pipeline in a stable order', () => {
const result = formatFormValues(
{
period: [1, 2],
tags: ['admin', 'editor'],
title: ' Ada ',
},
[
{
component: 'text',
fieldName: 'title',
valueFormat: (value: string) => value.trim(),
},
],
[['period', ['startTime', 'endTime'], null]],
['tags'],
);
expect(result).toEqual({
endTime: 2,
startTime: 1,
tags: 'admin,editor',
title: 'Ada',
});
});
});

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { z, ZodString } from 'zod';
import { getDefaultsForSchema } from 'zod-defaults';
import {
getBaseRules,
getDefaultValueInZodStack,
} from '../src/form-render/helper';
describe('zod v4 schema helpers', () => {
it('unwraps optional and default schemas with public APIs', () => {
const schema = z.string().default('default value').optional();
expect(getBaseRules(schema)).toBeInstanceOf(ZodString);
expect(getDefaultValueInZodStack(schema)).toBe('default value');
});
it('unwraps the input side of a transform pipe', () => {
const schema = z.string().transform((value) => value.length);
expect(getBaseRules(schema)).toBeInstanceOf(ZodString);
});
it('returns undefined when a schema rejects undefined', () => {
expect(getDefaultValueInZodStack(z.string())).toBeUndefined();
});
it('does not throw for an asynchronous default pipeline', () => {
const schema = z
.string()
.default('default value')
.transform(async (value) => value.toUpperCase());
expect(getDefaultValueInZodStack(schema)).toBeUndefined();
});
it('uses zod v4 error callbacks for required and invalid inputs', () => {
const schema = z.number({
error: (issue) =>
issue.input === undefined ? 'required' : 'invalid number',
});
expect(schema.safeParse(undefined).error?.issues[0]?.message).toBe(
'required',
);
expect(schema.safeParse('1').error?.issues[0]?.message).toBe(
'invalid number',
);
});
it('extracts defaults from intersections without private schema access', () => {
const schema = z.intersection(
z.object({ enabled: z.boolean().default(true), name: z.string() }),
z.object({ count: z.number(), note: z.string().default('note') }),
);
expect(getDefaultsForSchema(schema)).toEqual({
count: 0,
enabled: true,
name: '',
note: 'note',
});
});
it('keeps nullable and coerce input semantics explicit', () => {
expect(z.string().nullable().safeParse(undefined).success).toBe(false);
expect(z.coerce.number().parse('42')).toBe(42);
});
});

View File

@@ -40,19 +40,19 @@
}
},
"dependencies": {
"@tanstack/vue-form": "catalog:",
"@vben-core/composables": "workspace:*",
"@vben-core/icons": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/shared": "workspace:*",
"@vben-core/typings": "workspace:*",
"@vee-validate/zod": "catalog:",
"@vueuse/core": "catalog:",
"vee-validate": "catalog:",
"vue": "catalog:",
"zod": "catalog:",
"zod-defaults": "catalog:"
},
"devDependencies": {
"@vue/test-utils": "catalog:",
"unplugin-vue": "catalog:"
}
}

View File

@@ -38,13 +38,7 @@ async function handleSubmit(e: Event) {
return;
}
const { valid } = await props.formApi.validate();
if (!valid) {
return;
}
const values = toRaw(await props.formApi.getValues()) ?? {};
await props.handleSubmit?.(values);
await props.formApi.validateAndSubmit();
}
async function handleReset(e: Event) {
@@ -57,7 +51,7 @@ async function handleReset(e: Event) {
if (isFunction(props.handleReset)) {
await props.handleReset?.(values);
} else {
form.resetForm();
form.reset();
}
}

View File

@@ -10,10 +10,9 @@ import {
VbenIconButton,
VbenRenderContent,
} from '@vben-core/shadcn-ui';
import { cn, set } from '@vben-core/shared/utils';
import { useFieldArray } from 'vee-validate';
import { cn, isObject, set } from '@vben-core/shared/utils';
import { injectRenderFormProps } from '../form-render/context';
import FormField from '../form-render/form-field.vue';
import { createArrayChildSchema } from '../form-render/schema';
@@ -40,9 +39,7 @@ const props = withDefaults(
max?: number;
/** 最少行数 */
min?: number;
/**
* 字段路径,由外层 FormField 通过 componentField 透传vee-validate 的 name
*/
/** 字段路径,由外层 FormField 通过 componentField 透传 */
name?: string;
/**
* 列定义,每一列就是一个子字段(复用 FormSchema
@@ -68,10 +65,19 @@ const props = withDefaults(
);
const arrayPath = computed(() => props.name);
const formRenderProps = injectRenderFormProps();
const form = formRenderProps.form;
if (!form) {
throw new Error('Form api is required in <VbenFormFieldArray />');
}
const formActions = form;
const arrayValue = formActions.useFieldValue(props.name);
const rowKeys = new WeakMap<object, string>();
let nextRowKey = 0;
const { fields, push, remove } = useFieldArray<Record<string, any>>(
() => arrayPath.value,
);
const fields = computed<Record<string, any>[]>(() => {
return Array.isArray(arrayValue.value) ? arrayValue.value : [];
});
const canAdd = computed(() => fields.value.length < props.max);
const canRemove = computed(() => fields.value.length > props.min);
@@ -93,12 +99,12 @@ function buildDefaultRow(): Record<string, any> {
const row: Record<string, any> = {};
props.schema.forEach((col) => {
const value =
Reflect.has(col, 'defaultValue') && col.defaultValue !== undefined
? col.defaultValue
: 'type' in col && col.type === 'array'
? []
: null;
let value: any = null;
if (Reflect.has(col, 'defaultValue') && col.defaultValue !== undefined) {
value = col.defaultValue;
} else if ('type' in col && col.type === 'array') {
value = [];
}
set(row, col.fieldName, value);
});
return row;
@@ -108,14 +114,28 @@ function addRow() {
if (props.disabled || !canAdd.value) {
return;
}
push(buildDefaultRow());
formActions.pushFieldValue(arrayPath.value, buildDefaultRow());
}
function removeRow(index: number) {
if (props.disabled || !canRemove.value) {
return;
}
remove(index);
void formActions.removeFieldValue(arrayPath.value, index);
}
function getRowKey(row: Record<string, any>, index: number) {
if (!isObject(row)) {
return `${arrayPath.value}-${index}`;
}
const existingKey = rowKeys.get(row);
if (existingKey) {
return existingKey;
}
nextRowKey += 1;
const key = `${arrayPath.value}-${nextRowKey}`;
rowKeys.set(row, key);
return key;
}
function rowSchemas(index: number) {
@@ -160,7 +180,7 @@ function rowSchemas(index: number) {
<div
v-for="(entry, index) in fields"
:key="entry.key"
:key="getRowKey(entry, index)"
class="border-border/60 border-b p-3 last:border-b-0 sm:grid sm:p-0"
:style="gridStyle"
>

View File

@@ -18,9 +18,9 @@ import {
} from '@vben-core/shadcn-ui';
import { globalShareState } from '@vben-core/shared/global-state';
import { defineRule } from 'vee-validate';
import VbenFormFieldArray from './components/form-field-array.vue';
import { warnDeprecatedOnce } from './deprecation';
import { registerFormRules } from './rule-registry';
const DEFAULT_MODEL_PROP_NAME = 'modelValue';
@@ -46,24 +46,25 @@ export const COMPONENT_BIND_EVENT_MAP: Partial<
export function setupVbenForm<
T extends BaseFormComponentType = BaseFormComponentType,
>(options: VbenFormAdapterOptions<T>) {
const { config, defineRules } = options;
const { config, defineRules, rules } = options;
const {
disabledOnChangeListener = true,
disabledOnInputListener = true,
emptyStateValue = undefined,
} = (config || {}) as FormCommonConfig;
const { changeEventFallback = false, emptyStateValue = undefined } =
(config || {}) as FormCommonConfig;
Object.assign(DEFAULT_FORM_COMMON_CONFIG, {
disabledOnChangeListener,
disabledOnInputListener,
changeEventFallback,
emptyStateValue,
});
if (defineRules) {
for (const key of Object.keys(defineRules)) {
defineRule(key, defineRules[key as never]);
}
warnDeprecatedOnce(
'setup-vben-form-define-rules',
'[Vben Form] `setupVbenForm({ defineRules })` is deprecated. Use `setupVbenForm({ rules })` instead.',
);
registerFormRules(defineRules);
}
if (rules) {
registerFormRules(rules);
}
const baseModelPropName =

View File

@@ -0,0 +1,18 @@
const warnedDeprecations = new Set<string>();
export function resetDeprecationWarnings() {
warnedDeprecations.clear();
}
export function warnDeprecatedOnce(
key: string,
message: string,
options: { production?: boolean } = {},
) {
const production = options.production ?? import.meta.env.PROD;
if (production || warnedDeprecations.has(key)) {
return;
}
warnedDeprecations.add(key);
console.warn(message);
}

View File

@@ -1,3 +1,63 @@
import { get, isObject, set } from '@vben-core/shared/utils';
export function deleteValueByFieldName(
values: Record<string, any>,
fieldName: string,
) {
const { pathSegments, rawKey } = resolveFieldNamePath(fieldName);
if (rawKey) {
Reflect.deleteProperty(values, rawKey);
return;
}
if (pathSegments.length === 0) {
Reflect.deleteProperty(values, fieldName);
return;
}
let target: Record<string, any> | undefined = values;
for (const segment of pathSegments.slice(0, -1)) {
if (!target || !isObject(target)) {
return;
}
target = target[segment];
}
const lastPathSegment = pathSegments.at(-1);
if (!target || !isObject(target) || !lastPathSegment) {
return;
}
Reflect.deleteProperty(target, lastPathSegment);
}
export function getValueByFieldName(
values: Record<string, any>,
fieldName: string,
) {
const { rawKey } = resolveFieldNamePath(fieldName);
return rawKey ? values[rawKey] : get(values, fieldName);
}
export function resolveChildUpdateFieldName(
parentFieldName: string,
fieldName: string,
) {
if (fieldName.startsWith(`${parentFieldName}.`)) {
return fieldName.slice(parentFieldName.length + 1);
}
const indexedPrefix = `${parentFieldName}[`;
if (!fieldName.startsWith(indexedPrefix)) {
return;
}
const closeIndex = fieldName.indexOf(']', indexedPrefix.length);
if (closeIndex === -1 || fieldName[closeIndex + 1] !== '.') {
return;
}
return fieldName.slice(closeIndex + 2);
}
export function resolveFieldNamePath(fieldName: string) {
if (fieldName.startsWith('[') && fieldName.endsWith(']')) {
const rawKey = fieldName.slice(1, -1);
@@ -12,3 +72,35 @@ export function resolveFieldNamePath(fieldName: string) {
rawKey: undefined,
};
}
export function resolveValueFormatFieldName(
fieldName: string,
parentPath?: string,
) {
if (!parentPath) {
return fieldName;
}
if (fieldName.startsWith('$root.')) {
return fieldName.slice('$root.'.length);
}
if (fieldName.startsWith('$row.')) {
return `${parentPath}.${fieldName.slice('$row.'.length)}`;
}
if (fieldName === parentPath || fieldName.startsWith(`${parentPath}.`)) {
return fieldName;
}
return `${parentPath}.${fieldName}`;
}
export function setValueByFieldName(
values: Record<string, any>,
fieldName: string,
value: any,
) {
const { rawKey } = resolveFieldNamePath(fieldName);
if (rawKey) {
values[rawKey] = value;
return;
}
set(values, fieldName, value);
}

View File

@@ -1,15 +1,17 @@
import type {
FormState,
GenericObject,
ResetFormOpts,
ValidationOptions,
} from 'vee-validate';
import type { ComponentPublicInstance } from 'vue';
import type { Recordable } from '@vben-core/typings';
import type { FormActions, FormSchema, VbenFormProps } from './types';
import type {
BaseFormComponentType,
FormActions,
FormFieldName,
FormFieldValue,
FormResetOptions,
FormResetState,
FormSchema,
FormValues,
FormValueSnapshot,
VbenFormProps,
} from './types';
import { isRef, toRaw } from 'vue';
@@ -17,25 +19,36 @@ import { Store } from '@vben-core/shared/store';
import {
bindMethods,
cloneDeep,
createMerge,
formatDate,
get,
isDate,
isDayjsObject,
isFunction,
isObject,
mergeWithArrayOverride,
set,
StateHandler,
} from '@vben-core/shared/utils';
import { warnDeprecatedOnce } from './deprecation';
import { resolveFieldNamePath } from './field-name';
import {
getFormArraySchemaChildren,
resolveArrayChildFieldName,
} from './form-render/schema';
import { updateFormSchemaList } from './form-render/schema';
import { formatFormValues } from './form-value-transform';
function getDefaultState(): VbenFormProps {
type FormApiProps<
TValues extends FormValues,
T extends BaseFormComponentType,
P extends Record<string, any>,
> = VbenFormProps<T, P, TValues>;
type FormApiSchema<
TValues extends FormValues,
T extends BaseFormComponentType,
P extends Record<string, any>,
> = FormSchema<T, P, TValues>;
function getDefaultState<
TValues extends FormValues,
T extends BaseFormComponentType,
P extends Record<string, any>,
>(): FormApiProps<TValues, T, P> {
return {
actionWrapperClass: '',
collapsed: false,
@@ -59,15 +72,19 @@ function getDefaultState(): VbenFormProps {
};
}
export class FormApi {
export class FormApi<
TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> {
// private api: Pick<VbenFormProps, 'handleReset' | 'handleSubmit'>;
public form = {} as FormActions;
public form = {} as FormActions<TValues>;
isMounted = false;
public state: null | VbenFormProps = null;
public state: FormApiProps<TValues, T, P> | null = null;
stateHandler: StateHandler;
public store: Store<VbenFormProps>;
public store: Store<FormApiProps<TValues, T, P>>;
/**
* 组件实例映射
@@ -75,16 +92,16 @@ export class FormApi {
private componentRefMap: Map<string, unknown> = new Map();
// 最后一次点击提交时的表单值
private latestSubmissionValues: null | Recordable<any> = null;
private latestSubmissionValues: null | Partial<TValues> = null;
private prevState: null | VbenFormProps = null;
private prevState: FormApiProps<TValues, T, P> | null = null;
constructor(options: VbenFormProps = {}) {
constructor(options: FormApiProps<TValues, T, P> = {}) {
const { ...storeState } = options;
const defaultState = getDefaultState();
const defaultState = getDefaultState<TValues, T, P>();
this.store = new Store<VbenFormProps>({
this.store = new Store<FormApiProps<TValues, T, P>>({
...defaultState,
...storeState,
});
@@ -100,6 +117,24 @@ export class FormApi {
bindMethods(this);
}
async clearValidation(
fieldNames?: FormFieldName<TValues> | FormFieldName<TValues>[],
) {
const form = await this.getForm();
form.clearValidation(fieldNames);
}
formatValues<TResult extends FormValues = TValues>(
rawValues: Readonly<FormValues>,
) {
return formatFormValues(
toRaw(rawValues),
this.state?.schema ?? [],
this.state?.fieldMappingTime,
this.state?.arrayToStringFields,
) as TResult;
}
/**
* 获取字段组件实例
* @param fieldName 字段名
@@ -161,29 +196,41 @@ export class FormApi {
return this.latestSubmissionValues || {};
}
async getRawValues<TResult extends FormValues = TValues>() {
const form = await this.getForm();
return cloneDeep(toRaw(form.values ?? {})) as unknown as TResult;
}
getState() {
return this.state;
}
async getValues<T = Recordable<any>>() {
async getValues<TResult extends FormValues = TValues>() {
const form = await this.getForm();
const values = form.values
? this.handleRangeTimeValue(cloneDeep(toRaw(form.values)))
: {};
return this.handleValueFormat(values) as T;
return this.formatValues<TResult>(toRaw(form.values ?? {}));
}
async isFieldValid(fieldName: string) {
async getValueSnapshot<TResult extends FormValues = TValues>(): Promise<
FormValueSnapshot<TResult>
> {
const rawValues = await this.getRawValues<TResult>();
return {
rawValues,
values: this.formatValues<TResult>(rawValues),
};
}
async isFieldValid(fieldName: FormFieldName<TValues>) {
const form = await this.getForm();
return form.isFieldValid(fieldName);
}
merge(formApi: FormApi) {
merge(formApi: FormApi<any, any, any>) {
const chain = [this, formApi];
const proxy = new Proxy(formApi, {
get(target: any, prop: any) {
if (prop === 'merge') {
return (nextFormApi: FormApi) => {
return (nextFormApi: FormApi<any, any, any>) => {
chain.push(nextFormApi);
return proxy;
};
@@ -218,16 +265,19 @@ export class FormApi {
return proxy;
}
mount(formActions: FormActions, componentRefMap?: Map<string, unknown>) {
mount(
formActions: FormActions<TValues>,
componentRefMap?: Map<string, unknown>,
) {
if (!this.isMounted) {
Object.assign(this.form, formActions);
this.form = formActions;
this.stateHandler.setConditionTrue();
const initialValues = this.form.values
? this.handleRangeTimeValue(cloneDeep(toRaw(this.form.values)))
? this.formatValues(toRaw(this.form.values))
: {};
this.setLatestSubmissionValues({
...this.handleValueFormat(initialValues),
});
...initialValues,
} as Partial<TValues>);
this.componentRefMap =
componentRefMap ?? this.componentRefMap ?? new Map();
this.isMounted = true;
@@ -252,20 +302,27 @@ export class FormApi {
/**
* 重置表单
*/
async resetForm(
state?: Partial<FormState<GenericObject>> | undefined,
opts?: Partial<ResetFormOpts>,
) {
async reset(state?: FormResetState<TValues>, opts?: FormResetOptions) {
const form = await this.getForm();
return form.resetForm(state, opts);
return form.reset(state, opts);
}
/** @deprecated Use `reset` instead. */
async resetForm(state?: FormResetState<TValues>, opts?: FormResetOptions) {
warnDeprecatedOnce(
'form-api-reset-form',
'[Vben Form] `formApi.resetForm()` is deprecated. Use `formApi.reset()` instead.',
);
return this.reset(state, opts);
}
/** @deprecated Use `clearValidation` instead. */
async resetValidate() {
const form = await this.getForm();
const fields = Object.keys(form.errors.value);
fields.forEach((field) => {
form.setFieldError(field, undefined);
});
warnDeprecatedOnce(
'form-api-reset-validate',
'[Vben Form] `formApi.resetValidate()` is deprecated. Use `formApi.clearValidation()` instead.',
);
return this.clearValidation();
}
/**
@@ -273,7 +330,6 @@ export class FormApi {
* @param errors 验证错误对象
*/
scrollToFirstError(errors: Record<string, any> | string) {
// https://github.com/logaretm/vee-validate/discussions/3835
const firstErrorFieldName =
typeof errors === 'string' ? errors : Object.keys(errors)[0];
@@ -285,7 +341,7 @@ export class FormApi {
`[name="${firstErrorFieldName}"]`,
) as HTMLElement;
// 如果通过 name 属性找不到,尝试通过组件引用查找, 正常情况下不会走到这,怕哪天 vee-validate 改了 name 属性有个兜底的
// 如果通过 name 属性找不到,尝试通过组件引用查找
if (!el) {
const componentRef = this.getFieldComponentRef(firstErrorFieldName);
if (componentRef && componentRef.$el instanceof HTMLElement) {
@@ -303,19 +359,27 @@ export class FormApi {
}
}
async setFieldValue(field: string, value: any, shouldValidate?: boolean) {
async setFieldValue<TFieldName extends FormFieldName<TValues>>(
field: TFieldName,
value: FormFieldValue<TValues, NoInfer<TFieldName>>,
shouldValidate?: boolean,
) {
const form = await this.getForm();
form.setFieldValue(field, value, shouldValidate);
await form.setFieldValue(field, value, shouldValidate);
}
setLatestSubmissionValues(values: null | Recordable<any>) {
this.latestSubmissionValues = { ...toRaw(values) };
setLatestSubmissionValues(values: null | Partial<TValues>) {
this.latestSubmissionValues = {
...toRaw(values),
} as Partial<TValues>;
}
setState(
stateOrFn:
| ((prev: VbenFormProps) => Partial<VbenFormProps>)
| Partial<VbenFormProps>,
| ((
prev: FormApiProps<TValues, T, P>,
) => Partial<FormApiProps<TValues, T, P>>)
| Partial<FormApiProps<TValues, T, P>>,
) {
if (isFunction(stateOrFn)) {
this.store.setState((prev) => {
@@ -333,7 +397,7 @@ export class FormApi {
* @param shouldValidate
*/
async setValues(
fields: Record<string, any>,
fields: Partial<TValues>,
filterFields: boolean = true,
shouldValidate: boolean = false,
) {
@@ -343,41 +407,67 @@ export class FormApi {
return;
}
/**
* 合并算法有待改进目前的算法不支持object类型的值。
* antd的日期时间相关组件的值类型为dayjs对象
* element-plus的日期时间相关组件的值类型可能为Date对象
* 以上两种类型需要排除深度合并
*/
const fieldMergeFn = createMerge((obj, key, value) => {
if (key in obj) {
obj[key] =
!Array.isArray(obj[key]) &&
isObject(obj[key]) &&
!isDayjsObject(obj[key]) &&
!isDate(obj[key])
? fieldMergeFn(value, obj[key])
: value;
const schemaFieldPaths = (this.state?.schema ?? []).map(
(schema) => resolveFieldNamePath(schema.fieldName).pathSegments,
);
const filterValue = (
value: unknown,
parentPath: string[] = [],
): unknown => {
if (
!isObject(value) ||
Array.isArray(value) ||
isDate(value) ||
isDayjsObject(value)
) {
return value;
}
return true;
});
const filteredFields = fieldMergeFn(fields, form.values);
form.setValues(filteredFields, shouldValidate);
const result: Record<string, unknown> = {};
for (const [key, currentValue] of Object.entries(value)) {
const currentPath = [...parentPath, key];
const matchingPaths = schemaFieldPaths.filter(
(schemaPath) =>
schemaPath.length >= currentPath.length &&
currentPath.every(
(pathSegment, index) => schemaPath[index] === pathSegment,
),
);
if (matchingPaths.length === 0) {
continue;
}
result[key] = matchingPaths.some(
(schemaPath) => schemaPath.length === currentPath.length,
)
? currentValue
: filterValue(currentValue, currentPath);
}
return result;
};
const filteredFields = filterValue(fields) as Partial<TValues>;
form.setValues(filteredFields as Partial<TValues>, shouldValidate);
}
async submitForm(e?: Event) {
async submit(e?: Event) {
e?.preventDefault();
e?.stopPropagation();
const form = await this.getForm();
await form.submitForm();
const rawValues = toRaw(await this.getValues());
await this.state?.handleSubmit?.(rawValues);
await form.submit();
return this.submitValues();
}
return rawValues;
/** @deprecated Use `submit` instead. */
async submitForm(e?: Event) {
warnDeprecatedOnce(
'form-api-submit-form',
'[Vben Form] `formApi.submitForm()` is deprecated. Use `formApi.submit()` instead.',
);
return this.submit(e);
}
unmount() {
this.form?.resetForm?.();
this.form?.reset?.();
// this.state = null;
this.componentRefMap = new Map();
this.latestSubmissionValues = null;
@@ -385,8 +475,8 @@ export class FormApi {
this.stateHandler.reset();
}
updateSchema(schema: Partial<FormSchema>[]) {
const updated: Partial<FormSchema>[] = [...schema];
updateSchema(schema: Partial<FormApiSchema<TValues, T, P>>[]) {
const updated: Partial<FormApiSchema<TValues, T, P>>[] = [...schema];
const hasField = updated.every(
(item) => Reflect.has(item, 'fieldName') && item.fieldName,
);
@@ -397,163 +487,55 @@ export class FormApi {
);
return;
}
const currentSchema = this.updateSchemaList(
const currentSchema = updateFormSchemaList(
[...(this.state?.schema ?? [])],
updated,
);
this.setState({ schema: currentSchema });
}
async validate(opts?: Partial<ValidationOptions>) {
async validate() {
const form = await this.getForm();
const validateResult = await form.validate(opts);
const validateResult = await form.validate();
if (Object.keys(validateResult?.errors ?? {}).length > 0) {
console.error('validate error', validateResult?.errors);
if (this.state?.scrollToFirstError) {
this.scrollToFirstError(validateResult.errors);
}
if (
Object.keys(validateResult?.errors ?? {}).length > 0 &&
this.state?.scrollToFirstError
) {
this.scrollToFirstError(validateResult.errors);
}
return validateResult;
}
async validateAndSubmit() {
const { valid } = await this.validate();
if (!valid) return;
return this.submitValues();
}
/** @deprecated Use `validateAndSubmit` instead. */
async validateAndSubmitForm() {
const form = await this.getForm();
const { valid, errors } = await form.validate();
if (!valid) {
if (this.state?.scrollToFirstError) {
this.scrollToFirstError(errors);
}
return;
}
return await this.submitForm();
warnDeprecatedOnce(
'form-api-validate-and-submit-form',
'[Vben Form] `formApi.validateAndSubmitForm()` is deprecated. Use `formApi.validateAndSubmit()` instead.',
);
return this.validateAndSubmit();
}
async validateField(fieldName: string, opts?: Partial<ValidationOptions>) {
async validateField(fieldName: FormFieldName<TValues>) {
const form = await this.getForm();
const validateResult = await form.validateField(fieldName, opts);
const validateResult = await form.validateField(fieldName);
if (Object.keys(validateResult?.errors ?? {}).length > 0) {
console.error('validate error', validateResult?.errors);
if (this.state?.scrollToFirstError) {
this.scrollToFirstError(fieldName);
}
if (
Object.keys(validateResult?.errors ?? {}).length > 0 &&
this.state?.scrollToFirstError
) {
this.scrollToFirstError(fieldName);
}
return validateResult;
}
private applyValueFormatBySchemas(
schemas: FormSchema[],
values: Record<string, any>,
parentPath?: string,
parentContext?: {
arrayField?: string;
row?: Record<string, any>;
rowIndex?: number;
rowPath?: string;
},
) {
schemas.forEach((schema) => {
const fieldName = parentPath
? resolveArrayChildFieldName(parentPath, schema.fieldName)
: schema.fieldName;
const row =
parentPath && parentContext?.rowPath
? this.resolveValueByFieldName(values, parentContext.rowPath)
: parentContext?.row;
const schemaContext = {
...parentContext,
fieldName,
originalFieldName: schema.fieldName,
rootValues: values,
row,
};
const children = getFormArraySchemaChildren(schema);
if (children.length > 0) {
const arrayValue = this.resolveValueByFieldName(values, fieldName);
if (Array.isArray(arrayValue)) {
arrayValue.forEach((rowValue, index) => {
const rowPath = `${fieldName}[${index}]`;
this.applyValueFormatBySchemas(
children as FormSchema[],
values,
rowPath,
{
arrayField: fieldName,
row: rowValue,
rowIndex: index,
rowPath,
},
);
});
}
}
if (schema.valueFormat) {
const value = this.resolveValueByFieldName(values, fieldName);
this.deleteValueByFieldName(values, fieldName);
const formattedValue = schema.valueFormat(
value,
(key, nextValue) => {
this.setValueByFieldName(
values,
this.resolveValueFormatFieldName(key, parentPath),
nextValue,
);
},
values,
schemaContext,
);
if (formattedValue !== undefined) {
this.setValueByFieldName(values, fieldName, formattedValue);
}
}
});
}
private deleteValueByFieldName(
values: Record<string, any>,
fieldName: string,
) {
const { pathSegments, rawKey } = resolveFieldNamePath(fieldName);
if (rawKey) {
Reflect.deleteProperty(values, rawKey);
return;
}
if (!pathSegments || pathSegments.length === 0) {
Reflect.deleteProperty(values, fieldName);
return;
}
let target: Record<string, any> | undefined = values;
for (const segment of pathSegments.slice(0, -1)) {
if (!target || !isObject(target)) {
return;
}
target = target[segment];
}
if (!target || !isObject(target)) {
return;
}
const lastPathSegment = pathSegments.at(-1);
if (!lastPathSegment) {
return;
}
Reflect.deleteProperty(target, lastPathSegment);
}
private async getForm() {
if (!this.isMounted) {
// 等待form挂载
@@ -565,266 +547,11 @@ export class FormApi {
return this.form;
}
private handleMultiFields = (originValues: Record<string, any>) => {
const arrayToStringFields = this.state?.arrayToStringFields;
if (!arrayToStringFields || !Array.isArray(arrayToStringFields)) {
return;
}
const processFields = (fields: string[], separator: string = ',') => {
this.processFields(fields, separator, originValues, (value, sep) => {
if (Array.isArray(value)) {
return value.join(sep);
} else if (typeof value === 'string') {
// 处理空字符串的情况
if (value === '') {
return [];
}
// 处理复杂分隔符的情况
const escapedSeparator = sep.replaceAll(
/[.*+?^${}()|[\]\\]/g,
String.raw`\$&`,
);
return value.split(new RegExp(escapedSeparator));
} else {
return value;
}
});
};
// 处理简单数组格式 ['field1', 'field2', ';'] 或 ['field1', 'field2']
if (arrayToStringFields.every((item) => typeof item === 'string')) {
const lastItem =
arrayToStringFields[arrayToStringFields.length - 1] || '';
const fields =
lastItem.length === 1
? arrayToStringFields.slice(0, -1)
: arrayToStringFields;
const separator = lastItem.length === 1 ? lastItem : ',';
processFields(fields, separator);
return;
}
// 处理嵌套数组格式 [['field1'], ';']
arrayToStringFields.forEach((fieldConfig) => {
if (Array.isArray(fieldConfig)) {
const [fields, separator = ','] = fieldConfig;
// 根据类型定义fields 应该始终是字符串数组
if (!Array.isArray(fields)) {
console.warn(
`Invalid field configuration: fields should be an array of strings, got ${typeof fields}`,
);
return;
}
processFields(fields, separator);
}
});
};
private handleRangeTimeValue = (originValues: Record<string, any>) => {
const values = { ...originValues };
const fieldMappingTime = this.state?.fieldMappingTime;
this.handleMultiFields(values);
if (!fieldMappingTime || !Array.isArray(fieldMappingTime)) {
return values;
}
fieldMappingTime.forEach(
([field, [startTimeKey, endTimeKey], format = 'YYYY-MM-DD']) => {
if (startTimeKey && endTimeKey && values[field] === null) {
Reflect.deleteProperty(values, startTimeKey);
Reflect.deleteProperty(values, endTimeKey);
// delete values[startTimeKey];
// delete values[endTimeKey];
}
if (!values[field]) {
Reflect.deleteProperty(values, field);
// delete values[field];
return;
}
const [startTime, endTime] = values[field];
if (format === null) {
values[startTimeKey] = startTime;
values[endTimeKey] = endTime;
} else if (isFunction(format)) {
values[startTimeKey] = format(startTime, startTimeKey);
values[endTimeKey] = format(endTime, endTimeKey);
} else {
const [startTimeFormat, endTimeFormat] = Array.isArray(format)
? format
: [format, format];
values[startTimeKey] = startTime
? formatDate(startTime, startTimeFormat)
: undefined;
values[endTimeKey] = endTime
? formatDate(endTime, endTimeFormat)
: undefined;
}
// delete values[field];
Reflect.deleteProperty(values, field);
},
);
private async submitValues() {
const { rawValues, values } = await this.getValueSnapshot();
this.setLatestSubmissionValues(values);
await this.state?.handleSubmit?.(values, rawValues);
return values;
};
private handleValueFormat = (originValues: Record<string, any>) => {
const values = { ...originValues };
this.applyValueFormatBySchemas(this.state?.schema ?? [], values);
return values;
};
private processFields = (
fields: string[],
separator: string,
originValues: Record<string, any>,
transformFn: (value: any, separator: string) => any,
) => {
fields.forEach((field) => {
const value = originValues[field];
if (value === undefined || value === null) {
return;
}
originValues[field] = transformFn(value, separator);
});
};
private resolveChildUpdateFieldName(
parentFieldName: string,
fieldName: string,
) {
if (fieldName.startsWith(`${parentFieldName}.`)) {
return fieldName.slice(parentFieldName.length + 1);
}
const indexedPrefix = `${parentFieldName}[`;
if (!fieldName.startsWith(indexedPrefix)) {
return;
}
const closeIndex = fieldName.indexOf(']', indexedPrefix.length);
if (closeIndex === -1 || fieldName[closeIndex + 1] !== '.') {
return;
}
return fieldName.slice(closeIndex + 2);
}
private resolveValueByFieldName(
values: Record<string, any>,
fieldName: string,
) {
const { rawKey } = resolveFieldNamePath(fieldName);
if (rawKey) {
return values[rawKey];
}
return get(values, fieldName);
}
private resolveValueFormatFieldName(fieldName: string, parentPath?: string) {
if (!parentPath) {
return fieldName;
}
if (fieldName.startsWith('$root.')) {
return fieldName.slice('$root.'.length);
}
if (fieldName.startsWith('$row.')) {
return `${parentPath}.${fieldName.slice('$row.'.length)}`;
}
if (fieldName === parentPath || fieldName.startsWith(`${parentPath}.`)) {
return fieldName;
}
return `${parentPath}.${fieldName}`;
}
private setSchemaChildren(schema: FormSchema, children: FormSchema[]) {
if ('children' in schema && Array.isArray(schema.children)) {
return {
...schema,
children,
} as FormSchema;
}
if (
!isFunction(schema.componentProps) &&
schema.componentProps &&
Array.isArray((schema.componentProps as Record<string, any>).schema)
) {
return {
...schema,
componentProps: {
...(schema.componentProps as Record<string, any>),
schema: children,
},
} as FormSchema;
}
return schema;
}
private setValueByFieldName(
values: Record<string, any>,
fieldName: string,
value: any,
) {
const { rawKey } = resolveFieldNamePath(fieldName);
if (rawKey) {
values[rawKey] = value;
return;
}
set(values, fieldName, value);
}
private updateSchemaList(
currentSchema: FormSchema[],
updated: Partial<FormSchema>[],
): FormSchema[] {
return currentSchema.map((schema): FormSchema => {
const exactUpdatedData = updated.find(
(item) => item.fieldName === schema.fieldName,
);
if (exactUpdatedData) {
return mergeWithArrayOverride(exactUpdatedData, schema) as FormSchema;
}
const children = getFormArraySchemaChildren(schema);
if (children.length === 0) {
return schema;
}
const childUpdates = updated
.map((item) => {
const fieldName = item.fieldName
? this.resolveChildUpdateFieldName(schema.fieldName, item.fieldName)
: undefined;
return fieldName
? ({
...item,
fieldName,
} as Partial<FormSchema>)
: undefined;
})
.filter(Boolean) as Partial<FormSchema>[];
if (childUpdates.length === 0) {
return schema;
}
return this.setSchemaChildren(
schema,
this.updateSchemaList(children as FormSchema[], childUpdates),
);
});
}
private updateState() {
@@ -839,7 +566,10 @@ export class FormApi {
(item) => !currentFields.has(item.fieldName),
);
for (const schema of deletedSchema) {
this.form?.setFieldValue?.(schema.fieldName, undefined);
this.form?.setFieldValue?.(
schema.fieldName,
undefined as FormFieldValue<TValues, string>,
);
}
}
}

View File

@@ -1,20 +1,56 @@
import type {
ExtendedFormApi,
FormDependenciesResolveContext,
FormDependenciesResolvedState,
FormItemDependencies,
FormItemDependenciesLegacy,
FormItemDependenciesResolve,
FormSchemaContext,
FormSchemaRuleType,
MaybeComponentProps,
} from '../types';
import { computed, isRef, ref, watch } from 'vue';
import { computed, isRef, onScopeDispose, shallowRef, watch } from 'vue';
import { get, isBoolean, isFunction } from '@vben-core/shared/utils';
import { useFormValues } from 'vee-validate';
import {
cloneDeep,
get,
isBoolean,
isEqual,
isFunction,
} from '@vben-core/shared/utils';
import { warnDeprecatedOnce } from '../deprecation';
import { resolveFieldNamePath } from '../field-name';
import { injectFormProps } from '../use-form-context';
import { injectRenderFormProps } from './context';
interface DependencyState {
dynamicComponentProps: MaybeComponentProps;
dynamicHelp: FormDependenciesResolvedState['help'];
dynamicHelpResolved: boolean;
dynamicRenderComponentContent: FormDependenciesResolvedState['renderComponentContent'];
dynamicRenderComponentContentResolved: boolean;
dynamicRules: FormSchemaRuleType | undefined;
dynamicRulesResolved: boolean;
isDisabled: boolean;
isIf: boolean;
isRequired: boolean;
isShow: boolean;
}
const legacyDependencyKeys = [
'componentProps',
'disabled',
'if',
'required',
'rules',
'show',
'trigger',
] as const;
const mixedDependenciesWarnings = new WeakSet<object>();
/**
* 解析Nested Objects对应的字段值
* @param values 表单值
@@ -24,7 +60,7 @@ function resolveValueByFieldName(
values: Record<string, any>,
fieldName: string,
) {
// vee-validate[] 表示禁用嵌套
// [] 表示禁用嵌套
const { rawKey } = resolveFieldNamePath(fieldName);
if (rawKey) {
return values[rawKey];
@@ -32,11 +68,106 @@ function resolveValueByFieldName(
return get(values, fieldName);
}
function createDependencyState(
patch: FormDependenciesResolvedState = {},
): DependencyState {
return {
dynamicComponentProps: patch.componentProps ?? {},
dynamicHelp: patch.help,
dynamicHelpResolved: Reflect.has(patch, 'help'),
dynamicRenderComponentContent: patch.renderComponentContent,
dynamicRenderComponentContentResolved: Reflect.has(
patch,
'renderComponentContent',
),
dynamicRules: patch.rules,
dynamicRulesResolved: Reflect.has(patch, 'rules'),
isDisabled: patch.disabled ?? false,
isIf: patch.if ?? true,
isRequired: patch.required ?? false,
isShow: patch.show ?? true,
};
}
function isResolveDependencies(
dependencies: FormItemDependencies,
): dependencies is FormItemDependenciesResolve {
return isFunction(dependencies.resolve);
}
function warnMixedDependencies(dependencies: FormItemDependenciesResolve) {
if (
import.meta.env.PROD ||
mixedDependenciesWarnings.has(dependencies) ||
!legacyDependencyKeys.some(
(key) => Reflect.get(dependencies, key) !== undefined,
)
) {
return;
}
mixedDependenciesWarnings.add(dependencies);
console.warn(
'[Vben Form] `dependencies.resolve` cannot be combined with legacy dependency callbacks. `resolve` takes precedence.',
);
}
async function resolveLegacyDependencies(
dependencies: FormItemDependenciesLegacy,
context: FormDependenciesResolveContext,
): Promise<FormDependenciesResolvedState> {
const patch: FormDependenciesResolvedState = {};
const { actions, controller, values } = context;
const {
componentProps,
disabled,
if: whenIf,
required,
rules,
show,
trigger,
} = dependencies;
if (isFunction(whenIf)) {
patch.if = !!(await whenIf(values, actions, controller));
} else if (isBoolean(whenIf)) {
patch.if = whenIf;
}
if (patch.if === false) {
return patch;
}
if (isFunction(show)) {
patch.show = !!(await show(values, actions, controller));
} else if (isBoolean(show)) {
patch.show = show;
}
if (isFunction(componentProps)) {
patch.componentProps = await componentProps(values, actions, controller);
}
if (isFunction(rules)) {
patch.rules = await rules(values, actions, controller);
}
if (isFunction(disabled)) {
patch.disabled = !!(await disabled(values, actions, controller));
} else if (isBoolean(disabled)) {
patch.disabled = disabled;
}
if (isFunction(required)) {
patch.required = !!(await required(values, actions, controller));
}
if (isFunction(trigger)) {
await trigger(values, actions, controller);
}
return patch;
}
export default function useDependencies(
getDependencies: () => FormItemDependencies | undefined,
getSchemaContext: () => FormSchemaContext = () => ({}),
) {
const values = useFormValues();
const [extendApi] = injectFormProps();
const formRenderProps = injectRenderFormProps();
@@ -46,12 +177,12 @@ export default function useDependencies(
throw new Error('Form api is required in useDependencies');
}
if (!values) {
throw new Error('useDependencies should be used within <VbenForm>');
}
const values = formApi.useValues();
const initialTriggerFields = getDependencies()?.triggerFields ?? [];
const initialTriggerValues = formApi.useFieldValues(initialTriggerFields);
// 在 dependencies 里提供访问extendApi的能力
const getController = (): ExtendedFormApi => {
function getController(): ExtendedFormApi {
const controller = isRef(extendApi)
? extendApi.value.formApi
: extendApi.formApi;
@@ -60,112 +191,107 @@ export default function useDependencies(
throw new Error('formApi is required in useDependencies');
}
return controller;
};
return controller as unknown as ExtendedFormApi;
}
const isIf = ref(true);
const isDisabled = ref(false);
const isShow = ref(true);
const isRequired = ref(false);
const dynamicComponentProps = ref<MaybeComponentProps>({});
const dynamicRules = ref<FormSchemaRuleType>();
const dependencyState = shallowRef(createDependencyState());
let previousDependencies: FormItemDependencies | undefined;
let previousTriggerValues: any[] | undefined;
let dependencyEvaluationId = 0;
const triggerFieldValues = computed(() => {
// 该字段可能会被多个字段触发
const triggerFields = getDependencies()?.triggerFields ?? [];
const usesInitialTriggerFields =
triggerFields.length === initialTriggerFields.length &&
triggerFields.every(
(fieldName, index) => fieldName === initialTriggerFields[index],
);
if (usesInitialTriggerFields) {
return initialTriggerValues.value;
}
return triggerFields.map((dep) => {
return resolveValueByFieldName(values.value, dep);
});
});
const resetConditionState = () => {
isDisabled.value = false;
isIf.value = true;
isShow.value = true;
isRequired.value = false;
dynamicRules.value = undefined;
dynamicComponentProps.value = {};
};
function resetConditionState() {
dependencyState.value = createDependencyState();
}
watch(
[triggerFieldValues, getDependencies],
async ([_values, dependencies]) => {
async ([currentTriggerValues, dependencies]) => {
if (!dependencies || !dependencies?.triggerFields?.length) {
dependencyEvaluationId += 1;
previousDependencies = dependencies;
previousTriggerValues = undefined;
resetConditionState();
return;
}
resetConditionState();
const {
componentProps,
disabled,
if: whenIf,
required,
rules,
show,
trigger,
} = dependencies;
// 1. 优先判断if如果if为false则不渲染dom后续判断也不再执行
const formValues = values.value;
if (isFunction(whenIf)) {
isIf.value = !!(await whenIf(formValues, formApi, getController()));
// 不渲染
if (!isIf.value) return;
} else if (isBoolean(whenIf)) {
isIf.value = whenIf;
if (!isIf.value) return;
if (
dependencies === previousDependencies &&
previousTriggerValues &&
isEqual(currentTriggerValues, previousTriggerValues)
) {
return;
}
// 2. 判断show如果show为false则隐藏
if (isFunction(show)) {
isShow.value = !!(await show(formValues, formApi, getController()));
} else if (isBoolean(show)) {
isShow.value = show;
}
if (isFunction(componentProps)) {
dynamicComponentProps.value = await componentProps(
formValues,
formApi,
getController(),
previousDependencies = dependencies;
previousTriggerValues = cloneDeep(currentTriggerValues);
const currentEvaluationId = ++dependencyEvaluationId;
const context: FormDependenciesResolveContext = {
actions: formApi,
controller: getController(),
schema: {
...getSchemaContext(),
rootValues: values.value,
},
values: values.value,
};
let patch: FormDependenciesResolvedState | undefined;
if (isResolveDependencies(dependencies)) {
warnMixedDependencies(dependencies);
patch = await dependencies.resolve(context);
} else {
warnDeprecatedOnce(
'form-dependencies-legacy-callbacks',
'[Vben Form] Legacy dependency callbacks are deprecated. Use `dependencies.resolve(context)` instead.',
);
patch = await resolveLegacyDependencies(dependencies, context);
}
if (isFunction(rules)) {
dynamicRules.value = await rules(formValues, formApi, getController());
}
if (isFunction(disabled)) {
isDisabled.value = !!(await disabled(
formValues,
formApi,
getController(),
));
} else if (isBoolean(disabled)) {
isDisabled.value = disabled;
}
if (isFunction(required)) {
isRequired.value = !!(await required(
formValues,
formApi,
getController(),
));
}
if (isFunction(trigger)) {
await trigger(formValues, formApi, getController());
if (currentEvaluationId !== dependencyEvaluationId) {
return;
}
dependencyState.value = createDependencyState(patch);
},
{ deep: true, immediate: true },
{ immediate: true },
);
onScopeDispose(() => {
dependencyEvaluationId += 1;
});
return {
dynamicComponentProps,
dynamicRules,
isDisabled,
isIf,
isRequired,
isShow,
dynamicComponentProps: computed(
() => dependencyState.value.dynamicComponentProps,
),
dynamicHelp: computed(() => dependencyState.value.dynamicHelp),
dynamicHelpResolved: computed(
() => dependencyState.value.dynamicHelpResolved,
),
dynamicRenderComponentContent: computed(
() => dependencyState.value.dynamicRenderComponentContent,
),
dynamicRenderComponentContentResolved: computed(
() => dependencyState.value.dynamicRenderComponentContentResolved,
),
dynamicRules: computed(() => dependencyState.value.dynamicRules),
dynamicRulesResolved: computed(
() => dependencyState.value.dynamicRulesResolved,
),
isDisabled: computed(() => dependencyState.value.isDisabled),
isIf: computed(() => dependencyState.value.isIf),
isRequired: computed(() => dependencyState.value.isRequired),
isShow: computed(() => dependencyState.value.isShow),
};
}

View File

@@ -4,14 +4,18 @@ import type { ZodType } from 'zod';
import type {
FormActions,
FormFieldProps,
FormRuleContext,
FormRuntimeField,
MaybeComponentProps,
} from '../types';
import {
computed,
markRaw,
nextTick,
onUnmounted,
ref,
toRaw,
useTemplateRef,
watch,
} from 'vue';
@@ -30,18 +34,21 @@ import {
} from '@vben-core/shadcn-ui';
import { cn, isFunction, isObject, isString } from '@vben-core/shared/utils';
import { toTypedSchema } from '@vee-validate/zod';
import { useFieldError, useFormValues } from 'vee-validate';
import { getFormRule } from '../rule-registry';
import { injectComponentRefMap } from '../use-form-context';
import { injectRenderFormProps, useFormContext } from './context';
import useDependencies from './dependencies';
import FormLabel from './form-label.vue';
import { isEventObjectLike } from './helper';
import { getBaseRules, isEventObjectLike } from './helper';
interface Props extends FormFieldProps {}
interface RuntimeFieldSlotProps {
field: FormRuntimeField<any>;
}
const {
changeEventFallback,
colon,
commonComponentProps,
component,
@@ -49,8 +56,6 @@ const {
dependencies,
description,
disabled,
disabledOnChangeListener,
disabledOnInputListener,
emptyStateValue,
fieldName,
formFieldProps,
@@ -72,12 +77,14 @@ const {
const { componentBindEventMap, componentMap, isVertical } = useFormContext();
const formRenderProps = injectRenderFormProps();
const values = useFormValues();
const errors = useFieldError(fieldName);
const fieldComponentRef = useTemplateRef<HTMLInputElement>('fieldComponentRef');
const formApi = formRenderProps.form;
if (!formApi) {
throw new Error('Form api is required in <FormField />');
}
const error = formApi.useFieldError(fieldName);
const compact = computed(() => formRenderProps.compact);
const isInValid = computed(() => errors.value?.length > 0);
const isInValid = computed(() => Boolean(error.value));
const shouldApplyInvalidStyle = computed(() => {
return isInValid.value && component !== 'VbenFormFieldArray';
});
@@ -99,17 +106,25 @@ const FieldComponent = computed(() => {
// 组件未注册
console.warn(`Component ${component} is not registered`);
}
return finalComponent;
return finalComponent ? markRaw(toRaw(finalComponent)) : finalComponent;
});
const {
dynamicComponentProps,
dynamicHelp,
dynamicHelpResolved,
dynamicRenderComponentContent,
dynamicRenderComponentContentResolved,
dynamicRules,
dynamicRulesResolved,
isDisabled,
isIf,
isRequired,
isShow,
} = useDependencies(() => dependencies);
} = useDependencies(
() => dependencies,
() => ({ fieldName }),
);
const labelStyle = computed(() => {
return labelClass?.includes('w-') || isVertical.value
@@ -120,7 +135,10 @@ const labelStyle = computed(() => {
});
const currentRules = computed(() => {
return dynamicRules.value || rules;
const currentRule = dynamicRulesResolved.value ? dynamicRules.value : rules;
return currentRule && !isString(currentRule)
? toRaw(currentRule)
: currentRule;
});
const visible = computed(() => {
@@ -144,18 +162,7 @@ const shouldRequired = computed(() => {
return ['required', 'selectRequired'].includes(currentRules.value);
}
let isOptional = currentRules?.value?.isOptional?.();
// 如果有设置默认值,则不是必填,需要特殊处理
const typeName = currentRules?.value?._def?.typeName;
if (typeName === 'ZodDefault') {
const innerType = currentRules?.value?._def.innerType;
if (innerType) {
isOptional = innerType.isOptional?.();
}
}
return !isOptional;
return !currentRules.value.isOptional();
});
const fieldRules = computed(() => {
@@ -174,17 +181,56 @@ const fieldRules = computed(() => {
const isOptional = !shouldRequired.value;
if (!isOptional) {
const unwrappedRules = (rules as any)?.unwrap?.();
if (unwrappedRules) {
rules = unwrappedRules;
}
rules = getBaseRules(rules) ?? rules;
}
return toTypedSchema(rules as ZodType);
return rules as ZodType;
});
async function validateFieldValue({ value }: { value: any }) {
const activeRules = fieldRules.value;
if (!activeRules) {
return;
}
if (isString(activeRules)) {
const validator = getFormRule(activeRules);
if (!validator) {
console.warn(`Form rule ${activeRules} is not registered`);
return;
}
const ruleContext: FormRuleContext = {
field: {
label: isString(label) ? label : undefined,
name: fieldName,
},
label: isString(label) ? label : undefined,
name: fieldName,
};
const result = await validator(value, [], ruleContext);
return result === true ? undefined : result;
}
const result = await activeRules.safeParseAsync(value);
return result.success ? undefined : result.error.issues[0]?.message;
}
const fieldValidators = computed(() => {
const validators: Record<string, typeof validateFieldValue> = {
onSubmitAsync: validateFieldValue,
};
const validateOn = new Set(formFieldProps?.validateOn ?? ['blur', 'change']);
if (validateOn.has('blur')) {
validators.onBlurAsync = validateFieldValue;
}
if (validateOn.has('change')) {
validators.onChangeAsync = validateFieldValue;
}
return validators;
});
const computedProps = computed(() => {
const finalComponentProps = isFunction(componentProps)
? componentProps(values.value, getFormApi())
? componentProps({ fieldName })
: componentProps;
return {
@@ -196,14 +242,12 @@ const computedProps = computed(() => {
// 自定义帮助信息
const computedHelp = computed(() => {
const helpContent = help;
const helpContent = dynamicHelpResolved.value ? dynamicHelp.value : help;
if (!helpContent) {
return undefined;
}
return () =>
isFunction(helpContent)
? helpContent(values.value, getFormApi())
: helpContent;
isFunction(helpContent) ? helpContent({ fieldName }) : helpContent;
});
watch(
@@ -223,10 +267,13 @@ const shouldDisabled = computed(() => {
});
const customContentRender = computed(() => {
if (dynamicRenderComponentContentResolved.value) {
return dynamicRenderComponentContent.value ?? {};
}
if (!isFunction(renderComponentContent)) {
return {};
}
return renderComponentContent(values.value, getFormApi());
return renderComponentContent({ fieldName });
});
const renderContentKey = computed(() => {
@@ -234,18 +281,34 @@ const renderContentKey = computed(() => {
});
const fieldProps = computed(() => {
const rules = fieldRules.value;
return {
keepValue: true,
label: isString(label) ? label : '',
...(rules ? { rules } : {}),
...(formFieldProps as Record<string, any>),
asyncDebounceMs: formFieldProps?.asyncDebounceMs,
validators: fieldValidators.value,
};
});
function fieldBindEvent(slotProps: Record<string, any>) {
const modelValue = slotProps.componentField.modelValue;
const handler = slotProps.componentField['onUpdate:modelValue'];
function createFieldSlotProps(slotProps: RuntimeFieldSlotProps) {
const { field } = slotProps;
function handleChange(value: any) {
getFormApi().setFieldError(fieldName);
field.handleChange(value);
}
return {
...slotProps,
componentField: {
name: fieldName,
modelValue: field.state.value,
onBlur: field.handleBlur,
onChange: handleChange,
onInput: handleChange,
'onUpdate:modelValue': handleChange,
},
};
}
function fieldBindEvent(componentField: Record<string, any>) {
const modelValue = componentField.modelValue;
const handler = componentField['onUpdate:modelValue'];
const bindEventField =
modelPropName ||
@@ -260,34 +323,34 @@ function fieldBindEvent(slotProps: Record<string, any>) {
}
if (bindEventField) {
return {
[`onUpdate:${bindEventField}`]: handler,
[bindEventField]: value === undefined ? emptyStateValue : value,
onChange: disabledOnChangeListener
? undefined
: (e: Record<string, any>) => {
const shouldUnwrap = isEventObjectLike(e);
const onChange = slotProps?.componentField?.onChange;
if (!shouldUnwrap) {
return onChange?.(e);
}
const eventField = bindEventField;
return onChange?.(e?.target?.[bindEventField] ?? e);
},
...(disabledOnInputListener ? { onInput: undefined } : {}),
function handleChangeEvent(event: Record<string, any>) {
const value = isEventObjectLike(event)
? (event?.target?.[eventField] ?? event)
: event;
return handler?.(value);
}
return {
[`onUpdate:${eventField}`]: handler,
[eventField]: value === undefined ? emptyStateValue : value,
onChange: changeEventFallback ? handleChangeEvent : undefined,
onInput: undefined,
};
}
return {
...(disabledOnInputListener ? { onInput: undefined } : {}),
...(disabledOnChangeListener ? { onChange: undefined } : {}),
onChange: changeEventFallback ? componentField.onChange : undefined,
onInput: undefined,
};
}
function createComponentProps(slotProps: Record<string, any>) {
const bindEvents = fieldBindEvent(slotProps);
function createComponentProps(slotProps: RuntimeFieldSlotProps) {
const normalizedSlotProps = createFieldSlotProps(slotProps);
const bindEvents = fieldBindEvent(normalizedSlotProps.componentField);
const binds = {
...slotProps.componentField,
...normalizedSlotProps.componentField,
...computedProps.value,
...bindEvents,
...(Reflect.has(computedProps.value, 'onChange')
@@ -332,136 +395,150 @@ onUnmounted(() => {
</script>
<template>
<FormField
<component
v-if="!hide && isIf"
:is="formApi.fieldComponent"
v-bind="fieldProps"
v-slot="slotProps"
:name="fieldName"
>
<FormItem
v-show="isShow"
:class="{
'form-valid-error': shouldApplyInvalidStyle,
'form-is-required': shouldRequired,
'flex-col': isVertical,
'flex-row items-center': !isVertical,
'pb-4': !compact,
'pb-2': compact,
}"
class="relative flex"
v-bind="$attrs"
<FormField
:dirty="slotProps.field.state.meta.isDirty"
:error="error"
:name="fieldName"
:touched="slotProps.field.state.meta.isTouched"
:valid="slotProps.field.state.meta.isValid"
>
<FormLabel
v-if="!hideLabel"
:class="
cn(
'flex leading-6',
{
'mr-2 shrink-0 justify-end': !isVertical,
'mb-1 flex-row': isVertical,
'self-start': shouldCollapsible && !isVertical,
},
labelClass,
)
"
:help="computedHelp"
:colon="colon"
:label="label"
:required="shouldRequired && !hideRequiredMark"
:style="labelStyle"
<FormItem
v-show="isShow"
:class="{
'form-valid-error': shouldApplyInvalidStyle,
'form-is-required': shouldRequired,
'flex-col': isVertical,
'flex-row items-center': !isVertical,
'pb-4': !compact,
'pb-2': compact,
}"
class="relative flex"
v-bind="$attrs"
>
<template v-if="label">
<VbenRenderContent :content="label" />
</template>
<template #extra>
<Button
class="ml-0.5"
variant="icon"
size="icon"
@click.prevent="toggleCollapsed"
v-if="shouldCollapsible"
>
<ChevronsDown
:size="16"
class="transition-transform"
:class="{
'rotate-180': !collapseOpen,
}"
/>
</Button>
</template>
</FormLabel>
<div class="flex-auto overflow-hidden p-px">
<VbenCollapsible :show-trigger="false" v-model:open="collapseOpen">
<template #collapsibleContent>
<div :class="cn('relative flex w-full items-center', wrapperClass)">
<FormControl :class="cn(controlClass)">
<slot
v-bind="{
...slotProps,
...createComponentProps(slotProps),
disabled: shouldDisabled,
isInValid,
}"
>
<component
:is="FieldComponent"
ref="fieldComponentRef"
:class="{
'border-destructive hover:border-destructive/80 focus:border-destructive focus:shadow-[0_0_0_2px_rgba(255,38,5,0.06)]':
shouldApplyInvalidStyle,
}"
v-bind="createComponentProps(slotProps)"
:disabled="shouldDisabled"
>
<template
v-for="name in renderContentKey"
:key="name"
#[name]="renderSlotProps"
>
<VbenRenderContent
:content="customContentRender[name]"
v-bind="{ ...renderSlotProps, formContext: slotProps }"
/>
</template>
<!-- <slot></slot> -->
</component>
<VbenTooltip
v-if="compact && isInValid"
:delay-duration="300"
side="left"
>
<template #trigger>
<slot name="trigger">
<CircleAlert
:class="
cn(
'inline-flex size-5 cursor-pointer text-foreground/80 hover:text-foreground',
)
"
/>
</slot>
</template>
<FormMessage />
</VbenTooltip>
</slot>
</FormControl>
<!-- 自定义后缀 -->
<div v-if="suffix" class="ml-1">
<VbenRenderContent :content="suffix" />
</div>
</div>
<FormLabel
v-if="!hideLabel"
:class="
cn(
'flex leading-6',
{
'mr-2 shrink-0 justify-end': !isVertical,
'mb-1 flex-row': isVertical,
'self-start': shouldCollapsible && !isVertical,
},
labelClass,
)
"
:help="computedHelp"
:colon="colon"
:label="label"
:required="shouldRequired && !hideRequiredMark"
:style="labelStyle"
>
<template v-if="label">
<VbenRenderContent :content="label" />
</template>
</VbenCollapsible>
<template #extra>
<Button
class="ml-0.5"
variant="icon"
size="icon"
@click.prevent="toggleCollapsed"
v-if="shouldCollapsible"
>
<ChevronsDown
:size="16"
class="transition-transform"
:class="{
'rotate-180': !collapseOpen,
}"
/>
</Button>
</template>
</FormLabel>
<div class="flex-auto overflow-hidden p-px">
<VbenCollapsible :show-trigger="false" v-model:open="collapseOpen">
<template #collapsibleContent>
<div
:class="cn('relative flex w-full items-center', wrapperClass)"
>
<FormControl :class="cn(controlClass)">
<slot
v-bind="{
...createFieldSlotProps(slotProps),
...createComponentProps(slotProps),
disabled: shouldDisabled,
isInValid,
}"
>
<component
:is="FieldComponent"
ref="fieldComponentRef"
:class="{
'border-destructive hover:border-destructive/80 focus:border-destructive focus:shadow-[0_0_0_2px_rgba(255,38,5,0.06)]':
shouldApplyInvalidStyle,
}"
v-bind="createComponentProps(slotProps)"
:disabled="shouldDisabled"
>
<template
v-for="name in renderContentKey"
:key="name"
#[name]="renderSlotProps"
>
<VbenRenderContent
:content="customContentRender[name]"
v-bind="{
...renderSlotProps,
formContext: createFieldSlotProps(slotProps),
}"
/>
</template>
<!-- <slot></slot> -->
</component>
<VbenTooltip
v-if="compact && isInValid"
:delay-duration="300"
side="left"
>
<template #trigger>
<slot name="trigger">
<CircleAlert
:class="
cn(
'inline-flex size-5 cursor-pointer text-foreground/80 hover:text-foreground',
)
"
/>
</slot>
</template>
<FormMessage />
</VbenTooltip>
</slot>
</FormControl>
<!-- 自定义后缀 -->
<div v-if="suffix" class="ml-1">
<VbenRenderContent :content="suffix" />
</div>
</div>
</template>
</VbenCollapsible>
<FormDescription v-if="description" class="text-xs">
<VbenRenderContent :content="description" />
</FormDescription>
<FormDescription v-if="description" class="text-xs">
<VbenRenderContent :content="description" />
</FormDescription>
<Transition name="slide-up" v-if="!compact">
<FormMessage class="absolute" />
</Transition>
</div>
</FormItem>
</FormField>
<Transition name="slide-up" v-if="!compact">
<FormMessage class="absolute" />
</Transition>
</div>
</FormItem>
</FormField>
</component>
</template>

View File

@@ -1,13 +1,11 @@
<script setup lang="ts">
import type { GenericObject } from 'vee-validate';
import type { ZodTypeAny } from 'zod';
import type { ZodType } from 'zod';
import type { FormCommonConfig, FormRenderProps, FormShape } from '../types';
import type { NormalizedFormFieldSchema } from './schema';
import { computed } from 'vue';
import { computed, toRaw } from 'vue';
import { Form } from '@vben-core/shadcn-ui';
import { cn, isString } from '@vben-core/shared/utils';
import { provideFormRenderProps } from './context';
@@ -52,26 +50,21 @@ const shapes = computed(() => {
const resultShapes: FormShape[] = [];
props.schema?.forEach((schema) => {
const { fieldName } = schema;
const rules = schema.rules as ZodTypeAny;
const rules = toRaw(schema.rules) as ZodType;
let typeName = '';
if (rules && !isString(rules)) {
typeName = rules._def.typeName;
}
const baseRules = getBaseRules(rules) as ZodTypeAny;
const baseRules = getBaseRules(rules) as ZodType;
resultShapes.push({
default: getDefaultValueInZodStack(rules),
fieldName,
required: !['ZodNullable', 'ZodOptional'].includes(typeName),
required: Boolean(rules && !isString(rules) && !rules.isOptional()),
rules: baseRules,
});
});
return resultShapes;
});
const formComponent = computed(() => (props.form ? 'form' : Form));
const formComponent = 'form';
const formComponentProps = computed(() => {
return props.form
@@ -79,7 +72,10 @@ const formComponentProps = computed(() => {
onSubmit: props.form.handleSubmit((val) => emits('submit', val)),
}
: {
onSubmit: (val: GenericObject) => emits('submit', val),
onSubmit: (event: Event) => {
event.preventDefault();
emits('submit', event);
},
};
});

View File

@@ -1,55 +1,51 @@
import type {
AnyZodObject,
ZodDefault,
ZodEffects,
ZodNumber,
ZodString,
ZodTypeAny,
} from 'zod';
import type { ZodType } from 'zod';
import { toRaw } from 'vue';
import { isObject, isString } from '@vben-core/shared/utils';
import { ZodPipe } from 'zod';
type UnwrappableZodType = ZodType & {
unwrap?: () => ZodType;
};
/**
* Get the lowest level Zod type.
* This will unpack optionals, refinements, etc.
*/
export function getBaseRules<
ChildType extends AnyZodObject | ZodTypeAny = ZodTypeAny,
>(schema: ChildType | ZodEffects<ChildType>): ChildType | null {
export function getBaseRules(schema?: null | string | ZodType): null | ZodType {
if (!schema || isString(schema)) return null;
if ('innerType' in schema._def)
return getBaseRules(schema._def.innerType as ChildType);
const rawSchema = toRaw(schema);
if ('schema' in schema._def)
return getBaseRules(schema._def.schema as ChildType);
if (rawSchema instanceof ZodPipe) {
return getBaseRules(rawSchema.in as ZodType);
}
return schema as ChildType;
const unwrappedSchema = (rawSchema as UnwrappableZodType).unwrap?.();
if (unwrappedSchema && unwrappedSchema !== rawSchema) {
return getBaseRules(unwrappedSchema);
}
return rawSchema;
}
/**
* Search for a "ZodDefault" in the Zod stack and return its value.
*/
export function getDefaultValueInZodStack(schema: ZodTypeAny): any {
export function getDefaultValueInZodStack(
schema?: null | string | ZodType,
): any {
if (!schema || isString(schema)) {
return;
}
const typedSchema = schema as unknown as ZodDefault<ZodNumber | ZodString>;
if (typedSchema._def.typeName === 'ZodDefault')
return typedSchema._def.defaultValue();
if ('innerType' in typedSchema._def) {
return getDefaultValueInZodStack(
typedSchema._def.innerType as unknown as ZodTypeAny,
);
try {
const result = toRaw(schema).safeParse(undefined);
return result.success ? result.data : undefined;
} catch {
return undefined;
}
if ('schema' in typedSchema._def) {
return getDefaultValueInZodStack(
(typedSchema._def as any).schema as ZodTypeAny,
);
}
return undefined;
}
export function isEventObjectLike(obj: any) {

View File

@@ -2,8 +2,10 @@ import type {
BaseFormComponentType,
FormActions,
FormCommonConfig,
FormDependenciesResolveContext,
FormFieldProps,
FormItemDependencies,
FormItemDependenciesLegacy,
FormSchema,
FormSchemaContext,
MaybeComponentProps,
@@ -15,6 +17,8 @@ import {
mergeWithArrayOverride,
} from '@vben-core/shared/utils';
import { resolveChildUpdateFieldName } from '../field-name';
type AnyFormSchema = FormSchema<BaseFormComponentType, Record<string, any>>;
export type NormalizedFormFieldSchema = FormFieldProps & {
@@ -79,8 +83,7 @@ function wrapComponentProps(
return componentProps;
}
return (values: Partial<Record<string, any>>, actions: FormActions) =>
componentProps(values, actions, createSchemaContext(baseContext, values));
return () => componentProps(baseContext);
}
function wrapCustomParamsRender(
@@ -91,8 +94,7 @@ function wrapCustomParamsRender(
return render;
}
return (values: Partial<Record<string, any>>, actions: FormActions) =>
render(values, actions, createSchemaContext(baseContext, values));
return () => render(baseContext);
}
function wrapRenderComponentContent(
@@ -103,8 +105,7 @@ function wrapRenderComponentContent(
return render;
}
return (values: Partial<Record<string, any>>, actions: FormActions) =>
render(values, actions, createSchemaContext(baseContext, values));
return () => render(baseContext);
}
function wrapDependencyFn<T>(handler: T, baseContext: FormSchemaContext): T {
@@ -128,7 +129,7 @@ function wrapDependencyFn<T>(handler: T, baseContext: FormSchemaContext): T {
function scopeDependencies(
dependencies: FormItemDependencies | undefined,
baseContext: FormSchemaContext,
) {
): FormItemDependencies | undefined {
if (!dependencies) {
return dependencies;
}
@@ -138,19 +139,41 @@ function scopeDependencies(
return dependencies;
}
const triggerFields =
dependencies.triggerFields?.map((fieldName) =>
scopeRowFieldName(rowPath, fieldName),
) ?? [];
if (isFunction(dependencies.resolve)) {
const resolve = dependencies.resolve;
return {
resolve(context: FormDependenciesResolveContext) {
return resolve({
...context,
schema: createSchemaContext(
baseContext,
context.values as Partial<Record<string, any>>,
),
});
},
triggerFields,
};
}
const legacyDependencies = dependencies as FormItemDependenciesLegacy;
return {
...dependencies,
componentProps: wrapDependencyFn(dependencies.componentProps, baseContext),
disabled: wrapDependencyFn(dependencies.disabled, baseContext),
if: wrapDependencyFn(dependencies.if, baseContext),
required: wrapDependencyFn(dependencies.required, baseContext),
rules: wrapDependencyFn(dependencies.rules, baseContext),
show: wrapDependencyFn(dependencies.show, baseContext),
trigger: wrapDependencyFn(dependencies.trigger, baseContext),
triggerFields:
dependencies.triggerFields?.map((fieldName) =>
scopeRowFieldName(rowPath, fieldName),
) ?? [],
...legacyDependencies,
componentProps: wrapDependencyFn(
legacyDependencies.componentProps,
baseContext,
),
disabled: wrapDependencyFn(legacyDependencies.disabled, baseContext),
if: wrapDependencyFn(legacyDependencies.if, baseContext),
required: wrapDependencyFn(legacyDependencies.required, baseContext),
rules: wrapDependencyFn(legacyDependencies.rules, baseContext),
show: wrapDependencyFn(legacyDependencies.show, baseContext),
trigger: wrapDependencyFn(legacyDependencies.trigger, baseContext),
triggerFields,
};
}
@@ -166,9 +189,9 @@ function createArrayComponentProps(
const schemaProps = children.length > 0 ? { schema: children } : {};
if (isFunction(componentProps)) {
return (values: Partial<Record<string, any>>, actions: FormActions) => ({
return () => ({
...arrayProps,
...componentProps(values, actions),
...componentProps({ fieldName: schema.fieldName }),
commonConfig,
globalCommonConfig,
...schemaProps,
@@ -200,9 +223,47 @@ function createArrayFieldSchema(
};
}
export function getFormArraySchemaChildren(schema: Partial<AnyFormSchema>) {
interface FormArraySchemaLike {
children?: unknown;
componentProps?: unknown;
}
interface UpdatableFormSchemaLike extends FormArraySchemaLike {
fieldName: string;
}
function setSchemaChildren<TSchema extends UpdatableFormSchemaLike>(
schema: TSchema,
children: TSchema[],
) {
if ('children' in schema && Array.isArray(schema.children)) {
return schema.children;
return {
...schema,
children,
} as TSchema;
}
if (
!isFunction(schema.componentProps) &&
schema.componentProps &&
Array.isArray((schema.componentProps as Record<string, any>).schema)
) {
return {
...schema,
componentProps: {
...(schema.componentProps as Record<string, any>),
schema: children,
},
} as TSchema;
}
return schema;
}
export function getFormArraySchemaChildren<TSchema = FormSchema>(
schema: FormArraySchemaLike,
): TSchema[] {
if ('children' in schema && Array.isArray(schema.children)) {
return schema.children as TSchema[];
}
const componentProps = schema.componentProps;
@@ -211,7 +272,7 @@ export function getFormArraySchemaChildren(schema: Partial<AnyFormSchema>) {
componentProps &&
Array.isArray((componentProps as Record<string, any>).schema)
) {
return (componentProps as Record<string, any>).schema;
return (componentProps as Record<string, any>).schema as TSchema[];
}
return [];
@@ -229,6 +290,38 @@ export function resolveArrayChildFieldName(rowPath: string, fieldName: string) {
return scopeRowFieldName(rowPath, fieldName);
}
export function updateFormSchemaList<TSchema extends UpdatableFormSchemaLike>(
currentSchema: TSchema[],
updated: Partial<TSchema>[],
): TSchema[] {
return currentSchema.map((schema) => {
const exactUpdatedData = updated.find(
(item) => item.fieldName === schema.fieldName,
);
if (exactUpdatedData) {
return mergeWithArrayOverride(exactUpdatedData, schema) as TSchema;
}
const children = getFormArraySchemaChildren<TSchema>(schema);
if (children.length === 0) {
return schema;
}
const childUpdates = updated.flatMap((item) => {
const fieldName = item.fieldName
? resolveChildUpdateFieldName(schema.fieldName, item.fieldName)
: undefined;
return fieldName ? [{ ...item, fieldName } as Partial<TSchema>] : [];
});
if (childUpdates.length === 0) {
return schema;
}
return setSchemaChildren(
schema,
updateFormSchemaList(children, childUpdates),
);
});
}
export function createFormFieldSchema(
schema: AnyFormSchema,
options: CreateFormFieldSchemaOptions = {},
@@ -238,12 +331,11 @@ export function createFormFieldSchema(
options.globalCommonConfig ?? {},
);
const {
changeEventFallback = false,
colon = false,
componentProps = {},
controlClass = '',
disabled,
disabledOnChangeListener = true,
disabledOnInputListener = true,
emptyStateValue = undefined,
formFieldProps = {},
formItemClass = '',
@@ -270,9 +362,8 @@ export function createFormFieldSchema(
}
return {
changeEventFallback,
colon,
disabledOnChangeListener,
disabledOnInputListener,
emptyStateValue,
hideRequiredMark,
labelWidth,

View File

@@ -0,0 +1,77 @@
import type { Component } from 'vue';
import { defineComponent, h, markRaw, onUnmounted } from 'vue';
type AsyncFieldValidator = (...args: any[]) => Promise<unknown> | unknown;
export type FieldValidationInvalidator = () => void;
const asyncValidatorKeys = [
'onBlurAsync',
'onChangeAsync',
'onDynamicAsync',
'onSubmitAsync',
] as const;
export function createRuntimeFieldComponent(
fieldComponent: Component,
registerInvalidator: (
fieldName: string,
invalidator: FieldValidationInvalidator,
) => () => void,
) {
return markRaw(
defineComponent({
inheritAttrs: false,
setup(_, { attrs, slots }) {
const fieldName = String(attrs.name ?? '');
let validationRunId = 0;
let cachedValidators: Record<string, any> | undefined;
let cachedWrappedValidators: Record<string, any> | undefined;
const unregisterInvalidator = registerInvalidator(fieldName, () => {
validationRunId += 1;
});
onUnmounted(unregisterInvalidator);
function wrapValidators(validators: Record<string, any>) {
if (validators === cachedValidators && cachedWrappedValidators) {
return cachedWrappedValidators;
}
const wrappedValidators = { ...validators };
for (const key of asyncValidatorKeys) {
const validator = validators[key] as
| AsyncFieldValidator
| undefined;
if (!validator) {
continue;
}
wrappedValidators[key] = async (...args: any[]) => {
const currentValidationRunId = ++validationRunId;
const result = await validator(...args);
return currentValidationRunId === validationRunId
? result
: undefined;
};
}
cachedValidators = validators;
cachedWrappedValidators = wrappedValidators;
return wrappedValidators;
}
return () => {
const validators = attrs.validators as
| Record<string, any>
| undefined;
return h(
fieldComponent,
{
...attrs,
...(validators ? { validators: wrapValidators(validators) } : {}),
},
slots,
);
};
},
}),
);
}

View File

@@ -0,0 +1,303 @@
import type { FieldValidationInvalidator } from './form-runtime-field';
import type {
FormActions,
FormFieldName,
FormFieldValue,
FormResetOptions,
FormRuntimeState,
FormValues,
} from './types';
import { computed, shallowRef } from 'vue';
import { useForm } from '@tanstack/vue-form';
import { createRuntimeFieldComponent } from './form-runtime-field';
function normalizeError(error: unknown): string | undefined {
if (typeof error === 'string') {
return error;
}
if (error && typeof error === 'object' && 'message' in error) {
const message = Reflect.get(error, 'message');
return typeof message === 'string' ? message : undefined;
}
return error === undefined || error === null ? undefined : String(error);
}
function normalizeFieldMetaError(meta: unknown) {
if (!meta || typeof meta !== 'object' || !('errors' in meta)) {
return undefined;
}
const errors = Reflect.get(meta, 'errors');
return normalizeError(Array.isArray(errors) ? errors[0] : undefined);
}
export function useFormRuntime<TValues extends FormValues>(
defaultValues: TValues,
): FormActions<TValues> {
const rawForm = useForm({
defaultValues,
onSubmit: () => {},
});
const values = rawForm.useSelector((formState) => formState.values);
const fieldMeta = rawForm.useSelector((formState) => formState.fieldMeta);
const isDirty = rawForm.useSelector((formState) => formState.isDirty);
const isSubmitting = rawForm.useSelector(
(formState) => formState.isSubmitting,
);
const isValid = rawForm.useSelector((formState) => formState.isValid);
const isValidating = rawForm.useSelector(
(formState) => formState.isValidating,
);
const manualErrors = shallowRef(new Map<string, string>());
const validationInvalidators = new Map<
string,
Set<FieldValidationInvalidator>
>();
function registerValidationInvalidator(
fieldName: string,
invalidator: FieldValidationInvalidator,
) {
let fieldInvalidators = validationInvalidators.get(fieldName);
if (!fieldInvalidators) {
fieldInvalidators = new Set();
validationInvalidators.set(fieldName, fieldInvalidators);
}
fieldInvalidators.add(invalidator);
return () => {
invalidator();
fieldInvalidators.delete(invalidator);
if (fieldInvalidators.size === 0) {
validationInvalidators.delete(fieldName);
}
};
}
function invalidateFieldValidation(fieldName: string) {
for (const invalidator of validationInvalidators.get(fieldName) ?? []) {
invalidator();
}
}
const RuntimeField = createRuntimeFieldComponent(
rawForm.Field,
registerValidationInvalidator,
);
function getErrors() {
const result: Record<string, string> = {};
for (const [fieldName, meta] of Object.entries(fieldMeta.value)) {
const error = normalizeFieldMetaError(meta);
if (error) {
result[fieldName] = error;
}
}
for (const [fieldName, error] of manualErrors.value) {
result[fieldName] = error;
}
return result;
}
const errors = computed(getErrors);
const meta = computed(() => ({
dirty: isDirty.value,
submitting: isSubmitting.value,
valid: isValid.value && manualErrors.value.size === 0,
validating: isValidating.value,
}));
const runtimeState = computed<FormRuntimeState<TValues>>(() => ({
errors: errors.value,
meta: meta.value,
values: values.value,
}));
function getFieldError(fieldName: string) {
return (
manualErrors.value.get(fieldName) ??
normalizeFieldMetaError(Reflect.get(fieldMeta.value, fieldName))
);
}
function useFieldError(fieldName: string) {
const schemaError = rawForm.useSelector((formState) =>
normalizeFieldMetaError(Reflect.get(formState.fieldMeta, fieldName)),
);
return computed(
() => manualErrors.value.get(fieldName) ?? schemaError.value,
);
}
function useFieldValue<TFieldName extends FormFieldName<TValues>>(
fieldName: TFieldName,
) {
return rawForm.useSelector(
() =>
rawForm.getFieldValue(fieldName as never) as FormFieldValue<
TValues,
TFieldName
>,
);
}
function useFieldValues<TFieldName extends FormFieldName<TValues>>(
fieldNames: readonly TFieldName[],
) {
const selectedValues = fieldNames.map((fieldName) =>
useFieldValue(fieldName),
);
return computed(() => selectedValues.map((value) => value.value));
}
async function validateField(fieldName: string) {
await rawForm.validateField(fieldName as never, 'submit');
const error = getFieldError(fieldName);
return {
errors: error ? { [fieldName]: error } : {},
valid: !error,
};
}
async function validate() {
await rawForm.validateAllFields('submit');
const errors = getErrors();
return {
errors,
valid: Object.keys(errors).length === 0,
};
}
function setFieldError(fieldName: string, error?: string) {
invalidateFieldValidation(fieldName);
const nextManualErrors = new Map(manualErrors.value);
if (error) {
nextManualErrors.set(fieldName, error);
} else {
nextManualErrors.delete(fieldName);
}
manualErrors.value = nextManualErrors;
if (error || !rawForm.getFieldMeta(fieldName as never)) {
return;
}
rawForm.setFieldMeta(fieldName as never, (meta) => ({
...meta,
errorMap: {},
}));
}
function clearValidation(
fieldNames?: FormFieldName<TValues> | FormFieldName<TValues>[],
) {
let requestedFieldNames: FormFieldName<TValues>[] | undefined;
if (Array.isArray(fieldNames)) {
requestedFieldNames = fieldNames;
} else if (fieldNames) {
requestedFieldNames = [fieldNames];
}
const targetFieldNames = requestedFieldNames ?? [
...new Set([
...validationInvalidators.keys(),
...Object.keys(rawForm.getAllErrors().fields),
...manualErrors.value.keys(),
]),
];
for (const fieldName of targetFieldNames) {
setFieldError(fieldName, undefined);
}
}
async function reset(
resetState?: { values?: Partial<TValues> },
options?: FormResetOptions,
) {
for (const fieldName of validationInvalidators.keys()) {
invalidateFieldValidation(fieldName);
}
manualErrors.value = new Map();
rawForm.reset(resetState?.values as TValues | undefined, options);
}
async function submit() {
await rawForm.handleSubmit();
}
const actions: FormActions<TValues> = {
clearValidation,
get errors() {
return errors.value;
},
fieldComponent: RuntimeField,
get meta() {
return meta.value;
},
get values() {
return values.value;
},
getFieldError,
getFieldValue(fieldName) {
return rawForm.getFieldValue(fieldName as never) as FormFieldValue<
TValues,
typeof fieldName
>;
},
handleSubmit(callback) {
return async (event) => {
event?.preventDefault();
event?.stopPropagation();
const result = await validate();
if (result.valid) {
await callback(values.value);
}
};
},
isFieldValid(fieldName) {
return !getFieldError(fieldName);
},
pushFieldValue(fieldName, value) {
rawForm.pushFieldValue(fieldName as never, value as never);
},
async removeFieldValue(fieldName, index) {
await rawForm.removeFieldValue(fieldName as never, index);
},
reset,
resetForm: reset,
setFieldError,
async setFieldValue(fieldName, value, shouldValidate) {
rawForm.setFieldValue(fieldName as never, value as never, {
dontValidate: !shouldValidate,
});
if (shouldValidate) {
await validateField(fieldName);
}
},
async setValues(values, shouldValidate) {
for (const [fieldName, value] of Object.entries(values)) {
rawForm.setFieldValue(fieldName as never, value as never, {
dontValidate: !shouldValidate,
});
}
if (shouldValidate) {
await validate();
}
},
submit,
submitForm: submit,
useSelector(selector) {
return computed(() => selector(runtimeState.value));
},
useFieldError,
useFieldValue,
useFieldValues,
useValues() {
return values;
},
validate,
validateField,
};
return actions;
}

View File

@@ -0,0 +1,226 @@
import type {
ArrayToStringFields,
BaseFormComponentType,
FieldMappingTime,
FormSchema,
FormSchemaContext,
FormValues,
} from './types';
import { cloneDeep, formatDate, isFunction } from '@vben-core/shared/utils';
import {
deleteValueByFieldName,
getValueByFieldName,
resolveValueFormatFieldName,
setValueByFieldName,
} from './field-name';
import {
getFormArraySchemaChildren,
resolveArrayChildFieldName,
} from './form-render/schema';
type AnyFormSchema<TValues extends FormValues> = FormSchema<
BaseFormComponentType,
Record<string, any>,
TValues
>;
function processFields(
fields: string[],
separator: string,
values: Record<string, any>,
) {
for (const field of fields) {
const value = values[field];
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
values[field] = value.join(separator);
continue;
}
if (typeof value !== 'string') {
continue;
}
if (value === '') {
values[field] = [];
continue;
}
const escapedSeparator = separator.replaceAll(
/[.*+?^${}()|[\]\\]/g,
String.raw`\$&`,
);
values[field] = value.split(new RegExp(escapedSeparator));
}
}
function applyArrayToStringFields(
values: Record<string, any>,
arrayToStringFields?: ArrayToStringFields,
) {
if (!arrayToStringFields || !Array.isArray(arrayToStringFields)) {
return;
}
if (arrayToStringFields.every((item) => typeof item === 'string')) {
const fieldsConfig = arrayToStringFields as string[];
const lastItem = fieldsConfig.at(-1) ?? '';
const hasSeparator = lastItem.length === 1;
const fields = hasSeparator ? fieldsConfig.slice(0, -1) : fieldsConfig;
processFields(fields, hasSeparator ? lastItem : ',', values);
return;
}
for (const fieldConfig of arrayToStringFields) {
if (!Array.isArray(fieldConfig)) {
continue;
}
const [fields, separator = ','] = fieldConfig;
if (!Array.isArray(fields)) {
console.warn(
`Invalid field configuration: fields should be an array of strings, got ${typeof fields}`,
);
continue;
}
processFields(fields, separator, values);
}
}
function applyRangeTimeFields(
values: Record<string, any>,
fieldMappingTime?: FieldMappingTime,
) {
if (!fieldMappingTime || !Array.isArray(fieldMappingTime)) {
return;
}
for (const [
field,
[startTimeKey, endTimeKey],
format = 'YYYY-MM-DD',
] of fieldMappingTime) {
if (startTimeKey && endTimeKey && values[field] === null) {
Reflect.deleteProperty(values, startTimeKey);
Reflect.deleteProperty(values, endTimeKey);
}
if (!values[field]) {
Reflect.deleteProperty(values, field);
continue;
}
const [startTime, endTime] = values[field];
if (format === null) {
values[startTimeKey] = startTime;
values[endTimeKey] = endTime;
} else if (isFunction(format)) {
values[startTimeKey] = format(startTime, startTimeKey);
values[endTimeKey] = format(endTime, endTimeKey);
} else {
const [startTimeFormat, endTimeFormat] = Array.isArray(format)
? format
: [format, format];
values[startTimeKey] = startTime
? formatDate(startTime, startTimeFormat)
: undefined;
values[endTimeKey] = endTime
? formatDate(endTime, endTimeFormat)
: undefined;
}
Reflect.deleteProperty(values, field);
}
}
function applyValueFormatBySchemas<TValues extends FormValues>(
schemas: AnyFormSchema<TValues>[],
values: Record<string, any>,
parentPath?: string,
parentContext?: FormSchemaContext<TValues>,
) {
for (const schema of schemas) {
const fieldName = parentPath
? resolveArrayChildFieldName(parentPath, schema.fieldName)
: schema.fieldName;
const row =
parentPath && parentContext?.rowPath
? getValueByFieldName(values, parentContext.rowPath)
: parentContext?.row;
const schemaContext: FormSchemaContext<TValues> = {
...parentContext,
fieldName,
originalFieldName: schema.fieldName,
rootValues: values as TValues,
row,
};
const children = getFormArraySchemaChildren<AnyFormSchema<TValues>>(schema);
if (children.length > 0) {
const arrayValue = getValueByFieldName(values, fieldName);
if (Array.isArray(arrayValue)) {
arrayValue.forEach((rowValue, index) => {
const rowPath = `${fieldName}[${index}]`;
applyValueFormatBySchemas(children, values, rowPath, {
arrayField: fieldName,
row: rowValue,
rowIndex: index,
rowPath,
});
});
}
}
if (!schema.valueFormat) {
continue;
}
const value = getValueByFieldName(values, fieldName);
deleteValueByFieldName(values, fieldName);
const formattedValue = schema.valueFormat(
value,
(key, nextValue) => {
setValueByFieldName(
values,
resolveValueFormatFieldName(key, parentPath),
nextValue,
);
},
values as TValues,
schemaContext,
);
if (formattedValue !== undefined) {
setValueByFieldName(values, fieldName, formattedValue);
}
}
}
export function applyFormValueFormats<TValues extends FormValues>(
originValues: Record<string, any>,
schemas: AnyFormSchema<TValues>[],
) {
const values = cloneDeep(originValues);
applyValueFormatBySchemas(schemas, values);
return values;
}
export function formatFormValues<TValues extends FormValues>(
originValues: Readonly<Record<string, any>>,
schemas: AnyFormSchema<TValues>[],
fieldMappingTime?: FieldMappingTime,
arrayToStringFields?: ArrayToStringFields,
) {
const values = cloneDeep(originValues);
applyArrayToStringFields(values, arrayToStringFields);
applyRangeTimeFields(values, fieldMappingTime);
applyValueFormatBySchemas(schemas, values);
return values;
}
export function transformRangeTimeValues(
originValues: Record<string, any>,
fieldMappingTime?: FieldMappingTime,
arrayToStringFields?: ArrayToStringFields,
) {
const values = cloneDeep(originValues);
applyArrayToStringFields(values, arrayToStringFields);
applyRangeTimeFields(values, fieldMappingTime);
return values;
}

View File

@@ -3,11 +3,19 @@ export { setupVbenForm } from './config';
export type {
BaseFormComponentType,
ExtendedFormApi,
FormActions,
FormContextApi,
FormLayout,
FormSchemaContext,
FormValues,
VbenFormActionSlotProps,
VbenFormComponent,
VbenFormDefaultSlotProps,
VbenFormFieldArrayProps,
VbenFormFieldSlotProps,
VbenFormProps,
FormSchema as VbenFormSchema,
VbenFormSlots,
} from './types';
export * from './use-vben-form';

View File

@@ -0,0 +1,17 @@
import type { FormRuleValidator } from './types';
const FORM_RULES = new Map<string, FormRuleValidator>();
export function getFormRule(name: string) {
return FORM_RULES.get(name);
}
export function registerFormRules(
rules: Partial<Record<string, FormRuleValidator>>,
) {
for (const [name, validator] of Object.entries(rules)) {
if (validator) {
FORM_RULES.set(name, validator);
}
}
}

View File

@@ -1,5 +1,4 @@
import type { FieldOptions, FormContext, GenericObject } from 'vee-validate';
import type { ZodTypeAny } from 'zod';
import type { ZodType } from 'zod';
import type { Component, HtmlHTMLAttributes, Ref } from 'vue';
@@ -8,6 +7,17 @@ import type { ClassType, MaybeComputedRef } from '@vben-core/typings';
import type { FormApi } from './form-api';
export type FormValues = Record<string, any>;
export type FormFieldName<TValues extends FormValues = FormValues> =
| Extract<keyof TValues, string>
| (Record<never, never> & string);
export type FormFieldValue<
TValues extends FormValues,
TFieldName extends string,
> = TFieldName extends keyof TValues ? TValues[TFieldName] : unknown;
export type FormLayout = 'horizontal' | 'inline' | 'vertical';
export type BaseFormComponentType =
@@ -36,14 +46,12 @@ export type FormItemClassType =
| (Record<never, never> & string)
| WrapperClassType;
export type FormFieldOptions = Partial<
FieldOptions & {
validateOnBlur?: boolean;
validateOnChange?: boolean;
validateOnInput?: boolean;
validateOnModelUpdate?: boolean;
}
>;
export interface FormFieldOptions {
asyncDebounceMs?: number;
validateOn?: readonly FormValidationTrigger[];
}
export type FormValidationTrigger = 'blur' | 'change';
export interface FormShape {
/** 默认值 */
@@ -52,7 +60,33 @@ export interface FormShape {
fieldName: string;
/** 是否必填 */
required?: boolean;
rules?: ZodTypeAny;
rules?: ZodType;
}
export interface FormRuntimeField<TValue = unknown> {
handleBlur: () => void;
handleChange: (value: TValue) => void;
state: {
meta: {
errors: unknown[];
isDirty: boolean;
isTouched: boolean;
isValid: boolean;
};
value: TValue;
};
}
export interface FormComponentField<
TValue = unknown,
TFieldName extends string = string,
> {
modelValue: TValue;
name: TFieldName;
onBlur: () => void;
onChange: (value: TValue) => void;
onInput: (value: TValue) => void;
'onUpdate:modelValue': (value: TValue) => void;
}
export type MaybeComponentPropKey =
@@ -64,9 +98,175 @@ export type MaybeComponentPropKey =
export type MaybeComponentProps = { [K in MaybeComponentPropKey]?: any };
export type FormActions = FormContext<GenericObject>;
export interface FormMeta {
dirty: boolean;
submitting: boolean;
valid: boolean;
validating: boolean;
}
export interface FormSchemaContext {
export interface FormRuntimeState<TValues extends FormValues = FormValues> {
errors: Record<string, string>;
meta: FormMeta;
values: TValues;
}
export interface FormValidationResult {
errors: Record<string, string>;
valid: boolean;
}
export interface FormValueSnapshot<TValues extends FormValues = FormValues> {
rawValues: Readonly<TValues>;
values: TValues;
}
export interface FormResetState<TValues extends FormValues = FormValues> {
values?: Partial<TValues>;
}
export interface FormResetOptions {
force?: boolean;
keepDefaultValues?: boolean;
}
export interface FormContextApi<TValues extends FormValues = FormValues> {
clearValidation: (
fieldNames?: FormFieldName<TValues> | FormFieldName<TValues>[],
) => void;
readonly errors: Record<string, string>;
readonly fieldComponent: Component;
getFieldError: (fieldName: string) => string | undefined;
getFieldValue: <TFieldName extends FormFieldName<TValues>>(
fieldName: TFieldName,
) => FormFieldValue<TValues, TFieldName>;
handleSubmit: (
callback: (values: TValues) => Promise<void> | void,
) => (event?: Event) => Promise<void>;
isFieldValid: (fieldName: string) => boolean;
readonly meta: FormMeta;
pushFieldValue: (fieldName: string, value: any) => void;
removeFieldValue: (fieldName: string, index: number) => Promise<void>;
reset: (
state?: FormResetState<TValues>,
options?: FormResetOptions,
) => Promise<void>;
/** @deprecated Use `reset` instead. */
resetForm: (
state?: FormResetState<TValues>,
options?: FormResetOptions,
) => Promise<void>;
setFieldError: (fieldName: string, error?: string) => void;
setFieldValue: <TFieldName extends FormFieldName<TValues>>(
fieldName: TFieldName,
value: FormFieldValue<TValues, NoInfer<TFieldName>>,
shouldValidate?: boolean,
) => Promise<void>;
setValues: (
values: Partial<TValues>,
shouldValidate?: boolean,
) => Promise<void>;
submit: () => Promise<void>;
/** @deprecated Use `submit` instead. */
submitForm: () => Promise<void>;
useFieldError: (fieldName: string) => Readonly<Ref<string | undefined>>;
useFieldValue: <TFieldName extends FormFieldName<TValues>>(
fieldName: TFieldName,
) => Readonly<Ref<FormFieldValue<TValues, TFieldName>>>;
useFieldValues: <TFieldName extends FormFieldName<TValues>>(
fieldNames: readonly TFieldName[],
) => Readonly<Ref<FormFieldValue<TValues, TFieldName>[]>>;
useSelector: <T>(
selector: (state: FormRuntimeState<TValues>) => T,
) => Readonly<Ref<T>>;
useValues: () => Readonly<Ref<TValues>>;
validate: () => Promise<FormValidationResult>;
validateField: (fieldName: string) => Promise<FormValidationResult>;
readonly values: TValues;
}
/** @deprecated Use `FormContextApi` instead. */
export type FormActions<TValues extends FormValues = FormValues> =
FormContextApi<TValues>;
type ReservedFormSlotName =
| 'default'
| 'expand-after'
| 'expand-before'
| 'reset-before'
| 'submit-before';
type KnownFormFieldName<TValues extends FormValues> =
string extends Extract<keyof TValues, string>
? never
: Exclude<Extract<keyof TValues, string>, ReservedFormSlotName>;
export interface VbenFormActionSlotProps<
TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> {
formApi: ExtendedFormApi<TValues, T, P>;
values: TValues;
}
export interface VbenFormDefaultSlotProps<
TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> extends VbenFormActionSlotProps<TValues, T, P> {
shapes: FormShape[];
}
export interface VbenFormFieldSlotProps<
TValues extends FormValues = FormValues,
TFieldName extends KnownFormFieldName<TValues> = KnownFormFieldName<TValues>,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> extends VbenFormActionSlotProps<TValues, T, P> {
componentField: FormComponentField<TValues[TFieldName], TFieldName>;
disabled: boolean;
field: FormRuntimeField<TValues[TFieldName]>;
isInValid: boolean;
modelValue: TValues[TFieldName];
name: TFieldName;
}
type VbenFormFieldSlots<
TValues extends FormValues,
T extends BaseFormComponentType,
P extends Record<string, any>,
> =
string extends Extract<keyof TValues, string>
? Record<string, ((props: any) => any) | undefined>
: {
[TFieldName in KnownFormFieldName<TValues>]?: (
props: VbenFormFieldSlotProps<TValues, TFieldName, T, P>,
) => any;
};
export type VbenFormSlots<
TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> = VbenFormFieldSlots<TValues, T, P> & {
default?: (props: VbenFormDefaultSlotProps<TValues, T, P>) => any;
'expand-after'?: (props: VbenFormActionSlotProps<TValues, T, P>) => any;
'expand-before'?: (props: VbenFormActionSlotProps<TValues, T, P>) => any;
'reset-before'?: (props: VbenFormActionSlotProps<TValues, T, P>) => any;
'submit-before'?: (props: VbenFormActionSlotProps<TValues, T, P>) => any;
};
export type VbenFormComponent<
TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> = new () => {
$props: VbenFormProps<T, P, TValues>;
$slots: VbenFormSlots<TValues, T, P>;
};
export interface FormSchemaContext<TValues extends FormValues = FormValues> {
/** 数组字段名,例如 contacts */
arrayField?: string;
/** 当前真实字段名,例如 contacts[0].name */
@@ -74,7 +274,7 @@ export interface FormSchemaContext {
/** 原始 schema 字段名,例如 name */
originalFieldName?: string;
/** 表单完整值 */
rootValues?: Record<string, any>;
rootValues?: TValues;
/** 当前行数据 */
row?: Record<string, any>;
/** 当前行索引 */
@@ -86,12 +286,8 @@ export interface FormSchemaContext {
export type CustomRenderType = (() => Component | string) | string;
// 动态渲染参数
export type CustomParamsRenderType =
| ((
value: Partial<Record<string, any>>,
actions: FormActions,
ctx?: FormSchemaContext,
) => Component | string)
export type CustomParamsRenderType<TValues extends FormValues = FormValues> =
| ((ctx: FormSchemaContext<TValues>) => Component | string)
| string;
export type FormSchemaRuleType =
@@ -99,78 +295,138 @@ export type FormSchemaRuleType =
| 'selectRequired'
| null
| (Record<never, never> & string)
| ZodTypeAny;
| ZodType;
type FormItemDependenciesCondition<T = boolean | PromiseLike<boolean>> = (
value: Partial<Record<string, any>>,
actions: FormActions,
controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力
ctx?: FormSchemaContext,
) => T;
type FormItemDependenciesCondition<
TValues extends FormValues,
TResult = boolean | PromiseLike<boolean>,
> = (
value: Partial<TValues>,
actions: FormActions<TValues>,
controller: ExtendedFormApi<TValues>, // 在 dependencies 里提供访问extendApi的能力
ctx?: FormSchemaContext<TValues>,
) => TResult;
type FormItemDependenciesConditionWithRules = (
value: Partial<Record<string, any>>,
actions: FormActions,
controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力
ctx?: FormSchemaContext,
type FormItemDependenciesConditionWithRules<TValues extends FormValues> = (
value: Partial<TValues>,
actions: FormActions<TValues>,
controller: ExtendedFormApi<TValues>, // 在 dependencies 里提供访问extendApi的能力
ctx?: FormSchemaContext<TValues>,
) => FormSchemaRuleType | PromiseLike<FormSchemaRuleType>;
type FormItemDependenciesConditionWithProps = (
value: Partial<Record<string, any>>,
actions: FormActions,
controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力
ctx?: FormSchemaContext,
type FormItemDependenciesConditionWithProps<TValues extends FormValues> = (
value: Partial<TValues>,
actions: FormActions<TValues>,
controller: ExtendedFormApi<TValues>, // 在 dependencies 里提供访问extendApi的能力
ctx?: FormSchemaContext<TValues>,
) => MaybeComponentProps | PromiseLike<MaybeComponentProps>;
export interface FormItemDependencies {
/**
* 组件参数
* @returns 组件参数
*/
componentProps?: FormItemDependenciesConditionWithProps;
/**
* 是否禁用
* @returns 是否禁用
*/
disabled?: boolean | FormItemDependenciesCondition;
/**
* 是否渲染删除dom
* @returns 是否渲染
*/
if?: boolean | FormItemDependenciesCondition;
/**
* 是否必填
* @returns 是否必填
*/
required?: FormItemDependenciesCondition;
/**
* 字段规则
*/
rules?: FormItemDependenciesConditionWithRules;
/**
* 是否隐藏(Css)
* @returns 是否隐藏
*/
show?: boolean | FormItemDependenciesCondition;
/**
* 任意触发都会执行
*/
trigger?: FormItemDependenciesCondition<void>;
interface FormItemDependenciesBase {
/**
* 触发字段
*/
triggerFields: string[];
}
type ComponentProps =
| ((
value: Partial<Record<string, any>>,
actions: FormActions,
ctx?: FormSchemaContext,
) => MaybeComponentProps)
export interface FormDependenciesResolveContext<
TValues extends FormValues = FormValues,
> {
actions: FormActions<TValues>;
controller: ExtendedFormApi<TValues>;
schema: FormSchemaContext<TValues>;
values: Readonly<TValues>;
}
export interface FormDependenciesResolvedState {
componentProps?: MaybeComponentProps;
disabled?: boolean;
help?: CustomRenderType;
if?: boolean;
renderComponentContent?: Record<string, any>;
required?: boolean;
rules?: FormSchemaRuleType;
show?: boolean;
}
export interface FormItemDependenciesLegacy<
TValues extends FormValues = FormValues,
> extends FormItemDependenciesBase {
/**
* 组件参数
* @returns 组件参数
* @deprecated Use `dependencies.resolve` instead.
*/
componentProps?: FormItemDependenciesConditionWithProps<TValues>;
/**
* 是否禁用
* @returns 是否禁用
* @deprecated Use `dependencies.resolve` instead.
*/
disabled?: boolean | FormItemDependenciesCondition<TValues>;
/**
* 是否渲染删除dom
* @returns 是否渲染
* @deprecated Use `dependencies.resolve` instead.
*/
if?: boolean | FormItemDependenciesCondition<TValues>;
/**
* 是否必填
* @returns 是否必填
* @deprecated Use `dependencies.resolve` instead.
*/
required?: FormItemDependenciesCondition<TValues>;
resolve?: never;
/**
* 字段规则
* @deprecated Use `dependencies.resolve` instead.
*/
rules?: FormItemDependenciesConditionWithRules<TValues>;
/**
* 是否隐藏(Css)
* @returns 是否隐藏
* @deprecated Use `dependencies.resolve` instead.
*/
show?: boolean | FormItemDependenciesCondition<TValues>;
/**
* 任意触发都会执行
* @deprecated Use `dependencies.resolve` instead.
*/
trigger?: FormItemDependenciesCondition<TValues, void>;
}
export interface FormItemDependenciesResolve<
TValues extends FormValues = FormValues,
> extends FormItemDependenciesBase {
componentProps?: never;
disabled?: never;
if?: never;
required?: never;
resolve: (
context: FormDependenciesResolveContext<TValues>,
) =>
| FormDependenciesResolvedState
| PromiseLike<FormDependenciesResolvedState | undefined>
| undefined;
rules?: never;
show?: never;
trigger?: never;
}
export type FormItemDependencies<TValues extends FormValues = FormValues> =
| FormItemDependenciesLegacy<TValues>
| FormItemDependenciesResolve<TValues>;
type ComponentProps<TValues extends FormValues = FormValues> =
| ((ctx: FormSchemaContext<TValues>) => MaybeComponentProps)
| MaybeComponentProps;
export interface FormCommonConfig {
export interface FormCommonConfig<TValues extends FormValues = FormValues> {
/**
* 是否启用 change 事件兼容回退。
* 仅当组件不发送 update:*、只发送 change 时启用。
* @default false
*/
changeEventFallback?: boolean;
/**
* 是否可折叠的
* @default false
@@ -183,7 +439,7 @@ export interface FormCommonConfig {
/**
* 所有表单项的props
*/
componentProps?: ComponentProps;
componentProps?: ComponentProps<TValues>;
/**
* 所有表单项的控件样式
*/
@@ -198,16 +454,6 @@ export interface FormCommonConfig {
* @default false
*/
disabled?: boolean;
/**
* 是否禁用所有表单项的change事件监听
* @default true
*/
disabledOnChangeListener?: boolean;
/**
* 是否禁用所有表单项的input事件监听
* @default true
*/
disabledOnInputListener?: boolean;
/**
* 所有表单项的空状态值,默认都是undefinednaive-ui的空状态值是null
*/
@@ -252,18 +498,12 @@ export interface FormCommonConfig {
wrapperClass?: string;
}
type RenderComponentContentType = (
value: Partial<Record<string, any>>,
api: FormActions,
ctx?: FormSchemaContext,
type RenderComponentContentType<TValues extends FormValues = FormValues> = (
ctx: FormSchemaContext<TValues>,
) => Record<string, any>;
type MappedComponentProps<P> =
| ((
value: Partial<Record<string, any>>,
actions: FormActions,
ctx?: FormSchemaContext,
) => P & Record<string, any>)
type MappedComponentProps<P, TValues extends FormValues = FormValues> =
| ((ctx: FormSchemaContext<TValues>) => P & Record<string, any>)
| (P & Record<string, any>);
/**
@@ -273,30 +513,33 @@ type MappedComponentProps<P> =
* - 返回其他值:会将当前字段恢复/写回为该返回值
* - `setValue` 回调签名为 `(key, nextValue) => void`
*/
export type FormValueFormat = (
export type FormValueFormat<TValues extends FormValues = FormValues> = (
value: any,
setValue: (fieldName: string, value: any) => void,
values: Record<string, any>,
ctx?: FormSchemaContext,
values: TValues,
ctx?: FormSchemaContext<TValues>,
) => any;
interface FormSchemaBody extends Omit<FormCommonConfig, 'componentProps'> {
interface FormSchemaBody<TValues extends FormValues = FormValues> extends Omit<
FormCommonConfig<TValues>,
'componentProps'
> {
/** 默认值 */
defaultValue?: any;
/** 依赖 */
dependencies?: FormItemDependencies;
dependencies?: FormItemDependencies<TValues>;
/** 描述 */
description?: CustomRenderType;
/** 字段名 */
fieldName: string;
/** 帮助信息 */
help?: CustomParamsRenderType;
help?: CustomParamsRenderType<TValues>;
/** 是否隐藏表单项 */
hide?: boolean;
/** 表单项 */
label?: CustomRenderType;
// 自定义组件内部渲染
renderComponentContent?: RenderComponentContentType;
renderComponentContent?: RenderComponentContentType<TValues>;
/** 字段规则 */
rules?: FormSchemaRuleType;
/** 后缀 */
@@ -306,54 +549,60 @@ interface FormSchemaBody extends Omit<FormCommonConfig, 'componentProps'> {
* - 返回值不为 `undefined` 时,会回写到当前 fieldName
* - 返回值为 `undefined` 时,可通过 setValue 写入一个或多个目标字段
*/
valueFormat?: FormValueFormat;
valueFormat?: FormValueFormat<TValues>;
}
type FormSchemaDiscriminated<
T extends BaseFormComponentType,
P extends Record<string, any>,
TValues extends FormValues,
> = {
[K in Extract<keyof P, T>]: {
/** 组件 */
component: K;
/** 组件参数 */
componentProps?: MappedComponentProps<P[K]>;
} & FormSchemaBody;
componentProps?: MappedComponentProps<P[K], TValues>;
} & FormSchemaBody<TValues>;
}[Extract<keyof P, T>];
type FormSchemaFallback<T extends BaseFormComponentType> = {
type FormSchemaFallback<
T extends BaseFormComponentType,
TValues extends FormValues,
> = {
/** 组件 */
component: Component | T;
/** 组件参数 */
componentProps?: ComponentProps;
} & FormSchemaBody;
componentProps?: ComponentProps<TValues>;
} & FormSchemaBody<TValues>;
type FormArraySchema<
T extends BaseFormComponentType,
P extends Record<string, any>,
TValues extends FormValues,
> = {
/** 内置数组编辑器参数 */
arrayProps?: Omit<
VbenFormFieldArrayProps<T, P>,
VbenFormFieldArrayProps<T, P, TValues>,
'disabled' | 'globalCommonConfig' | 'name' | 'schema'
>;
/** 数组子字段定义 */
children: FormSchema<T, P>[];
children: FormSchema<T, P, TValues>[];
/** 兼容显式指定内置数组编辑器 */
component?: Component | T;
/** 兼容通过 componentProps 传递数组编辑器参数 */
componentProps?: ComponentProps;
componentProps?: ComponentProps<TValues>;
/** 数组字段标记 */
type: 'array';
} & FormSchemaBody;
} & FormSchemaBody<TValues>;
export type FormSchema<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TValues extends FormValues = FormValues,
> =
| FormArraySchema<T, P>
| FormSchemaDiscriminated<T, P>
| FormSchemaFallback<T>;
| FormArraySchema<T, P, TValues>
| FormSchemaDiscriminated<T, P, TValues>
| FormSchemaFallback<T, TValues>;
/**
* 数组编辑器VbenFormFieldArray的组件参数
@@ -361,20 +610,21 @@ export type FormSchema<
export interface VbenFormFieldArrayProps<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TValues extends FormValues = FormValues,
> {
/** 操作列表头文案 */
actionText?: string;
/** 「添加」按钮文案 */
addButtonText?: string;
/** 子字段通用配置 */
commonConfig?: FormCommonConfig;
commonConfig?: FormCommonConfig<TValues>;
/** 新增一行时生成的默认数据;缺省时按列定义的 fieldName 生成空对象 */
createRow?: () => Record<string, any>;
disabled?: boolean;
/** 空数据文案 */
emptyText?: string;
/** 子字段全局通用配置 */
globalCommonConfig?: FormCommonConfig;
globalCommonConfig?: FormCommonConfig<TValues>;
/** 最多行数 */
max?: number;
/** 最少行数 */
@@ -382,17 +632,18 @@ export interface VbenFormFieldArrayProps<
/** 数组字段路径,由外层 FormField 透传 */
name?: string;
/** 列定义,每一列是一个子字段(复用 FormSchema */
schema?: FormSchema<T, P>[];
schema?: FormSchema<T, P, TValues>[];
/** 是否显示序号列 */
showIndex?: boolean;
}
export type HandleSubmitFn = (
values: Record<string, any>,
export type HandleSubmitFn<TValues extends FormValues = FormValues> = (
values: TValues,
rawValues: Readonly<TValues>,
) => Promise<void> | void;
export type HandleResetFn = (
values: Record<string, any>,
export type HandleResetFn<TValues extends FormValues = FormValues> = (
values: TValues,
) => Promise<void> | void;
export type FieldMappingTimeItem = [
@@ -416,16 +667,18 @@ export type ArrayToStringFields = Array<
export interface FormFieldProps<
T extends BaseFormComponentType = BaseFormComponentType,
> extends FormSchemaBody {
TValues extends FormValues = FormValues,
> extends FormSchemaBody<TValues> {
/** 组件 */
component: Component | T;
/** 组件参数 */
componentProps?: ComponentProps;
componentProps?: ComponentProps<TValues>;
}
export interface FormRenderProps<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TValues extends FormValues = FormValues,
> {
/**
* 表单字段数组映射字符串配置 默认使用","
@@ -449,7 +702,7 @@ export interface FormRenderProps<
/**
* 表单项通用后备配置,当子项目没配置时使用这里的配置,子项目配置优先级高于此配置
*/
commonConfig?: FormCommonConfig;
commonConfig?: FormCommonConfig<TValues>;
/**
* 紧凑模式(移除表单每一项底部为校验信息预留的空间)
*/
@@ -469,7 +722,7 @@ export interface FormRenderProps<
/**
* 表单实例
*/
form?: FormContext<GenericObject>;
form?: FormActions<TValues>;
/**
* 表单项布局
*/
@@ -477,7 +730,7 @@ export interface FormRenderProps<
/**
* 表单定义
*/
schema?: FormSchema<T, P>[];
schema?: FormSchema<T, P, TValues>[];
/**
* 是否显示展开/折叠
@@ -503,8 +756,9 @@ export interface ActionButtonOptions extends VbenButtonProps {
export interface VbenFormProps<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TValues extends FormValues = FormValues,
> extends Omit<
FormRenderProps<T, P>,
FormRenderProps<T, P, TValues>,
'componentBindEventMap' | 'componentMap' | 'form'
> {
/**
@@ -544,17 +798,18 @@ export interface VbenFormProps<
/**
* 表单重置回调
*/
handleReset?: HandleResetFn;
handleReset?: HandleResetFn<TValues>;
/**
* 表单提交回调
*/
handleSubmit?: HandleSubmitFn;
handleSubmit?: HandleSubmitFn<TValues>;
/**
* 表单值变化回调
*/
handleValuesChange?: (
values: Record<string, any>,
values: Readonly<TValues>,
fieldsChanged: string[],
getFormattedValues: () => TValues,
) => void;
/**
@@ -592,10 +847,14 @@ export interface VbenFormProps<
submitOnEnter?: boolean;
}
export type ExtendedFormApi = FormApi & {
useStore: <T = NoInfer<VbenFormProps>>(
selector?: (state: NoInfer<VbenFormProps>) => T,
) => Readonly<Ref<T>>;
export type ExtendedFormApi<
TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> = FormApi<TValues, T, P> & {
useStore: <TResult = NoInfer<VbenFormProps<T, P, TValues>>>(
selector?: (state: NoInfer<VbenFormProps<T, P, TValues>>) => TResult,
) => Readonly<Ref<TResult>>;
};
export interface VbenFormAdapterOptions<
@@ -603,21 +862,31 @@ export interface VbenFormAdapterOptions<
> {
config?: {
baseModelPropName?: string;
disabledOnChangeListener?: boolean;
disabledOnInputListener?: boolean;
/**
* 是否启用 change 事件兼容回退。
* 仅用于只发送 change 的兼容组件。
* @default false
*/
changeEventFallback?: boolean;
emptyStateValue?: null | undefined;
modelPropNameMap?: Partial<Record<T, string>>;
};
defineRules?: {
required?: (
value: any,
params: any,
ctx: Record<string, any>,
) => boolean | string;
selectRequired?: (
value: any,
params: any,
ctx: Record<string, any>,
) => boolean | string;
};
/** @deprecated Use `rules` instead. */
defineRules?: Partial<Record<string, FormRuleValidator>>;
rules?: Partial<Record<string, FormRuleValidator>>;
}
export interface FormRuleContext {
field: {
label?: string;
name: string;
};
label?: string;
name: string;
}
export type FormRuleValidator = (
value: any,
params: any,
context: FormRuleContext,
) => boolean | Promise<boolean | string> | string;

View File

@@ -1,19 +1,22 @@
import type { ZodRawShape } from 'zod';
import type { ZodType } from 'zod';
import type { ComputedRef } from 'vue';
import type { ExtendedFormApi, FormActions, VbenFormProps } from './types';
import { computed, unref, useSlots } from 'vue';
import { computed, toRaw, unref, useSlots } from 'vue';
import { createContext } from '@vben-core/shadcn-ui';
import { isString, mergeWithArrayOverride, set } from '@vben-core/shared/utils';
import { useForm } from 'vee-validate';
import { object, ZodIntersection, ZodNumber, ZodObject, ZodString } from 'zod';
import { getDefaultsForSchema } from 'zod-defaults';
type ExtendFormProps = VbenFormProps & { formApi?: ExtendedFormApi };
import { useFormRuntime } from './form-runtime';
type ExtendFormProps = VbenFormProps & {
formApi?: ExtendedFormApi<any, any, any>;
};
export const [injectFormProps, provideFormProps] =
createContext<[ComputedRef<ExtendFormProps> | ExtendFormProps, FormActions]>(
@@ -29,9 +32,7 @@ export function useFormInitial(
const slots = useSlots();
const initialValues = generateInitialValues();
const form = useForm({
...(Object.keys(initialValues)?.length ? { initialValues } : {}),
});
const form = useFormRuntime(initialValues);
const delegatedSlots = computed(() => {
const resultSlots: string[] = [];
@@ -47,14 +48,15 @@ export function useFormInitial(
function generateInitialValues() {
const initialValues: Record<string, any> = {};
const zodObject: ZodRawShape = {};
const zodObject: Record<string, ZodType> = {};
(unref(props).schema || []).forEach((item) => {
if (Reflect.has(item, 'defaultValue')) {
set(initialValues, item.fieldName, item.defaultValue);
} else if (item.rules && !isString(item.rules)) {
// 检查规则是否适合提取默认值
const customDefaultValue = getCustomDefaultValue(item.rules);
zodObject[item.fieldName] = item.rules;
const rawRules = toRaw(item.rules);
const customDefaultValue = getCustomDefaultValue(rawRules);
zodObject[item.fieldName] = rawRules;
if (customDefaultValue !== undefined) {
initialValues[item.fieldName] = customDefaultValue;
}
@@ -71,6 +73,7 @@ export function useFormInitial(
}
// 自定义默认值提取逻辑
function getCustomDefaultValue(rule: any): any {
rule = toRaw(rule);
if (rule instanceof ZodString) {
return ''; // 默认为空字符串
} else if (rule instanceof ZodNumber) {
@@ -83,20 +86,7 @@ export function useFormInitial(
}
return defaultValues;
} else if (rule instanceof ZodIntersection) {
// 对于交集类型从schema 提取默认值
const leftDefaultValue = getCustomDefaultValue(rule._def.left);
const rightDefaultValue = getCustomDefaultValue(rule._def.right);
// 如果左右两边都能提取默认值,合并它们
if (
typeof leftDefaultValue === 'object' &&
typeof rightDefaultValue === 'object'
) {
return { ...leftDefaultValue, ...rightDefaultValue };
}
// 否则优先使用左边的默认值
return leftDefaultValue ?? rightDefaultValue;
return getDefaultsForSchema(rule);
} else {
return undefined; // 其他类型不提供默认值
}

View File

@@ -1,6 +1,8 @@
import type {
BaseFormComponentType,
ExtendedFormApi,
FormValues,
VbenFormComponent,
VbenFormProps,
} from './types';
@@ -11,14 +13,30 @@ import { useSelector } from '@vben-core/shared/store';
import { FormApi } from './form-api';
import VbenUseForm from './vben-use-form.vue';
type UseVbenFormReturn<
TValues extends FormValues,
T extends BaseFormComponentType,
P extends Record<string, any>,
> = readonly [VbenFormComponent<TValues, T, P>, ExtendedFormApi<TValues, T, P>];
export function useVbenForm<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
>(options: VbenFormProps<T, P>) {
>(options: VbenFormProps<T, P>): UseVbenFormReturn<FormValues, T, P>;
export function useVbenForm<
TValues extends FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
>(options: VbenFormProps<T, P, TValues>): UseVbenFormReturn<TValues, T, P>;
export function useVbenForm(
options: VbenFormProps<any, any, any>,
): UseVbenFormReturn<any, any, any> {
const IS_REACTIVE = isReactive(options);
const api = new FormApi(options as unknown as VbenFormProps);
const extendedApi: ExtendedFormApi = api as never;
extendedApi.useStore = (selector) => {
const api = new FormApi<any, any, any>(options);
const extendedApi = api as ExtendedFormApi<any, any, any>;
extendedApi.useStore = (selector: any) => {
return useSelector(api.store, selector);
};
@@ -47,5 +65,5 @@ export function useVbenForm<
);
}
return [Form, extendedApi] as const;
return [Form, extendedApi] as unknown as UseVbenFormReturn<any, any, any>;
}

View File

@@ -1,12 +1,10 @@
<script setup lang="ts">
import type { Recordable } from '@vben-core/typings';
import type { ExtendedFormApi, VbenFormProps, VbenFormSlots } from './types';
import type { ExtendedFormApi, VbenFormProps } from './types';
import { nextTick, onMounted, watch } from 'vue';
import { nextTick, onMounted, readonly, watch } from 'vue';
import { useForwardPriorityValues } from '@vben-core/composables';
import { cloneDeep, get, isEqual, set } from '@vben-core/shared/utils';
import { get, isEqual } from '@vben-core/shared/utils';
import { useDebounceFn } from '@vueuse/core';
@@ -25,29 +23,39 @@ import {
// 通过 extends 会导致热更新卡死,所以重复写了一遍
interface Props extends VbenFormProps {
formApi?: ExtendedFormApi;
formApi?: ExtendedFormApi<any, any, any>;
}
const props = defineProps<Props>();
defineSlots<
Record<string, ((props: Record<string, any>) => any) | undefined> &
VbenFormSlots<any, any, any>
>();
const state = props.formApi?.useStore?.();
const formApi = props.formApi;
if (!formApi) {
throw new Error('Form api is required in <VbenUseForm />');
}
const state = formApi.useStore();
const forward = useForwardPriorityValues(props, state);
const componentRefMap = new Map<string, unknown>();
const { delegatedSlots, form } = useFormInitial(forward);
const values = form.useValues();
provideFormProps([forward, form]);
provideComponentRefMap(componentRefMap);
props.formApi?.mount?.(form, componentRefMap);
formApi.mount(form, componentRefMap);
const handleUpdateCollapsed = (value: boolean) => {
function handleUpdateCollapsed(value: boolean) {
props.formApi?.setState({ collapsed: value });
// 触发收起展开状态变化回调
forward.value.handleCollapsedChange?.(value);
};
}
function handleKeyDownEnter(event: KeyboardEvent) {
if (!state?.value.submitOnEnter || !forward.value.formApi?.isMounted) {
@@ -60,51 +68,42 @@ function handleKeyDownEnter(event: KeyboardEvent) {
}
event.preventDefault();
forward.value.formApi?.validateAndSubmitForm();
forward.value.formApi?.validateAndSubmit();
}
const handleValuesChangeDebounced = useDebounceFn(async () => {
state?.value.submitOnChange && forward.value.formApi?.validateAndSubmitForm();
state?.value.submitOnChange && forward.value.formApi?.validateAndSubmit();
}, state?.value?.changeDebouncedTime ?? 300);
const valuesCache: Recordable<any> = {};
let valuesChangeReady = false;
onMounted(async () => {
// 只在挂载后开始监听form.values会有一个初始化的过程
await nextTick();
watch(
() => form.values,
async (newVal) => {
if (forward.value.handleValuesChange) {
const fields = state?.value.schema?.map((item) => {
return item.fieldName;
});
valuesChangeReady = true;
});
if (fields && fields.length > 0) {
const changedFields: string[] = [];
fields.forEach((field) => {
const newFieldValue = get(newVal, field);
const oldFieldValue = get(valuesCache, field);
if (!isEqual(newFieldValue, oldFieldValue)) {
changedFields.push(field);
set(valuesCache, field, cloneDeep(newFieldValue));
}
});
if (changedFields.length > 0) {
// 调用handleValuesChange回调传入所有表单值的深拷贝和变更的字段列表
const values = await forward.value.formApi?.getValues();
forward.value.handleValuesChange(
cloneDeep(values ?? {}) as Record<string, any>,
changedFields,
);
}
}
}
handleValuesChangeDebounced();
},
{ deep: true },
);
watch(values, (currentValues, previousValues) => {
if (!valuesChangeReady) {
return;
}
const fields = state?.value.schema?.map((item) => item.fieldName) ?? [];
if (forward.value.handleValuesChange && fields.length > 0) {
const changedFields = fields.filter((field) => {
return !isEqual(
get(currentValues, field),
get(previousValues ?? {}, field),
);
});
if (changedFields.length > 0) {
forward.value.handleValuesChange(
readonly(currentValues),
changedFields,
() => formApi.formatValues(currentValues),
);
}
}
handleValuesChangeDebounced();
});
</script>
@@ -123,26 +122,51 @@ onMounted(async () => {
:key="slotName"
#[slotName]="slotProps"
>
<slot :name="slotName" v-bind="slotProps"></slot>
<slot
:name="slotName"
v-bind="slotProps"
:form-api="formApi"
:values="form.values"
></slot>
</template>
<template #default="slotProps">
<slot v-bind="slotProps">
<slot v-bind="slotProps" :form-api="formApi" :values="form.values">
<FormActions
v-if="forward.showDefaultActions"
:model-value="state?.collapsed"
@update:model-value="handleUpdateCollapsed"
>
<template #reset-before="resetSlotProps">
<slot name="reset-before" v-bind="resetSlotProps"></slot>
<slot
name="reset-before"
v-bind="resetSlotProps"
:form-api="formApi"
:values="form.values"
></slot>
</template>
<template #submit-before="submitSlotProps">
<slot name="submit-before" v-bind="submitSlotProps"></slot>
<slot
name="submit-before"
v-bind="submitSlotProps"
:form-api="formApi"
:values="form.values"
></slot>
</template>
<template #expand-before="expandBeforeSlotProps">
<slot name="expand-before" v-bind="expandBeforeSlotProps"></slot>
<slot
name="expand-before"
v-bind="expandBeforeSlotProps"
:form-api="formApi"
:values="form.values"
></slot>
</template>
<template #expand-after="expandAfterSlotProps">
<slot name="expand-after" v-bind="expandAfterSlotProps"></slot>
<slot
name="expand-after"
v-bind="expandAfterSlotProps"
:form-api="formApi"
:values="form.values"
></slot>
</template>
</FormActions>
</slot>

View File

@@ -46,7 +46,6 @@
"@vueuse/core": "catalog:",
"class-variance-authority": "catalog:",
"reka-ui": "catalog:",
"vee-validate": "catalog:",
"vue": "catalog:"
}
}

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { provide, toRefs } from 'vue';
import { FORM_FIELD_INJECTION_KEY } from './injectionKeys';
const props = withDefaults(
defineProps<{
dirty?: boolean;
error?: string;
name: string;
touched?: boolean;
valid?: boolean;
}>(),
{
dirty: false,
error: undefined,
touched: false,
valid: true,
},
);
const { dirty, error, name, touched, valid } = toRefs(props);
provide(FORM_FIELD_INJECTION_KEY, {
dirty,
error,
name,
touched,
valid,
});
</script>
<template>
<slot></slot>
</template>

View File

@@ -1,27 +1,24 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue';
import { toValue } from 'vue';
import { cn } from '@vben-core/shared/utils';
import { ErrorMessage } from 'vee-validate';
import { useFormField } from './useFormField';
const props = defineProps<{
class?: HTMLAttributes['class'];
}>();
const { name, formMessageId } = useFormField();
const { error, formMessageId } = useFormField();
</script>
<template>
<ErrorMessage
<p
v-if="error"
:id="formMessageId"
data-slot="form-message"
as="p"
:name="toValue(name)"
:class="cn('text-destructive text-sm', props.class)"
/>
>
{{ error }}
</p>
</template>

View File

@@ -1,11 +1,10 @@
export { default as FormControl } from './FormControl.vue';
export { default as FormDescription } from './FormDescription.vue';
export { default as FormField } from './FormField.vue';
export { default as FormItem } from './FormItem.vue';
export { default as FormLabel } from './FormLabel.vue';
export { default as FormMessage } from './FormMessage.vue';
export { FORM_ITEM_INJECTION_KEY } from './injectionKeys';
export {
Form,
Field as FormField,
FieldArray as FormFieldArray,
} from 'vee-validate';
FORM_FIELD_INJECTION_KEY,
FORM_ITEM_INJECTION_KEY,
} from './injectionKeys';

View File

@@ -1,3 +1,13 @@
import type { InjectionKey } from 'vue';
import type { InjectionKey, Ref } from 'vue';
export interface FormFieldContext {
dirty: Readonly<Ref<boolean>>;
error: Readonly<Ref<string | undefined>>;
name: Readonly<Ref<string>>;
touched: Readonly<Ref<boolean>>;
valid: Readonly<Ref<boolean>>;
}
export const FORM_ITEM_INJECTION_KEY = Symbol() as InjectionKey<string>;
export const FORM_FIELD_INJECTION_KEY =
Symbol() as InjectionKey<FormFieldContext>;

View File

@@ -1,23 +1,26 @@
import { computed, inject } from 'vue';
import { FieldContextKey } from 'vee-validate';
import { FORM_ITEM_INJECTION_KEY } from './injectionKeys';
import {
FORM_FIELD_INJECTION_KEY,
FORM_ITEM_INJECTION_KEY,
} from './injectionKeys';
export function useFormField() {
const fieldContext = inject(FieldContextKey);
const fieldContext = inject(FORM_FIELD_INJECTION_KEY);
const fieldItemContext = inject(FORM_ITEM_INJECTION_KEY);
if (!fieldContext)
throw new Error('useFormField should be used within <FormField>');
if (!fieldItemContext)
throw new Error('useFormField should be used within <FormItem>');
const { name, errorMessage: error, meta } = fieldContext;
const { dirty, error, name, touched, valid } = fieldContext;
const id = fieldItemContext;
const fieldState = {
valid: computed(() => meta.valid),
isDirty: computed(() => meta.dirty),
isTouched: computed(() => meta.touched),
valid: computed(() => valid.value),
isDirty: computed(() => dirty.value),
isTouched: computed(() => touched.value),
error,
};

View File

@@ -4,7 +4,6 @@ import type { Recordable } from '@vben/types';
import type { SettingProps } from './types';
import {
Form,
FormControl,
FormDescription,
FormField,
@@ -26,7 +25,7 @@ function handleChange(fieldName: string, value: boolean) {
}
</script>
<template>
<Form class="space-y-8">
<form class="space-y-8">
<div class="space-y-4">
<template v-for="item in formSchema" :key="item.fieldName">
<FormField type="checkbox" :name="item.fieldName">
@@ -49,5 +48,5 @@ function handleChange(fieldName: string, value: boolean) {
</FormField>
</template>
</div>
</Form>
</form>
</template>

View File

@@ -4,7 +4,6 @@ import type { Recordable } from '@vben/types';
import type { SettingProps } from './types';
import {
Form,
FormControl,
FormDescription,
FormField,
@@ -26,7 +25,7 @@ function handleChange(fieldName: string, value: boolean) {
}
</script>
<template>
<Form class="space-y-8">
<form class="space-y-8">
<div class="space-y-4">
<template v-for="item in formSchema" :key="item.fieldName">
<FormField type="checkbox" :name="item.fieldName">
@@ -49,5 +48,5 @@ function handleChange(fieldName: string, value: boolean) {
</FormField>
</template>
</div>
</Form>
</form>
</template>

View File

@@ -41,7 +41,7 @@ const [Form, { resetForm, validate, getValues, getFieldComponentRef }] =
placeholder: $t('ui.widgets.lockScreen.placeholder'),
},
fieldName: 'lockScreenPassword',
formFieldProps: { validateOnBlur: false },
formFieldProps: { validateOn: ['change'] as const },
label: $t('authentication.password'),
rules: z
.string()

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { SelectOption } from '@vben/types';
import { computed, nextTick, onMounted, ref } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { GripVertical } from '@vben/icons';
import { $t } from '@vben/locales';
@@ -62,6 +62,10 @@ function initSortable() {
}
onMounted(initSortable);
onUnmounted(() => {
sortableInstance?.destroy();
sortableInstance = null;
});
function setPosition(key: string, event: Event) {
const value = (event.target as HTMLSelectElement).value as

View File

@@ -131,7 +131,7 @@ const [Form, formApi] = useTableForm({
},
handleReset: async () => {
const prevValues = await formApi.getValues();
await formApi.resetForm();
await formApi.reset();
const formValues = await formApi.getValues();
formApi.setLatestSubmissionValues(formValues);
// 如果值发生了变化submitOnChange会触发刷新。所以只在submitOnChange为false或者值没有发生变化时手动刷新

View File

@@ -1,6 +1,7 @@
import type {
VbenFormProps as FormProps,
VbenFormSchema as FormSchema,
FormValues,
} from '@vben/common-ui';
import type { ComponentPropsMap, ComponentType } from './component';
@@ -21,7 +22,7 @@ async function initSetupVbenForm() {
Upload: 'fileList',
},
},
defineRules: {
rules: {
// 输入项目必填国际化适配
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
@@ -40,9 +41,18 @@ async function initSetupVbenForm() {
});
}
const useVbenForm = useForm<ComponentType, ComponentPropsMap>;
function useVbenForm<TValues extends FormValues = FormValues>(
options: FormProps<ComponentType, ComponentPropsMap, TValues>,
) {
return useForm<TValues, ComponentType, ComponentPropsMap>(options);
}
export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType, ComponentPropsMap>;
export type VbenFormProps = FormProps<ComponentType, ComponentPropsMap>;
export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps<
ComponentType,
ComponentPropsMap,
TValues
>;

View File

@@ -46,7 +46,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { password } = values;
return z
.string({ required_error: $t('authentication.passwordTip') })
.string({ error: $t('authentication.passwordTip') })
.min(1, { message: $t('authentication.passwordTip') })
.refine((value) => value === password, {
message: $t('authentication.confirmPasswordTip'),

View File

@@ -38,7 +38,7 @@ const formSchema = computed((): VbenFormSchema[] => {
rules(values) {
const { newPassword } = values;
return z
.string({ required_error: '请再次输入新密码' })
.string({ error: '请再次输入新密码' })
.min(1, { message: '请再次输入新密码' })
.refine((value) => value === newPassword, {
message: '两次输入的密码不一致',

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { VbenFormSchema } from '#/adapter/form';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { Page } from '@vben/common-ui';
@@ -10,6 +10,18 @@ import { Button, Card, message, Space } from 'antdv-next';
import { useVbenForm, z } from '#/adapter/form';
const submitValues = ref<Record<string, any>>({});
const formattedSubmitValues = computed(() =>
JSON.stringify(submitValues.value, null, 2),
);
const outputClass = [
'bg-muted',
'text-muted-foreground',
'max-h-[420px]',
'overflow-auto',
'rounded-md',
'p-3',
'text-xs',
];
const schema: VbenFormSchema[] = [
{
@@ -62,8 +74,8 @@ const schema: VbenFormSchema[] = [
children: [
{
component: 'Input',
componentProps: (_values, _form, ctx) => ({
placeholder: `${(ctx?.rowIndex ?? 0) + 1} 行姓名`,
componentProps: (ctx) => ({
placeholder: `${(ctx.rowIndex ?? 0) + 1} 行姓名`,
}),
defaultValue: '',
fieldName: 'name',
@@ -147,7 +159,7 @@ const [Form, formApi] = useVbenForm({
});
async function handleSubmit() {
await formApi.validateAndSubmitForm();
await formApi.validateAndSubmit();
}
async function handleGetValues() {
@@ -178,9 +190,7 @@ function handlePatchChildRule() {
</Card>
<Card title="输出">
<pre
class="bg-muted text-muted-foreground max-h-[420px] overflow-auto rounded-md p-3 text-xs"
>{{ JSON.stringify(submitValues, null, 2) }}</pre>
<pre :class="outputClass" v-text="formattedSubmitValues"></pre>
</Card>
</div>
</Page>

View File

@@ -35,7 +35,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
drawerApi.close();
},
onConfirm: async () => {
await formApi.submitForm();
await formApi.submit();
drawerApi.close();
},
onOpenChange(isOpen: boolean) {

View File

@@ -285,11 +285,16 @@ const [BaseForm, baseFormApi] = useVbenForm({
},
{
component: 'DatePicker',
dependencies: {
resolve: ({ values }) => ({
help: () =>
[`这是一个可输出其他字段值的帮助信息${values.rate}`].map((value) =>
h('p', value),
),
}),
triggerFields: ['rate'],
},
fieldName: 'datePicker',
help: (values) =>
[`这是一个可输出其他字段值的帮助信息${values?.rate}`].map((v) =>
h('p', v),
),
label: '日期选择框',
},
{

View File

@@ -22,8 +22,8 @@ const layout = ref<FormLayout>('vertical');
function getNumberValidator(key: string, limit?: [number?, number?]) {
let validator = z.number({
required_error: `${key} 值不能为空`,
invalid_type_error: `${key} 值只能为数字`,
error: (issue) =>
issue.input === undefined ? `${key} 值不能为空` : `${key} 值只能为数字`,
});
if (limit) {
@@ -257,14 +257,14 @@ function handleSetFormValue() {
}
function handleResetFormValue() {
baseFormApi.resetForm(undefined, { force: true });
baseFormApi.reset(undefined, { force: true });
}
async function handleSubmitFormValue() {
const { valid } = await baseFormApi.validate();
if (valid) {
baseFormApi.submitForm();
baseFormApi.submit();
}
}
</script>

View File

@@ -57,7 +57,7 @@ const [Form] = useVbenForm({
{
component: markRaw(TwoFields),
defaultValue: [undefined, ''],
disabledOnChangeListener: false,
changeEventFallback: true,
fieldName: 'field4',
formItemClass: 'col-span-1',
label: '组合字段',

View File

@@ -70,7 +70,7 @@ const [Form, formApi] = useVbenForm({
fieldName: 'field4',
// 界面显示的label
label: '邮箱',
rules: z.string().email('请输入正确的邮箱'),
rules: z.email('请输入正确的邮箱'),
},
{
component: 'InputNumber',
@@ -184,8 +184,7 @@ const [Form, formApi] = useVbenForm({
},
fieldName: 'input-blur',
formFieldProps: {
validateOnChange: false,
validateOnModelUpdate: false,
validateOn: ['blur'],
},
help: 'blur时才会触发校验',
label: 'blur触发',
@@ -235,7 +234,7 @@ function onSubmit(values: Record<string, any>) {
<Card title="基础组件校验示例">
<template #extra>
<Button @click="() => formApi.validate()">校验表单</Button>
<Button class="mx-2" @click="() => formApi.resetValidate()">
<Button class="mx-2" @click="() => formApi.clearValidation()">
清空校验信息
</Button>
</template>

View File

@@ -98,7 +98,7 @@ const [Form, formApi] = useVbenForm({
// 测试 validateAndSubmitForm验证并提交
async function testValidateAndSubmit() {
await formApi.validateAndSubmitForm();
await formApi.validateAndSubmit();
}
// 测试 validate手动验证整个表单
@@ -112,13 +112,13 @@ async function testValidateField() {
}
// 切换滚动功能
function toggleScrollToError() {
formApi.setState({ scrollToFirstError: scrollEnabled.value });
function toggleScrollToError(checked: boolean) {
formApi.setState({ scrollToFirstError: checked });
}
// 填充部分数据测试
async function fillPartialData() {
await formApi.resetForm();
await formApi.reset();
await formApi.setFieldValue('username', '测试用户');
await formApi.setFieldValue('email', 'test@example.com');
}
@@ -153,13 +153,13 @@ async function fillPartialData() {
<h4 class="mb-3 font-medium">验证方法测试</h4>
<div class="flex flex-wrap gap-2">
<Button type="primary" @click="testValidateAndSubmit">
测试 validateAndSubmitForm()
测试 validateAndSubmit()
</Button>
<Button @click="testValidate"> 测试 validate() </Button>
<Button @click="testValidateField"> 测试 validateField() </Button>
</div>
<div class="mt-2 text-xs text-gray-500">
<p> validateAndSubmitForm(): 验证表单并提交</p>
<p> validateAndSubmit(): 验证表单并提交</p>
<p> validate(): 手动验证整个表单</p>
<p> validateField(): 验证单个字段这里测试用户名字段</p>
</div>
@@ -169,7 +169,7 @@ async function fillPartialData() {
<h4 class="mb-3 font-medium">数据填充测试</h4>
<div class="flex flex-wrap gap-2">
<Button @click="fillPartialData"> 填充部分数据 </Button>
<Button @click="() => formApi.resetForm()"> 清空表单 </Button>
<Button @click="() => formApi.reset()"> 清空表单 </Button>
</div>
<div class="mt-2 text-xs text-gray-500">
<p> 填充部分数据后验证会滚动到第一个错误字段</p>

View File

@@ -54,7 +54,7 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
await formApi.validateAndSubmitForm();
await formApi.validateAndSubmit();
// modalApi.close();
},
onOpenChange(isOpen: boolean) {

View File

@@ -28,7 +28,7 @@ const [Form, formApi] = useVbenForm({
});
function resetForm() {
formApi.resetForm();
formApi.reset();
formApi.setValues(formData.value || {});
}

View File

@@ -56,12 +56,13 @@ const schema: VbenFormSchema[] = [
async (value: string) => {
return !(await isMenuNameExists(value, formData.value?.id));
},
(value) => ({
message: $t('ui.formRules.alreadyExists', [
$t('system.menu.menuName'),
value,
]),
}),
{
error: (issue) =>
$t('ui.formRules.alreadyExists', [
$t('system.menu.menuName'),
issue.input,
]),
},
),
},
{
@@ -139,12 +140,13 @@ const schema: VbenFormSchema[] = [
async (value: string) => {
return !(await isMenuPathExists(value, formData.value?.id));
},
(value) => ({
message: $t('ui.formRules.alreadyExists', [
$t('system.menu.path'),
value,
]),
}),
{
error: (issue) =>
$t('ui.formRules.alreadyExists', [
$t('system.menu.path'),
issue.input,
]),
},
),
},
{
@@ -227,7 +229,7 @@ const schema: VbenFormSchema[] = [
},
fieldName: 'linkSrc',
label: $t('system.menu.linkSrc'),
rules: z.string().url($t('ui.formRules.invalidURL')),
rules: z.url($t('ui.formRules.invalidURL')),
},
{
component: 'Input',
@@ -278,18 +280,18 @@ const schema: VbenFormSchema[] = [
},
{
component: 'Input',
componentProps: (values) => {
return {
allowClear: true,
class: 'w-full',
disabled: values.meta?.badgeType !== 'normal',
};
},
dependencies: {
show: (values) => {
return values.type !== 'button';
resolve: ({ values }) => {
return {
componentProps: {
allowClear: true,
class: 'w-full',
disabled: values.meta?.badgeType !== 'normal',
},
show: values.type !== 'button',
};
},
triggerFields: ['type'],
triggerFields: ['meta.badgeType', 'type'],
},
fieldName: 'meta.badge',
label: $t('system.menu.badge'),
@@ -451,7 +453,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
? $t(formData.value.meta.title)
: '';
} else {
formApi.resetForm();
formApi.reset();
titleSuffix.value = '';
}
}

View File

@@ -51,7 +51,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
async onOpenChange(isOpen) {
if (isOpen) {
const data = drawerApi.getData<SystemRoleApi.SystemRole>();
formApi.resetForm();
formApi.reset();
if (data) {
formData.value = data;

View File

@@ -50,7 +50,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
async onOpenChange(isOpen) {
if (isOpen) {
const data = drawerApi.getData<SystemUserApi.SystemUser>();
formApi.resetForm();
formApi.reset();
if (data) {
formData.value = data;

180
pnpm-lock.yaml generated
View File

@@ -75,6 +75,9 @@ catalogs:
'@tailwindcss/vite':
specifier: ^4.3.3
version: 4.3.3
'@tanstack/vue-form':
specifier: ^1.33.2
version: 1.33.2
'@tanstack/vue-query':
specifier: ^5.101.2
version: 5.101.4
@@ -153,9 +156,6 @@ catalogs:
'@typescript-eslint/parser':
specifier: ^8.64.0
version: 8.65.0
'@vee-validate/zod':
specifier: ^4.15.1
version: 4.15.1
'@vite-pwa/vitepress':
specifier: ^1.1.0
version: 1.1.0
@@ -450,9 +450,6 @@ catalogs:
unplugin-vue:
specifier: ^7.2.0
version: 7.2.0
vee-validate:
specifier: ^4.15.1
version: 4.15.1
vite:
specifier: ^8.1.5
version: 8.1.5
@@ -508,11 +505,11 @@ catalogs:
specifier: ^2.1.0
version: 2.1.0
zod:
specifier: ^3.25.76
version: 3.25.76
specifier: ^4.4.3
version: 4.4.3
zod-defaults:
specifier: 0.1.3
version: 0.1.3
specifier: ^0.2.3
version: 0.2.3
overrides:
'@ast-grep/napi': ^0.44.1
@@ -524,6 +521,10 @@ overrides:
importers:
.:
dependencies:
sortablejs:
specifier: 'catalog:'
version: 1.15.7
devDependencies:
'@changesets/changelog-github':
specifier: 'catalog:'
@@ -1399,6 +1400,9 @@ importers:
packages/@core/ui-kit/form-ui:
dependencies:
'@tanstack/vue-form':
specifier: 'catalog:'
version: 1.33.2(vue@3.5.40(typescript@6.0.3))
'@vben-core/composables':
specifier: workspace:*
version: link:../../composables
@@ -1414,25 +1418,22 @@ importers:
'@vben-core/typings':
specifier: workspace:*
version: link:../../base/typings
'@vee-validate/zod':
specifier: 'catalog:'
version: 4.15.1(vue@3.5.40(typescript@6.0.3))(zod@3.25.76)
'@vueuse/core':
specifier: 'catalog:'
version: 14.3.0(vue@3.5.40(typescript@6.0.3))
vee-validate:
specifier: 'catalog:'
version: 4.15.1(vue@3.5.40(typescript@6.0.3))
vue:
specifier: ^3.5.40
version: 3.5.40(typescript@6.0.3)
zod:
specifier: 'catalog:'
version: 3.25.76
version: 4.4.3
zod-defaults:
specifier: 'catalog:'
version: 0.1.3(zod@3.25.76)
version: 0.2.3(zod@4.4.3)
devDependencies:
'@vue/test-utils':
specifier: 'catalog:'
version: 2.4.11(@vue/compiler-dom@3.5.40)(@vue/server-renderer@3.5.40)(vue@3.5.40(typescript@6.0.3))
unplugin-vue:
specifier: 'catalog:'
version: 7.2.0(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.7.0(supports-color@10.2.2))(rolldown@1.2.0)(rollup@4.62.2)(sass-embedded@1.100.0)(sass@1.101.3)(terser@5.49.0)(vue@3.5.40(typescript@6.0.3))(yaml@2.9.0)
@@ -1562,9 +1563,6 @@ importers:
reka-ui:
specifier: 'catalog:'
version: 2.10.1(vue@3.5.40(typescript@6.0.3))
vee-validate:
specifier: 'catalog:'
version: 4.15.1(vue@3.5.40(typescript@6.0.3))
vue:
specifier: ^3.5.40
version: 3.5.40(typescript@6.0.3)
@@ -5183,10 +5181,22 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
'@tanstack/devtools-event-client@0.4.4':
resolution: {integrity: sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==}
engines: {node: '>=18'}
hasBin: true
'@tanstack/form-core@1.33.2':
resolution: {integrity: sha512-F60zJd15bGrXKonc1kpRYnNRNfiES7F+hgvrPMrsZznPLqZtO2DIg76OU6R25kCYkqYQY5xvuKteuWcUsc587A==}
'@tanstack/match-sorter-utils@8.19.4':
resolution: {integrity: sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==}
engines: {node: '>=12'}
'@tanstack/pacer-lite@0.1.1':
resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==}
engines: {node: '>=18'}
'@tanstack/query-core@5.101.4':
resolution: {integrity: sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==}
@@ -5196,6 +5206,11 @@ packages:
'@tanstack/virtual-core@3.17.5':
resolution: {integrity: sha512-AXfBC3sq6PuYSwyxYORqqgHCNjPGAvKJvZuBBJ1klhztWBB5cgqgwsq8+fNfaQJG7/K4xYBja9S90QFn2zmQAg==}
'@tanstack/vue-form@1.33.2':
resolution: {integrity: sha512-yolxEs4XSQu2RhBpM48YgqmmpftR4v28XniH5UjGg9/Z3WOJAv+COSmWwC64ozs2OZIKF+j5ufu1ki62jtP6nA==}
peerDependencies:
vue: ^3.5.40
'@tanstack/vue-query@5.101.4':
resolution: {integrity: sha512-UYjkUZhnWQIFGNb7SdgjCitAftNyYQfOIVUh6vBUQAG4SQKNMSBKeQERFDubpxLAMpIBJTaOrE8fM4c1kZcIGQ==}
peerDependencies:
@@ -5822,11 +5837,6 @@ packages:
peerDependencies:
valibot: ^1.4.0
'@vee-validate/zod@4.15.1':
resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==}
peerDependencies:
zod: ^3.24.0
'@vercel/nft@1.10.2':
resolution: {integrity: sha512-w+WyX5Ulmj4dtTZrxaulqrjaLZHSbnPzx75SJsTNYmotKsqn1JlLnDJa+lz5hn90HJofhl/2MAtw0mCrgM3qYw==}
engines: {node: '>=20'}
@@ -5949,9 +5959,6 @@ packages:
'@vue/devtools-api@6.6.4':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
'@vue/devtools-api@7.7.10':
resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==}
'@vue/devtools-api@8.1.5':
resolution: {integrity: sha512-YJipMVAKe5wT5CWf5kTYCaNV7NMNjFVxJkIkJaJ4W/nCxEBzlZzrOsYKeCymdCrFZmBS/+wTWFoUs3Jf/Q6XSQ==}
@@ -5960,15 +5967,9 @@ packages:
peerDependencies:
vue: ^3.5.40
'@vue/devtools-kit@7.7.10':
resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==}
'@vue/devtools-kit@8.1.5':
resolution: {integrity: sha512-FcSAxsi4eWuXLCB7Rv9lj0aIVHHPNVQ2BazGf4RJTc2JCqb4BQg0hk87ZFhminCfl+mD5OUI0rX2cgyu4kJOGA==}
'@vue/devtools-shared@7.7.10':
resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==}
'@vue/devtools-shared@8.1.5':
resolution: {integrity: sha512-mhT4zcPFhF+Xk1O4BfhhrbXzpmfqY03fS6xGpcllbQG7lDjhQf8pQHcTIhqQIYx1hfwtHmk/6jM96ele0UxPqQ==}
@@ -6787,10 +6788,6 @@ packages:
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
engines: {node: '>=12.13'}
copy-anything@4.0.5:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
core-js-compat@3.49.0:
resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==}
@@ -8238,10 +8235,6 @@ packages:
resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
engines: {node: '>=12.13'}
is-what@5.5.0:
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
engines: {node: '>=18'}
is-windows@1.0.2:
resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
engines: {node: '>=0.10.0'}
@@ -9218,9 +9211,6 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
perfect-debounce@2.1.0:
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
@@ -10021,10 +10011,6 @@ packages:
spawndamnit@3.0.1:
resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==}
speakingurl@14.0.1:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
@@ -10213,10 +10199,6 @@ packages:
stylis@4.4.0:
resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==}
superjson@2.2.6:
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
engines: {node: '>=16'}
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
@@ -10811,11 +10793,6 @@ packages:
peerDependencies:
vue: ^3.5.40
vee-validate@4.15.1:
resolution: {integrity: sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==}
peerDependencies:
vue: ^3.5.40
verkit@0.1.2:
resolution: {integrity: sha512-WqkT8n3hqizuCu71W3bUzf5fjBmkbXcudsehe/NbxA8PgqoKnSOY5K0Ba2ckg1qaRaSpSz7as/n9K1R9JXjQKg==}
engines: {node: '>=18.12.0'}
@@ -11339,13 +11316,10 @@ packages:
resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==}
engines: {node: '>=18'}
zod-defaults@0.1.3:
resolution: {integrity: sha512-Cp4UjuVfXqwaOx8A5LlM0IRBZs8B7AXgF+XOZWF8CjMDhsY/Jki+y7VS4adVtQj9NNSsAPwylUlJp9INuxPEnA==}
zod-defaults@0.2.3:
resolution: {integrity: sha512-7pYkOH1/c+Ril5AZUYtsbhMkehkI8CMqzFZ7YZXfC9SMLRvZuLyonQE7BAIVSNxeTpqTZmW5BLxGSzWMnKNdIw==}
peerDependencies:
zod: ^3.23.8
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod: ^4.1.12
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
@@ -14292,16 +14266,34 @@ snapshots:
tailwindcss: 4.3.3
vite: 8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(less@4.7.0(supports-color@10.2.2))(sass-embedded@1.100.0)(sass@1.101.3)(terser@5.49.0)(yaml@2.9.0)
'@tanstack/devtools-event-client@0.4.4': {}
'@tanstack/form-core@1.33.2':
dependencies:
'@tanstack/devtools-event-client': 0.4.4
'@tanstack/pacer-lite': 0.1.1
'@tanstack/store': 0.11.0
'@tanstack/match-sorter-utils@8.19.4':
dependencies:
remove-accents: 0.5.0
'@tanstack/pacer-lite@0.1.1': {}
'@tanstack/query-core@5.101.4': {}
'@tanstack/store@0.11.0': {}
'@tanstack/virtual-core@3.17.5': {}
'@tanstack/vue-form@1.33.2(vue@3.5.40(typescript@6.0.3))':
dependencies:
'@tanstack/form-core': 1.33.2
'@tanstack/vue-store': 0.11.0(vue@3.5.40(typescript@6.0.3))
vue: 3.5.40(typescript@6.0.3)
transitivePeerDependencies:
- '@vue/composition-api'
'@tanstack/vue-query@5.101.4(vue@3.5.40(typescript@6.0.3))':
dependencies:
'@tanstack/match-sorter-utils': 8.19.4
@@ -14650,7 +14642,7 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.9.5
'@types/node': 26.1.1
'@typescript-eslint/parser@8.65.0(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)':
dependencies:
@@ -14964,14 +14956,6 @@ snapshots:
dependencies:
valibot: 1.4.2(typescript@6.0.3)
'@vee-validate/zod@4.15.1(vue@3.5.40(typescript@6.0.3))(zod@3.25.76)':
dependencies:
type-fest: 4.41.0
vee-validate: 4.15.1(vue@3.5.40(typescript@6.0.3))
zod: 3.25.76
transitivePeerDependencies:
- vue
'@vercel/nft@1.10.2(rollup@4.62.2)(supports-color@10.2.2)':
dependencies:
'@mapbox/node-pre-gyp': 2.0.3(supports-color@10.2.2)
@@ -15184,10 +15168,6 @@ snapshots:
'@vue/devtools-api@6.6.4': {}
'@vue/devtools-api@7.7.10':
dependencies:
'@vue/devtools-kit': 7.7.10
'@vue/devtools-api@8.1.5':
dependencies:
'@vue/devtools-kit': 8.1.5
@@ -15198,16 +15178,6 @@ snapshots:
'@vue/devtools-shared': 8.1.5
vue: 3.5.40(typescript@6.0.3)
'@vue/devtools-kit@7.7.10':
dependencies:
'@vue/devtools-shared': 7.7.10
birpc: 2.9.0
hookable: 5.5.3
mitt: 3.0.1
perfect-debounce: 1.0.0
speakingurl: 14.0.1
superjson: 2.2.6
'@vue/devtools-kit@8.1.5':
dependencies:
'@vue/devtools-shared': 8.1.5
@@ -15215,10 +15185,6 @@ snapshots:
hookable: 5.5.3
perfect-debounce: 2.1.0
'@vue/devtools-shared@7.7.10':
dependencies:
rfdc: 1.4.1
'@vue/devtools-shared@8.1.5': {}
'@vue/language-core@3.3.7':
@@ -15784,7 +15750,7 @@ snapshots:
buffer-image-size@0.6.4:
dependencies:
'@types/node': 25.9.5
'@types/node': 26.1.1
buffer@6.0.3:
dependencies:
@@ -16068,10 +16034,6 @@ snapshots:
dependencies:
is-what: 4.1.16
copy-anything@4.0.5:
dependencies:
is-what: 5.5.0
core-js-compat@3.49.0:
dependencies:
browserslist: 4.28.7
@@ -17302,7 +17264,7 @@ snapshots:
happy-dom@20.11.0:
dependencies:
'@types/node': 25.9.5
'@types/node': 26.1.1
'@types/whatwg-mimetype': 3.0.2
'@types/ws': 8.18.1
buffer-image-size: 0.6.4
@@ -17672,8 +17634,6 @@ snapshots:
is-what@4.1.16: {}
is-what@5.5.0: {}
is-windows@1.0.2: {}
is-wsl@3.1.1:
@@ -18768,8 +18728,6 @@ snapshots:
pathe@2.0.3: {}
perfect-debounce@1.0.0: {}
perfect-debounce@2.1.0: {}
picocolors@1.1.1: {}
@@ -19645,8 +19603,6 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
speakingurl@14.0.1: {}
sprintf-js@1.0.3: {}
stackback@0.0.2: {}
@@ -19901,10 +19857,6 @@ snapshots:
stylis@4.4.0: {}
superjson@2.2.6:
dependencies:
copy-anything: 4.0.5
supports-color@10.2.2: {}
supports-color@7.2.0:
@@ -20483,12 +20435,6 @@ snapshots:
evtd: 0.2.4
vue: 3.5.40(typescript@6.0.3)
vee-validate@4.15.1(vue@3.5.40(typescript@6.0.3)):
dependencies:
'@vue/devtools-api': 7.7.10
type-fest: 4.41.0
vue: 3.5.40(typescript@6.0.3)
verkit@0.1.2: {}
vfile-message@4.0.3:
@@ -21189,11 +21135,9 @@ snapshots:
normalize-path: 3.0.0
readable-stream: 4.7.0
zod-defaults@0.1.3(zod@3.25.76):
zod-defaults@0.2.3(zod@4.4.3):
dependencies:
zod: 3.25.76
zod@3.25.76: {}
zod: 4.4.3
zod@4.4.3: {}

View File

@@ -62,6 +62,7 @@ catalog:
'@stylistic/stylelint-plugin': ^5.2.1
'@tailwindcss/typography': ^0.5.20
'@tailwindcss/vite': ^4.3.3
'@tanstack/vue-form': ^1.33.2
'@tanstack/vue-query': ^5.101.2
'@tanstack/vue-store': ^0.11.0
'@tiptap/core': ^3.28.0
@@ -88,7 +89,6 @@ catalog:
'@types/qs': ^6.15.1
'@types/sortablejs': ^1.15.9
'@typescript-eslint/parser': ^8.64.0
'@vee-validate/zod': ^4.15.1
'@vite-pwa/vitepress': ^1.1.0
'@vitejs/plugin-vue': ^6.0.8
'@vitejs/plugin-vue-jsx': ^5.1.6
@@ -189,7 +189,6 @@ catalog:
unplugin-dts: ^1.0.3
unplugin-element-plus: ^0.11.2
unplugin-vue: ^7.2.0
vee-validate: ^4.15.1
vite: ^8.1.5
vite-plugin-compression: ^0.5.1
vite-plugin-lazy-import: ^1.0.7
@@ -209,8 +208,8 @@ catalog:
vxe-table: ^4.20.5
watermark-js-plus: ^1.6.6
yaml-eslint-parser: ^2.1.0
zod: ^3.25.76
zod-defaults: 0.1.3
zod: ^4.4.3
zod-defaults: ^0.2.3
allowBuilds:
'@parcel/watcher': true