From b0d43dc2775a2241499de9ab1b165a5ad3febcf3 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Wed, 2 Sep 2026 16:44:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(editor,form):=20=E5=90=88=E5=B9=B6=E5=AE=BF?= =?UTF-8?q?=E4=B8=BB=20FORM=5FCONTEXT=20=E5=B9=B6=E4=BF=AE=E5=A4=8D=20form?= =?UTF-8?q?State=20mForm=20=E8=87=AA=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 编辑器不再遮蔽外层 provide 的业务上下文。 formState Proxy 支持 Vue2 时代 vm.mForm 跨字段通信, 并修复 context 自引用导致的栈溢出。 Select 对 valueKey 路径使用可选链。 --- packages/editor/src/hooks/use-form-context.ts | 28 +- packages/editor/tests/unit/Editor.spec.ts | 51 ++- .../tests/unit/components/CompareForm.spec.ts | 28 +- .../tests/unit/components/ViewForm.spec.ts | 3 +- .../tests/unit/hooks/use-compare-form.spec.ts | 3 +- .../tests/unit/hooks/use-form-context.spec.ts | 128 +++++++ .../layouts/props-panel/FormPanel.spec.ts | 24 +- packages/form/src/fields/Select.vue | 2 +- packages/form/src/utils/formStateProxy.ts | 55 ++- .../tests/unit/fields/Select.remote.spec.ts | 311 +++++++++++++++ .../form/tests/unit/fields/Select.spec.ts | 359 ++++++++++++++++++ .../tests/unit/utils/formStateProxy.spec.ts | 71 ++++ 12 files changed, 1045 insertions(+), 18 deletions(-) create mode 100644 packages/editor/tests/unit/hooks/use-form-context.spec.ts create mode 100644 packages/form/tests/unit/fields/Select.remote.spec.ts diff --git a/packages/editor/src/hooks/use-form-context.ts b/packages/editor/src/hooks/use-form-context.ts index d759c996..57280c97 100644 --- a/packages/editor/src/hooks/use-form-context.ts +++ b/packages/editor/src/hooks/use-form-context.ts @@ -16,9 +16,9 @@ * limitations under the License. */ -import { computed, type ComputedRef } from 'vue'; +import { computed, type ComputedRef, inject, unref } from 'vue'; -import type { FormContext } from '@tmagic/form'; +import { FORM_CONTEXT_KEY, type FormContext, mergeFormContexts } from '@tmagic/form'; import type { Services } from '@editor/type'; @@ -28,13 +28,29 @@ import type { Services } from '@editor/type'; * 由 `Editor.vue`(provide `FORM_CONTEXT_KEY`)、`FormPanel.vue` 与 `useCompareForm` * 共用。`stage` 走 computed 而非快照,保证切换画布后配置回调读到的是最新实例。 * + * 宿主可能在 `` 外层 provide 了自己的业务字段,这里必须把那一层合并进来: + * 否则编辑器再 provide 一次会把宿主整层遮蔽掉,属性面板、对比表单里的配置回调就 + * 读不到宿主字段了。`services` / `stage` 由编辑器兜底,优先级高于宿主同名字段。 + * * 字段类型见 `@editor/type` 里对 `@tmagic/form-schema` 的 `FormContext` 模块增强。 */ -export const useEditorFormContext = (getServices: () => Services | undefined): ComputedRef => - computed(() => { +export const useEditorFormContext = (getServices: () => Services | undefined): ComputedRef => { + // inject 只能在 setup 期取值,因此本 hook 必须在 setup 中调用(`use` 前缀即此约定)。 + // 返回的 computed 可以随便传递,但 hook 本身不能延迟到事件回调或 onMounted 里再调。 + const hostContext = inject(FORM_CONTEXT_KEY, undefined); + + return computed(() => { const services = getServices(); - return { + const host = unref(hostContext); + + // 上层(通常是 Editor.vue)已经用同一份 services 合并过了。FormPanel / useCompareForm + // 都是它的后代,再合并一次只会多套一层 Proxy,让每次属性 miss 多一轮线性查找。 + // services 相同即可判定 stage 也相同——两边都是从同一个 editorService 读的。 + if (host && host.services === services) return host; + + return mergeFormContexts(host, { services, stage: services?.editorService.get('stage'), - }; + }); }); +}; diff --git a/packages/editor/tests/unit/Editor.spec.ts b/packages/editor/tests/unit/Editor.spec.ts index 73073479..b30cb0a1 100644 --- a/packages/editor/tests/unit/Editor.spec.ts +++ b/packages/editor/tests/unit/Editor.spec.ts @@ -4,7 +4,7 @@ * Copyright (C) 2025 Tencent. */ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { type ComputedRef, defineComponent, h, inject, nextTick } from 'vue'; +import { computed, type ComputedRef, defineComponent, h, inject, nextTick, provide, ref } from 'vue'; import { mount } from '@vue/test-utils'; import { FORM_CONTEXT_KEY, type FormContext } from '@tmagic/form'; @@ -184,6 +184,55 @@ describe('Editor', () => { expect(context.stage).toBe(stageStub); }); + test('合并宿主在外层 provide 的 FORM_CONTEXT_KEY,不遮蔽其业务字段', async () => { + const hostComponent = defineComponent({ + setup: () => { + provide( + FORM_CONTEXT_KEY, + computed(() => ({ username: 'tester', editor: { editorCore: {} } }) as unknown as FormContext), + ); + return () => h(Editor, {} as any); + }, + }); + + mount(hostComponent); + await nextTick(); + + const context = injectedFormContext!.value as any; + expect(context.username).toBe('tester'); + expect(context.editor.editorCore).toBeDefined(); + // 编辑器自己的字段优先级更高,仍然兜底 + expect(context.services.editorService).toBeDefined(); + expect(context.stage).toBe(stageStub); + }); + + test('宿主上下文的 accessor 保持读时求值', async () => { + const counter = ref(0); + const hostComponent = defineComponent({ + setup: () => { + provide( + FORM_CONTEXT_KEY, + computed( + () => + ({ + get buildVersion() { + return counter.value; + }, + }) as unknown as FormContext, + ), + ); + return () => h(Editor, {} as any); + }, + }); + + mount(hostComponent); + await nextTick(); + + expect((injectedFormContext!.value as any).buildVersion).toBe(0); + counter.value = 7; + expect((injectedFormContext!.value as any).buildVersion).toBe(7); + }); + test('stage 走 computed 读时求值,切换画布后能读到新实例', async () => { const editorServiceMod = (await import('@editor/services/editor')) as any; mount(Editor, { props: {} as any }); diff --git a/packages/editor/tests/unit/components/CompareForm.spec.ts b/packages/editor/tests/unit/components/CompareForm.spec.ts index 021abc2f..2d18c11f 100644 --- a/packages/editor/tests/unit/components/CompareForm.spec.ts +++ b/packages/editor/tests/unit/components/CompareForm.spec.ts @@ -4,10 +4,11 @@ * Copyright (C) 2025 Tencent. */ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { defineComponent, h, nextTick, ref } from 'vue'; +import { computed, defineComponent, h, nextTick, ref } from 'vue'; import { mount, type VueWrapper } from '@vue/test-utils'; import { HookType } from '@tmagic/core'; +import { FORM_CONTEXT_KEY } from '@tmagic/form'; import CompareForm from '@editor/components/CompareForm.vue'; @@ -44,7 +45,8 @@ vi.mock('@editor/utils/code-block', () => ({ getCodeBlockFormConfig: vi.fn(() => [{ type: 'text', name: 'content' }]), })); -vi.mock('@tmagic/form', () => ({ +vi.mock('@tmagic/form', async (importOriginal) => ({ + ...(await importOriginal()), MForm: defineComponent({ name: 'MForm', props: ['config', 'initValues', 'lastValues', 'isCompare', 'disabled', 'labelWidth', 'context', 'showDiff'], @@ -95,6 +97,28 @@ describe('CompareForm.vue', () => { expect(capturedFormProps.context).toHaveProperty('stage'); }); + test('宿主 provide 的上下文不被遮蔽,且 services 由编辑器兜底', async () => { + const wrapper = mount(CompareForm, { + props: { + category: 'node', + type: 'text', + value: { id: 'n1' }, + lastValue: { id: 'n1' }, + services, + }, + global: { + provide: { + codeOptions: {}, + [FORM_CONTEXT_KEY as symbol]: computed(() => ({ username: 'alice', services: 'host-services' })), + }, + }, + }); + await waitForFormReady(wrapper); + + expect(capturedFormProps.context.username).toBe('alice'); + expect(capturedFormProps.context.services).toEqual(services); + }); + test('node 类别缺少 type 时不渲染 MForm', async () => { const wrapper = mount(CompareForm, { props: { diff --git a/packages/editor/tests/unit/components/ViewForm.spec.ts b/packages/editor/tests/unit/components/ViewForm.spec.ts index ec5ac061..a62ed3ee 100644 --- a/packages/editor/tests/unit/components/ViewForm.spec.ts +++ b/packages/editor/tests/unit/components/ViewForm.spec.ts @@ -35,7 +35,8 @@ vi.mock('@editor/utils/code-block', () => ({ getCodeBlockFormConfig: vi.fn(() => [{ type: 'text', name: 'content' }]), })); -vi.mock('@tmagic/form', () => ({ +vi.mock('@tmagic/form', async (importOriginal) => ({ + ...(await importOriginal()), MForm: defineComponent({ name: 'MForm', props: ['config', 'initValues', 'disabled', 'labelWidth', 'context', 'size'], diff --git a/packages/editor/tests/unit/hooks/use-compare-form.spec.ts b/packages/editor/tests/unit/hooks/use-compare-form.spec.ts index 974f272d..4941a484 100644 --- a/packages/editor/tests/unit/hooks/use-compare-form.spec.ts +++ b/packages/editor/tests/unit/hooks/use-compare-form.spec.ts @@ -46,7 +46,8 @@ vi.mock('@editor/utils/code-block', () => ({ }), })); -vi.mock('@tmagic/form', () => ({ +vi.mock('@tmagic/form', async (importOriginal) => ({ + ...(await importOriginal()), MForm: defineComponent({ name: 'MForm', setup(_, { expose }) { diff --git a/packages/editor/tests/unit/hooks/use-form-context.spec.ts b/packages/editor/tests/unit/hooks/use-form-context.spec.ts new file mode 100644 index 00000000..f375e15c --- /dev/null +++ b/packages/editor/tests/unit/hooks/use-form-context.spec.ts @@ -0,0 +1,128 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { describe, expect, test } from 'vitest'; +import { computed, type ComputedRef, defineComponent, h, provide, ref } from 'vue'; +import { mount } from '@vue/test-utils'; + +import { FORM_CONTEXT_KEY, type FormContext } from '@tmagic/form'; + +import { useEditorFormContext } from '@editor/hooks/use-form-context'; +import type { Services } from '@editor/type'; + +const makeServices = (stage: any = { name: 'stage' }): Services => + ({ + editorService: { get: (key: string) => (key === 'stage' ? stage : undefined) }, + propsService: {}, + }) as unknown as Services; + +/** + * 在组件 setup 里调用 hook 并把结果取出来,可选地先在外层 provide 宿主上下文。 + */ +const setup = (getServices: () => Services | undefined, hostContext?: ComputedRef) => { + let context: ComputedRef | undefined; + + const inner = defineComponent({ + setup() { + context = useEditorFormContext(getServices); + return () => h('div'); + }, + }); + + const outer = defineComponent({ + setup() { + if (hostContext) provide(FORM_CONTEXT_KEY, hostContext); + return () => h(inner); + }, + }); + + mount(outer); + + return context!; +}; + +describe('useEditorFormContext', () => { + test('没有宿主上下文时只提供 services 与 stage', () => { + const stage = { name: 'stage-1' }; + const context = setup(() => makeServices(stage)) as any; + + expect(context.value.services.editorService).toBeDefined(); + expect(context.value.stage).toBe(stage); + }); + + test('getServices 返回 undefined 时不抛错', () => { + const context = setup(() => undefined) as any; + + expect(context.value.services).toBeUndefined(); + expect(context.value.stage).toBeUndefined(); + }); + + test('stage 读时求值,切换画布后拿到新实例', () => { + const stage = ref({ name: 'stage-1' }); + const context = setup(() => makeServices(stage.value)) as any; + + expect(context.value.stage).toEqual({ name: 'stage-1' }); + stage.value = { name: 'stage-2' }; + expect(context.value.stage).toEqual({ name: 'stage-2' }); + }); + + test('合并宿主在外层 provide 的上下文,宿主字段不被遮蔽', () => { + const host = computed(() => ({ username: 'alice', $store: { getters: {} } }) as unknown as FormContext); + const context = setup(() => makeServices(), host) as any; + + expect(context.value.username).toBe('alice'); + expect(context.value.$store.getters).toBeDefined(); + expect(context.value.services).toBeDefined(); + }); + + test('services / stage 由编辑器兜底,优先级高于宿主同名字段', () => { + const stage = { name: 'editor-stage' }; + const host = computed(() => ({ stage: 'host-stage', services: 'host-services' }) as unknown as FormContext); + const context = setup(() => makeServices(stage), host) as any; + + expect(context.value.stage).toBe(stage); + expect(context.value.services).not.toBe('host-services'); + }); + + /** + * Editor.vue 已经用同一份 services 合并过,FormPanel / useCompareForm 都是它的后代。 + * 再合并一次只会多套一层 Proxy,让每次属性 miss 多一轮线性查找。 + */ + test('上层已用同一份 services 合并过时直接复用,不再套一层 Proxy', () => { + const services = makeServices(); + const host = computed(() => ({ services, username: 'alice' }) as unknown as FormContext); + const context = setup(() => services, host) as any; + + expect(context.value).toBe(host.value); + expect(context.value.username).toBe('alice'); + }); + + test('services 不同则仍然合并,编辑器的 services 优先', () => { + const hostServices = makeServices(); + const ownServices = makeServices({ name: 'own-stage' }); + const host = computed(() => ({ services: hostServices }) as unknown as FormContext); + const context = setup(() => ownServices, host) as any; + + expect(context.value).not.toBe(host.value); + expect(context.value.services).toBe(ownServices); + }); + + test('宿主上下文里的 accessor 保持读时求值', () => { + const counter = ref(0); + const host = computed( + () => + ({ + get buildVersion() { + return counter.value; + }, + }) as unknown as FormContext, + ); + const context = setup(() => makeServices(), host) as any; + + expect(context.value.buildVersion).toBe(0); + counter.value = 3; + expect(context.value.buildVersion).toBe(3); + }); +}); diff --git a/packages/editor/tests/unit/layouts/props-panel/FormPanel.spec.ts b/packages/editor/tests/unit/layouts/props-panel/FormPanel.spec.ts index be27cdd8..57bae94f 100644 --- a/packages/editor/tests/unit/layouts/props-panel/FormPanel.spec.ts +++ b/packages/editor/tests/unit/layouts/props-panel/FormPanel.spec.ts @@ -4,9 +4,11 @@ * Copyright (C) 2025 Tencent. */ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { defineComponent, h, nextTick } from 'vue'; +import { computed, defineComponent, h, nextTick } from 'vue'; import { mount } from '@vue/test-utils'; +import { FORM_CONTEXT_KEY } from '@tmagic/form'; + import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps'; import FormPanel from '@editor/layouts/props-panel/FormPanel.vue'; @@ -111,6 +113,26 @@ describe('FormPanel', () => { expect(wrapper.findComponent({ name: 'MForm' }).exists()).toBe(true); }); + /** + * 属性面板是宿主字段读不到时最先暴露问题的地方:编辑器自己 provide 一层 context 时 + * 不能把宿主在 外层 provide 的那层遮蔽掉。 + */ + test('宿主 provide 的上下文不被遮蔽,且 services / stage 由编辑器兜底', () => { + const wrapper = mount(FormPanel, { + props: { config: [], values: {} } as any, + global: { + provide: { + [FORM_CONTEXT_KEY as symbol]: computed(() => ({ username: 'alice', services: 'host-services' })), + }, + }, + }); + + const context = wrapper.findComponent({ name: 'MForm' }).props('context') as any; + expect(context.username).toBe('alice'); + expect(context.services).not.toBe('host-services'); + expect(context.stage).toEqual({ id: 'stage' }); + }); + test('mounted 事件 emit', async () => { const wrapper = mount(FormPanel, { props: { config: [], values: {} } as any }); await nextTick(); diff --git a/packages/form/src/fields/Select.vue b/packages/form/src/fields/Select.vue index 218dc94f..9a9c447f 100644 --- a/packages/form/src/fields/Select.vue +++ b/packages/form/src/fields/Select.vue @@ -57,7 +57,7 @@ Reflect.has(t, k) || (typeof k !== 'symbol' && k in resolve()), + // `mForm` 在这里为真,但不进 ownKeys、getOwnPropertyDescriptor 也返回 undefined + // (见 SELF_REF_KEY 注释)。因此展开 / Object.entries 不会循环引用, + // 但对 proxy 直接做递归遍历(`for...in`、深拷贝、直接 JSON.stringify(formState))仍会。 + has: (t, k) => Reflect.has(t, k) || (typeof k !== 'symbol' && (k in resolve() || k === SELF_REF_KEY)), ownKeys: (t) => [ ...new Set([...Reflect.ownKeys(t), ...Reflect.ownKeys(resolve()).filter((k) => typeof k !== 'symbol')]), ], @@ -101,4 +144,6 @@ export const createFormStateProxy = ( return { configurable: true, enumerable: true, writable: true, value: ctx[k] }; }, }) as FormState; + + return proxy; }; diff --git a/packages/form/tests/unit/fields/Select.remote.spec.ts b/packages/form/tests/unit/fields/Select.remote.spec.ts new file mode 100644 index 00000000..057e0960 --- /dev/null +++ b/packages/form/tests/unit/fields/Select.remote.spec.ts @@ -0,0 +1,311 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { defineComponent, h, nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; +import ElementPlus from 'element-plus'; + +/** + * 远程模式下的滚动分页依赖 `tMagicSelect.value.scrollbarWrap`,而真实 ElSelect 并不暴露它, + * 因此这里替换掉 TMagicSelect,换成一个只暴露所需实例方法的壳组件。 + * + * 壳组件不渲染默认插槽:el-option 需要 inject ElSelect 的上下文,脱离真实 select 会报错, + * 而这批用例只关心脚本里的远程加载逻辑,不关心选项怎么渲染。 + */ +const selectStub = { + scrollbarWrap: undefined as HTMLElement | undefined, + setQuery: vi.fn(), + setPreviousQuery: vi.fn(), + setSelectedLabel: vi.fn(), + setSelected: vi.fn(), +}; + +vi.mock('@tmagic/design', async (importOriginal) => ({ + ...(await importOriginal()), + TMagicSelect: defineComponent({ + name: 'TMagicSelectStub', + props: { modelValue: null, remoteMethod: { type: Function, default: undefined } }, + emits: ['update:modelValue', 'visible-change'], + setup(_props, { expose }) { + expose(selectStub); + return () => h('div', { class: 'stub-select' }); + }, + }), +})); + +const MagicForm = (await import('@form/index')).default; +const { MForm, MSelect } = await import('@form/index'); +const { setConfig } = await import('@form/utils/config'); + +let request: ReturnType; + +const flushAsync = async () => { + await nextTick(); + await new Promise((r) => setTimeout(r, 0)); + await nextTick(); +}; + +const mountSelect = (config: any, initValues: any = {}) => + mount(MForm, { + global: { plugins: [ElementPlus as any, [MagicForm as any, { request }]] }, + props: { config: [{ name: 's', type: 'select', text: 's', ...config }], initValues }, + }); + +const getStub = (wrapper: any) => wrapper.findComponent({ name: 'TMagicSelectStub' }); + +/** + * 远程用例统一带上 initUrl。 + * + * MForm 会把无初值的字段初始化成 ''(不是 undefined),而 getInitOption 在没有 initUrl 时 + * 会直接落到 getInitLocalOption 去打列表接口,把结果缓存进 localOptions——后续 getOptions + * 就命中缓存不再发请求,远程分支全都测不到。带上 initUrl 后,空值会在 init 的空值守卫处 + * 提前返回,localOptions 保持为空,展开 / 搜索 / 触底才会真正走远程加载。 + */ +const remoteOption = (extra: any = {}) => ({ + url: 'https://example.com/list', + initUrl: 'https://example.com/init', + root: 'data.list', + totalKey: 'total', + ...extra, +}); + +beforeEach(() => { + const wrap = document.createElement('div'); + selectStub.scrollbarWrap = wrap; + selectStub.setQuery.mockClear(); + selectStub.setPreviousQuery.mockClear(); + selectStub.setSelectedLabel.mockClear(); + selectStub.setSelected.mockClear(); + + request = vi.fn(async () => ({ data: { list: [{ text: 'A', value: 'a' }] }, total: 50 })); + setConfig({ request }); +}); + +afterEach(() => { + setConfig({}); +}); + +describe('Select - visibleHandler', () => { + test('下拉收起时不触发请求', async () => { + const wrapper = mountSelect({ + remote: true, + option: remoteOption(), + }); + await flushAsync(); + request.mockClear(); + + getStub(wrapper).vm.$emit('visible-change', false); + await flushAsync(); + + expect(request).not.toHaveBeenCalled(); + }); + + test('非 remote 配置展开下拉时不触发请求', async () => { + const wrapper = mountSelect({ option: remoteOption() }); + await flushAsync(); + request.mockClear(); + + getStub(wrapper).vm.$emit('visible-change', true); + await flushAsync(); + + expect(request).not.toHaveBeenCalled(); + }); + + test('remote 展开且选项不足时拉取选项', async () => { + const wrapper = mountSelect({ + remote: true, + option: remoteOption(), + }); + await flushAsync(); + request.mockClear(); + + getStub(wrapper).vm.$emit('visible-change', true); + await flushAsync(); + + expect(request).toHaveBeenCalledTimes(1); + expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([{ text: 'A', value: 'a' }]); + }); + + test('已有搜索词时展开下拉,回填搜索状态而不重新拉取', async () => { + const wrapper = mountSelect({ + remote: true, + option: remoteOption(), + }); + await flushAsync(); + + // 先通过远程搜索写入 query + await getStub(wrapper).props('remoteMethod')('kw'); + await flushAsync(); + request.mockClear(); + + getStub(wrapper).vm.$emit('visible-change', true); + await flushAsync(); + + expect(selectStub.setQuery).toHaveBeenCalledWith('kw'); + expect(selectStub.setPreviousQuery).toHaveBeenCalledWith('kw'); + expect(selectStub.setSelectedLabel).toHaveBeenCalledWith('kw'); + expect(request).not.toHaveBeenCalled(); + }); +}); + +describe('Select - remoteMethod', () => { + test('远程搜索会重置分页并按关键词请求', async () => { + const wrapper = mountSelect({ + remote: true, + option: remoteOption(), + }); + await flushAsync(); + request.mockClear(); + + await getStub(wrapper).props('remoteMethod')('kw'); + await flushAsync(); + + expect(request).toHaveBeenCalledTimes(1); + expect(request.mock.calls[0][0].data).toMatchObject({ query: 'kw', pgIndex: 0 }); + }); + + test('多选远程搜索后刷新已选状态', async () => { + const wrapper = mountSelect( + { + remote: true, + multiple: true, + option: remoteOption(), + }, + { s: [] }, + ); + await flushAsync(); + + await getStub(wrapper).props('remoteMethod')('kw'); + await flushAsync(); + + expect(selectStub.setSelected).toHaveBeenCalled(); + }); + + test('已有本地选项时远程搜索不再请求', async () => { + // 无 initUrl:初始化会走 getInitLocalOption,把结果缓存进 localOptions + const wrapper = mountSelect( + { + remote: true, + option: { url: 'https://example.com/list', root: 'data.list' }, + }, + { s: 'a' }, + ); + await flushAsync(); + request.mockClear(); + + await getStub(wrapper).props('remoteMethod')('kw'); + await flushAsync(); + + expect(request).not.toHaveBeenCalled(); + }); +}); + +describe('Select - 滚动分页', () => { + const scrollToBottom = async (wrapper: any) => { + selectStub.scrollbarWrap!.dispatchEvent(new Event('scroll')); + await flushAsync(); + return wrapper; + }; + + const remoteConfig = { remote: true, option: remoteOption() }; + + test('触底且仍有剩余数据时加载下一页', async () => { + const wrapper = mountSelect(remoteConfig); + await flushAsync(); + + // 先展开一次填充 options 与 total(total=50 > 已加载 1 条) + getStub(wrapper).vm.$emit('visible-change', true); + await flushAsync(); + request.mockClear(); + + await scrollToBottom(wrapper); + + expect(request).toHaveBeenCalledTimes(1); + expect(request.mock.calls[0][0].data).toMatchObject({ pgIndex: 1 }); + }); + + test('已加载条数达到总数时触底不再请求', async () => { + request = vi.fn(async () => ({ data: { list: [{ text: 'A', value: 'a' }] }, total: 1 })); + setConfig({ request }); + + const wrapper = mountSelect(remoteConfig); + await flushAsync(); + getStub(wrapper).vm.$emit('visible-change', true); + await flushAsync(); + request.mockClear(); + + await scrollToBottom(wrapper); + + expect(request).not.toHaveBeenCalled(); + }); + + test('未触底时不加载下一页', async () => { + const wrapper = mountSelect(remoteConfig); + await flushAsync(); + getStub(wrapper).vm.$emit('visible-change', true); + await flushAsync(); + request.mockClear(); + + // 距底部还有距离 + Object.defineProperty(selectStub.scrollbarWrap!, 'scrollHeight', { value: 500, configurable: true }); + Object.defineProperty(selectStub.scrollbarWrap!, 'clientHeight', { value: 100, configurable: true }); + await scrollToBottom(wrapper); + + expect(request).not.toHaveBeenCalled(); + }); +}); + +describe('Select - 多选保留已选项', () => { + test('新一页结果不含已选项时,已选项仍保留在选项列表里', async () => { + // initUrl 让初始化走 init 接口,localOptions 保持为空, + // 这样后续展开下拉才会真正调用列表接口 + request = vi.fn(async (postOptions: Record) => { + if (postOptions.url.includes('init')) { + return { data: { list: [{ text: 'A', value: 'a' }] } }; + } + return { data: { list: [{ text: 'B', value: 'b' }] }, total: 50 }; + }); + setConfig({ request }); + + const wrapper = mountSelect( + { + remote: true, + multiple: true, + option: { + url: 'https://example.com/list', + initUrl: 'https://example.com/init', + initRoot: 'data.list', + root: 'data.list', + totalKey: 'total', + }, + }, + { s: ['a'] }, + ); + await flushAsync(); + + expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([{ text: 'A', value: 'a' }]); + + getStub(wrapper).vm.$emit('visible-change', true); + await flushAsync(); + + expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([ + { text: 'A', value: 'a' }, + { text: 'B', value: 'b' }, + ]); + }); +}); + +describe('Select - changeHandler', () => { + test('选中值变化时向上抛 change', async () => { + const wrapper = mountSelect({ options: [{ text: 'A', value: 'a' }] }, { s: '' }); + await flushAsync(); + + getStub(wrapper).vm.$emit('update:modelValue', 'a'); + await flushAsync(); + + expect(wrapper.findComponent(MSelect).emitted('change')?.[0]).toEqual(['a']); + }); +}); diff --git a/packages/form/tests/unit/fields/Select.spec.ts b/packages/form/tests/unit/fields/Select.spec.ts index 72b1f07a..4053f2f1 100644 --- a/packages/form/tests/unit/fields/Select.spec.ts +++ b/packages/form/tests/unit/fields/Select.spec.ts @@ -76,6 +76,81 @@ describe('Select', () => { expect(wrapper.findComponent(MSelect).exists()).toBe(true); }); + /** + * key 必须始终取 valueKey 指向的值。曾经写成 `valueKey && option.value?.[valueKey] ? ... : option.value`, + * 把嵌套值当布尔用,导致 `{ id: 0 }` 这类假值退化成「整个对象」当 key: + * valueKey 被静默忽略、key 类型在数字与对象间不一致,且对象引用每次重建列表都会变, + * Vue 只能销毁重建而非按 key 复用。对象值下拉里 id 为 0 很常见。 + */ + test('valueKey 指向假值时,key 仍取该值而非整个对象', async () => { + const wrapper = mountForm( + [ + { + name: 's', + type: 'select', + text: 's', + valueKey: 'id', + options: [ + { text: 'A', value: { id: 0 } }, + { text: 'B', value: { id: 1 } }, + { text: 'C', value: { id: '' } }, + ], + }, + ], + { s: { id: 1 } }, + ); + await nextTick(); + await nextTick(); + + const keys = wrapper.findAllComponents({ name: 'ElOption' }).map((o) => (o.vm as any).$.vnode.key); + expect(keys).toEqual([0, 1, '']); + }); + + test('valueKey 配置下 option.value 为 null 不抛错', async () => { + const wrapper = mountForm( + [ + { + name: 's', + type: 'select', + text: 's', + valueKey: 'id', + options: [ + { text: 'A', value: { id: 1 } }, + { text: 'B', value: null }, + ], + }, + ], + {}, + ); + await nextTick(); + await nextTick(); + + const keys = wrapper.findAllComponents({ name: 'ElOption' }).map((o) => (o.vm as any).$.vnode.key); + expect(keys).toEqual([1, null]); + }); + + test('未配置 valueKey 时 key 取 option.value 本身', async () => { + const wrapper = mountForm( + [ + { + name: 's', + type: 'select', + text: 's', + options: [ + { text: 'A', value: 0 }, + { text: 'B', value: 'b' }, + ], + }, + ], + {}, + ); + await nextTick(); + await nextTick(); + + const keys = wrapper.findAllComponents({ name: 'ElOption' }).map((o) => (o.vm as any).$.vnode.key); + expect(keys).toEqual([0, 'b']); + }); + test('multiple 多选', async () => { const wrapper = mountForm( [ @@ -333,3 +408,287 @@ describe('Select - config.option model value watch', () => { ); }); }); + +/** + * 无 initUrl 时走 getInitLocalOption -> getOptions(列表接口)这条路径, + * 覆盖 option 上的各类钩子与动态取值。 + */ +describe('Select - getOptions 列表接口分支', () => { + let request: ReturnType; + + const mountFormWithRequest = (config: any[], initValues: any = {}) => + mount(MForm, { + global: { plugins: [ElementPlus as any, [MagicForm as any, { request }]] }, + props: { config, initValues }, + }); + + const flushAsync = async () => { + await nextTick(); + await new Promise((r) => setTimeout(r, 0)); + await nextTick(); + }; + + const buildConfig = (option: any, extra: any = {}) => [{ name: 's', type: 'select', text: 's', ...extra, option }]; + + beforeEach(() => { + request = vi.fn(async () => ({ data: { list: [{ text: 'A', value: 'a' }] }, total: 50 })); + setConfig({ request }); + }); + + afterEach(() => { + setConfig({}); + vi.restoreAllMocks(); + }); + + test('url 与 body 为函数时先求值再请求,并带上分页参数', async () => { + const url = vi.fn(async () => 'https://example.com/dyn'); + const body = vi.fn(() => ({ extra: 1 })); + + mountFormWithRequest(buildConfig({ url, body, root: 'data.list' }), { s: 'a' }); + await flushAsync(); + + expect(url).toHaveBeenCalled(); + expect(body).toHaveBeenCalled(); + const arg = request.mock.calls[0][0]; + expect(arg.url).toBe('https://example.com/dyn'); + expect(arg.data).toMatchObject({ extra: 1, query: '', pgSize: 20, pgIndex: 0 }); + }); + + test('beforeRequest / afterRequest 钩子可改写请求与响应', async () => { + const beforeRequest = vi.fn(async (_mForm: any, postOptions: any) => ({ + ...postOptions, + headers: { token: 't' }, + })); + const afterRequest = vi.fn(async () => ({ data: { list: [{ text: 'AR', value: 'a' }] } })); + + const wrapper = mountFormWithRequest( + buildConfig({ url: 'https://example.com/list', root: 'data.list', beforeRequest, afterRequest }), + { s: 'a' }, + ); + await flushAsync(); + + expect(beforeRequest).toHaveBeenCalled(); + expect(afterRequest).toHaveBeenCalled(); + expect(request.mock.calls[0][0].headers).toEqual({ token: 't' }); + expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([{ text: 'AR', value: 'a' }]); + }); + + test('method 为 jsonp 时补 jsonpCallback', async () => { + mountFormWithRequest(buildConfig({ url: 'https://example.com/list', root: 'data.list', method: 'jsonp' }), { + s: 'a', + }); + await flushAsync(); + + expect(request.mock.calls[0][0]).toMatchObject({ method: 'jsonp', jsonpCallback: 'callback' }); + }); + + test('自定义 jsonpCallback 优先', async () => { + mountFormWithRequest( + buildConfig({ url: 'https://example.com/list', root: 'data.list', method: 'JSONP', jsonpCallback: 'cb' }), + { s: 'a' }, + ); + await flushAsync(); + + expect(request.mock.calls[0][0].jsonpCallback).toBe('cb'); + }); + + test('option.item 自定义映射结果,totalKey 命中时记录总数', async () => { + request = vi.fn(async () => ({ data: { list: [{ n: 'A', v: 'a' }] }, total: 50 })); + setConfig({ request }); + + const item = vi.fn((data: any[]) => data.map((d) => ({ text: d.n, value: d.v }))); + const wrapper = mountFormWithRequest( + buildConfig({ url: 'https://example.com/list', root: 'data.list', totalKey: 'total', item }), + { s: 'a' }, + ); + await flushAsync(); + + expect(item).toHaveBeenCalled(); + expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([{ text: 'A', value: 'a' }]); + }); + + test('option.text / option.value 为函数时按函数取值', async () => { + request = vi.fn(async () => ({ data: { list: [{ n: 'A', v: 'a' }] } })); + setConfig({ request }); + + const wrapper = mountFormWithRequest( + buildConfig({ + url: 'https://example.com/list', + root: 'data.list', + text: (i: any) => `T-${i.n}`, + value: (i: any) => i.v, + }), + { s: 'a' }, + ); + await flushAsync(); + + expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([{ text: 'T-A', value: 'a' }]); + }); + + test('valueKey 下用对象值比对,命中后不再重复请求', async () => { + request = vi.fn(async () => ({ data: { list: [{ text: 'A', value: { id: 'a' } }] } })); + setConfig({ request }); + + const wrapper = mountFormWithRequest( + buildConfig({ url: 'https://example.com/list', root: 'data.list' }, { valueKey: 'id' }), + { s: { id: 'a' } }, + ); + await flushAsync(); + + expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([{ text: 'A', value: { id: 'a' } }]); + + // 已有匹配项,hasOption 命中后不应再打接口 + const count = request.mock.calls.length; + (wrapper.vm as any).values.s = { id: 'a' }; + await flushAsync(); + expect(request.mock.calls.length).toBe(count); + }); + + test('group 单选:按组过滤出命中项所在的组', async () => { + request = vi.fn(async () => ({ data: { list: [{ text: 'A', value: 'a' }] } })); + setConfig({ request }); + + const item = vi.fn(() => [ + { label: 'G1', options: [{ text: 'A', value: 'a' }] }, + { label: 'G2', options: [{ text: 'B', value: 'b' }] }, + ]); + const wrapper = mountFormWithRequest( + buildConfig({ url: 'https://example.com/list', root: 'data.list', item }, { group: true }), + { s: 'a' }, + ); + await flushAsync(); + + const opts = (wrapper.findComponent(MSelect).vm as any).options; + expect(opts).toHaveLength(1); + expect(opts[0].label).toBe('G1'); + }); + + test('group 多选:按组过滤出命中项所在的组', async () => { + request = vi.fn(async () => ({ data: { list: [] } })); + setConfig({ request }); + + const item = vi.fn(() => [ + { label: 'G1', options: [{ text: 'A', value: 1 }] }, + { label: 'G2', options: [{ text: 'B', value: 2 }] }, + ]); + const wrapper = mountFormWithRequest( + buildConfig({ url: 'https://example.com/list', root: 'data.list', item }, { group: true, multiple: true }), + { s: [2] }, + ); + await flushAsync(); + + const opts = (wrapper.findComponent(MSelect).vm as any).options; + expect(opts).toHaveLength(1); + expect(opts[0].label).toBe('G2'); + }); + + test('本地选项已加载后再次触发不重复请求', async () => { + const wrapper = mountFormWithRequest(buildConfig({ url: 'https://example.com/list', root: 'data.list' }), { + s: 'a', + }); + await flushAsync(); + const count = request.mock.calls.length; + + // 换一个不在 options 里的值:会再次走 getInitLocalOption, + // 但 localOptions 已有缓存,getOptions 直接返回不再打接口 + (wrapper.vm as any).values.s = 'zzz'; + await flushAsync(); + + expect(request.mock.calls.length).toBe(count); + }); +}); + +/** + * initUrl 存在时走 getInitOption 这条路径,覆盖 init 专用的钩子与非数组响应。 + */ +describe('Select - getInitOption 初始化接口分支', () => { + let request: ReturnType; + + const mountFormWithRequest = (option: any, initValues: any = {}) => + mount(MForm, { + global: { plugins: [ElementPlus as any, [MagicForm as any, { request }]] }, + props: { config: [{ name: 's', type: 'select', text: 's', option }], initValues }, + }); + + const flushAsync = async () => { + await nextTick(); + await new Promise((r) => setTimeout(r, 0)); + await nextTick(); + }; + + beforeEach(() => { + request = vi.fn(async () => ({ data: { obj: { text: 'A', value: 'a' } } })); + setConfig({ request }); + }); + + afterEach(() => { + setConfig({}); + vi.restoreAllMocks(); + }); + + test('initUrl / initBody 为函数,且 beforeInitRequest 可改写请求', async () => { + const initUrl = vi.fn(async () => 'https://example.com/init-dyn'); + const initBody = vi.fn(() => ({ b: 1 })); + const beforeInitRequest = vi.fn(async (_mForm: any, postOptions: any) => ({ + ...postOptions, + headers: { token: 't' }, + })); + + mountFormWithRequest({ initUrl, initBody, beforeInitRequest, initRoot: 'data.obj' }, { s: 'a' }); + await flushAsync(); + + expect(initUrl).toHaveBeenCalled(); + expect(initBody).toHaveBeenCalled(); + expect(beforeInitRequest).toHaveBeenCalled(); + + const arg = request.mock.calls[0][0]; + expect(arg.url).toBe('https://example.com/init-dyn'); + expect(arg.data).toMatchObject({ id: 'a', b: 1 }); + expect(arg.headers).toEqual({ token: 't' }); + }); + + test('init 请求为 jsonp 时补 jsonpCallback', async () => { + mountFormWithRequest({ initUrl: 'https://example.com/init', initRoot: 'data.obj', method: 'jsonp' }, { s: 'a' }); + await flushAsync(); + + expect(request.mock.calls[0][0]).toMatchObject({ method: 'jsonp', jsonpCallback: 'callback' }); + }); + + test('afterRequest 可改写 init 响应;非数组结果会被包成数组', async () => { + const afterRequest = vi.fn(async () => ({ data: { obj: { text: 'ONE', value: 'a' } } })); + + const wrapper = mountFormWithRequest( + { initUrl: 'https://example.com/init', initRoot: 'data.obj', afterRequest }, + { s: 'a' }, + ); + await flushAsync(); + + expect(afterRequest).toHaveBeenCalled(); + expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([{ text: 'ONE', value: 'a' }]); + }); + + test('init 结果走 option.item 自定义映射', async () => { + request = vi.fn(async () => ({ data: { obj: [{ n: 'A', v: 'a' }] } })); + setConfig({ request }); + + const item = vi.fn((data: any[]) => data.map((d) => ({ text: d.n, value: d.v }))); + const wrapper = mountFormWithRequest( + { initUrl: 'https://example.com/init', initRoot: 'data.obj', item }, + { s: 'a' }, + ); + await flushAsync(); + + expect(item).toHaveBeenCalled(); + expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([{ text: 'A', value: 'a' }]); + }); + + test('init 响应取不到数据时 options 为空', async () => { + request = vi.fn(async () => ({})); + setConfig({ request }); + + const wrapper = mountFormWithRequest({ initUrl: 'https://example.com/init', initRoot: 'data.obj' }, { s: 'a' }); + await flushAsync(); + + expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([]); + }); +}); diff --git a/packages/form/tests/unit/utils/formStateProxy.spec.ts b/packages/form/tests/unit/utils/formStateProxy.spec.ts index 10f4007a..c37c9095 100644 --- a/packages/form/tests/unit/utils/formStateProxy.spec.ts +++ b/packages/form/tests/unit/utils/formStateProxy.spec.ts @@ -106,6 +106,77 @@ describe('createFormStateProxy', () => { expect(Object.getOwnPropertyDescriptor(formState, 'username')?.enumerable).toBe(true); }); + test('mForm 自引用回 formState,读能穿到 core 与 context', () => { + const formState = createFormStateProxy(makeCore({ keyProp: 'id' }), () => ({ username: 'alice' })); + const vm = formState as any; + + expect(vm.mForm).toBe(formState); + expect(vm.mForm.keyProp).toBe('id'); + expect(vm.mForm.username).toBe('alice'); + expect('mForm' in formState).toBe(true); + }); + + test('往 mForm 上挂的方法落到 core 并持久可读,跨回调可取回', () => { + const core = makeCore(); + const formState = createFormStateProxy(core, () => ({ username: 'alice' })); + const vm = formState as any; + + // 存量配置的跨字段通信写法:一个 validator 里挂,另一处取出来调用 + const check = () => 'checked'; + vm.mForm.checkPropertyLimit = check; + + expect((core as any).checkPropertyLimit).toBe(check); + expect(vm.mForm.checkPropertyLimit()).toBe('checked'); + expect(vm.checkPropertyLimit).toBe(check); + }); + + test('mForm 不进入枚举,避免循环引用', () => { + const formState = createFormStateProxy(makeCore(), () => ({ username: 'alice' })); + + expect(Object.keys(formState)).not.toContain('mForm'); + expect(() => JSON.stringify({ ...formState })).not.toThrow(); + }); + + test('core 或 context 显式提供 mForm 时不被自引用覆盖', () => { + const host = { name: 'host-mForm' }; + const fromContext = createFormStateProxy(makeCore(), () => ({ mForm: host }) as any); + expect((fromContext as any).mForm).toBe(host); + + const own = { name: 'core-mForm' }; + const fromCore = createFormStateProxy(makeCore({ mForm: own }), () => ({ mForm: host }) as any); + expect((fromCore as any).mForm).toEqual(own); + }); + + /** + * 嵌套表单(ComponentForm)把父 formState 整体当 context 传给子表单, + * 此时 mForm 应命中 context 指向父表单——与 extendState 时代把父状态并入子状态一致, + * 跨字段通信仍落在同一份对象上。不要「修正」成指向子表单。 + */ + test('父 formState 作为 context 时,mForm 指向父表单而非自引用', () => { + const parent = createFormStateProxy(makeCore({ owner: 'parent' }), () => ({}) as any); + const child = createFormStateProxy(makeCore({ owner: 'child' }), () => parent as any); + + expect((child as any).mForm).toBe(parent); + // 子表单自己的 core 字段仍优先,不被父级覆盖 + expect((child as any).owner).toBe('child'); + + // 跨字段通信:一个回调往 mForm 上挂,另一个回调取回,落在同一份对象上 + (child as any).mForm.checkLimit = () => 'ok'; + expect((parent as any).checkLimit()).toBe('ok'); + }); + + test('把 formState 自己当 context 传回来不爆栈', () => { + // 闭包只在 trap 触发时求值,构造期不会读到未初始化的 formState + const formState: any = createFormStateProxy(makeCore({ a: 1 }), () => formState); + + expect(formState.a).toBe(1); + expect(formState.missing).toBeUndefined(); + expect('missing' in formState).toBe(false); + // 自引用不提供额外字段,mForm 仍由兜底合成 + expect(formState.mForm).toBe(formState); + expect(Object.keys(formState)).toContain('a'); + }); + test('getContext 可以是 Ref', () => { const ctx = ref({ username: 'alice' }); const formState = createFormStateProxy(makeCore(), ctx);