Browse Source

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

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

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

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

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

* refactor(project): update form adapters

* refactor(project): migrate application form consumers

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

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

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

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

* chore: fix merge artifacts and formatting

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

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

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

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

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

- list added, changed, removed, and deprecated form APIs
- document atomic dependencies and raw versus formatted values
pull/8182/head
dream-weave 2 months ago
committed by GitHub
parent
commit
28757fb9c8
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 18
      apps/web-antd/src/adapter/form.ts
  2. 2
      apps/web-antd/src/views/_core/authentication/register.vue
  3. 2
      apps/web-antd/src/views/_core/profile/password-setting.vue
  4. 18
      apps/web-antdv-next/src/adapter/form.ts
  5. 2
      apps/web-antdv-next/src/views/_core/authentication/register.vue
  6. 2
      apps/web-antdv-next/src/views/_core/profile/password-setting.vue
  7. 18
      apps/web-ele/src/adapter/form.ts
  8. 2
      apps/web-ele/src/views/_core/authentication/register.vue
  9. 2
      apps/web-ele/src/views/_core/profile/password-setting.vue
  10. 18
      apps/web-naive/src/adapter/form.ts
  11. 2
      apps/web-naive/src/views/_core/authentication/register.vue
  12. 2
      apps/web-naive/src/views/_core/profile/password-setting.vue
  13. 2
      apps/web-naive/src/views/demos/form/modal.vue
  14. 6
      apps/web-naive/src/views/demos/naive/array-form/index.vue
  15. 18
      apps/web-tdesign/src/adapter/form.ts
  16. 2
      apps/web-tdesign/src/views/_core/authentication/register.vue
  17. 2
      apps/web-tdesign/src/views/_core/profile/password-setting.vue
  18. 20
      docs/src/_env/adapter/form.ts
  19. 190
      docs/src/components/common-ui/vben-form.md
  20. 34
      docs/src/demos/vben-form/dynamic/index.vue
  21. 2
      docs/src/demos/vben-form/rules/index.vue
  22. 90
      docs/src/en/components/common-ui/vben-form.md
  23. 242
      docs/src/en/guide/in-depth/zod-v4-form-migration.md
  24. 274
      docs/src/guide/in-depth/zod-v4-form-migration.md
  25. 3
      package.json
  26. 101
      packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts
  27. 101
      packages/@core/ui-kit/form-ui/__tests__/form-compatibility.test.ts
  28. 656
      packages/@core/ui-kit/form-ui/__tests__/form-integration.test.ts
  29. 236
      packages/@core/ui-kit/form-ui/__tests__/form-runtime.test.ts
  30. 153
      packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts
  31. 90
      packages/@core/ui-kit/form-ui/__tests__/form-value-transform.test.ts
  32. 69
      packages/@core/ui-kit/form-ui/__tests__/zod-v4-schema.test.ts
  33. 4
      packages/@core/ui-kit/form-ui/package.json
  34. 10
      packages/@core/ui-kit/form-ui/src/components/form-actions.vue
  35. 56
      packages/@core/ui-kit/form-ui/src/components/form-field-array.vue
  36. 27
      packages/@core/ui-kit/form-ui/src/config.ts
  37. 18
      packages/@core/ui-kit/form-ui/src/deprecation.ts
  38. 92
      packages/@core/ui-kit/form-ui/src/field-name.ts
  39. 696
      packages/@core/ui-kit/form-ui/src/form-api.ts
  40. 314
      packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts
  41. 439
      packages/@core/ui-kit/form-ui/src/form-render/form-field.vue
  42. 24
      packages/@core/ui-kit/form-ui/src/form-render/form.vue
  43. 60
      packages/@core/ui-kit/form-ui/src/form-render/helper.ts
  44. 147
      packages/@core/ui-kit/form-ui/src/form-render/schema.ts
  45. 77
      packages/@core/ui-kit/form-ui/src/form-runtime-field.ts
  46. 303
      packages/@core/ui-kit/form-ui/src/form-runtime.ts
  47. 226
      packages/@core/ui-kit/form-ui/src/form-value-transform.ts
  48. 8
      packages/@core/ui-kit/form-ui/src/index.ts
  49. 17
      packages/@core/ui-kit/form-ui/src/rule-registry.ts
  50. 533
      packages/@core/ui-kit/form-ui/src/types.ts
  51. 38
      packages/@core/ui-kit/form-ui/src/use-form-context.ts
  52. 28
      packages/@core/ui-kit/form-ui/src/use-vben-form.ts
  53. 128
      packages/@core/ui-kit/form-ui/src/vben-use-form.vue
  54. 1
      packages/@core/ui-kit/shadcn-ui/package.json
  55. 35
      packages/@core/ui-kit/shadcn-ui/src/ui/form/FormField.vue
  56. 15
      packages/@core/ui-kit/shadcn-ui/src/ui/form/FormMessage.vue
  57. 9
      packages/@core/ui-kit/shadcn-ui/src/ui/form/index.ts
  58. 12
      packages/@core/ui-kit/shadcn-ui/src/ui/form/injectionKeys.ts
  59. 19
      packages/@core/ui-kit/shadcn-ui/src/ui/form/useFormField.ts
  60. 5
      packages/effects/common-ui/src/ui/profile/notification-setting.vue
  61. 5
      packages/effects/common-ui/src/ui/profile/security-setting.vue
  62. 2
      packages/effects/layouts/src/widgets/lock-screen/lock-screen-modal.vue
  63. 6
      packages/effects/layouts/src/widgets/preferences/blocks/draggable-list.vue
  64. 2
      packages/effects/plugins/src/vxe-table/use-vxe-grid.vue
  65. 18
      playground/src/adapter/form.ts
  66. 2
      playground/src/views/_core/authentication/register.vue
  67. 2
      playground/src/views/_core/profile/password-setting.vue
  68. 24
      playground/src/views/demos/form-array/index.vue
  69. 2
      playground/src/views/examples/drawer/form-drawer-demo.vue
  70. 13
      playground/src/views/examples/form/basic.vue
  71. 8
      playground/src/views/examples/form/collapsible.vue
  72. 2
      playground/src/views/examples/form/custom.vue
  73. 7
      playground/src/views/examples/form/rules.vue
  74. 14
      playground/src/views/examples/form/scroll-to-error-test.vue
  75. 2
      playground/src/views/examples/modal/form-modal-demo.vue
  76. 2
      playground/src/views/system/dept/modules/form.vue
  77. 50
      playground/src/views/system/menu/modules/form.vue
  78. 2
      playground/src/views/system/role/modules/form.vue
  79. 2
      playground/src/views/system/user/modules/form.vue
  80. 180
      pnpm-lock.yaml
  81. 7
      pnpm-workspace.yaml

