fix(form): 修复切换同类型节点时表单字段配置串台

- MForm 新增 formKey,数据实体切换时只重挂叶子字段,保留 tab 等容器状态;异步 initValue 仅采用最后一次结果
- 代码块 / 数据源方法参数配置改为按 model 派生,避免 setup 期快照残留旧节点配置
- 补充相关单测与 form-props 文档
This commit is contained in:
roymondchen 2026-11-05 15:17:03 +08:00
parent b4747c3e89
commit abcd875731
11 changed files with 422 additions and 32 deletions

View File

@ -69,6 +69,18 @@
</script>
```
## formKey
- **详情:** 当前表单绑定的数据实体标识(编辑器属性面板里是节点 id)。
配置结构相同但换了数据实体时(例如两个同类型组件之间切换选中),表单树会被复用,
字段组件在 setup 期从 `model` 派生的本地配置(如事件动作的参数字段)仍属于上一个实体,
会出现「值来自新节点、配置来自旧节点」的串台。传入 `formKey` 后,值变化时只重挂叶子字段组件;
tab、group-list 等容器保持复用,激活 tab、滚动位置和展开态不会丢失。
同一实体的 `initValues` 更新(例如编辑后重新下发节点数据)不会触发重挂。
- **类型:** `string | number`
## lastValues
- **详情:** 需对比的值(开启对比模式时传入)

View File

@ -53,7 +53,7 @@
</template>
<script lang="ts" setup>
import { computed, inject, ref, watch } from 'vue';
import { computed, inject } from 'vue';
import { Edit, View } from '@element-plus/icons-vue';
import { isEmpty, map } from 'lodash-es';
@ -132,16 +132,14 @@ const getParamItemsConfig = (codeId?: Id): CodeParamStatement[] => {
};
const codeDsl = computed(() => codeBlockService.getCodeDsl());
const paramsConfig = ref<CodeParamStatement[]>(getParamItemsConfig(props.model[props.name]));
watch(
() => props.model[props.name],
(v, preV) => {
if (v !== preV) {
paramsConfig.value = getParamItemsConfig(v);
}
},
);
/**
* 参数字段配置跟随当前 model / codeDsl 派生,而不是 setup 期的一次性快照。
*
* 同类型组件切换选中时字段实例会被复用,且新节点的 codeId 可能与旧节点相同,
* 按值 watch 不会触发,参数配置会停留在上一个节点上。
*/
const paramsConfig = computed<CodeParamStatement[]>(() => getParamItemsConfig(props.model[props.name]));
const selectConfig: SelectConfig = {
type: 'select',
@ -161,8 +159,8 @@ const selectConfig: SelectConfig = {
};
const onCodeIdChangeHandler = (value: any) => {
// 通过下拉框选择的codeId变化后修正model的值,避免写入其他codeId的params
paramsConfig.value = getParamItemsConfig(value);
// model 尚未写入新值(由 MForm 收到 change 后统一写回),参数字段配置先按新 codeId 算
const nextParamsConfig = getParamItemsConfig(value);
const changeRecords = [
{
@ -173,8 +171,8 @@ const onCodeIdChangeHandler = (value: any) => {
changeRecords.push({
propPath: props.prop.replace(`${props.name}`, 'params'),
value: paramsConfig.value.length
? createValues(mForm, paramsConfig.value as unknown as FormItemConfig[], {}, props.model.params)
value: nextParamsConfig.length
? createValues(mForm, nextParamsConfig as unknown as FormItemConfig[], {}, props.model.params)
: {},
});

View File

@ -36,7 +36,7 @@
</template>
<script lang="ts" setup name="">
import { computed, inject, ref } from 'vue';
import { computed, inject } from 'vue';
import { Edit, View } from '@element-plus/icons-vue';
import type { Id } from '@tmagic/core';
@ -85,6 +85,7 @@ const notEditable = computed(() => filterFunction(mForm, props.config.notEditabl
const isCompare = computed(() => Boolean(mForm?.isCompare));
const dataSources = computed(() => dataSourceService.get('dataSources'));
const codeOptions = inject('codeOptions', {});
const isCustomMethod = computed(() => {
const [id, name] = props.model[props.name];
@ -125,7 +126,7 @@ const getParamItemsConfig = ([dataSourceId, methodName]: [Id, string] = ['', '']
return type;
},
language: 'javascript',
options: inject('codeOptions', {}),
options: codeOptions,
autosize: {
minRows: 1,
maxRows: 10,
@ -146,7 +147,16 @@ const getParamItemsConfig = ([dataSourceId, methodName]: [Id, string] = ['', '']
}));
};
const paramsConfig = ref<CodeParamStatement[]>(getParamItemsConfig(props.model[props.name || 'dataSourceMethod']));
/**
* 参数字段配置跟随当前 model 派生,而不是 setup 期的一次性快照。
*
* 属性面板在同类型组件之间切换选中时会复用表单字段实例,此时 model 已换成新节点;
* 快照会让「新节点的方法」配上「旧节点的参数字段」,看起来像两个组件的事件配置混在了一起。
* 数据源列表异步就绪时也能随 computed 自动补齐参数配置。
*/
const paramsConfig = computed<CodeParamStatement[]>(() =>
getParamItemsConfig(props.model[props.name || 'dataSourceMethod']),
);
const methodsOptions = computed(
() =>
@ -176,7 +186,8 @@ const cascaderConfig = computed<CascaderConfig>(() => ({
* 参数值修改更新
*/
const onChangeHandler = (value: any) => {
paramsConfig.value = getParamItemsConfig(value);
// model 尚未写入新值(由 MForm 收到 change 后统一写回),参数字段配置先按新选中的方法算
const nextParamsConfig = getParamItemsConfig(value);
const changeRecords = [
{
@ -187,8 +198,8 @@ const onChangeHandler = (value: any) => {
changeRecords.push({
propPath: props.prop.replace(`${props.name}`, 'params'),
value: paramsConfig.value.length
? createValues(mForm, paramsConfig.value as unknown as FormItemConfig[], {}, props.model.params)
value: nextParamsConfig.length
? createValues(mForm, nextParamsConfig as unknown as FormItemConfig[], {}, props.model.params)
: {},
});

View File

@ -12,6 +12,7 @@
:size="propsPanelSize"
:init-values="values"
:config="config"
:form-key="formKey"
:type-match-valid="enablePropsFormValidate"
:context="formContext"
:validate-on-init="enablePropsFormValidate"
@ -72,6 +73,8 @@ defineOptions({
const props = defineProps<{
config: FormConfig;
values: FormValue;
/** 当前选中节点的 id:切换节点后让 MForm 重挂叶子字段,避免表单实例状态串台 */
formKey?: string | number;
disabledShowSrc?: boolean;
labelWidth?: string;
codeValueKey?: string;

View File

@ -7,6 +7,7 @@
:class="{ 'show-style-panel': showStylePanel }"
:config="curFormConfig"
:values="values"
:form-key="formKey"
:disabledShowSrc="disabledShowSrc"
@submit="(v, eventData, error) => submit(v, eventData, error, 'props')"
@submit-error="errorHandler"
@ -24,6 +25,7 @@
code-value-key="style"
:config="styleFormConfig"
:values="values"
:form-key="formKey"
:disabledShowSrc="disabledShowSrc"
@submit="(v, eventData, error) => submit(v, eventData, error, 'style')"
@submit-error="errorHandler"
@ -103,6 +105,11 @@ const values = ref<FormValue>({});
const curFormConfig = ref<any>([]);
const node = computed(() => editorService.get('node'));
const nodes = computed(() => editorService.get('nodes'));
// 同类型组件的配置结构完全一致,MForm 不会重建整棵表单树;用节点 id 标识数据实体,
// 让叶子字段在切换选中时重挂,避免上一个节点的字段本地状态(如事件动作参数)串到新节点。
// 必须跟随 values 而不是 node:values 在 getPropsConfig 异步返回后才更新,
// 若 key 先于 values 变化,叶子字段会以旧节点的值重挂,新值到达后不再重挂。
const formKey = computed(() => values.value.id ?? '');
const styleFormConfig = [
{

View File

@ -4,7 +4,7 @@
* Copyright (C) 2025 Tencent.
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h } from 'vue';
import { defineComponent, h, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import CodeSelectCol from '@editor/fields/CodeSelectCol.vue';
@ -118,6 +118,20 @@ describe('CodeSelectCol', () => {
expect(wrapper.find('.fake-params').exists()).toBe(false);
});
test('代码块 DSL 异步就绪后参数字段配置随之补齐', async () => {
const dsl = ref<any>(null);
codeBlockService.getCodeDsl.mockImplementation(() => dsl.value);
const wrapper = mount(CodeSelectCol, { props: baseProps() as any });
expect(wrapper.find('.fake-params').exists()).toBe(false);
dsl.value = { c1: { name: 'C1', params: [{ name: 'p1', type: 'text' }] } };
await nextTick();
expect(wrapper.find('.fake-params').exists()).toBe(true);
expect((wrapper.findComponent({ name: 'CodeParams' }).props('paramsConfig') as any[])[0].name).toBe('p1');
});
test('onCodeIdChangeHandler emit change 包含 changeRecords', async () => {
const wrapper = mount(CodeSelectCol, { props: baseProps() as any });
await wrapper.findComponent({ name: 'MSelect' }).vm.$emit('change', 'c2');

View File

@ -8,6 +8,7 @@ import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
import DataSourceMethodSelect from '@editor/fields/DataSourceMethodSelect.vue';
import { getFieldType } from '@editor/utils';
const dataSourceService = {
get: vi.fn(() => [
@ -199,4 +200,50 @@ describe('DataSourceMethodSelect', () => {
});
expect(wrapper.find('.fake-params').exists()).toBe(true);
});
test('切换同类型节点后参数字段按新节点的方法重建,不残留旧节点的参数配置', async () => {
dataSourceService.get.mockReturnValue([
{
id: 'ds1',
type: 'http',
title: 'DS1',
methods: [
{ name: 'doFetch', params: [{ name: 'p1', type: 'text' }] },
{ name: 'doSave', params: [{ name: 'p2', type: 'number' }] },
],
},
]);
dataSourceService.getDataSourceById.mockReturnValue({ id: 'ds1', methods: [] });
const wrapper = mount(DataSourceMethodSelect, {
props: baseProps({ model: { dataSourceMethod: ['ds1', 'doFetch'], params: {} } }) as any,
});
const params = () => wrapper.findComponent({ name: 'CodeParams' }).props('paramsConfig') as any[];
expect(params()[0].name).toBe('p1');
// 属性面板复用同一个字段实例,只把 model 换成另一个节点的事件动作
await wrapper.setProps({ model: { dataSourceMethod: ['ds1', 'doSave'], params: {} } });
expect(params()[0].name).toBe('p2');
});
test('设置数据方法的 data 参数按数据源字段类型切换控件', () => {
dataSourceService.getDataSourceById.mockReturnValue({ id: 'ds1', methods: [{ name: '__set_data__' }] });
const wrapper = mount(DataSourceMethodSelect, {
props: baseProps({ model: { dataSourceMethod: ['ds1', '__set_data__'], params: {} } }) as any,
});
const params = wrapper.findComponent({ name: 'CodeParams' }).props('paramsConfig') as any[];
const dataConfig = params.find((item) => item.name === 'data');
vi.mocked(getFieldType)
.mockReturnValueOnce('number')
.mockReturnValueOnce('string')
.mockReturnValueOnce('boolean')
.mockReturnValueOnce('unknown');
expect(dataConfig.type(null, { model: { field: 'f1' } })).toBe('number');
expect(dataConfig.type(null, { model: { field: 'f1' } })).toBe('text');
expect(dataConfig.type(null, { model: { field: 'f1' } })).toBe('switch');
expect(dataConfig.type(null, { model: { field: 'f1' } })).toBe('vs-code');
});
});

View File

@ -71,7 +71,7 @@ const mountedHandlers: any[] = [];
vi.mock('@editor/layouts/props-panel/FormPanel.vue', () => ({
default: defineComponent({
name: 'FormPanel',
props: ['config', 'values', 'disabledShowSrc'],
props: ['config', 'values', 'formKey', 'disabledShowSrc'],
emits: ['submit', 'submit-error', 'form-error', 'mounted', 'unmounted'],
setup(_p, { emit, expose }) {
mountedHandlers.push(emit);
@ -164,6 +164,42 @@ describe('PropsPanel', () => {
expect(propsService.getPropsConfig).toHaveBeenCalled();
});
test('把当前节点 id 作为 formKey 传给两个 FormPanel', async () => {
const wrapper = mount(PropsPanel, { props: {} as any });
await wrapper.vm.$nextTick();
await new Promise((r) => setTimeout(r, 0));
await wrapper.vm.$nextTick();
const panels = wrapper.findAllComponents({ name: 'FormPanel' });
expect(panels.length).toBeGreaterThan(0);
panels.forEach((panel) => expect(panel.props('formKey')).toBe('n1'));
});
test('formKey 与 values 同步更新,不早于异步返回的新节点值', async () => {
const node = ref<any>({ id: 'n1', type: 'text' });
editorService.get.mockImplementation((k: string) => (k === 'node' ? node.value : [node.value]));
let release!: () => void;
const wrapper = mount(PropsPanel, { props: {} as any });
await new Promise((r) => setTimeout(r, 0));
const panel = () => wrapper.findComponent({ name: 'FormPanel' });
expect(panel().props('formKey')).toBe('n1');
propsService.getPropsConfig.mockImplementationOnce(
() => new Promise((resolve) => (release = () => resolve([{ name: 'y' }]))),
);
node.value = { id: 'n2', type: 'text' };
await nextTick();
expect(panel().props('formKey')).toBe('n1');
expect(panel().props('values').id).toBe('n1');
release();
await new Promise((r) => setTimeout(r, 0));
expect(panel().props('formKey')).toBe('n2');
expect(panel().props('values').id).toBe('n2');
});
test('node 为空时清空 config', async () => {
editorService.get.mockImplementation((k: string) => (k === 'nodes' ? [] : null));
const wrapper = mount(PropsPanel, { props: {} as any });

View File

@ -121,6 +121,17 @@ const props = withDefaults(
* 避免被 lodash `isEqual` 误判为差异。
*/
showDiff?: (_data: { curValue: any; lastValue: any; config: any }) => boolean;
/**
* 当前表单绑定的数据实体标识(编辑器里一般是节点 id)。
*
* 配置结构相同但实体切换时(例如在两个同类型组件之间切换选中),`initialized` 不会
* 置回 false,整棵表单树会被复用。字段组件在 setup 期从 model 派生的本地状态
* (事件动作的参数配置等)仍属于上一个实体,出现「值来自新节点、配置来自旧节点」的串台。
*
* 该值变化时只重挂叶子字段组件,容器(tab / group-list)保持稳定,激活 tab、滚动位置
* 等视图状态不会丢失;同一实体的值更新(如编辑后重新下发 initValues)不会触发重挂。
*/
formKey?: string | number;
/**
* 自定义「自接管对比」的字段类型(仅在对比模式下生效)。
*
@ -173,6 +184,8 @@ const tMagicFormRef = useTemplateRef('tMagicForm');
const initialized = ref(false);
const values = ref<FormValue>({});
const lastValuesProcessed = ref<FormValue>({});
/** 数据实体版本:`formKey` 变化时递增,供 Container 重挂叶子字段以清理实例内的本地状态 */
const fieldVersion = ref(0);
/**
* 当前表单生效的主题名称:
@ -240,6 +253,7 @@ const coreState: FormState = reactive<FormState>({
},
values,
lastValuesProcessed,
fieldVersion,
$emit: emit as (_event: string, ..._args: any[]) => void,
...createFormStateBase({ $message: tMagicMessage, $messageBox: tMagicMessageBox }),
});
@ -288,9 +302,17 @@ const changeRecords = shallowRef<ChangeRecord[]>([]);
const isSameConfigShape = (config: unknown, preConfig: unknown) =>
isEqualWith(config, preConfig, (a, b) => (typeof a === 'function' && typeof b === 'function' ? true : undefined));
// initValue / onInitValue 可能是异步的,快速切换实体时只让最后一次结果落到表单上
let initSeq = 0;
/** 已渲染的数据实体:只有换了实体才递增 fieldVersion,同一实体的值更新不重挂字段 */
let renderedFormKey = props.formKey;
watch(
[() => props.config, () => props.initValues],
[() => props.config, () => props.initValues, () => props.formKey],
async ([config], [preConfig]) => {
initSeq += 1;
const seq = initSeq;
const { formKey } = props;
changeRecords.value = [];
if (!isSameConfigShape(toRaw(config), toRaw(preConfig))) {
@ -301,7 +323,13 @@ watch(
initValues: props.initValues,
config: props.config,
}).then((value) => {
if (seq !== initSeq) return;
values.value = value;
// 必须等新实体的值落位后再换 leaf key,否则字段会以旧 model 重新挂载
if (formKey !== renderedFormKey) {
renderedFormKey = formKey;
fieldVersion.value += 1;
}
// 字段的值初始化写入统一在这里执行,字段组件自身不再改写 model。
// 必须放在赋值之后:effect 与动态 type / display 回调读到的 formValue 才是这一轮的值。
applyMountValueEffects(formState, props.config, values.value);
@ -321,6 +349,7 @@ watch(
initValues: props.lastValues,
config: props.config,
}).then((value) => {
if (seq !== initSeq) return;
lastValuesProcessed.value = value;
// 对比模式下待对比的那份值同样要规整,否则会与当前值比出「格式差异」这种假差异
applyMountValueEffects(formState, props.config, lastValuesProcessed.value);

View File

@ -435,15 +435,24 @@ const display = computed((): boolean => {
return value;
});
const fieldsProps = computed(() => ({
size: props.size,
config: props.config,
name: name.value,
disabled: disabled.value,
prop: itemProp.value,
key: (props.config as Record<string, any>)[mForm?.keyProps],
style: props.config.fieldStyle,
}));
const fieldsProps = computed(() => {
const configKey = (props.config as Record<string, any>)[mForm?.keyProps];
const version = (mForm?.fieldVersion as number | undefined) ?? 0;
const isContainer = Boolean(items.value);
const keepConfigKey = isContainer || version === 0;
return {
size: props.size,
config: props.config,
name: name.value,
disabled: disabled.value,
prop: itemProp.value,
// 换了数据实体时重挂叶子字段,清掉字段实例内由旧 model 派生的本地状态;
// 容器字段(tab / group-list 等)保持原 key,激活 tab、滚动、展开态不随之丢失。
key: keepConfigKey ? configKey : `${version}-${itemProp.value}-${configKey ?? ''}`,
style: props.config.fieldStyle,
};
});
const formItemProps = computed(() => ({
prop: itemProp.value,

View File

@ -882,3 +882,227 @@ describe('Form.vue —— 配置变化是否触发重挂', () => {
expect(wrapper.find('input').element).not.toBe(before);
});
});
describe('Form.vue —— 数据实体切换(formKey)', () => {
// 模拟「在 setup 期从 model 派生配置」的字段(如事件动作里的代码块 / 数据源方法参数)
const snapshotField = defineComponent({
name: 'SnapshotField',
props: ['model', 'name', 'config'],
setup(props: any) {
const snapshot = ref(props.model?.[props.name]);
return () => h('span', { class: 'snapshot-field' }, `${snapshot.value}`);
},
});
const makeConfig = () => [
{
type: 'tab',
items: [
{ title: '属性', items: [{ name: 'id', type: 'text', text: 'ID' }] },
{
title: '事件',
items: [
{
type: 'group-list',
name: 'events',
expandAll: true,
items: [{ type: 'entity-snapshot', name: 'label', text: '事件' }],
},
],
},
],
},
];
const mountEntityForm = (props: Record<string, any>) =>
mount(MForm, {
global: {
plugins: [
ElementPlus as any,
[MagicForm as any, { fields: { 'entity-snapshot': { component: snapshotField } } }] as any,
],
},
props: { config: makeConfig(), ...props },
});
const flush = async () => {
await nextTick();
await Promise.resolve();
await nextTick();
};
const nodeA = { id: 'A', type: 'button', events: [{ label: 'A-click' }] };
const nodeB = { id: 'B', type: 'button', events: [{ label: 'B-click' }] };
test('同类型节点切换时叶子字段重挂,不再残留上一个节点的配置', async () => {
const wrapper = mountEntityForm({ formKey: 'A', initValues: nodeA });
await flush();
expect(wrapper.find('.snapshot-field').text()).toBe('A-click');
await wrapper.setProps({ formKey: 'B', initValues: nodeB });
await flush();
expect(wrapper.vm.values.events[0].label).toBe('B-click');
expect(wrapper.find('.snapshot-field').text()).toBe('B-click');
expect((wrapper.vm.formState as any).fieldVersion).toBe(1);
});
test('同一实体的值更新不重挂字段,输入焦点/DOM 保持不变', async () => {
const wrapper = mountEntityForm({ formKey: 'A', initValues: nodeA });
await flush();
const before = wrapper.find('.snapshot-field').element;
await wrapper.setProps({ initValues: { ...nodeA, events: [{ label: 'A-click-2' }] } });
await flush();
expect(wrapper.find('.snapshot-field').element).toBe(before);
expect((wrapper.vm.formState as any).fieldVersion).toBe(0);
expect(wrapper.vm.values.events[0].label).toBe('A-click-2');
});
test('切换实体保留 tab 容器实例,激活 tab / 滚动状态不重置', async () => {
const wrapper = mountEntityForm({ formKey: 'A', initValues: nodeA });
await flush();
const tabsUid = (wrapper.findComponent({ name: 'MFormTabs' }).vm as any).$.uid;
await wrapper.setProps({ formKey: 'B', initValues: nodeB });
await flush();
expect((wrapper.findComponent({ name: 'MFormTabs' }).vm as any).$.uid).toBe(tabsUid);
expect(wrapper.find('.snapshot-field').text()).toBe('B-click');
});
test('快速切换实体时只采用最后一次 initValue 的结果', async () => {
const pending: ((v: any) => void)[] = [];
const config: any = [
{
type: 'tab',
onInitValue: (_mForm: any, { formValue }: any) =>
new Promise((resolve) => pending.push((v) => resolve({ ...formValue, ...v }))),
items: [{ title: '属性', items: [{ name: 'id', type: 'text', text: 'ID' }] }],
},
];
const wrapper = mount(MForm, {
global: { plugins: [ElementPlus as any, MagicForm as any] },
props: { config, formKey: 'A', initValues: nodeA },
});
await nextTick();
expect(pending.length).toBe(1);
await wrapper.setProps({ formKey: 'B', initValues: nodeB });
await nextTick();
expect(pending.length).toBe(2);
// 故意让旧实体的异步初始化后完成
pending[1]({ id: 'B' });
await flush();
pending[0]({ id: 'A' });
await flush();
expect(wrapper.vm.values.id).toBe('B');
expect(wrapper.vm.initialized).toBe(true);
});
});
describe('Container.vue —— 值处理与对比分支', () => {
const emitField = defineComponent({
name: 'EmitField',
props: ['model', 'name', 'config'],
emits: ['change'],
setup(_props: any, { emit }: any) {
return () =>
h('button', { class: 'emit-field', onClick: () => emit('change', ' hello ', { changeRecords: [] }) });
},
});
const mountEmitForm = (config: any[], props: Record<string, any> = {}) =>
mount(MForm, {
global: {
plugins: [
ElementPlus as any,
[MagicForm as any, { fields: { 'emit-field': { component: emitField } } }] as any,
],
},
props: { config, initValues: { title: 'old' }, ...props },
});
const flush = async () => {
await nextTick();
await Promise.resolve();
await nextTick();
};
test('filter / trim / onChange 回调链按最新表单值处理字段改动', async () => {
const getFormValueInFilter = vi.fn();
const getFormValueInChange = vi.fn();
const wrapper = mountEmitForm([
{
type: 'emit-field',
name: 'title',
text: '标题',
filter: (_mForm: any, value: any, ctx: any) => {
getFormValueInFilter(ctx.getFormValue('title'));
return value;
},
trim: true,
onChange: (_mForm: any, value: any, ctx: any) => {
getFormValueInChange(ctx.getFormValue('title'));
// 读取 model proxy 的字段,验证其按 props.model 读穿
void ctx.model.title;
return value;
},
},
]);
await flush();
await wrapper.find('.emit-field').trigger('click');
await flush();
expect(getFormValueInFilter).toHaveBeenCalledWith('old');
expect(getFormValueInChange).toHaveBeenCalledWith('old');
expect(wrapper.vm.values.title).toBe('hello');
});
test('onChange 抛错时不影响表单继续可用', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const wrapper = mountEmitForm([
{
type: 'emit-field',
name: 'title',
onChange: () => {
throw new Error('boom');
},
},
]);
await flush();
await wrapper.find('.emit-field').trigger('click');
await flush();
expect(errorSpy).toHaveBeenCalled();
expect(wrapper.vm.initialized).toBe(true);
errorSpy.mockRestore();
});
test('对比模式支持自定义 showDiff 与 selfDiffFieldTypes', async () => {
const showDiff = vi.fn(() => true);
const selfDiffTypes = vi.fn((defaults: string[]) => [...defaults]);
const wrapper = mountForm({
config: [{ type: 'text', name: 'title', text: '标题' }],
initValues: { title: 'new' },
lastValues: { title: 'old' },
isCompare: true,
showDiff,
selfDiffFieldTypes: selfDiffTypes,
});
await flush();
expect(showDiff).toHaveBeenCalled();
expect(selfDiffTypes).toHaveBeenCalled();
expect(wrapper.findComponent({ name: 'MFormContainer' }).exists()).toBe(true);
await wrapper.setProps({ selfDiffFieldTypes: ['text'] });
await flush();
expect(wrapper.findComponent({ name: 'MFormContainer' }).exists()).toBe(true);
});
});