committed by
GitHub
11 changed files with 620 additions and 15 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