18
apps/web-antd/src/adapter/form.ts

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

2
apps/web-antd/src/views/_core/authentication/register.vue

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

2
apps/web-antd/src/views/_core/profile/password-setting.vue

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

18
apps/web-antdv-next/src/adapter/form.ts

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

2
apps/web-antdv-next/src/views/_core/authentication/register.vue

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

2
apps/web-antdv-next/src/views/_core/profile/password-setting.vue

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

18
apps/web-ele/src/adapter/form.ts

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

2
apps/web-ele/src/views/_core/authentication/register.vue

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

2
apps/web-ele/src/views/_core/profile/password-setting.vue

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

18
apps/web-naive/src/adapter/form.ts

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

2
apps/web-naive/src/views/_core/authentication/register.vue

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

2
apps/web-naive/src/views/_core/profile/password-setting.vue

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

2
apps/web-naive/src/views/demos/form/modal.vue

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

6
apps/web-naive/src/views/demos/naive/array-form/index.vue

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

18
apps/web-tdesign/src/adapter/form.ts

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

2
apps/web-tdesign/src/views/_core/authentication/register.vue

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

2
apps/web-tdesign/src/views/_core/profile/password-setting.vue

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

20
docs/src/_env/adapter/form.ts

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

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

34
docs/src/demos/vben-form/dynamic/index.vue

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

