Browse Source
让插槽 / 复合组件在内部注册取值函数,把值交给所在表单项做校验; 值仍归表单所有,setValues、重置继续通过 modelValue 流回组件。 开启 deep 时表单里存的是值的副本,原地修改同一个对象也能被识别。 close #8381 Co-authored-by: Cursor <cursoragent@cursor.com>pull/8382/head
10 changed files with 613 additions and 5 deletions
@ -0,0 +1,5 @@ |
|||
--- |
|||
'@vben-core/form-ui': minor |
|||
--- |
|||
|
|||
feat: add useCustomFieldValue for custom controls inside form fields |
|||
@ -0,0 +1,303 @@ |
|||
import type { VueWrapper } from '@vue/test-utils'; |
|||
import type { PropType } from 'vue'; |
|||
|
|||
import { flushPromises, mount } from '@vue/test-utils'; |
|||
import { defineComponent, h, reactive, ref } from 'vue'; |
|||
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'; |
|||
import { z } from 'zod'; |
|||
|
|||
import { useCustomFieldValue } from '../src/use-custom-field-value'; |
|||
import { useVbenForm } from '../src/use-vben-form'; |
|||
|
|||
const wrappers: VueWrapper[] = []; |
|||
|
|||
// 字段的组件由插槽接管,schema 上只需要一个占位组件
|
|||
const Placeholder = () => h('span'); |
|||
|
|||
// 内部维护选中项、不接收 modelValue 的复合组件
|
|||
const TagPicker = defineComponent({ |
|||
props: { |
|||
immediate: { default: false, type: Boolean }, |
|||
}, |
|||
setup(props) { |
|||
const tags = ref<string[]>([]); |
|||
const { disabled, error, fieldName, value } = useCustomFieldValue( |
|||
() => [...tags.value], |
|||
{ immediate: props.immediate }, |
|||
); |
|||
|
|||
return () => |
|||
h( |
|||
'button', |
|||
{ |
|||
class: 'tag-picker', |
|||
'data-disabled': String(disabled.value), |
|||
'data-error': error.value ?? '', |
|||
'data-field-name': fieldName ?? '', |
|||
'data-value': JSON.stringify(value.value ?? null), |
|||
onClick: () => { |
|||
tags.value = [...tags.value, `tag-${tags.value.length}`]; |
|||
}, |
|||
}, |
|||
'add', |
|||
); |
|||
}, |
|||
}); |
|||
|
|||
// 原地修改同一个数组的复合组件,只有 deep 才能感知到变化
|
|||
const DeepTagPicker = defineComponent({ |
|||
setup() { |
|||
const tags = reactive<string[]>([]); |
|||
const { error, value } = useCustomFieldValue(() => tags, { deep: true }); |
|||
|
|||
return () => |
|||
h( |
|||
'button', |
|||
{ |
|||
class: 'tag-picker', |
|||
'data-error': error.value ?? '', |
|||
'data-value': JSON.stringify(value.value ?? null), |
|||
onClick: () => { |
|||
tags.push(`tag-${tags.length}`); |
|||
}, |
|||
}, |
|||
'add', |
|||
); |
|||
}, |
|||
}); |
|||
|
|||
// 值由表单通过 modelValue 下发的复合组件,取值函数只服务于校验
|
|||
const BoundTagPicker = defineComponent({ |
|||
props: { |
|||
modelValue: { default: () => [], type: Array as PropType<string[]> }, |
|||
}, |
|||
emits: ['update:modelValue'], |
|||
setup(props, { emit }) { |
|||
const { error, value } = useCustomFieldValue(() => props.modelValue); |
|||
|
|||
return () => |
|||
h( |
|||
'button', |
|||
{ |
|||
class: 'tag-picker', |
|||
'data-error': error.value ?? '', |
|||
'data-value': JSON.stringify(value.value ?? null), |
|||
onClick: () => { |
|||
emit('update:modelValue', [ |
|||
...props.modelValue, |
|||
`tag-${props.modelValue.length}`, |
|||
]); |
|||
}, |
|||
}, |
|||
'add', |
|||
); |
|||
}, |
|||
}); |
|||
|
|||
function mountTagForm( |
|||
options: { |
|||
formFieldProps?: Record<string, any>; |
|||
immediate?: boolean; |
|||
} = {}, |
|||
) { |
|||
const [Form, formApi] = useVbenForm({ |
|||
schema: [ |
|||
{ |
|||
component: Placeholder, |
|||
defaultValue: [], |
|||
fieldName: 'tags', |
|||
formFieldProps: options.formFieldProps, |
|||
label: 'Tags', |
|||
rules: z.array(z.string()).max(1, 'Too many tags'), |
|||
}, |
|||
], |
|||
}); |
|||
const wrapper = mount(Form, { |
|||
slots: { |
|||
tags: () => h(TagPicker, { immediate: options.immediate ?? false }), |
|||
}, |
|||
}); |
|||
wrappers.push(wrapper); |
|||
return { formApi, wrapper }; |
|||
} |
|||
|
|||
function mountDeepTagForm() { |
|||
const [Form, formApi] = useVbenForm({ |
|||
schema: [ |
|||
{ |
|||
component: Placeholder, |
|||
defaultValue: [], |
|||
fieldName: 'tags', |
|||
label: 'Tags', |
|||
rules: z.array(z.string()).max(1, 'Too many tags'), |
|||
}, |
|||
], |
|||
}); |
|||
const wrapper = mount(Form, { |
|||
slots: { |
|||
tags: () => h(DeepTagPicker), |
|||
}, |
|||
}); |
|||
wrappers.push(wrapper); |
|||
return { formApi, wrapper }; |
|||
} |
|||
|
|||
function mountBoundTagForm() { |
|||
const [Form, formApi] = useVbenForm({ |
|||
schema: [ |
|||
{ |
|||
component: Placeholder, |
|||
defaultValue: [], |
|||
fieldName: 'tags', |
|||
label: 'Tags', |
|||
rules: z.array(z.string()).min(1, 'Pick at least one tag'), |
|||
}, |
|||
], |
|||
}); |
|||
const wrapper = mount(Form, { |
|||
slots: { |
|||
tags: (slotProps: any) => h(BoundTagPicker, slotProps.componentProps), |
|||
}, |
|||
}); |
|||
wrappers.push(wrapper); |
|||
return { formApi, wrapper }; |
|||
} |
|||
|
|||
afterEach(() => { |
|||
for (const wrapper of wrappers.splice(0)) { |
|||
wrapper.unmount(); |
|||
} |
|||
vi.restoreAllMocks(); |
|||
}); |
|||
|
|||
describe('useCustomFieldValue', () => { |
|||
it('writes the custom value into the form and validates on change', async () => { |
|||
const { formApi, wrapper } = mountTagForm(); |
|||
await flushPromises(); |
|||
|
|||
await wrapper.get('.tag-picker').trigger('click'); |
|||
await flushPromises(); |
|||
|
|||
expect(await formApi.getValues()).toEqual({ tags: ['tag-0'] }); |
|||
expect(formApi.form.getFieldError('tags')).toBeUndefined(); |
|||
|
|||
await wrapper.get('.tag-picker').trigger('click'); |
|||
await flushPromises(); |
|||
|
|||
expect(await formApi.getValues()).toEqual({ tags: ['tag-0', 'tag-1'] }); |
|||
expect(formApi.form.getFieldError('tags')).toBe('Too many tags'); |
|||
}); |
|||
|
|||
it('validates every in-place mutation when deep is enabled', async () => { |
|||
const { formApi, wrapper } = mountDeepTagForm(); |
|||
await flushPromises(); |
|||
|
|||
await wrapper.get('.tag-picker').trigger('click'); |
|||
await flushPromises(); |
|||
|
|||
expect(await formApi.getValues()).toEqual({ tags: ['tag-0'] }); |
|||
expect(formApi.form.getFieldError('tags')).toBeUndefined(); |
|||
|
|||
await wrapper.get('.tag-picker').trigger('click'); |
|||
await flushPromises(); |
|||
|
|||
expect(await formApi.getValues()).toEqual({ tags: ['tag-0', 'tag-1'] }); |
|||
expect(formApi.form.getFieldError('tags')).toBe('Too many tags'); |
|||
}); |
|||
|
|||
it('exposes the field state to the custom component', async () => { |
|||
const { formApi, wrapper } = mountTagForm(); |
|||
await flushPromises(); |
|||
|
|||
const picker = wrapper.get('.tag-picker'); |
|||
expect(picker.attributes('data-field-name')).toBe('tags'); |
|||
expect(picker.attributes('data-disabled')).toBe('false'); |
|||
|
|||
await formApi.setFieldValue('tags', ['from-form']); |
|||
await flushPromises(); |
|||
|
|||
expect(picker.attributes('data-value')).toBe('["from-form"]'); |
|||
}); |
|||
|
|||
it('lets reset flow back into a form-driven component without validating', async () => { |
|||
const { formApi, wrapper } = mountBoundTagForm(); |
|||
await flushPromises(); |
|||
|
|||
await wrapper.get('.tag-picker').trigger('click'); |
|||
await flushPromises(); |
|||
|
|||
expect(await formApi.getValues()).toEqual({ tags: ['tag-0'] }); |
|||
expect(wrapper.get('.tag-picker').attributes('data-value')).toBe( |
|||
'["tag-0"]', |
|||
); |
|||
|
|||
await formApi.reset(); |
|||
await flushPromises(); |
|||
|
|||
expect(await formApi.getValues()).toEqual({ tags: [] }); |
|||
expect(wrapper.get('.tag-picker').attributes('data-value')).toBe('[]'); |
|||
expect(formApi.form.getFieldError('tags')).toBeUndefined(); |
|||
}); |
|||
|
|||
it('respects validateOn and skips validation on change', async () => { |
|||
const { formApi, wrapper } = mountTagForm({ |
|||
formFieldProps: { validateOn: [] }, |
|||
}); |
|||
await flushPromises(); |
|||
|
|||
await wrapper.get('.tag-picker').trigger('click'); |
|||
await wrapper.get('.tag-picker').trigger('click'); |
|||
await flushPromises(); |
|||
|
|||
expect(formApi.form.getFieldError('tags')).toBeUndefined(); |
|||
expect(await formApi.validate()).toEqual({ |
|||
errors: { tags: 'Too many tags' }, |
|||
valid: false, |
|||
}); |
|||
}); |
|||
|
|||
it('writes the initial value on mount when immediate is enabled', async () => { |
|||
const { formApi } = mountTagForm({ immediate: true }); |
|||
await flushPromises(); |
|||
|
|||
expect(await formApi.getValues()).toEqual({ tags: [] }); |
|||
expect(formApi.form.getFieldError('tags')).toBeUndefined(); |
|||
}); |
|||
|
|||
it('keeps the first getter when a field registers twice', async () => { |
|||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
|||
const [Form] = useVbenForm({ |
|||
schema: [ |
|||
{ |
|||
component: Placeholder, |
|||
defaultValue: [], |
|||
fieldName: 'tags', |
|||
}, |
|||
], |
|||
}); |
|||
const wrapper = mount(Form, { |
|||
slots: { |
|||
tags: () => [h(TagPicker), h(TagPicker)], |
|||
}, |
|||
}); |
|||
wrappers.push(wrapper); |
|||
await flushPromises(); |
|||
|
|||
expect(warn).toHaveBeenCalledWith( |
|||
'表单项 tags 已存在自定义取值函数,本次注册被忽略', |
|||
); |
|||
}); |
|||
|
|||
it('warns when used outside a form field', async () => { |
|||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); |
|||
const wrapper = mount(TagPicker); |
|||
wrappers.push(wrapper); |
|||
await flushPromises(); |
|||
|
|||
expect(warn).toHaveBeenCalledWith( |
|||
'useCustomFieldValue 只能在 VbenForm 的表单项内部使用', |
|||
); |
|||
expect(wrapper.get('.tag-picker').attributes('data-field-name')).toBe(''); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,124 @@ |
|||
import type { ComputedRef, InjectionKey, Ref, ShallowRef } from 'vue'; |
|||
|
|||
import type { FormValidationTrigger } from './types'; |
|||
|
|||
import { computed, inject, onUnmounted, provide, toRaw, watch } from 'vue'; |
|||
|
|||
import { cloneDeep, isEqual } from '@vben-core/shared/utils'; |
|||
|
|||
export interface FormCustomFieldContext { |
|||
/** 已注册的取值函数,同一表单项只接受一个 */ |
|||
customValue: ShallowRef<(() => any) | undefined>; |
|||
disabled: ComputedRef<boolean>; |
|||
error: Readonly<Ref<string | undefined>>; |
|||
fieldName: string; |
|||
resetValidation: () => void; |
|||
setValue: (value: any) => void; |
|||
validateWithTrigger: (trigger: FormValidationTrigger) => void; |
|||
value: Readonly<Ref<any>>; |
|||
} |
|||
|
|||
export interface UseCustomFieldValueOptions { |
|||
/** 取值为对象/数组且原地修改时开启 */ |
|||
deep?: boolean; |
|||
/** 挂载时把当前值写入表单(不触发校验) */ |
|||
immediate?: boolean; |
|||
} |
|||
|
|||
export interface UseCustomFieldValueReturn<T> { |
|||
/** 表单项的禁用态(含表单级、schema 级、依赖计算) */ |
|||
disabled: ComputedRef<boolean>; |
|||
/** 表单项当前的校验错误 */ |
|||
error: Readonly<Ref<string | undefined>>; |
|||
/** 所在表单项的字段名,不在表单项内时为 undefined */ |
|||
fieldName: string | undefined; |
|||
/** 清除该表单项的校验状态 */ |
|||
resetValidation: () => void; |
|||
/** 表单中该字段的值,可用于响应 setValues / resetForm */ |
|||
value: ComputedRef<T | undefined>; |
|||
} |
|||
|
|||
const CUSTOM_FIELD_INJECTION_KEY: InjectionKey<FormCustomFieldContext> = Symbol( |
|||
'VbenFormCustomField', |
|||
); |
|||
|
|||
export function provideFormCustomField(context: FormCustomFieldContext) { |
|||
provide(CUSTOM_FIELD_INJECTION_KEY, context); |
|||
} |
|||
|
|||
/** |
|||
* 让 VbenForm 表单项内的自定义组件把自己的值交给表单。 |
|||
* |
|||
* 组件既没有绑定 `componentProps`(插槽用法),也没有实现 `modelValue` 时, |
|||
* 表单拿不到它的值,schema 上的 rules 也就无从校验。调用该函数后,取值函数的 |
|||
* 结果会写回表单字段,并按表单项的 `validateOn` 触发校验。 |
|||
* |
|||
* 值仍归表单所有:组件应继续用 `modelValue` 接收表单下发的值(插槽用法就是 |
|||
* `v-bind="slotProps.componentProps"`),`setValues`、重置才能顺着 props 流回组件。 |
|||
* 只有完全自持内部状态的组件,才需要用返回的 `value` 自行同步。 |
|||
*/ |
|||
export function useCustomFieldValue<T = any>( |
|||
customValue: () => T, |
|||
options: UseCustomFieldValueOptions = {}, |
|||
): UseCustomFieldValueReturn<T> { |
|||
const field = inject(CUSTOM_FIELD_INJECTION_KEY, null); |
|||
|
|||
if (!field) { |
|||
console.warn('useCustomFieldValue 只能在 VbenForm 的表单项内部使用'); |
|||
return { |
|||
disabled: computed(() => false), |
|||
error: computed(() => undefined), |
|||
fieldName: undefined, |
|||
resetValidation: () => {}, |
|||
value: computed(() => undefined), |
|||
}; |
|||
} |
|||
|
|||
// 一个字段只能有一个值来源,后来者会互相覆盖
|
|||
if (field.customValue.value) { |
|||
console.warn( |
|||
`表单项 ${field.fieldName} 已存在自定义取值函数,本次注册被忽略`, |
|||
); |
|||
} else { |
|||
field.customValue.value = customValue; |
|||
|
|||
onUnmounted(() => { |
|||
if (field.customValue.value === customValue) { |
|||
field.customValue.value = undefined; |
|||
} |
|||
}); |
|||
|
|||
// deep 时组件原地改的就是这个对象,存一份副本进表单,
|
|||
// 下次比较才不是拿它跟自己比,原地改动也就不会被当成没变
|
|||
const toFormValue = (value: T) => |
|||
options.deep ? cloneDeep(toRaw(value)) : value; |
|||
|
|||
if (options.immediate) { |
|||
field.setValue(toFormValue(customValue())); |
|||
} |
|||
|
|||
watch( |
|||
customValue, |
|||
(value) => { |
|||
// 组件由表单驱动(v-model / componentProps)时,setValues、重置下发的新值
|
|||
// 会经组件再流回这里,此时值与表单一致:不重复写回,也不触发校验,
|
|||
// 否则一点重置就立刻冒出必填错误
|
|||
if (isEqual(value, toRaw(field.value.value))) { |
|||
return; |
|||
} |
|||
field.setValue(toFormValue(value)); |
|||
field.resetValidation(); |
|||
field.validateWithTrigger('change'); |
|||
}, |
|||
{ deep: options.deep }, |
|||
); |
|||
} |
|||
|
|||
return { |
|||
disabled: field.disabled, |
|||
error: field.error, |
|||
fieldName: field.fieldName, |
|||
resetValidation: field.resetValidation, |
|||
value: computed(() => field.value.value as T | undefined), |
|||
}; |
|||
} |
|||
@ -0,0 +1,37 @@ |
|||
<script lang="ts" setup> |
|||
import { useCustomFieldValue } from '@vben/common-ui'; |
|||
|
|||
import { CheckableTag } from 'antdv-next'; |
|||
|
|||
const options = ['前端', '后端', '运维', '测试']; |
|||
|
|||
// 值归表单所有:表单通过 componentProps 的 modelValue 下发,组件只负责 emit 回去, |
|||
// 所以 setValues、重置能顺着 props 流回来,组件里不必再存一份选中项 |
|||
const modelValue = defineModel<string[]>({ default: () => [] }); |
|||
|
|||
// 选中项不落在单个原生控件上,靠 useCustomFieldValue 把它交给表单项做校验 |
|||
const { disabled } = useCustomFieldValue(() => modelValue.value); |
|||
|
|||
function toggle(option: string, checked: boolean) { |
|||
if (disabled.value) { |
|||
return; |
|||
} |
|||
modelValue.value = checked |
|||
? [...modelValue.value, option] |
|||
: modelValue.value.filter((item) => item !== option); |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="flex w-full items-center gap-1"> |
|||
<CheckableTag |
|||
v-for="option in options" |
|||
:key="option" |
|||
:checked="modelValue.includes(option)" |
|||
:disabled="disabled" |
|||
@update:checked="(checked: boolean) => toggle(option, checked)" |
|||
> |
|||
{{ option }} |
|||
</CheckableTag> |
|||
</div> |
|||
</template> |
|||
Loading…
Reference in new issue