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:
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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: '两次输入的密码不一致',
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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: '两次输入的密码不一致',
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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: '两次输入的密码不一致',
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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: '两次输入的密码不一致',
|
||||
|
||||
@@ -50,7 +50,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
await formApi.validateAndSubmitForm();
|
||||
await formApi.validateAndSubmit();
|
||||
// modalApi.close();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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: '两次输入的密码不一致',
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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.value、componentField.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。
|
||||
|
||||
#### 预定义的校验规则
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
@@ -69,7 +69,7 @@ const [Form] = useVbenForm({
|
||||
fieldName: 'field4',
|
||||
// 界面显示的label
|
||||
label: '邮箱',
|
||||
rules: z.string().email('请输入正确的邮箱'),
|
||||
rules: z.email('请输入正确的邮箱'),
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
242
docs/src/en/guide/in-depth/zod-v4-form-migration.md
Normal file
242
docs/src/en/guide/in-depth/zod-v4-form-migration.md
Normal 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)
|
||||
274
docs/src/guide/in-depth/zod-v4-form-migration.md
Normal file
274
docs/src/guide/in-depth/zod-v4-form-migration.md
Normal 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 helper:default、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)
|
||||
@@ -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:",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
656
packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts
Normal file
656
packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
236
packages/@core/ui-kit/form-ui/__tests__/form-runtime.test.ts
Normal file
236
packages/@core/ui-kit/form-ui/__tests__/form-runtime.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
153
packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts
Normal file
153
packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts
Normal 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']
|
||||
>();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 =
|
||||
|
||||
18
packages/@core/ui-kit/form-ui/src/deprecation.ts
Normal file
18
packages/@core/ui-kit/form-ui/src/deprecation.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
77
packages/@core/ui-kit/form-ui/src/form-runtime-field.ts
Normal file
77
packages/@core/ui-kit/form-ui/src/form-runtime-field.ts
Normal 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,
|
||||
);
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
303
packages/@core/ui-kit/form-ui/src/form-runtime.ts
Normal file
303
packages/@core/ui-kit/form-ui/src/form-runtime.ts
Normal 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;
|
||||
}
|
||||
226
packages/@core/ui-kit/form-ui/src/form-value-transform.ts
Normal file
226
packages/@core/ui-kit/form-ui/src/form-value-transform.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
17
packages/@core/ui-kit/form-ui/src/rule-registry.ts
Normal file
17
packages/@core/ui-kit/form-ui/src/rule-registry.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
/**
|
||||
* 所有表单项的空状态值,默认都是undefined,naive-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;
|
||||
|
||||
@@ -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; // 其他类型不提供默认值
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"@vueuse/core": "catalog:",
|
||||
"class-variance-authority": "catalog:",
|
||||
"reka-ui": "catalog:",
|
||||
"vee-validate": "catalog:",
|
||||
"vue": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
35
packages/@core/ui-kit/shadcn-ui/src/ui/form/FormField.vue
Normal file
35
packages/@core/ui-kit/shadcn-ui/src/ui/form/FormField.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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或者值没有发生变化时,手动刷新
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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: '两次输入的密码不一致',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -35,7 +35,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
||||
drawerApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
await formApi.submitForm();
|
||||
await formApi.submit();
|
||||
drawerApi.close();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
|
||||
@@ -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: '日期选择框',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -57,7 +57,7 @@ const [Form] = useVbenForm({
|
||||
{
|
||||
component: markRaw(TwoFields),
|
||||
defaultValue: [undefined, ''],
|
||||
disabledOnChangeListener: false,
|
||||
changeEventFallback: true,
|
||||
fieldName: 'field4',
|
||||
formItemClass: 'col-span-1',
|
||||
label: '组合字段',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -54,7 +54,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
await formApi.validateAndSubmitForm();
|
||||
await formApi.validateAndSubmit();
|
||||
// modalApi.close();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
|
||||
@@ -28,7 +28,7 @@ const [Form, formApi] = useVbenForm({
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
formApi.resetForm();
|
||||
formApi.reset();
|
||||
formApi.setValues(formData.value || {});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
180
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user