diff --git a/apps/web-antd/src/adapter/form.ts b/apps/web-antd/src/adapter/form.ts index b14015a5..c939b726 100644 --- a/apps/web-antd/src/adapter/form.ts +++ b/apps/web-antd/src/adapter/form.ts @@ -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; +function useVbenForm( + options: FormProps, +) { + return useForm(options); +} export { initSetupVbenForm, useVbenForm, z }; -export type VbenFormSchema = FormSchema; -export type VbenFormProps = FormProps; +export type VbenFormSchema = + FormSchema; +export type VbenFormProps = FormProps< + ComponentType, + ComponentPropsMap, + TValues +>; diff --git a/apps/web-antd/src/views/_core/authentication/register.vue b/apps/web-antd/src/views/_core/authentication/register.vue index 8c429531..a1175357 100644 --- a/apps/web-antd/src/views/_core/authentication/register.vue +++ b/apps/web-antd/src/views/_core/authentication/register.vue @@ -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'), diff --git a/apps/web-antd/src/views/_core/profile/password-setting.vue b/apps/web-antd/src/views/_core/profile/password-setting.vue index e5609c0b..2f3527d4 100644 --- a/apps/web-antd/src/views/_core/profile/password-setting.vue +++ b/apps/web-antd/src/views/_core/profile/password-setting.vue @@ -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: '两次输入的密码不一致', diff --git a/apps/web-antdv-next/src/adapter/form.ts b/apps/web-antdv-next/src/adapter/form.ts index 3d062941..5ff63809 100644 --- a/apps/web-antdv-next/src/adapter/form.ts +++ b/apps/web-antdv-next/src/adapter/form.ts @@ -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; +function useVbenForm( + options: FormProps, +) { + return useForm(options); +} export { initSetupVbenForm, useVbenForm, z }; -export type VbenFormSchema = FormSchema; -export type VbenFormProps = FormProps; +export type VbenFormSchema = + FormSchema; +export type VbenFormProps = FormProps< + ComponentType, + ComponentPropsMap, + TValues +>; diff --git a/apps/web-antdv-next/src/views/_core/authentication/register.vue b/apps/web-antdv-next/src/views/_core/authentication/register.vue index 8c429531..a1175357 100644 --- a/apps/web-antdv-next/src/views/_core/authentication/register.vue +++ b/apps/web-antdv-next/src/views/_core/authentication/register.vue @@ -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'), diff --git a/apps/web-antdv-next/src/views/_core/profile/password-setting.vue b/apps/web-antdv-next/src/views/_core/profile/password-setting.vue index adb065a2..a79d083a 100644 --- a/apps/web-antdv-next/src/views/_core/profile/password-setting.vue +++ b/apps/web-antdv-next/src/views/_core/profile/password-setting.vue @@ -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: '两次输入的密码不一致', diff --git a/apps/web-ele/src/adapter/form.ts b/apps/web-ele/src/adapter/form.ts index 0da7d067..8c50f4a2 100644 --- a/apps/web-ele/src/adapter/form.ts +++ b/apps/web-ele/src/adapter/form.ts @@ -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; +function useVbenForm( + options: FormProps, +) { + return useForm(options); +} export { initSetupVbenForm, useVbenForm, z }; -export type VbenFormSchema = FormSchema; -export type VbenFormProps = FormProps; +export type VbenFormSchema = + FormSchema; +export type VbenFormProps = FormProps< + ComponentType, + ComponentPropsMap, + TValues +>; diff --git a/apps/web-ele/src/views/_core/authentication/register.vue b/apps/web-ele/src/views/_core/authentication/register.vue index 8c429531..a1175357 100644 --- a/apps/web-ele/src/views/_core/authentication/register.vue +++ b/apps/web-ele/src/views/_core/authentication/register.vue @@ -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'), diff --git a/apps/web-ele/src/views/_core/profile/password-setting.vue b/apps/web-ele/src/views/_core/profile/password-setting.vue index a0e8c7eb..61b0ebfc 100644 --- a/apps/web-ele/src/views/_core/profile/password-setting.vue +++ b/apps/web-ele/src/views/_core/profile/password-setting.vue @@ -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: '两次输入的密码不一致', diff --git a/apps/web-naive/src/adapter/form.ts b/apps/web-naive/src/adapter/form.ts index 1e58a1af..d2b97322 100644 --- a/apps/web-naive/src/adapter/form.ts +++ b/apps/web-naive/src/adapter/form.ts @@ -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; +function useVbenForm( + options: FormProps, +) { + return useForm(options); +} export { initSetupVbenForm, useVbenForm, z }; -export type VbenFormSchema = FormSchema; -export type VbenFormProps = FormProps; +export type VbenFormSchema = + FormSchema; +export type VbenFormProps = FormProps< + ComponentType, + ComponentPropsMap, + TValues +>; diff --git a/apps/web-naive/src/views/_core/authentication/register.vue b/apps/web-naive/src/views/_core/authentication/register.vue index fac32700..ed71f13b 100644 --- a/apps/web-naive/src/views/_core/authentication/register.vue +++ b/apps/web-naive/src/views/_core/authentication/register.vue @@ -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'), diff --git a/apps/web-naive/src/views/_core/profile/password-setting.vue b/apps/web-naive/src/views/_core/profile/password-setting.vue index 9857dc89..bacc0c8b 100644 --- a/apps/web-naive/src/views/_core/profile/password-setting.vue +++ b/apps/web-naive/src/views/_core/profile/password-setting.vue @@ -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: '两次输入的密码不一致', diff --git a/apps/web-naive/src/views/demos/form/modal.vue b/apps/web-naive/src/views/demos/form/modal.vue index 52e23542..85c7a7b1 100644 --- a/apps/web-naive/src/views/demos/form/modal.vue +++ b/apps/web-naive/src/views/demos/form/modal.vue @@ -50,7 +50,7 @@ const [Modal, modalApi] = useVbenModal({ modalApi.close(); }, onConfirm: async () => { - await formApi.validateAndSubmitForm(); + await formApi.validateAndSubmit(); // modalApi.close(); }, onOpenChange(isOpen: boolean) { diff --git a/apps/web-naive/src/views/demos/naive/array-form/index.vue b/apps/web-naive/src/views/demos/naive/array-form/index.vue index f08551e9..b715f880 100644 --- a/apps/web-naive/src/views/demos/naive/array-form/index.vue +++ b/apps/web-naive/src/views/demos/naive/array-form/index.vue @@ -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() {
diff --git a/apps/web-tdesign/src/adapter/form.ts b/apps/web-tdesign/src/adapter/form.ts index 5c1e2087..37002dff 100644 --- a/apps/web-tdesign/src/adapter/form.ts +++ b/apps/web-tdesign/src/adapter/form.ts @@ -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; +function useVbenForm( + options: FormProps, +) { + return useForm(options); +} export { initSetupVbenForm, useVbenForm, z }; -export type VbenFormSchema = FormSchema; -export type VbenFormProps = FormProps; +export type VbenFormSchema = + FormSchema; +export type VbenFormProps = FormProps< + ComponentType, + ComponentPropsMap, + TValues +>; diff --git a/apps/web-tdesign/src/views/_core/authentication/register.vue b/apps/web-tdesign/src/views/_core/authentication/register.vue index 4489bcd1..6805d558 100644 --- a/apps/web-tdesign/src/views/_core/authentication/register.vue +++ b/apps/web-tdesign/src/views/_core/authentication/register.vue @@ -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'), diff --git a/apps/web-tdesign/src/views/_core/profile/password-setting.vue b/apps/web-tdesign/src/views/_core/profile/password-setting.vue index 1db11945..eda3f3d4 100644 --- a/apps/web-tdesign/src/views/_core/profile/password-setting.vue +++ b/apps/web-tdesign/src/views/_core/profile/password-setting.vue @@ -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: '两次输入的密码不一致', diff --git a/docs/src/_env/adapter/form.ts b/docs/src/_env/adapter/form.ts index 7ebd4aca..a72db5cc 100644 --- a/docs/src/_env/adapter/form.ts +++ b/docs/src/_env/adapter/form.ts @@ -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({ 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({ }, }); -const useVbenForm = useForm; +function useVbenForm( + options: FormProps, TValues>, +) { + return useForm>(options); +} export { useVbenForm, z }; -export type VbenFormSchema = FormSchema; -export type { VbenFormProps }; +export type VbenFormSchema = + FormSchema, TValues>; +export type VbenFormProps = FormProps< + ComponentType, + Record, + TValues +>; diff --git a/docs/src/components/common-ui/vben-form.md b/docs/src/components/common-ui/vben-form.md index f99c543c..c9bfa382 100644 --- a/docs/src/components/common-ui/vben-form.md +++ b/docs/src/components/common-ui/vben-form.md @@ -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({ 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({ Upload: 'fileList', }, }, - defineRules: { + rules: { // 输入项目必填国际化适配 required: (value, _params, ctx) => { if (value === undefined || value === null || value.length === 0) { @@ -70,11 +75,20 @@ setupVbenForm({ }, }); -const useVbenForm = useForm; +function useVbenForm( + options: FormProps, TValues>, +) { + return useForm>(options); +} export { useVbenForm, z }; -export type VbenFormSchema = FormSchema; -export type { VbenFormProps }; +export type VbenFormSchema = + FormSchema, TValues>; +export type VbenFormProps = FormProps< + ComponentType, + Record, + TValues +>; ``` ::: @@ -252,6 +266,8 @@ export { initComponentAdapter }; _注意_ 需要指定 `dependencies` 的 `triggerFields` 属性,设置由谁的改动来触发,以便表单组件能够正确的联动。 +新代码推荐使用 `dependencies.resolve(context)` 一次返回完整动态状态。它只在 `triggerFields` 变化时执行,并原子更新 `if`、`show`、`disabled`、`required`、`rules`、`componentProps`、`help` 和 `renderComponentContent`,避免多个异步回调产生中间状态。原有多回调结构继续兼容。 + ## 自定义组件 @@ -287,29 +303,105 @@ const [Form, formApi] = useVbenForm({ ``` +### 类型传递与插槽 + +通过 `useVbenForm` 定义一次表单值类型后,`getValues`、`setValues`、`setFieldValue`、`handleSubmit`、`handleValuesChange`、`formApi.form.values` 和 selector 都会沿用该类型,可直接作为 API 请求参数: + +```vue + + + +``` + +字段命名插槽提供 `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>` | - | -| validateAndSubmitForm | 提交并校验表单 | `(e:Event)=>Promise>` | - | -| resetForm | 重置表单 | `()=>Promise` | - | -| setValues | 设置表单值, 默认会过滤不在schema中定义的field, 可通过filterFields形参关闭过滤 | `(fields: Record, filterFields?: boolean, shouldValidate?: boolean) => Promise` | - | -| getValues | 获取表单值 | `(fields:Record,shouldValidate: boolean = false)=>Promise` | - | -| validate | 表单校验 | `()=>Promise` | - | -| validateField | 校验指定字段 | `(fieldName: string)=>Promise>` | - | +| submit | 提交表单 | `(e?: Event) => Promise` | - | +| validateAndSubmit | 校验通过后提交表单 | `() => Promise` | - | +| reset | 重置表单 | `(state?: FormResetState, options?: FormResetOptions) => Promise` | - | +| clearValidation | 清空指定字段或全部校验,并取消进行中的异步校验 | `(fieldNames?: FormFieldName \| FormFieldName[]) => Promise` | - | +| setValues | 设置表单值,默认会过滤不在 schema 中定义的字段 | `(fields: Partial, filterFields?: boolean, shouldValidate?: boolean) => Promise` | - | +| getValues | 获取经过字段映射和 valueFormat 的值 | `() => Promise` | - | +| getRawValues | 获取未格式化的独立值快照 | `() => Promise` | - | +| getValueSnapshot | 一次获取原始值和格式化值 | `() => Promise>` | - | +| formatValues | 格式化指定的原始值快照 | `(rawValues: Readonly) => TValues` | - | +| validate | 表单校验 | `() => Promise` | - | +| validateField | 校验指定字段 | `(fieldName: string) => Promise` | - | | isFieldValid | 检查某个字段是否已通过校验 | `(fieldName: string)=>Promise` | - | -| resetValidate | 重置表单校验 | `()=>Promise` | - | | updateSchema | 更新formSchema | `(schema:FormSchema[])=>void` | - | | setFieldValue | 设置字段值 | `(field: string, value: any, shouldValidate?: boolean)=>Promise` | - | | setState | 设置组件状态(props) | `(stateOrFn:\| ((prev: VbenFormProps) => Partial)\| Partial)=>Promise` | - | | getState | 获取组件状态(props) | `()=>Promise` | - | -| form | 表单对象实例,可以操作表单,见 [useForm](https://vee-validate.logaretm.com/v4/api/use-form/) | - | - | +| form | 稳定的 `FormContextApi`,提供 values、errors、set/reset/validate/submit 与数组字段操作,不暴露底层 TanStack 泛型 | `FormContextApi` | - | | getFieldComponentRef | 获取指定字段的组件实例 | `(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>` | 订阅一个字段值。 | +| `useFieldValues(fieldNames)` | `Readonly>` | 订阅一组声明字段值。 | +| `useFieldError(fieldName)` | `Readonly>` | 订阅一个字段错误。 | +| `useValues()` | `Readonly>` | 订阅整份表单值。 | +| `useSelector(selector)` | `Readonly>` | 兼容入口,可从 `{ 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,) => Promise \| void` | - | -| handleSubmit | 表单提交回调 | `(values: Record,) => Promise \| void` | - | -| handleValuesChange | 表单值变化回调 | `(values: Record, fieldsChanged: string[]) => void` | - | +| handleSubmit | 表单提交回调 | `(values: TValues, rawValues: Readonly) => Promise \| void` | - | +| handleValuesChange | 表单值变化回调 | `(rawValues: Readonly, 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; + 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) => MaybeComponentProps); /** 默认值 */ defaultValue?: any; /** 依赖 */ @@ -489,13 +591,15 @@ export interface FormSchema< /** 字段名,也作为自定义插槽的名称 */ fieldName: string; /** 帮助信息 */ - help?: CustomRenderType; + help?: string | ((ctx: FormSchemaContext) => Component | string); /** 是否隐藏表单项 */ hide?: boolean; /** 表单的标签(如果是一个string,会用于默认必选规则的消息提示) */ label?: CustomRenderType; /** 自定义组件内部渲染 */ - renderComponentContent?: RenderComponentContentType; + renderComponentContent?: ( + ctx: FormSchemaContext, + ) => Record; /** 字段规则 */ 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。 #### 预定义的校验规则 diff --git a/docs/src/demos/vben-form/dynamic/index.vue b/docs/src/demos/vben-form/dynamic/index.vue index 7764a2da..b300d5fb 100644 --- a/docs/src/demos/vben-form/dynamic/index.vue +++ b/docs/src/demos/vben-form/dynamic/index.vue @@ -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'], }, diff --git a/docs/src/demos/vben-form/rules/index.vue b/docs/src/demos/vben-form/rules/index.vue index 93d7f8e1..7edf7d16 100644 --- a/docs/src/demos/vben-form/rules/index.vue +++ b/docs/src/demos/vben-form/rules/index.vue @@ -69,7 +69,7 @@ const [Form] = useVbenForm({ fieldName: 'field4', // 界面显示的label label: '邮箱', - rules: z.string().email('请输入正确的邮箱'), + rules: z.email('请输入正确的邮箱'), }, { component: 'InputNumber', diff --git a/docs/src/en/components/common-ui/vben-form.md b/docs/src/en/components/common-ui/vben-form.md index aa8d0589..79ce724d 100644 --- a/docs/src/en/components/common-ui/vben-form.md +++ b/docs/src/en/components/common-ui/vben-form.md @@ -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({ 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({ }, }); -const useVbenForm = useForm; +function useVbenForm( + options: FormProps, TValues>, +) { + return useForm>(options); +} export { useVbenForm, z }; -export type VbenFormSchema = FormSchema; -export type { VbenFormProps }; +export type VbenFormSchema = + FormSchema, TValues>; +export type VbenFormProps = FormProps< + ComponentType, + Record, + TValues +>; ``` ### Component Adapter Example @@ -191,6 +205,55 @@ Create the form through `useVbenForm`: +## Typed Values and Slots + +Declare the value shape once with `useVbenForm`. The same type flows through value APIs, callbacks, selectors, and field/default/action slots: + +```vue + + + +``` + +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` 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 diff --git a/docs/src/en/guide/in-depth/zod-v4-form-migration.md b/docs/src/en/guide/in-depth/zod-v4-form-migration.md new file mode 100644 index 00000000..9928201c --- /dev/null +++ b/docs/src/en/guide/in-depth/zod-v4-form-migration.md @@ -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({ + 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) diff --git a/docs/src/guide/in-depth/zod-v4-form-migration.md b/docs/src/guide/in-depth/zod-v4-form-migration.md new file mode 100644 index 00000000..8f53e6ce --- /dev/null +++ b/docs/src/guide/in-depth/zod-v4-form-migration.md @@ -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({ + 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) diff --git a/package.json b/package.json index 3ecf8b37..7496acf9 100644 --- a/package.json +++ b/package.json @@ -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:", diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts b/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts index 50ae2b75..cae8a8ac 100644 --- a/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts +++ b/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts @@ -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', () => { diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-compatibility.test.ts b/packages/@core/ui-kit/form-ui/__tests__/form-compatibility.test.ts new file mode 100644 index 00000000..0157800e --- /dev/null +++ b/packages/@core/ui-kit/form-ui/__tests__/form-compatibility.test.ts @@ -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.', + ); + }); +}); diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts b/packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts new file mode 100644 index 00000000..2c435359 --- /dev/null +++ b/packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts @@ -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() { + let resolvePromise: (value: T) => void = () => {}; + const promise = new Promise((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) => { + 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 }) => { + 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(); + const optionalRules = createDeferred(); + 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) => ({ + 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((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(); + }); +}); diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-runtime.test.ts b/packages/@core/ui-kit/form-ui/__tests__/form-runtime.test.ts new file mode 100644 index 00000000..e32c9f33 --- /dev/null +++ b/packages/@core/ui-kit/form-ui/__tests__/form-runtime.test.ts @@ -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[] = []; + +function mountRuntime( + defaultValues: Record, + validator?: (input: { value: any }) => Promise, +) { + 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) => + 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((resolve) => { + notifyValidationStarted = resolve; + }); + const validator = vi.fn(() => { + notifyValidationStarted?.(); + return new Promise((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' }); + }); +}); diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts b/packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts new file mode 100644 index 00000000..d1945a22 --- /dev/null +++ b/packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts @@ -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().toEqualTypeOf(); + expectTypeOf() + .parameter(0) + .toMatchTypeOf(); + expectTypeOf< + FormActions['validate'] + >().returns.resolves.toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf<[]>(); + expectTypeOf>().toEqualTypeOf< + [fieldName: string] + >(); + }); + + it('accepts both new and deprecated rule registration options', () => { + expectTypeOf().toMatchTypeOf<{ + defineRules?: Record; + rules?: Record; + }>(); + }); + + it('supports resolve and legacy dependency contracts', () => { + const resolveDependencies: FormItemDependencies = { + resolve({ actions, controller, schema, values }) { + expectTypeOf(values).toEqualTypeOf>(); + expectTypeOf(actions).toEqualTypeOf>(); + expectTypeOf(controller).toEqualTypeOf< + ExtendedFormApi + >(); + expectTypeOf(schema.fieldName).toEqualTypeOf(); + return { disabled: !values.email, rules: null }; + }, + triggerFields: ['email'], + }; + const legacyDependencies: FormItemDependencies = { + show(values) { + expectTypeOf(values).toEqualTypeOf>(); + return Boolean(values.email); + }, + triggerFields: ['email'], + }; + const fieldOptions: FormFieldOptions = { + asyncDebounceMs: 200, + validateOn: ['blur', 'change'], + }; + + expectTypeOf(resolveDependencies).toMatchTypeOf< + FormItemDependencies + >(); + expectTypeOf(legacyDependencies).toMatchTypeOf< + FormItemDependencies + >(); + expectTypeOf(fieldOptions).toMatchTypeOf(); + }); + + it('propagates form value types through public APIs and callbacks', () => { + const options: VbenFormProps< + BaseFormComponentType, + Record, + AccountFormValues + > = { + handleSubmit(values, rawValues) { + expectTypeOf(values).toEqualTypeOf(); + expectTypeOf(rawValues).toEqualTypeOf>(); + }, + handleValuesChange(values, _fieldsChanged, getFormattedValues) { + expectTypeOf(values).toEqualTypeOf>(); + expectTypeOf(getFormattedValues()).toEqualTypeOf(); + }, + schema: [], + }; + const [Form, formApi] = useVbenForm(options); + + expectTypeOf(formApi).toEqualTypeOf>(); + + function assertContextApi( + contextApi: FormContextApi, + typedFormApi: ExtendedFormApi, + ) { + expectTypeOf( + typedFormApi.getValues(), + ).resolves.toEqualTypeOf(); + expectTypeOf( + typedFormApi.getRawValues(), + ).resolves.toEqualTypeOf(); + expectTypeOf(typedFormApi.getValueSnapshot()).resolves.toEqualTypeOf<{ + rawValues: Readonly; + values: AccountFormValues; + }>(); + expectTypeOf(typedFormApi.setValues) + .parameter(0) + .toEqualTypeOf>(); + expectTypeOf(typedFormApi.form.values).toEqualTypeOf(); + expectTypeOf(contextApi.getFieldValue('email')).toEqualTypeOf(); + expectTypeOf( + contextApi.useSelector((state) => state.values.profile.nickname), + ).toEqualTypeOf>>(); + } + + expectTypeOf(assertContextApi).toBeFunction(); + + type FormSlots = InstanceType['$slots']; + type EmailSlot = NonNullable; + type EmailSlotProps = Parameters[0]; + type DefaultSlot = NonNullable; + type DefaultSlotProps = Parameters[0]; + + expectTypeOf< + EmailSlotProps['field']['state']['value'] + >().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + ExtendedFormApi + >(); + expectTypeOf< + DefaultSlotProps['values'] + >().toEqualTypeOf(); + }); + + it('exposes canonical names alongside deprecated aliases', () => { + expectTypeOf().toEqualTypeOf< + FormContextApi['resetForm'] + >(); + expectTypeOf().toEqualTypeOf< + FormContextApi['submitForm'] + >(); + }); +}); diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-value-transform.test.ts b/packages/@core/ui-kit/form-ui/__tests__/form-value-transform.test.ts new file mode 100644 index 00000000..463a19e2 --- /dev/null +++ b/packages/@core/ui-kit/form-ui/__tests__/form-value-transform.test.ts @@ -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', + }); + }); +}); diff --git a/packages/@core/ui-kit/form-ui/__tests__/zod-v4-schema.test.ts b/packages/@core/ui-kit/form-ui/__tests__/zod-v4-schema.test.ts new file mode 100644 index 00000000..133bf57e --- /dev/null +++ b/packages/@core/ui-kit/form-ui/__tests__/zod-v4-schema.test.ts @@ -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); + }); +}); diff --git a/packages/@core/ui-kit/form-ui/package.json b/packages/@core/ui-kit/form-ui/package.json index 8becac7f..87e5b46b 100644 --- a/packages/@core/ui-kit/form-ui/package.json +++ b/packages/@core/ui-kit/form-ui/package.json @@ -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:" } } diff --git a/packages/@core/ui-kit/form-ui/src/components/form-actions.vue b/packages/@core/ui-kit/form-ui/src/components/form-actions.vue index d09e1a60..1fd5e748 100644 --- a/packages/@core/ui-kit/form-ui/src/components/form-actions.vue +++ b/packages/@core/ui-kit/form-ui/src/components/form-actions.vue @@ -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(); } } diff --git a/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue b/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue index f2b2da4d..963e547a 100644 --- a/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue +++ b/packages/@core/ui-kit/form-ui/src/components/form-field-array.vue @@ -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 '); +} +const formActions = form; +const arrayValue = formActions.useFieldValue(props.name); +const rowKeys = new WeakMap(); +let nextRowKey = 0; -const { fields, push, remove } = useFieldArray>( - () => arrayPath.value, -); +const fields = computed[]>(() => { + 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 { const row: Record = {}; 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, 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) {
diff --git a/packages/@core/ui-kit/form-ui/src/config.ts b/packages/@core/ui-kit/form-ui/src/config.ts index 9448d2ac..350fe319 100644 --- a/packages/@core/ui-kit/form-ui/src/config.ts +++ b/packages/@core/ui-kit/form-ui/src/config.ts @@ -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) { - 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 = diff --git a/packages/@core/ui-kit/form-ui/src/deprecation.ts b/packages/@core/ui-kit/form-ui/src/deprecation.ts new file mode 100644 index 00000000..ed848ceb --- /dev/null +++ b/packages/@core/ui-kit/form-ui/src/deprecation.ts @@ -0,0 +1,18 @@ +const warnedDeprecations = new Set(); + +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); +} diff --git a/packages/@core/ui-kit/form-ui/src/field-name.ts b/packages/@core/ui-kit/form-ui/src/field-name.ts index 0e2a2da7..c6d92909 100644 --- a/packages/@core/ui-kit/form-ui/src/field-name.ts +++ b/packages/@core/ui-kit/form-ui/src/field-name.ts @@ -1,3 +1,63 @@ +import { get, isObject, set } from '@vben-core/shared/utils'; + +export function deleteValueByFieldName( + values: Record, + 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 | 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, + 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, + fieldName: string, + value: any, +) { + const { rawKey } = resolveFieldNamePath(fieldName); + if (rawKey) { + values[rawKey] = value; + return; + } + set(values, fieldName, value); +} diff --git a/packages/@core/ui-kit/form-ui/src/form-api.ts b/packages/@core/ui-kit/form-ui/src/form-api.ts index ba42a6e9..0f8c8598 100644 --- a/packages/@core/ui-kit/form-ui/src/form-api.ts +++ b/packages/@core/ui-kit/form-ui/src/form-api.ts @@ -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, +> = VbenFormProps; + +type FormApiSchema< + TValues extends FormValues, + T extends BaseFormComponentType, + P extends Record, +> = FormSchema; + +function getDefaultState< + TValues extends FormValues, + T extends BaseFormComponentType, + P extends Record, +>(): FormApiProps { 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 = Record, +> { // private api: Pick; - public form = {} as FormActions; + public form = {} as FormActions; isMounted = false; - public state: null | VbenFormProps = null; + public state: FormApiProps | null = null; stateHandler: StateHandler; - public store: Store; + public store: Store>; /** * 组件实例映射 @@ -75,16 +92,16 @@ export class FormApi { private componentRefMap: Map = new Map(); // 最后一次点击提交时的表单值 - private latestSubmissionValues: null | Recordable = null; + private latestSubmissionValues: null | Partial = null; - private prevState: null | VbenFormProps = null; + private prevState: FormApiProps | null = null; - constructor(options: VbenFormProps = {}) { + constructor(options: FormApiProps = {}) { const { ...storeState } = options; - const defaultState = getDefaultState(); + const defaultState = getDefaultState(); - this.store = new Store({ + this.store = new Store>({ ...defaultState, ...storeState, }); @@ -100,6 +117,24 @@ export class FormApi { bindMethods(this); } + async clearValidation( + fieldNames?: FormFieldName | FormFieldName[], + ) { + const form = await this.getForm(); + form.clearValidation(fieldNames); + } + + formatValues( + rawValues: Readonly, + ) { + 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() { + const form = await this.getForm(); + return cloneDeep(toRaw(form.values ?? {})) as unknown as TResult; + } + getState() { return this.state; } - async getValues>() { + async getValues() { const form = await this.getForm(); - const values = form.values - ? this.handleRangeTimeValue(cloneDeep(toRaw(form.values))) - : {}; - return this.handleValueFormat(values) as T; + return this.formatValues(toRaw(form.values ?? {})); } - async isFieldValid(fieldName: string) { + async getValueSnapshot(): Promise< + FormValueSnapshot + > { + const rawValues = await this.getRawValues(); + return { + rawValues, + values: this.formatValues(rawValues), + }; + } + + async isFieldValid(fieldName: FormFieldName) { const form = await this.getForm(); return form.isFieldValid(fieldName); } - merge(formApi: FormApi) { + merge(formApi: FormApi) { const chain = [this, formApi]; const proxy = new Proxy(formApi, { get(target: any, prop: any) { if (prop === 'merge') { - return (nextFormApi: FormApi) => { + return (nextFormApi: FormApi) => { chain.push(nextFormApi); return proxy; }; @@ -218,16 +265,19 @@ export class FormApi { return proxy; } - mount(formActions: FormActions, componentRefMap?: Map) { + mount( + formActions: FormActions, + componentRefMap?: Map, + ) { 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); this.componentRefMap = componentRefMap ?? this.componentRefMap ?? new Map(); this.isMounted = true; @@ -252,20 +302,27 @@ export class FormApi { /** * 重置表单 */ - async resetForm( - state?: Partial> | undefined, - opts?: Partial, - ) { + async reset(state?: FormResetState, 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, 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) { - // 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>( + field: TFieldName, + value: FormFieldValue>, + shouldValidate?: boolean, + ) { const form = await this.getForm(); - form.setFieldValue(field, value, shouldValidate); + await form.setFieldValue(field, value, shouldValidate); } - setLatestSubmissionValues(values: null | Recordable) { - this.latestSubmissionValues = { ...toRaw(values) }; + setLatestSubmissionValues(values: null | Partial) { + this.latestSubmissionValues = { + ...toRaw(values), + } as Partial; } setState( stateOrFn: - | ((prev: VbenFormProps) => Partial) - | Partial, + | (( + prev: FormApiProps, + ) => Partial>) + | Partial>, ) { if (isFunction(stateOrFn)) { this.store.setState((prev) => { @@ -333,7 +397,7 @@ export class FormApi { * @param shouldValidate */ async setValues( - fields: Record, + fields: Partial, 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 = {}; + 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; + form.setValues(filteredFields as Partial, 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[]) { - const updated: Partial[] = [...schema]; + updateSchema(schema: Partial>[]) { + const updated: Partial>[] = [...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) { + 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) { + async validateField(fieldName: FormFieldName) { 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, - parentPath?: string, - parentContext?: { - arrayField?: string; - row?: Record; - 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, - 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 | 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) => { - 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) => { - 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) => { - const values = { ...originValues }; - this.applyValueFormatBySchemas(this.state?.schema ?? [], values); - - return values; - }; - - private processFields = ( - fields: string[], - separator: string, - originValues: Record, - 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, - 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).schema) - ) { - return { - ...schema, - componentProps: { - ...(schema.componentProps as Record), - schema: children, - }, - } as FormSchema; - } - - return schema; - } - - private setValueByFieldName( - values: Record, - 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[] { - 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) - : undefined; - }) - .filter(Boolean) as Partial[]; - - 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, + ); } } } diff --git a/packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts b/packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts index 8eb656ea..a19aafc6 100644 --- a/packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts +++ b/packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts @@ -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(); + /** * 解析Nested Objects对应的字段值 * @param values 表单值 @@ -24,7 +60,7 @@ function resolveValueByFieldName( values: Record, 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 { + 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 '); - } + 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({}); - const dynamicRules = ref(); + 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), }; } diff --git a/packages/@core/ui-kit/form-ui/src/form-render/form-field.vue b/packages/@core/ui-kit/form-ui/src/form-render/form-field.vue index 3e11270b..52b95059 100644 --- a/packages/@core/ui-kit/form-ui/src/form-render/form-field.vue +++ b/packages/@core/ui-kit/form-ui/src/form-render/form-field.vue @@ -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; +} + 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('fieldComponentRef'); const formApi = formRenderProps.form; +if (!formApi) { + throw new Error('Form api is required in '); +} +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 = { + 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), + asyncDebounceMs: formFieldProps?.asyncDebounceMs, + validators: fieldValidators.value, }; }); -function fieldBindEvent(slotProps: Record) { - 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) { + const modelValue = componentField.modelValue; + const handler = componentField['onUpdate:modelValue']; const bindEventField = modelPropName || @@ -260,34 +323,34 @@ function fieldBindEvent(slotProps: Record) { } if (bindEventField) { - return { - [`onUpdate:${bindEventField}`]: handler, - [bindEventField]: value === undefined ? emptyStateValue : value, - onChange: disabledOnChangeListener - ? undefined - : (e: Record) => { - 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) { + 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) { - 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(() => {