2
docs/src/demos/vben-form/rules/index.vue

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

90
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. `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. > If some details are not obvious from the docs, check the live demos as well.
## Adapter Setup ## Adapter Setup
@ -23,8 +27,9 @@ The current adapter pattern is:
```ts ```ts
import type { import type {
FormValues,
VbenFormProps as FormProps,
VbenFormSchema as FormSchema, VbenFormSchema as FormSchema,
VbenFormProps,
} from '@vben/common-ui'; } from '@vben/common-ui';
import type { ComponentType } from './component'; import type { ComponentType } from './component';
@ -46,7 +51,7 @@ setupVbenForm<ComponentType>({
Upload: 'fileList', Upload: 'fileList',
}, },
}, },
defineRules: { rules: {
required: (value, _params, ctx) => { required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) { if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]); return $t('ui.formRules.required', [ctx.label]);
@ -62,11 +67,20 @@ setupVbenForm<ComponentType>({
}, },
}); });
const useVbenForm = useForm<ComponentType>; function useVbenForm<TValues extends FormValues = FormValues>(
options: FormProps<ComponentType, Record<never, never>, TValues>,
) {
return useForm<TValues, ComponentType, Record<never, never>>(options);
}
export { useVbenForm, z }; export { useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType>; export type VbenFormSchema<TValues extends FormValues = FormValues> =
export type { VbenFormProps }; FormSchema<ComponentType, Record<never, never>, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps<
ComponentType,
Record<never, never>,
TValues
>;
``` ```
### Component Adapter Example ### Component Adapter Example
@ -191,6 +205,55 @@ Create the form through `useVbenForm`:
<DemoPreview dir="demos/vben-form/basic" /> <DemoPreview dir="demos/vben-form/basic" />
## Typed Values and Slots
Declare the value shape once with `useVbenForm<TValues>`. The same type flows through value APIs, callbacks, selectors, and field/default/action slots:
```vue
<script setup lang="ts">
import { useVbenForm } from '#/adapter/form';
interface AccountFormValues {
email: string;
nickname: string;
}
const [Form, formApi] = useVbenForm<AccountFormValues>({
handleSubmit(values) {
return addAccount(values); // AccountFormValues
},
schema: [
{ component: 'Input', fieldName: 'email', label: 'Email' },
{ component: 'Input', fieldName: 'nickname', label: 'Nickname' },
],
});
async function fillForm() {
await formApi.setValues({ email: 'user@example.com' });
return formApi.getValues(); // Promise<AccountFormValues>
}
</script>
<template>
<Form>
<template #email="{ componentField, field, formApi, values }">
<!-- field.state.value and componentField.modelValue are strings -->
<input v-bind="componentField" :data-email="values.email" />
<button type="button" @click="formApi.clearValidation('email')">
Clear
</button>
</template>
<template #default="{ formApi, shapes, values }">
<button type="button" @click="formApi.submit()">
Submit {{ shapes.length }} fields for {{ values.email }}
</button>
</template>
</Form>
</template>
```
Named field slots expose `field`, `componentField`, `modelValue`, `name`, `disabled`, `isInValid`, `values`, and `formApi`. The default slot exposes `shapes`, `values`, and `formApi`; action slots expose `values` and `formApi`. Forms without an explicit `TValues` remain compatible with arbitrary slot names and broad props.
## Value Formatting ## 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. 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 ## Key API Notes
- `useVbenForm` returns `[Form, formApi]` - `useVbenForm` returns `[Form, formApi]`
- `useVbenForm<TValues>` propagates values through APIs, callbacks, schema callbacks, and slots
- prefer `reset`, `submit`, `validateAndSubmit`, and `clearValidation`
- `resetForm`, `submitForm`, `validateAndSubmitForm`, and `resetValidate` remain deprecated aliases that warn once in development
- `clearValidation` invalidates in-flight async results before clearing errors
- `formApi.getFieldComponentRef()` and `formApi.getFocusedField()` are available in current versions - `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 - `fieldMappingTime` and `scrollToFirstError` are part of the current form props
- `schema.valueFormat` lets `getValues()` transform UI values into backend-friendly payloads - `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 ## Reference

242
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<AccountFormValues>({
handleSubmit(values) {
return addAccount(values);
},
schema: [
{ component: 'Input', fieldName: 'email' },
{ component: 'Input', fieldName: 'nickname' },
],
});
```
`TValues` flows through `VbenFormProps`, `FormSchema`, `FormApi`, `FormContextApi`, value APIs, submit/change callbacks, selectors, and dynamic schema callbacks. The returned `Form` component also exposes typed slots: known field slots use the matching value type for `field.state.value` and `componentField.modelValue`, while all field/default/action slots receive the complete `values` and matching `formApi`. Legacy forms without `TValues` retain arbitrary slot names and broad props.
## New and Legacy Rule Registration
Use `rules` in new code:
```ts
setupVbenForm({
rules: {
required(value, _params, context) {
const isEmpty =
value === undefined ||
value === null ||
value === '' ||
(Array.isArray(value) && value.length === 0);
return isEmpty ? `${context.label} is required` : true;
},
},
});
```
The legacy `defineRules` option forwards to the same registry:
```ts
setupVbenForm({
defineRules: {
required: legacyRequiredRule,
},
});
```
Legacy runtime usage emits one warning per deprecation key in development and no warnings in production. If both options define the same rule, `rules` wins. The `FormActions` type remains as a deprecated alias of `FormContextApi`; editors report the type deprecation because type-only usage cannot emit runtime warnings.
## Running the Codemod
Run the pinned tool against each affected tsconfig from a clean Git worktree:
```bash
npx --yes zod-v3-to-v4@1.21.3 path/to/tsconfig.json
```
The tool edits `.ts`, `.tsx`, and `.vue` files in place and has no dry-run mode. Always review `git diff` afterward.
The codemod primarily recognizes direct `zod` imports. Schemas that obtain `z` through `@vben/common-ui` or an application adapter need manual review, especially constructor errors, string formats, and dynamic refinement messages.
## Zod 4 Changes
### Unified Error Parameters
Replace `required_error` and `invalid_type_error` with `error`:
```ts
const count = z.number({
error: (issue) =>
issue.input === undefined ? 'Count is required' : 'Count must be a number',
});
```
Use an `error(issue)` callback for dynamic refinement messages instead of passing a function that returns params as the second argument to `.refine()`.
### String Formats and Errors
Prefer top-level format schemas:
```ts
z.email('Invalid email');
z.url('Invalid URL');
z.uuid('Invalid UUID');
```
Read validation details from `ZodError.issues`; the old `.errors` property is removed.
### Defaults and Optionality
Zod 4 defaults may return immediately when the input is `undefined`. Review `.default().optional()` using actual parse behavior instead of internal type names.
Vben initial values use this precedence:
1. explicit schema `defaultValue`
2. Zod `.default()`
3. Zod 4-compatible `zod-defaults`
4. component empty-state conventions
Required markers are derived from whether the schema accepts `undefined`.
### Wrappers, Refine, Transform, and Coerce
Do not read `_def`, `_zod.def`, or `typeName`. Use public `.unwrap()` APIs and public pipe inputs. Delegate intersection defaults to the Zod 4-compatible `zod-defaults` package.
Standard Schema validation does not write transform/coerce output back into TanStack Form state. Keep using `valueFormat` for submission payload conversion, or explicitly call `parseAsync` at the submission boundary when transformed schema output is required.
Also review these changes:
- `z.record()` should specify key and value schemas
- `z.enum()` replaces former `nativeEnum` use cases
- number integer, Infinity, and finite behavior
- object strictness, merge, and unknown keys
- intersection merge conflicts
- coerce input types defaulting to `unknown`
- removal of Zod 3 types such as `ZodEffects`, `ZodTypeAny`, and `AnyZodObject`
## Form Engine Behavior
`formFieldProps.validateOn` accepts `blur` and `change`, with both enabled by default; submit always validates fields. `asyncDebounceMs` configures TanStack Field async debounce. The four vee-style `validateOn*` booleans and `force/silent/validated-only` modes have been removed.
The shadcn form primitives now use a Vben-owned field context. Labels, controls, descriptions, and messages continue to provide ids, `aria-invalid`, `aria-describedby`, touched, dirty, valid, and error states.
`clearValidation(fieldNames?)` advances Vben's validator generation and clears public error state without relying on a private TanStack AbortController. A Promise that finishes later is discarded as stale. Omitting `fieldNames` covers every registered field and every field with an existing error.
`dependencies.resolve(context)` is the recommended API: it evaluates once and atomically commits one dynamic-state patch, while stale async results are discarded as a unit. Legacy `if/show/disabled/required/rules/componentProps/trigger` callbacks remain supported through the same normalized executor, but are marked `@deprecated` and emit one development warning. Both APIs react only to declared `triggerFields`.
`handleValuesChange(rawValues, fieldsChanged, getFormattedValues)` receives readonly raw values and formats only when its third argument is called. `getRawValues()` and `getValues()` each create only the requested snapshot; use `getValueSnapshot()` when both are required. `handleSubmit(values, rawValues)` receives both forms at submission. The formatter performs one deep clone, then applies array-to-string, range mapping, and schema `valueFormat` in order. Array fields keep using TanStack push/remove operations and stable row identity.
## Test and Acceptance Matrix
Required coverage includes:
- Zod defaults, optional, nullable, intersection, pipe, transform, coerce, and errors
- runtime values, selectors, reset, manual errors, validation, and async validation
- field binding, blur/change triggers, error messages, ARIA, dependencies, and arrays
- new/legacy API equivalence, warning deduplication, production silence, and type aliases
- complete `useVbenForm` lifecycle, submission, `handleValuesChange`, submit-on-change, and async race handling
Acceptance requires zero TypeScript errors, zero build errors, all tests passing, no unhandled browser errors, modified-file formatting and lint passing, and no source dependency on vee or Zod private structures.
## References
- [Zod 4 release notes](https://zod.dev/v4)
- [Zod migration guide](https://zod.dev/v4/changelog)
- [TanStack Form Vue overview](https://tanstack.com/form/latest/docs/framework/vue/overview)
- [TanStack Form validation](https://tanstack.com/form/latest/docs/framework/vue/guides/validation)

274
docs/src/guide/in-depth/zod-v4-form-migration.md

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

3
package.json

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

101
packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts

@ -98,6 +98,11 @@ describe('formApi', () => {
startTime: 1_710_000_000_000, 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); expect(formActions.values).toEqual(originalValuesSnapshot);
}); });
@ -159,24 +164,59 @@ describe('formApi', () => {
); );
}); });
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: {},
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 () => { it('should reset form', async () => {
const resetFormMock = vi.fn(); const resetMock = vi.fn();
const formActions: any = { const formActions: any = {
meta: {}, meta: {},
resetForm: resetFormMock, reset: resetMock,
values: { name: 'test' }, values: { name: 'test' },
}; };
await formApi.mount(formActions, new Map()); await formApi.mount(formActions, new Map());
await formApi.resetForm(); await formApi.reset();
expect(resetFormMock).toHaveBeenCalled(); expect(resetMock).toHaveBeenCalled();
}); });
it('should call handleSubmit on submit', async () => { it('should call handleSubmit on submit', async () => {
const handleSubmitMock = vi.fn(); const handleSubmitMock = vi.fn();
const formActions: any = { const formActions: any = {
meta: {}, meta: {},
submitForm: vi.fn().mockResolvedValue(true), submit: vi.fn().mockResolvedValue(true),
values: { name: 'test' }, values: { name: 'test' },
}; };
@ -187,9 +227,12 @@ describe('formApi', () => {
formApi.setState(state); formApi.setState(state);
await formApi.mount(formActions, new Map()); await formApi.mount(formActions, new Map());
const result = await formApi.submitForm(); const result = await formApi.submit();
expect(formActions.submitForm).toHaveBeenCalled(); expect(formActions.submit).toHaveBeenCalled();
expect(handleSubmitMock).toHaveBeenCalledWith({ name: 'test' }); expect(handleSubmitMock).toHaveBeenCalledWith(
{ name: 'test' },
{ name: 'test' },
);
expect(result).toEqual({ name: 'test' }); expect(result).toEqual({ name: 'test' });
}); });
@ -243,6 +286,48 @@ describe('formApi', () => {
expect(validateMock).toHaveBeenCalled(); expect(validateMock).toHaveBeenCalled();
expect(isValid).toBe(true); 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', () => { describe('updateSchema', () => {

101
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.',
);
});
});

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

