diff --git a/packages/form/src/Form.vue b/packages/form/src/Form.vue index a8f89eee..a750a753 100644 --- a/packages/form/src/Form.vue +++ b/packages/form/src/Form.vue @@ -92,14 +92,45 @@ const fields = new Map(); const requestFuc = getConfig('request') as Function; +/** + * formState 实现说明: + * + * 1. 与 props 直接对应的字段(config / initValues / lastValues / isCompare / parentValues / + * keyProp / popperClass)使用「访问器(getter)」定义,每次读取都会回到 `props.xxx` + * 取最新值,不存在「props 变了但 formState 还没同步过来」的中间态。 + * + * 2. `values` / `lastValuesProcessed` 是 ref,Vue 的 `reactive` 会自动解包,因此每次 + * 访问 `formState.values` / `formState.lastValuesProcessed` 也都是当前 ref 值。 + * + * 3. `extendState` 注入的字段在下方的 `watchEffect` 中合并到 `formState`: + * - data 描述符(普通字段)通过 `formState[key] = value` 写入,走 reactive proxy 的 + * set,触发依赖通知;`extendState` 同步段读到的响应式数据变化时会自动重跑, + * 把最新值刷进 formState。 + * - accessor 描述符(`{ get stage() { return ... } }`)按原样写入,调用方可以控制 + * 读时求值,每次读取都会重新执行 getter。 + */ const formState: FormState = reactive({ - keyProp: props.keyProp, - popperClass: props.popperClass, - config: props.config, - initValues: props.initValues, - isCompare: props.isCompare, - lastValues: props.lastValues, - parentValues: props.parentValues, + get keyProp() { + return props.keyProp; + }, + get popperClass() { + return props.popperClass; + }, + get config() { + return props.config; + }, + get initValues() { + return props.initValues; + }, + get isCompare() { + return props.isCompare; + }, + get lastValues() { + return props.lastValues; + }, + get parentValues() { + return props.parentValues; + }, values, lastValuesProcessed, $emit: emit as (_event: string, ..._args: any[]) => void, @@ -119,20 +150,52 @@ const formState: FormState = reactive({ }, }); -watchEffect(async () => { - formState.initValues = props.initValues; - formState.lastValues = props.lastValues; - formState.isCompare = props.isCompare; - formState.config = props.config; - formState.keyProp = props.keyProp; - formState.popperClass = props.popperClass; - formState.parentValues = props.parentValues; +/** + * `extendState` 的同步段(直到第一个 `await` 之前)所访问的任何响应式数据, + * 都会被 `watchEffect` 自动跟踪。这样可以兼容历史用法 —— + * + * extendState: (formState) => ({ + * username: store.username, // 同步读 store,会被跟踪 + * env: store.env, + * }) + * + * 当 `store.username` 变化时,整个 effect 重跑,新值会被刷进 `formState`。 + * + * prop 派生字段(initValues / config / ...)已经在上方用 getter 定义, + * 这里不再重复同步;因此 `props.initValues` 这类高频变化也不会再触发 + * `extendState` 重跑(旧版的性能问题修复点)。 + * + * 实现细节: + * - data 描述符:通过 `formState[key] = value` 走 reactive proxy 的 set, + * 触发依赖通知;与旧版「逐项赋值」语义完全等价。 + * - accessor 描述符(`{ get stage() {...} }`)按原样写入 formState,调用方 + * 可以自行控制读时求值;强制 `configurable: true` 以便下一次重跑可再 define。 + */ +watchEffect(async (onCleanup) => { + const { extendState } = props; + if (typeof extendState !== 'function') return; - if (typeof props.extendState === 'function') { - const state = (await props.extendState(formState)) || {}; - Object.entries(state).forEach(([key, value]) => { - formState[key] = value; - }); + let stale = false; + onCleanup(() => { + stale = true; + }); + + let state: Record = {}; + try { + state = (await extendState(formState)) || {}; + } catch (e) { + console.error('[MForm] extendState failed:', e); + return; + } + if (stale) return; + + for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(state))) { + if ('value' in descriptor) { + (formState as any)[key] = (state as any)[key]; + } else { + descriptor.configurable = true; + Object.defineProperty(formState, key, descriptor); + } } }); diff --git a/packages/form/tests/unit/Form.extra.spec.ts b/packages/form/tests/unit/Form.extra.spec.ts new file mode 100644 index 00000000..b20223e9 --- /dev/null +++ b/packages/form/tests/unit/Form.extra.spec.ts @@ -0,0 +1,515 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { nextTick, ref } from 'vue'; +import MagicForm, { MForm } from '@form/index'; +import { mount } from '@vue/test-utils'; +import ElementPlus from 'element-plus'; + +const mountForm = (props: Record = {}, options: Record = {}) => + mount(MForm, { + global: { + plugins: [ElementPlus as any, MagicForm as any], + }, + props: { + initValues: {}, + config: [], + ...props, + }, + ...options, + }); + +describe('Form.vue —— 默认 props', () => { + test('未传任何 props 时使用默认值,渲染不报错', async () => { + const wrapper = mountForm(); + await nextTick(); + + expect(wrapper.find('.m-form').exists()).toBe(true); + expect(wrapper.vm.values).toEqual({}); + expect(wrapper.vm.changeRecords).toEqual([]); + }); + + test('height/labelWidth 透传到样式与子表单', async () => { + const wrapper = mountForm({ height: '300px', labelWidth: '120px' }); + await nextTick(); + + const formEl = wrapper.find('.m-form').element as HTMLElement; + expect(formEl.getAttribute('style') || '').toContain('height: 300px'); + }); +}); + +describe('Form.vue —— formState getter 行为', () => { + test('formState 的 keyProp / popperClass / config / initValues / isCompare / lastValues / parentValues 始终回读最新 props', async () => { + const wrapper = mountForm({ + keyProp: 'id', + popperClass: 'pop-a', + isCompare: false, + initValues: { a: 1 }, + lastValues: { a: 0 }, + parentValues: { x: 1 }, + config: [{ text: 'a', name: 'a' }], + }); + await nextTick(); + + const fs1: any = wrapper.vm.formState; + expect(fs1.keyProp).toBe('id'); + expect(fs1.popperClass).toBe('pop-a'); + expect(fs1.isCompare).toBe(false); + expect(fs1.initValues).toEqual({ a: 1 }); + expect(fs1.lastValues).toEqual({ a: 0 }); + expect(fs1.parentValues).toEqual({ x: 1 }); + expect(Array.isArray(fs1.config)).toBe(true); + + // 修改 props,formState 上的 getter 应直接反映新值(无中间态) + await wrapper.setProps({ + keyProp: 'uuid', + popperClass: 'pop-b', + isCompare: true, + parentValues: { x: 2 }, + }); + + const fs2: any = wrapper.vm.formState; + expect(fs2.keyProp).toBe('uuid'); + expect(fs2.popperClass).toBe('pop-b'); + expect(fs2.isCompare).toBe(true); + expect(fs2.parentValues).toEqual({ x: 2 }); + }); + + test('values / lastValuesProcessed 在 formState 上自动解包为 ref 当前值', async () => { + const wrapper = mountForm({ + isCompare: true, + initValues: { a: '1' }, + lastValues: { a: '2' }, + config: [{ text: 'a', type: 'text', name: 'a' }], + }); + await nextTick(); + await nextTick(); + await nextTick(); + + expect((wrapper.vm.formState as any).values).toEqual({ a: '1' }); + expect((wrapper.vm.formState as any).lastValuesProcessed).toEqual({ a: '2' }); + }); +}); + +describe('Form.vue —— extendState', () => { + test('extendState 抛错时被 catch,不影响表单渲染', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const extendState = vi.fn(async () => { + throw new Error('boom'); + }); + + const wrapper = mountForm({ + extendState, + config: [{ text: 'text', name: 'text', type: 'text' }], + }); + + await nextTick(); + await nextTick(); + + expect(extendState).toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalled(); + expect(wrapper.find('.m-form').exists()).toBe(true); + + errorSpy.mockRestore(); + }); + + test('extendState 返回的普通字段被合并到 formState', async () => { + const wrapper = mountForm({ + extendState: async () => ({ extra: 'hello', count: 42 }), + }); + + await nextTick(); + await nextTick(); + await nextTick(); + + expect((wrapper.vm.formState as any).extra).toBe('hello'); + expect((wrapper.vm.formState as any).count).toBe(42); + }); + + test('extendState 返回的 accessor 描述符按原样定义并支持读时求值', async () => { + let counter = 0; + + const wrapper = mountForm({ + extendState: () => + Object.defineProperties( + {}, + { + stage: { + enumerable: true, + get() { + counter += 1; + return `stage-${counter}`; + }, + }, + }, + ), + }); + + await nextTick(); + await nextTick(); + await nextTick(); + + const v1 = (wrapper.vm.formState as any).stage; + const v2 = (wrapper.vm.formState as any).stage; + + expect(v1).not.toEqual(v2); + expect(v1).toMatch(/^stage-/); + expect(v2).toMatch(/^stage-/); + }); + + test('extendState 同步段读到的响应式数据变化时会重跑', async () => { + const username = ref('alice'); + const calls: string[] = []; + + const wrapper = mountForm({ + // 同步读取 ref,会被 watchEffect 跟踪 + extendState: (_state: any) => { + calls.push(username.value); + return { username: username.value }; + }, + }); + + await nextTick(); + await nextTick(); + expect((wrapper.vm.formState as any).username).toBe('alice'); + + username.value = 'bob'; + await nextTick(); + await nextTick(); + await nextTick(); + + expect((wrapper.vm.formState as any).username).toBe('bob'); + // 至少跑了两次(初始 + 响应变化) + expect(calls.length).toBeGreaterThanOrEqual(2); + }); + + test('未传 extendState 时 watchEffect 早退,不抛错', async () => { + const wrapper = mountForm({}); + await nextTick(); + expect(wrapper.find('.m-form').exists()).toBe(true); + }); +}); + +describe('Form.vue —— resetForm / changeRecords', () => { + test('resetForm 会清空 changeRecords', async () => { + const wrapper = mountForm({ + config: [{ text: 'text', type: 'text', name: 'text' }], + }); + await nextTick(); + + wrapper.find('input').setValue('hi'); + await nextTick(); + + expect(wrapper.vm.changeRecords.length).toBeGreaterThan(0); + + wrapper.vm.resetForm(); + await nextTick(); + + expect(wrapper.vm.changeRecords).toEqual([]); + }); +}); + +describe('Form.vue —— submitForm 实例方法', () => { + test('校验通过返回 cloneDeep 后的 values', async () => { + const wrapper = mountForm({ + config: [{ text: 'text', type: 'text', name: 'text' }], + initValues: { text: 'hi' }, + }); + await nextTick(); + + const result = await wrapper.vm.submitForm(); + expect(result).toEqual({ text: 'hi' }); + // 默认 cloneDeep,应该不是同一引用 + expect(result).not.toBe(wrapper.vm.values); + }); + + test('native=true 直接返回原 values 引用', async () => { + const wrapper = mountForm({ + config: [{ text: 'text', type: 'text', name: 'text' }], + initValues: { text: 'hi' }, + }); + await nextTick(); + + const result = await wrapper.vm.submitForm(true); + expect(result).toBe(wrapper.vm.values); + }); + + test('校验失败时 emit error 并抛出汇总后的错误(错误信息中包含字段 text)', async () => { + const wrapper = mountForm({ + config: [ + { + text: '名称', + type: 'text', + name: 'name', + }, + ], + initValues: { name: '' }, + }); + await nextTick(); + + // 替换 useTemplateRef 暴露的 validate(写入 $.exposed 才能影响内部 setup 中的 tMagicFormRef.value) + const tmForm = wrapper.findComponent({ name: 'TMForm' }); + expect(tmForm.exists()).toBe(true); + + const invalidFields = { + name: [{ field: 'name', message: '必填' }], + }; + const { exposed } = (tmForm.vm as any).$; + exposed.validate = vi.fn().mockRejectedValue(invalidFields); + + let caught: Error | null = null; + try { + await wrapper.vm.submitForm(); + } catch (e: any) { + caught = e; + } + + expect(caught).toBeInstanceOf(Error); + expect(caught!.message).toContain('名称'); + expect(caught!.message).toContain('必填'); + expect(wrapper.emitted('error')).toBeTruthy(); + expect(wrapper.emitted('error')![0][0]).toEqual(invalidFields); + }); + + test('校验返回非 true(tdesign 风格)时也走错误分支', async () => { + const wrapper = mountForm({ + config: [{ text: '账号', type: 'text', name: 'account' }], + initValues: { account: '' }, + }); + await nextTick(); + + const tmForm = wrapper.findComponent({ name: 'TMForm' }); + const invalidFields = { + account: [{ field: 'account', message: '不能为空' }], + }; + const { exposed } = (tmForm.vm as any).$; + exposed.validate = vi.fn().mockResolvedValue(invalidFields); + + let caught: Error | null = null; + try { + await wrapper.vm.submitForm(); + } catch (e: any) { + caught = e; + } + + expect(caught).toBeInstanceOf(Error); + expect(caught!.message).toContain('账号'); + expect(caught!.message).toContain('不能为空'); + }); + + test('校验失败但 invalidFields 中字段无对应 text 时回退使用 field/prop 名', async () => { + const wrapper = mountForm({ + config: [{ text: 'a', type: 'text', name: 'a' }], + initValues: { a: '' }, + }); + await nextTick(); + + const tmForm = wrapper.findComponent({ name: 'TMForm' }); + const { exposed } = (tmForm.vm as any).$; + exposed.validate = vi.fn().mockRejectedValue({ + unknown: [{ field: '', message: '出错' }], + }); + + let caught: Error | null = null; + try { + await wrapper.vm.submitForm(); + } catch (e: any) { + caught = e; + } + + expect(caught).toBeInstanceOf(Error); + // field 为空 -> 用 prop(unknown) + expect(caught!.message).toContain('unknown'); + expect(caught!.message).toContain('出错'); + }); +}); + +describe('Form.vue —— getTextByName', () => { + let wrapper: ReturnType; + + beforeEach(async () => { + wrapper = mountForm({ + config: [ + { text: '名称', type: 'text', name: 'name' }, + { + name: 'object', + items: [ + { text: '内层名称', type: 'text', name: 'inner' }, + { + name: 'nested', + items: [{ text: '深层', type: 'text', name: 'deep' }], + }, + ], + }, + // 无 name 的容器,items 应能继续被搜索 + { + items: [{ text: '无名容器内字段', type: 'text', name: 'plain' }], + }, + // text 非字符串 + { text: { foo: 'bar' } as any, type: 'text', name: 'nonString' }, + ], + }); + await nextTick(); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + test('单层名匹配', () => { + expect(wrapper.vm.getTextByName('name')).toBe('名称'); + }); + + test('点分隔多层路径匹配', () => { + expect(wrapper.vm.getTextByName('object.inner')).toBe('内层名称'); + expect(wrapper.vm.getTextByName('object.nested.deep')).toBe('深层'); + }); + + test('无 name 容器的 items 也能被搜索到', () => { + expect(wrapper.vm.getTextByName('plain')).toBe('无名容器内字段'); + }); + + test('找不到时返回 undefined', () => { + expect(wrapper.vm.getTextByName('not.exist')).toBeUndefined(); + expect(wrapper.vm.getTextByName('object.unknown')).toBeUndefined(); + }); + + test('text 非字符串时返回 undefined', () => { + expect(wrapper.vm.getTextByName('nonString')).toBeUndefined(); + }); + + test('参数非法时返回 undefined', () => { + expect(wrapper.vm.getTextByName('')).toBeUndefined(); + // @ts-expect-error 故意传非数组 + expect(wrapper.vm.getTextByName('name', null)).toBeUndefined(); + }); +}); + +describe('Form.vue —— preventSubmitDefault', () => { + test('preventSubmitDefault=true 时 submit 事件 preventDefault 被调用', async () => { + const wrapper = mountForm({ + config: [{ text: 'text', type: 'text', name: 'text' }], + preventSubmitDefault: true, + }); + await nextTick(); + + const formEl = wrapper.find('.m-form').element as HTMLFormElement; + const evt = new Event('submit', { cancelable: true, bubbles: true }); + const spy = vi.spyOn(evt, 'preventDefault'); + formEl.dispatchEvent(evt); + + expect(spy).toHaveBeenCalled(); + }); + + test('preventSubmitDefault=false(默认)时不调用 preventDefault', async () => { + const wrapper = mountForm({ + config: [{ text: 'text', type: 'text', name: 'text' }], + }); + await nextTick(); + + const formEl = wrapper.find('.m-form').element as HTMLFormElement; + const evt = new Event('submit', { cancelable: true, bubbles: true }); + const spy = vi.spyOn(evt, 'preventDefault'); + formEl.dispatchEvent(evt); + + expect(spy).not.toHaveBeenCalled(); + }); +}); + +describe('Form.vue —— isCompare 模式', () => { + test('isCompare=true 时 lastValuesProcessed 会被初始化', async () => { + const wrapper = mountForm({ + isCompare: true, + config: [{ text: 'text', type: 'text', name: 'text' }], + initValues: { text: 'a' }, + lastValues: { text: 'b' }, + }); + + await nextTick(); + await nextTick(); + await nextTick(); + + expect(wrapper.vm.values.text).toBe('a'); + expect(wrapper.vm.lastValuesProcessed.text).toBe('b'); + expect(wrapper.vm.initialized).toBe(true); + }); + + test('isCompare=false 时 lastValuesProcessed 不会被填充', async () => { + const wrapper = mountForm({ + isCompare: false, + config: [{ text: 'text', type: 'text', name: 'text' }], + initValues: { text: 'a' }, + lastValues: { text: 'b' }, + }); + + await nextTick(); + await nextTick(); + await nextTick(); + + expect(wrapper.vm.values.text).toBe('a'); + expect(wrapper.vm.lastValuesProcessed).toEqual({}); + expect(wrapper.vm.initialized).toBe(true); + }); +}); + +describe('Form.vue —— config 变化', () => { + test('config 引用变化会重新初始化(initialized 短暂置 false 后回 true)', async () => { + const wrapper = mountForm({ + config: [{ text: 'a', type: 'text', name: 'a' }], + initValues: { a: '1' }, + }); + await nextTick(); + await nextTick(); + + expect(wrapper.vm.initialized).toBe(true); + + await wrapper.setProps({ + config: [{ text: 'b', type: 'text', name: 'b' }], + initValues: { b: '2' }, + }); + + // 第一次 microtask 后还在重建 + await nextTick(); + await nextTick(); + await nextTick(); + + expect(wrapper.vm.initialized).toBe(true); + expect(wrapper.vm.values).toHaveProperty('b'); + }); + + test('config 变化会清空 changeRecords', async () => { + const wrapper = mountForm({ + config: [{ text: 'a', type: 'text', name: 'a' }], + }); + await nextTick(); + + wrapper.find('input').setValue('xx'); + await nextTick(); + expect(wrapper.vm.changeRecords.length).toBeGreaterThan(0); + + await wrapper.setProps({ + config: [{ text: 'b', type: 'text', name: 'b' }], + initValues: {}, + }); + await nextTick(); + await nextTick(); + + expect(wrapper.vm.changeRecords).toEqual([]); + }); +});