diff --git a/.changeset/calm-apes-select.md b/.changeset/calm-apes-select.md new file mode 100644 index 000000000..10d89e057 --- /dev/null +++ b/.changeset/calm-apes-select.md @@ -0,0 +1,5 @@ +--- +'@vben/common-ui': patch +--- + +fix: forward ApiComponent updates for custom model value props diff --git a/packages/effects/common-ui/src/components/api-component/__tests__/api-component.test.ts b/packages/effects/common-ui/src/components/api-component/__tests__/api-component.test.ts new file mode 100644 index 000000000..a3a7a6e06 --- /dev/null +++ b/packages/effects/common-ui/src/components/api-component/__tests__/api-component.test.ts @@ -0,0 +1,131 @@ +import { mount } from '@vue/test-utils'; +import { defineComponent, h, markRaw, nextTick, ref } from 'vue'; + +import { describe, expect, it, vi } from 'vitest'; + +import ApiComponent from '../api-component.vue'; + +const ValueInput = defineComponent({ + name: 'ValueInput', + props: { + value: { type: String, default: undefined }, + }, + emits: ['update:value'], + setup(props, { emit }) { + return () => + h( + 'button', + { onClick: () => emit('update:value', 'selected') }, + props.value, + ); + }, +}); + +const ModelValueInput = defineComponent({ + name: 'ModelValueInput', + props: { + modelValue: { type: String, default: undefined }, + }, + emits: ['update:modelValue'], + setup(props, { emit }) { + return () => + h( + 'button', + { onClick: () => emit('update:modelValue', 'selected') }, + props.modelValue, + ); + }, +}); + +const KebabModelInput = defineComponent({ + name: 'KebabModelInput', + props: { + modelValue: { type: String, default: undefined }, + }, + emits: ['update:modelValue'], + setup(props, { emit }) { + return () => + h( + 'button', + { onClick: () => emit('update:modelValue', 'selected') }, + props.modelValue, + ); + }, +}); + +describe('api-component.vue', () => { + it('bridges a custom model prop in both directions', async () => { + const outerValue = ref('initial'); + const handleUpdate = vi.fn((value: string) => { + outerValue.value = value; + }); + const Harness = defineComponent({ + setup() { + return () => + h(ApiComponent, { + component: markRaw(ValueInput), + modelPropName: 'value', + value: outerValue.value, + 'onUpdate:value': handleUpdate, + }); + }, + }); + const wrapper = mount(Harness); + const input = wrapper.findComponent(ValueInput); + + expect(input.props('value')).toBe('initial'); + + await input.trigger('click'); + await nextTick(); + expect(handleUpdate).toHaveBeenCalledWith('selected'); + expect(input.props('value')).toBe('selected'); + + outerValue.value = 'external'; + await nextTick(); + expect(input.props('value')).toBe('external'); + }); + + it('preserves the default modelValue protocol', async () => { + const wrapper = mount(ApiComponent, { + props: { + component: markRaw(ModelValueInput), + modelValue: 'initial', + }, + }); + const input = wrapper.findComponent(ModelValueInput); + + expect(input.props('modelValue')).toBe('initial'); + + await input.trigger('click'); + await nextTick(); + expect(wrapper.emitted('update:modelValue')).toEqual([['selected']]); + expect(input.props('modelValue')).toBe('selected'); + }); + + it('bridges a kebab-case custom model prop', async () => { + const outerValue = ref('initial'); + const handleUpdate = vi.fn((value: string) => { + outerValue.value = value; + }); + const Harness = defineComponent({ + setup() { + return () => + h(ApiComponent, { + component: markRaw(KebabModelInput), + modelPropName: 'model-value', + 'model-value': outerValue.value, + 'onUpdate:model-value': handleUpdate, + }); + }, + }); + const wrapper = mount(Harness); + const input = wrapper.findComponent(KebabModelInput); + + expect(input.props('modelValue')).toBe('initial'); + + await input.trigger('click'); + await nextTick(); + expect(handleUpdate).toHaveBeenCalledWith('selected'); + expect(input.props('modelValue')).toBe('selected'); + }); +}); diff --git a/packages/effects/common-ui/src/components/api-component/api-component.vue b/packages/effects/common-ui/src/components/api-component/api-component.vue index 2c6d69317..1ea19380a 100644 --- a/packages/effects/common-ui/src/components/api-component/api-component.vue +++ b/packages/effects/common-ui/src/components/api-component/api-component.vue @@ -44,6 +44,14 @@ const emit = defineEmits<{ const modelValue = defineModel({ default: undefined }); const attrs = useAttrs(); +const usesDefaultModelValue = computed(() => { + return ['model-value', 'modelValue'].includes(props.modelPropName); +}); +const currentModelValue = computed(() => { + return usesDefaultModelValue.value + ? modelValue.value + : attrs[props.modelPropName]; +}); const innerParams = ref({}); const refOptions = ref([]); const loading = ref(false); @@ -89,13 +97,14 @@ const getOptions = computed(() => { }); const bindProps = computed(() => { + const updateEvent = `onUpdate:${props.modelPropName}`; return { - [props.modelPropName]: unref(modelValue), + [props.modelPropName]: unref(currentModelValue), [props.optionsPropName]: unref(getOptions), - [`onUpdate:${props.modelPropName}`]: (val: string) => { - modelValue.value = val; + [updateEvent]: (val: string) => { + updateModelValue(val); }, - ...objectOmit(attrs, [`onUpdate:${props.modelPropName}`]), + ...objectOmit(attrs, [props.modelPropName, updateEvent]), ...(props.visibleEvent ? { [props.visibleEvent]: handleFetchForVisible, @@ -104,6 +113,17 @@ const bindProps = computed(() => { }; }); +function updateModelValue(value: any) { + if (usesDefaultModelValue.value) { + modelValue.value = value; + return; + } + const updateHandler = attrs[`onUpdate:${props.modelPropName}`]; + if (isFunction(updateHandler)) { + updateHandler(value); + } +} + async function fetchApi() { const { api, beforeFetch, shouldFetch, afterFetch, resultField } = props; @@ -192,7 +212,7 @@ watch( function emitChange() { if ( - modelValue.value === undefined && + currentModelValue.value === undefined && props.autoSelect && unref(getOptions).length > 0 ) { @@ -218,7 +238,7 @@ function emitChange() { } } - if (firstOption) modelValue.value = firstOption.value; + if (firstOption) updateModelValue(firstOption.value); } emit('optionsChange', unref(getOptions)); } @@ -227,7 +247,7 @@ defineExpose({ /** 获取options数据 */ getOptions: () => unref(getOptions), /** 获取当前值 */ - getValue: () => unref(modelValue), + getValue: () => unref(currentModelValue), /** 获取被包装的组件实例 */ getComponentRef: () => componentRef.value as T, /** 更新Api参数 */