236
packages/@core/ui-kit/form-ui/__tests__/form-runtime.test.ts

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

153
packages/@core/ui-kit/form-ui/__tests__/form-types.test.ts

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

90
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',
});
});
});

69
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);
});
});

4
packages/@core/ui-kit/form-ui/package.json

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

10
packages/@core/ui-kit/form-ui/src/components/form-actions.vue

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

56
packages/@core/ui-kit/form-ui/src/components/form-field-array.vue

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

27
packages/@core/ui-kit/form-ui/src/config.ts

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

18
packages/@core/ui-kit/form-ui/src/deprecation.ts

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

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

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

314
packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts

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

439
packages/@core/ui-kit/form-ui/src/form-render/form-field.vue

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

24
packages/@core/ui-kit/form-ui/src/form-render/form.vue

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

60
packages/@core/ui-kit/form-ui/src/form-render/helper.ts

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

147
packages/@core/ui-kit/form-ui/src/form-render/schema.ts

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

77
packages/@core/ui-kit/form-ui/src/form-runtime-field.ts

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

303
packages/@core/ui-kit/form-ui/src/form-runtime.ts

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

226
packages/@core/ui-kit/form-ui/src/form-value-transform.ts

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

8
packages/@core/ui-kit/form-ui/src/index.ts

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

