refactor(@vben-core/form-ui): migrate to Zod 4 and TanStack Form (#8176)
* build(@vben-core/form-ui): update form validation dependencies * refactor(@vben-core/form-ui): replace vee-validate with TanStack Form * test(@vben-core/form-ui): cover TanStack Form migration * docs(@vben/docs): document Zod 4 form migration * refactor(project): update form adapters * refactor(project): migrate application form consumers * refactor(@vben/playground): migrate form examples * docs(@vben/docs): fix validate return type and required rule empty check in form docs * fix(@vben-core/form-ui): resolve oxlint and eslint errors - rewrite nested ternary expressions as if/else in form-field-array.vue and form-runtime.ts - sort @tanstack/vue-form before @vben-core/composables in package.json * chore: fix merge artifacts and formatting - move dependencies before devDependencies in root package.json - update preferences snapshot for widget positioning fields - format form-array demo README * refactor(@vben-core/form-ui): optimize form runtime and value flow - add fine-grained field selectors and atomic dependency resolution - expose raw and formatted value snapshots with focused regression coverage * fix(@vben/layouts): dispose sortable instance on unmount * docs(@vben/docs): document form runtime API changes - list added, changed, removed, and deprecated form APIs - document atomic dependencies and raw versus formatted values
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
VbenFormProps as FormProps,
|
||||
VbenFormSchema as FormSchema,
|
||||
FormValues,
|
||||
} from '@vben/common-ui';
|
||||
|
||||
import type { ComponentPropsMap, ComponentType } from './component';
|
||||
@@ -21,7 +22,7 @@ async function initSetupVbenForm() {
|
||||
Upload: 'fileList',
|
||||
},
|
||||
},
|
||||
defineRules: {
|
||||
rules: {
|
||||
// 输入项目必填国际化适配
|
||||
required: (value, _params, ctx) => {
|
||||
if (value === undefined || value === null || value.length === 0) {
|
||||
@@ -40,9 +41,18 @@ async function initSetupVbenForm() {
|
||||
});
|
||||
}
|
||||
|
||||
const useVbenForm = useForm<ComponentType, ComponentPropsMap>;
|
||||
function useVbenForm<TValues extends FormValues = FormValues>(
|
||||
options: FormProps<ComponentType, ComponentPropsMap, TValues>,
|
||||
) {
|
||||
return useForm<TValues, ComponentType, ComponentPropsMap>(options);
|
||||
}
|
||||
|
||||
export { initSetupVbenForm, useVbenForm, z };
|
||||
|
||||
export type VbenFormSchema = FormSchema<ComponentType, ComponentPropsMap>;
|
||||
export type VbenFormProps = FormProps<ComponentType, ComponentPropsMap>;
|
||||
export type VbenFormSchema<TValues extends FormValues = FormValues> =
|
||||
FormSchema<ComponentType, ComponentPropsMap, TValues>;
|
||||
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps<
|
||||
ComponentType,
|
||||
ComponentPropsMap,
|
||||
TValues
|
||||
>;
|
||||
|
||||
@@ -46,7 +46,7 @@ const formSchema = computed((): VbenFormSchema[] => {
|
||||
rules(values) {
|
||||
const { password } = values;
|
||||
return z
|
||||
.string({ required_error: $t('authentication.passwordTip') })
|
||||
.string({ error: $t('authentication.passwordTip') })
|
||||
.min(1, { message: $t('authentication.passwordTip') })
|
||||
.refine((value) => value === password, {
|
||||
message: $t('authentication.confirmPasswordTip'),
|
||||
|
||||
@@ -38,7 +38,7 @@ const formSchema = computed((): VbenFormSchema[] => {
|
||||
rules(values) {
|
||||
const { newPassword } = values;
|
||||
return z
|
||||
.string({ required_error: '请再次输入新密码' })
|
||||
.string({ error: '请再次输入新密码' })
|
||||
.min(1, { message: '请再次输入新密码' })
|
||||
.refine((value) => value === newPassword, {
|
||||
message: '两次输入的密码不一致',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
@@ -10,6 +10,18 @@ import { Button, Card, message, Space } from 'antdv-next';
|
||||
import { useVbenForm, z } from '#/adapter/form';
|
||||
|
||||
const submitValues = ref<Record<string, any>>({});
|
||||
const formattedSubmitValues = computed(() =>
|
||||
JSON.stringify(submitValues.value, null, 2),
|
||||
);
|
||||
const outputClass = [
|
||||
'bg-muted',
|
||||
'text-muted-foreground',
|
||||
'max-h-[420px]',
|
||||
'overflow-auto',
|
||||
'rounded-md',
|
||||
'p-3',
|
||||
'text-xs',
|
||||
];
|
||||
|
||||
const schema: VbenFormSchema[] = [
|
||||
{
|
||||
@@ -62,8 +74,8 @@ const schema: VbenFormSchema[] = [
|
||||
children: [
|
||||
{
|
||||
component: 'Input',
|
||||
componentProps: (_values, _form, ctx) => ({
|
||||
placeholder: `第 ${(ctx?.rowIndex ?? 0) + 1} 行姓名`,
|
||||
componentProps: (ctx) => ({
|
||||
placeholder: `第 ${(ctx.rowIndex ?? 0) + 1} 行姓名`,
|
||||
}),
|
||||
defaultValue: '',
|
||||
fieldName: 'name',
|
||||
@@ -147,7 +159,7 @@ const [Form, formApi] = useVbenForm({
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
await formApi.validateAndSubmitForm();
|
||||
await formApi.validateAndSubmit();
|
||||
}
|
||||
|
||||
async function handleGetValues() {
|
||||
@@ -178,9 +190,7 @@ function handlePatchChildRule() {
|
||||
</Card>
|
||||
|
||||
<Card title="输出">
|
||||
<pre
|
||||
class="bg-muted text-muted-foreground max-h-[420px] overflow-auto rounded-md p-3 text-xs"
|
||||
>{{ JSON.stringify(submitValues, null, 2) }}</pre>
|
||||
<pre :class="outputClass" v-text="formattedSubmitValues"></pre>
|
||||
</Card>
|
||||
</div>
|
||||
</Page>
|
||||
|
||||
@@ -35,7 +35,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
||||
drawerApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
await formApi.submitForm();
|
||||
await formApi.submit();
|
||||
drawerApi.close();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
|
||||
@@ -285,11 +285,16 @@ const [BaseForm, baseFormApi] = useVbenForm({
|
||||
},
|
||||
{
|
||||
component: 'DatePicker',
|
||||
dependencies: {
|
||||
resolve: ({ values }) => ({
|
||||
help: () =>
|
||||
[`这是一个可输出其他字段值的帮助信息${values.rate}`].map((value) =>
|
||||
h('p', value),
|
||||
),
|
||||
}),
|
||||
triggerFields: ['rate'],
|
||||
},
|
||||
fieldName: 'datePicker',
|
||||
help: (values) =>
|
||||
[`这是一个可输出其他字段值的帮助信息${values?.rate}`].map((v) =>
|
||||
h('p', v),
|
||||
),
|
||||
label: '日期选择框',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -22,8 +22,8 @@ const layout = ref<FormLayout>('vertical');
|
||||
|
||||
function getNumberValidator(key: string, limit?: [number?, number?]) {
|
||||
let validator = z.number({
|
||||
required_error: `${key} 值不能为空`,
|
||||
invalid_type_error: `${key} 值只能为数字`,
|
||||
error: (issue) =>
|
||||
issue.input === undefined ? `${key} 值不能为空` : `${key} 值只能为数字`,
|
||||
});
|
||||
|
||||
if (limit) {
|
||||
@@ -257,14 +257,14 @@ function handleSetFormValue() {
|
||||
}
|
||||
|
||||
function handleResetFormValue() {
|
||||
baseFormApi.resetForm(undefined, { force: true });
|
||||
baseFormApi.reset(undefined, { force: true });
|
||||
}
|
||||
|
||||
async function handleSubmitFormValue() {
|
||||
const { valid } = await baseFormApi.validate();
|
||||
|
||||
if (valid) {
|
||||
baseFormApi.submitForm();
|
||||
baseFormApi.submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -57,7 +57,7 @@ const [Form] = useVbenForm({
|
||||
{
|
||||
component: markRaw(TwoFields),
|
||||
defaultValue: [undefined, ''],
|
||||
disabledOnChangeListener: false,
|
||||
changeEventFallback: true,
|
||||
fieldName: 'field4',
|
||||
formItemClass: 'col-span-1',
|
||||
label: '组合字段',
|
||||
|
||||
@@ -70,7 +70,7 @@ const [Form, formApi] = useVbenForm({
|
||||
fieldName: 'field4',
|
||||
// 界面显示的label
|
||||
label: '邮箱',
|
||||
rules: z.string().email('请输入正确的邮箱'),
|
||||
rules: z.email('请输入正确的邮箱'),
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
@@ -184,8 +184,7 @@ const [Form, formApi] = useVbenForm({
|
||||
},
|
||||
fieldName: 'input-blur',
|
||||
formFieldProps: {
|
||||
validateOnChange: false,
|
||||
validateOnModelUpdate: false,
|
||||
validateOn: ['blur'],
|
||||
},
|
||||
help: 'blur时才会触发校验',
|
||||
label: 'blur触发',
|
||||
@@ -235,7 +234,7 @@ function onSubmit(values: Record<string, any>) {
|
||||
<Card title="基础组件校验示例">
|
||||
<template #extra>
|
||||
<Button @click="() => formApi.validate()">校验表单</Button>
|
||||
<Button class="mx-2" @click="() => formApi.resetValidate()">
|
||||
<Button class="mx-2" @click="() => formApi.clearValidation()">
|
||||
清空校验信息
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -98,7 +98,7 @@ const [Form, formApi] = useVbenForm({
|
||||
|
||||
// 测试 validateAndSubmitForm(验证并提交)
|
||||
async function testValidateAndSubmit() {
|
||||
await formApi.validateAndSubmitForm();
|
||||
await formApi.validateAndSubmit();
|
||||
}
|
||||
|
||||
// 测试 validate(手动验证整个表单)
|
||||
@@ -112,13 +112,13 @@ async function testValidateField() {
|
||||
}
|
||||
|
||||
// 切换滚动功能
|
||||
function toggleScrollToError() {
|
||||
formApi.setState({ scrollToFirstError: scrollEnabled.value });
|
||||
function toggleScrollToError(checked: boolean) {
|
||||
formApi.setState({ scrollToFirstError: checked });
|
||||
}
|
||||
|
||||
// 填充部分数据测试
|
||||
async function fillPartialData() {
|
||||
await formApi.resetForm();
|
||||
await formApi.reset();
|
||||
await formApi.setFieldValue('username', '测试用户');
|
||||
await formApi.setFieldValue('email', 'test@example.com');
|
||||
}
|
||||
@@ -153,13 +153,13 @@ async function fillPartialData() {
|
||||
<h4 class="mb-3 font-medium">验证方法测试:</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button type="primary" @click="testValidateAndSubmit">
|
||||
测试 validateAndSubmitForm()
|
||||
测试 validateAndSubmit()
|
||||
</Button>
|
||||
<Button @click="testValidate"> 测试 validate() </Button>
|
||||
<Button @click="testValidateField"> 测试 validateField() </Button>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-gray-500">
|
||||
<p>• validateAndSubmitForm(): 验证表单并提交</p>
|
||||
<p>• validateAndSubmit(): 验证表单并提交</p>
|
||||
<p>• validate(): 手动验证整个表单</p>
|
||||
<p>• validateField(): 验证单个字段(这里测试用户名字段)</p>
|
||||
</div>
|
||||
@@ -169,7 +169,7 @@ async function fillPartialData() {
|
||||
<h4 class="mb-3 font-medium">数据填充测试:</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button @click="fillPartialData"> 填充部分数据 </Button>
|
||||
<Button @click="() => formApi.resetForm()"> 清空表单 </Button>
|
||||
<Button @click="() => formApi.reset()"> 清空表单 </Button>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-gray-500">
|
||||
<p>• 填充部分数据后验证,会滚动到第一个错误字段</p>
|
||||
|
||||
@@ -54,7 +54,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
await formApi.validateAndSubmitForm();
|
||||
await formApi.validateAndSubmit();
|
||||
// modalApi.close();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
|
||||
@@ -28,7 +28,7 @@ const [Form, formApi] = useVbenForm({
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
formApi.resetForm();
|
||||
formApi.reset();
|
||||
formApi.setValues(formData.value || {});
|
||||
}
|
||||
|
||||
|
||||
@@ -56,12 +56,13 @@ const schema: VbenFormSchema[] = [
|
||||
async (value: string) => {
|
||||
return !(await isMenuNameExists(value, formData.value?.id));
|
||||
},
|
||||
(value) => ({
|
||||
message: $t('ui.formRules.alreadyExists', [
|
||||
$t('system.menu.menuName'),
|
||||
value,
|
||||
]),
|
||||
}),
|
||||
{
|
||||
error: (issue) =>
|
||||
$t('ui.formRules.alreadyExists', [
|
||||
$t('system.menu.menuName'),
|
||||
issue.input,
|
||||
]),
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -139,12 +140,13 @@ const schema: VbenFormSchema[] = [
|
||||
async (value: string) => {
|
||||
return !(await isMenuPathExists(value, formData.value?.id));
|
||||
},
|
||||
(value) => ({
|
||||
message: $t('ui.formRules.alreadyExists', [
|
||||
$t('system.menu.path'),
|
||||
value,
|
||||
]),
|
||||
}),
|
||||
{
|
||||
error: (issue) =>
|
||||
$t('ui.formRules.alreadyExists', [
|
||||
$t('system.menu.path'),
|
||||
issue.input,
|
||||
]),
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -227,7 +229,7 @@ const schema: VbenFormSchema[] = [
|
||||
},
|
||||
fieldName: 'linkSrc',
|
||||
label: $t('system.menu.linkSrc'),
|
||||
rules: z.string().url($t('ui.formRules.invalidURL')),
|
||||
rules: z.url($t('ui.formRules.invalidURL')),
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
@@ -278,18 +280,18 @@ const schema: VbenFormSchema[] = [
|
||||
},
|
||||
{
|
||||
component: 'Input',
|
||||
componentProps: (values) => {
|
||||
return {
|
||||
allowClear: true,
|
||||
class: 'w-full',
|
||||
disabled: values.meta?.badgeType !== 'normal',
|
||||
};
|
||||
},
|
||||
dependencies: {
|
||||
show: (values) => {
|
||||
return values.type !== 'button';
|
||||
resolve: ({ values }) => {
|
||||
return {
|
||||
componentProps: {
|
||||
allowClear: true,
|
||||
class: 'w-full',
|
||||
disabled: values.meta?.badgeType !== 'normal',
|
||||
},
|
||||
show: values.type !== 'button',
|
||||
};
|
||||
},
|
||||
triggerFields: ['type'],
|
||||
triggerFields: ['meta.badgeType', 'type'],
|
||||
},
|
||||
fieldName: 'meta.badge',
|
||||
label: $t('system.menu.badge'),
|
||||
@@ -451,7 +453,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
||||
? $t(formData.value.meta.title)
|
||||
: '';
|
||||
} else {
|
||||
formApi.resetForm();
|
||||
formApi.reset();
|
||||
titleSuffix.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
||||
async onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = drawerApi.getData<SystemRoleApi.SystemRole>();
|
||||
formApi.resetForm();
|
||||
formApi.reset();
|
||||
|
||||
if (data) {
|
||||
formData.value = data;
|
||||
|
||||
@@ -50,7 +50,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
||||
async onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
const data = drawerApi.getData<SystemUserApi.SystemUser>();
|
||||
formApi.resetForm();
|
||||
formApi.reset();
|
||||
|
||||
if (data) {
|
||||
formData.value = data;
|
||||
|
||||
Reference in New Issue
Block a user