mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-11-06 01:50:42 +00:00
fix(form): 修复切换同类型节点时表单字段配置串台
- MForm 新增 formKey,数据实体切换时只重挂叶子字段,保留 tab 等容器状态;异步 initValue 仅采用最后一次结果 - 代码块 / 数据源方法参数配置改为按 model 派生,避免 setup 期快照残留旧节点配置 - 补充相关单测与 form-props 文档
This commit is contained in:
parent
b4747c3e89
commit
abcd875731
@ -69,6 +69,18 @@
|
||||
</script>
|
||||
```
|
||||
|
||||
## formKey
|
||||
|
||||
- **详情:** 当前表单绑定的数据实体标识(编辑器属性面板里是节点 id)。
|
||||
|
||||
配置结构相同但换了数据实体时(例如两个同类型组件之间切换选中),表单树会被复用,
|
||||
字段组件在 setup 期从 `model` 派生的本地配置(如事件动作的参数字段)仍属于上一个实体,
|
||||
会出现「值来自新节点、配置来自旧节点」的串台。传入 `formKey` 后,值变化时只重挂叶子字段组件;
|
||||
tab、group-list 等容器保持复用,激活 tab、滚动位置和展开态不会丢失。
|
||||
同一实体的 `initValues` 更新(例如编辑后重新下发节点数据)不会触发重挂。
|
||||
|
||||
- **类型:** `string | number`
|
||||
|
||||
## lastValues
|
||||
|
||||
- **详情:** 需对比的值(开启对比模式时传入)
|
||||
|
||||
@ -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)
|
||||
: {},
|
||||
});
|
||||
|
||||
|
||||
@ -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)
|
||||
: {},
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 = [
|
||||
{
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user