17
packages/@core/ui-kit/form-ui/src/rule-registry.ts

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

533
packages/@core/ui-kit/form-ui/src/types.ts

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

38
packages/@core/ui-kit/form-ui/src/use-form-context.ts

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

28
packages/@core/ui-kit/form-ui/src/use-vben-form.ts

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

128
packages/@core/ui-kit/form-ui/src/vben-use-form.vue

@ -1,12 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Recordable } from '@vben-core/typings'; import type { ExtendedFormApi, VbenFormProps, VbenFormSlots } from './types';
import type { ExtendedFormApi, VbenFormProps } from './types'; import { nextTick, onMounted, readonly, watch } from 'vue';
import { nextTick, onMounted, watch } from 'vue';
import { useForwardPriorityValues } from '@vben-core/composables'; import { useForwardPriorityValues } from '@vben-core/composables';
import { cloneDeep, get, isEqual, set } from '@vben-core/shared/utils'; import { get, isEqual } from '@vben-core/shared/utils';
import { useDebounceFn } from '@vueuse/core'; import { useDebounceFn } from '@vueuse/core';
@ -25,29 +23,39 @@ import {
// extends // extends
interface Props extends VbenFormProps { interface Props extends VbenFormProps {
formApi?: ExtendedFormApi; formApi?: ExtendedFormApi<any, any, any>;
} }
const props = defineProps<Props>(); const props = defineProps<Props>();
defineSlots<
Record<string, ((props: Record<string, any>) => any) | undefined> &
VbenFormSlots<any, any, any>
>();
const formApi = props.formApi;
if (!formApi) {
throw new Error('Form api is required in <VbenUseForm />');
}
const state = props.formApi?.useStore?.(); const state = formApi.useStore();
const forward = useForwardPriorityValues(props, state); const forward = useForwardPriorityValues(props, state);
const componentRefMap = new Map<string, unknown>(); const componentRefMap = new Map<string, unknown>();
const { delegatedSlots, form } = useFormInitial(forward); const { delegatedSlots, form } = useFormInitial(forward);
const values = form.useValues();
provideFormProps([forward, form]); provideFormProps([forward, form]);
provideComponentRefMap(componentRefMap); provideComponentRefMap(componentRefMap);
props.formApi?.mount?.(form, componentRefMap); formApi.mount(form, componentRefMap);
const handleUpdateCollapsed = (value: boolean) => { function handleUpdateCollapsed(value: boolean) {
props.formApi?.setState({ collapsed: value }); props.formApi?.setState({ collapsed: value });
// //
forward.value.handleCollapsedChange?.(value); forward.value.handleCollapsedChange?.(value);
}; }
function handleKeyDownEnter(event: KeyboardEvent) { function handleKeyDownEnter(event: KeyboardEvent) {
if (!state?.value.submitOnEnter || !forward.value.formApi?.isMounted) { if (!state?.value.submitOnEnter || !forward.value.formApi?.isMounted) {
@ -60,51 +68,42 @@ function handleKeyDownEnter(event: KeyboardEvent) {
} }
event.preventDefault(); event.preventDefault();
forward.value.formApi?.validateAndSubmitForm(); forward.value.formApi?.validateAndSubmit();
} }
const handleValuesChangeDebounced = useDebounceFn(async () => { const handleValuesChangeDebounced = useDebounceFn(async () => {
state?.value.submitOnChange && forward.value.formApi?.validateAndSubmitForm(); state?.value.submitOnChange && forward.value.formApi?.validateAndSubmit();
}, state?.value?.changeDebouncedTime ?? 300); }, state?.value?.changeDebouncedTime ?? 300);
const valuesCache: Recordable<any> = {}; let valuesChangeReady = false;
onMounted(async () => { onMounted(async () => {
// form.values // form.values
await nextTick(); await nextTick();
watch( valuesChangeReady = true;
() => form.values, });
async (newVal) => {
if (forward.value.handleValuesChange) { watch(values, (currentValues, previousValues) => {
const fields = state?.value.schema?.map((item) => { if (!valuesChangeReady) {
return item.fieldName; return;
}); }
const fields = state?.value.schema?.map((item) => item.fieldName) ?? [];
if (fields && fields.length > 0) { if (forward.value.handleValuesChange && fields.length > 0) {
const changedFields: string[] = []; const changedFields = fields.filter((field) => {
fields.forEach((field) => { return !isEqual(
const newFieldValue = get(newVal, field); get(currentValues, field),
const oldFieldValue = get(valuesCache, field); get(previousValues ?? {}, field),
if (!isEqual(newFieldValue, oldFieldValue)) { );
changedFields.push(field); });
set(valuesCache, field, cloneDeep(newFieldValue)); if (changedFields.length > 0) {
} forward.value.handleValuesChange(
}); readonly(currentValues),
changedFields,
if (changedFields.length > 0) { () => formApi.formatValues(currentValues),
// handleValuesChange );
const values = await forward.value.formApi?.getValues(); }
forward.value.handleValuesChange( }
cloneDeep(values ?? {}) as Record<string, any>, handleValuesChangeDebounced();
changedFields,
);
}
}
}
handleValuesChangeDebounced();
},
{ deep: true },
);
}); });
</script> </script>
@ -123,26 +122,51 @@ onMounted(async () => {
:key="slotName" :key="slotName"
#[slotName]="slotProps" #[slotName]="slotProps"
> >
<slot :name="slotName" v-bind="slotProps"></slot> <slot
:name="slotName"
v-bind="slotProps"
:form-api="formApi"
:values="form.values"
></slot>
</template> </template>
<template #default="slotProps"> <template #default="slotProps">
<slot v-bind="slotProps"> <slot v-bind="slotProps" :form-api="formApi" :values="form.values">
<FormActions <FormActions
v-if="forward.showDefaultActions" v-if="forward.showDefaultActions"
:model-value="state?.collapsed" :model-value="state?.collapsed"
@update:model-value="handleUpdateCollapsed" @update:model-value="handleUpdateCollapsed"
> >
<template #reset-before="resetSlotProps"> <template #reset-before="resetSlotProps">
<slot name="reset-before" v-bind="resetSlotProps"></slot> <slot
name="reset-before"
v-bind="resetSlotProps"
:form-api="formApi"
:values="form.values"
></slot>
</template> </template>
<template #submit-before="submitSlotProps"> <template #submit-before="submitSlotProps">
<slot name="submit-before" v-bind="submitSlotProps"></slot> <slot
name="submit-before"
v-bind="submitSlotProps"
:form-api="formApi"
:values="form.values"
></slot>
</template> </template>
<template #expand-before="expandBeforeSlotProps"> <template #expand-before="expandBeforeSlotProps">
<slot name="expand-before" v-bind="expandBeforeSlotProps"></slot> <slot
name="expand-before"
v-bind="expandBeforeSlotProps"
:form-api="formApi"
:values="form.values"
></slot>
</template> </template>
<template #expand-after="expandAfterSlotProps"> <template #expand-after="expandAfterSlotProps">
<slot name="expand-after" v-bind="expandAfterSlotProps"></slot> <slot
name="expand-after"
v-bind="expandAfterSlotProps"
:form-api="formApi"
:values="form.values"
></slot>
</template> </template>
</FormActions> </FormActions>
</slot> </slot>

1
packages/@core/ui-kit/shadcn-ui/package.json

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

35
packages/@core/ui-kit/shadcn-ui/src/ui/form/FormField.vue

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

15
packages/@core/ui-kit/shadcn-ui/src/ui/form/FormMessage.vue

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

9
packages/@core/ui-kit/shadcn-ui/src/ui/form/index.ts

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

12
packages/@core/ui-kit/shadcn-ui/src/ui/form/injectionKeys.ts

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

19
packages/@core/ui-kit/shadcn-ui/src/ui/form/useFormField.ts

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

5
packages/effects/common-ui/src/ui/profile/notification-setting.vue

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

5
packages/effects/common-ui/src/ui/profile/security-setting.vue

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

2
packages/effects/layouts/src/widgets/lock-screen/lock-screen-modal.vue

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

6
packages/effects/layouts/src/widgets/preferences/blocks/draggable-list.vue

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

2
packages/effects/plugins/src/vxe-table/use-vxe-grid.vue

@ -131,7 +131,7 @@ const [Form, formApi] = useTableForm({
}, },
handleReset: async () => { handleReset: async () => {
const prevValues = await formApi.getValues(); const prevValues = await formApi.getValues();
await formApi.resetForm(); await formApi.reset();
const formValues = await formApi.getValues(); const formValues = await formApi.getValues();
formApi.setLatestSubmissionValues(formValues); formApi.setLatestSubmissionValues(formValues);
// submitOnChangesubmitOnChangefalse // submitOnChangesubmitOnChangefalse

18
playground/src/adapter/form.ts

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

2
playground/src/views/_core/authentication/register.vue

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

2
playground/src/views/_core/profile/password-setting.vue

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

24
playground/src/views/demos/form-array/index.vue

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

2
playground/src/views/examples/drawer/form-drawer-demo.vue

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

13
playground/src/views/examples/form/basic.vue

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

8
playground/src/views/examples/form/collapsible.vue

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

2
playground/src/views/examples/form/custom.vue

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

7
playground/src/views/examples/form/rules.vue

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

14
playground/src/views/examples/form/scroll-to-error-test.vue

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

2
playground/src/views/examples/modal/form-modal-demo.vue

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

2
playground/src/views/system/dept/modules/form.vue

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

50
playground/src/views/system/menu/modules/form.vue

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

2
playground/src/views/system/role/modules/form.vue

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

2
playground/src/views/system/user/modules/form.vue

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

180
pnpm-lock.yaml

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

7
pnpm-workspace.yaml

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

Loading…
Cancel
Save