feat(form,editor): 新增 validateForm 校验能力并接入源码保存校验联动

This commit is contained in:
roymondchen 2026-07-16 19:37:46 +08:00
parent 4a15da2108
commit d07e48bb10
11 changed files with 1518 additions and 222 deletions

View File

@ -35,6 +35,16 @@ defineExpose({
return form.value?.validate();
},
clearValidate(props?: string | string[]) {
if (typeof form.value?.clearValidate === 'function') {
return form.value?.clearValidate(props);
}
// tdesign 使用 clearValidate,element-plus 也是 clearValidate;此处兜底其它可能的命名
if (typeof form.value?.clearValidateState === 'function') {
return form.value?.clearValidateState();
}
},
resetFields() {
if (typeof form.value?.resetFields === 'function') {
return form.value?.resetFields();

View File

@ -131,6 +131,7 @@ const eventNameConfig = computed(() => {
}
callback();
},
trigger: 'blur',
},
],
};

View File

@ -54,6 +54,7 @@ import MIcon from '@editor/components/Icon.vue';
import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps';
import { useEditorContentHeight } from '@editor/hooks/use-editor-content-height';
import { useServices } from '@editor/hooks/use-services';
import { validatePropsForm } from '@editor/utils/props';
import CodeEditor from '../CodeEditor.vue';
@ -133,8 +134,33 @@ const errorHandler = (e: any) => {
emit('form-error', e);
};
const saveCode = (values: any) => {
emit('submit', props.codeValueKey ? { [props.codeValueKey]: values } : values);
const saveCode = async (values: any) => {
const newValues = props.codeValueKey ? { [props.codeValueKey]: values } : values;
if (!enablePropsFormValidate) {
// 未启用校验联动:保持原行为,直接提交源码保存的值
emit('submit', newValues);
return;
}
// 启用校验联动:源码编辑器保存的值未经过表单交互,这里另建一个独立的 MForm 实例对最新配置
// 做一次静默校验(不复用、也不污染页面上正在展示的表单),并将校验结果(错误信息)随提交
// 一并抛给上层记录,使源码保存的错误状态与表单编辑保持一致。
try {
const error = await validatePropsForm({
config: props.config,
values: newValues,
appContext: internalInstance?.appContext ?? null,
services,
stage: stage.value,
extendState: props.extendState,
});
emit('submit', newValues, undefined, error ? new Error(error) : undefined);
} catch (e: any) {
console.log('validateForm error', e);
// 静默校验本身出现异常(如初始化超时)时,退回到不携带错误信息的提交,避免阻塞源码保存
emit('submit', newValues);
}
};
defineExpose({ configForm: configFormRef, submit });

View File

@ -17,6 +17,8 @@
* limitations under the License.
*/
import type { AppContext } from 'vue';
import {
HookType,
NODE_CONDS_KEY,
@ -25,7 +27,18 @@ import {
NODE_DISABLE_DATA_SOURCE_KEY,
} from '@tmagic/core';
import { tMagicMessage } from '@tmagic/design';
import type { ChildConfig, DisplayCondsConfig, FormConfig, TabConfig, TabPaneConfig } from '@tmagic/form';
import type {
ChildConfig,
DisplayCondsConfig,
FormConfig,
FormState,
FormValue,
TabConfig,
TabPaneConfig,
} from '@tmagic/form';
import { validateForm } from '@tmagic/form';
import type { Services } from '@editor/type';
export const arrayOptions = [
{ text: '包含', value: 'include' },
@ -75,7 +88,7 @@ export const getCondOpOptionsByFieldType = (type: string) => {
export const styleTabConfig: TabPaneConfig = {
title: '样式',
lazy: true,
display: ({ services }: any) => !(services.uiService.get('showStylePanel') ?? true),
display: ({ services }: any) => !(services?.uiService?.get('showStylePanel') ?? true),
items: [
{
name: 'style',
@ -348,3 +361,83 @@ export const fillConfig = (
return [tabConfig];
};
// #region ValidatePropsFormOptions
/**
* validatePropsForm 参数
*/
export interface ValidatePropsFormOptions {
/** 组件属性表单配置 */
config: FormConfig;
/** 待校验的表单值 */
values: FormValue;
/**
* 当前组件实例的 appContext(通常为 `getCurrentInstance()?.appContext`)。
* 会与 services 一并合入临时 MForm 的 appContext,使编辑器字段组件(DataSourceInput 等)能正常 inject。
*/
appContext?: AppContext | null;
/** 编辑器服务集合,注入到临时表单的 formState */
services?: Services;
/** stage 实例,注入到临时表单的 formState */
stage?: any;
/** 外部扩展的 formState */
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/**
* 调试模式,默认 `true`:以弹层形式可见地渲染表单,点击「确定」才触发校验。
* 置为 `false` 时静默挂载后自动校验。
*/
debug?: boolean;
}
// #endregion ValidatePropsFormOptions
/**
* 对一份「组件属性表单配置 + 值」做一次独立的校验,**不复用也不污染页面上正在展示的表单**。
*
* 内部基于 `@tmagic/form` 的 `validateForm` 另建一个独立的 MForm 实例完成校验,并统一处理
* 编辑器场景所需的上下文注入:将当前组件实例的 provides 合入 appContext,并向 formState 注入
* stage / services 及外部扩展状态,保证校验规则依赖的上下文可用。
*
* 常用于源码编辑器保存后,对最新配置做一次校验,并将校验结果(错误信息)随提交一并抛给上层记录,
* 使源码保存的错误状态与表单编辑保持一致。
*
* @returns 校验通过返回空字符串 `''`,否则返回以 `<br>` 拼接的错误文案。
* 仅在初始化超时或挂载失败等异常情况下才会 reject。
*
* @example
* ```ts
* const error = await validatePropsForm({
* config,
* values,
* appContext: getCurrentInstance()?.appContext,
* services,
* stage: editorService.get('stage'),
* extendState,
* });
* if (error) {
* // 配置不合法,error 为错误文案
* }
* ```
*/
export const validatePropsForm = ({
config,
values,
appContext = null,
services,
stage,
extendState,
debug,
}: ValidatePropsFormOptions): Promise<string> =>
validateForm({
config,
debug,
initValues: values,
// 将当前组件实例的 provides(含 Editor 顶层的 services / codeOptions 等组件级 provide)
// 合入 appContext,使临时 MForm 中的编辑器字段组件(DataSourceInput 等)能正常 inject
appContext: appContext ? { ...appContext, provides: { services } } : null,
// 与页面表单保持一致:注入 stage/services 及外部扩展状态,保证校验规则依赖的上下文可用
extendState: async (state) => ({
...((await extendState?.(state)) || {}),
stage,
services,
}),
});

View File

@ -64,11 +64,15 @@ vi.mock('@tmagic/design', () => ({
// 可控的 submitForm 实现:默认校验成功,测试可将其改为 reject 以模拟校验失败
let submitFormImpl: () => Promise<any> = async () => ({ a: 1 });
// 可控的静默校验 validateForm 实现:默认返回空字符串(校验通过)
let validateFormImpl: (_options?: any) => Promise<string> = async () => '';
vi.mock('@tmagic/form', async () => {
const actual = await vi.importActual<any>('@tmagic/form');
return {
...actual,
// 源码保存后的静默校验走独立的 validateForm(内部新建 MForm 实例),此处 mock 便于断言
validateForm: vi.fn((options?: any) => validateFormImpl(options)),
MForm: defineComponent({
name: 'MForm',
props: ['config', 'initValues', 'extendState'],
@ -95,6 +99,7 @@ vi.mock('@tmagic/form', async () => {
beforeEach(() => {
vi.clearAllMocks();
submitFormImpl = async () => ({ a: 1 });
validateFormImpl = async () => '';
uiService.get.mockImplementation((k: string) => (k === 'propsPanelSize' ? 'small' : null));
editorService.get.mockImplementation((k: string) => (k === 'stage' ? { id: 'stage' } : null));
});
@ -198,4 +203,102 @@ describe('FormPanel', () => {
await wrapper.find('.fake-code-editor').trigger('click');
expect(wrapper.emitted('submit')?.[0]?.[0]).toEqual({ foo: 'bar' });
});
test('未启用 enablePropsFormValidate 时源码保存不触发静默校验', async () => {
const validateSpy = vi.fn(async () => '');
validateFormImpl = validateSpy;
const wrapper = mount(FormPanel, { props: { config: [], values: {} } as any });
await wrapper.find('.fake-btn').trigger('click');
await wrapper.find('.fake-code-editor').trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(validateSpy).not.toHaveBeenCalled();
// 仅携带值,不携带 error
expect(wrapper.emitted('submit')?.[0]).toEqual([{ foo: 'bar' }]);
});
test('启用 enablePropsFormValidate 时源码保存通过新建的 MForm 做静默校验(携带 config/initValues)', async () => {
const validateSpy = vi.fn(async () => '');
validateFormImpl = validateSpy;
const wrapper = mount(FormPanel, {
props: { config: [], values: {}, codeValueKey: 'style' } as any,
global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } },
});
await wrapper.find('.fake-btn').trigger('click');
await wrapper.find('.fake-code-editor').trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(validateSpy).toHaveBeenCalledTimes(1);
// 以源码保存后的最新值作为待校验的 initValues
expect(validateSpy.mock.calls[0][0]).toMatchObject({ initValues: { style: { foo: 'bar' } } });
});
test('源码保存时传给 validateForm 的 extendState 注入 services 和 stage', async () => {
const validateSpy = vi.fn(async () => '');
validateFormImpl = validateSpy;
const wrapper = mount(FormPanel, {
props: { config: [], values: {} } as any,
global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } },
});
await wrapper.find('.fake-btn').trigger('click');
await wrapper.find('.fake-code-editor').trigger('click');
await new Promise((r) => setTimeout(r, 0));
const { extendState } = validateSpy.mock.calls[0][0];
expect(typeof extendState).toBe('function');
const result = await extendState({});
expect(result).toHaveProperty('services');
expect(result).toHaveProperty('stage');
});
test('启用 enablePropsFormValidate 且源码保存静默校验通过时 submit 不携带 error', async () => {
validateFormImpl = async () => '';
const wrapper = mount(FormPanel, {
props: { config: [], values: {} } as any,
global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } },
});
await wrapper.find('.fake-btn').trigger('click');
await wrapper.find('.fake-code-editor').trigger('click');
await new Promise((r) => setTimeout(r, 0));
const submitEvents = wrapper.emitted('submit');
expect(submitEvents?.[0]?.[0]).toEqual({ foo: 'bar' });
// eventData 为 undefined(源码保存),error 为 undefined(校验通过)
expect(submitEvents?.[0]?.[1]).toBeUndefined();
expect(submitEvents?.[0]?.[2]).toBeUndefined();
});
test('启用 enablePropsFormValidate 且源码保存静默校验失败时 submit 携带 error', async () => {
validateFormImpl = async () => '字段A -> 必填';
const wrapper = mount(FormPanel, {
props: { config: [], values: {} } as any,
global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } },
});
await wrapper.find('.fake-btn').trigger('click');
await wrapper.find('.fake-code-editor').trigger('click');
await new Promise((r) => setTimeout(r, 0));
const submitEvents = wrapper.emitted('submit');
expect(submitEvents?.[0]?.[0]).toEqual({ foo: 'bar' });
expect(submitEvents?.[0]?.[1]).toBeUndefined();
expect(submitEvents?.[0]?.[2]).toBeInstanceOf(Error);
expect((submitEvents?.[0]?.[2] as Error).message).toBe('字段A -> 必填');
});
test('启用 enablePropsFormValidate 时静默校验抛异常则退回普通提交', async () => {
validateFormImpl = async () => {
throw new Error('validate 异常');
};
const wrapper = mount(FormPanel, {
props: { config: [], values: {} } as any,
global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } },
});
await wrapper.find('.fake-btn').trigger('click');
await wrapper.find('.fake-code-editor').trigger('click');
await new Promise((r) => setTimeout(r, 0));
const submitEvents = wrapper.emitted('submit');
// 退回到仅携带值的提交
expect(submitEvents?.[0]).toEqual([{ foo: 'bar' }]);
});
});

View File

@ -3,9 +3,10 @@
*
* Copyright (C) 2025 Tencent.
*/
import { describe, expect, test, vi } from 'vitest';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { NODE_CONDS_RESULT_KEY } from '@tmagic/core';
import { validateForm } from '@tmagic/form';
import {
advancedTabConfig,
@ -18,6 +19,7 @@ import {
getCondOpOptionsByFieldType,
numberOptions,
styleTabConfig,
validatePropsForm,
} from '@editor/utils/props';
vi.mock('@tmagic/design', () => ({
@ -27,6 +29,10 @@ vi.mock('@tmagic/design', () => ({
},
}));
vi.mock('@tmagic/form', () => ({
validateForm: vi.fn(),
}));
describe('props 选项常量', () => {
test('eqOptions / arrayOptions / numberOptions / booleanOptions 内容稳定', () => {
expect(eqOptions.map((o) => o.value)).toEqual(['=', '!=']);
@ -164,3 +170,56 @@ describe('fillConfig', () => {
});
});
});
describe('validatePropsForm', () => {
beforeEach(() => {
vi.mocked(validateForm).mockReset();
});
test('合入 appContext.provides、注入 stage/services 并合并外部 extendState,debug 默认 true', async () => {
vi.mocked(validateForm).mockResolvedValue('err-text');
const services = { uiService: {} } as any;
const stage = { id: 'stage' };
const appContext = { app: {}, provides: { foo: 1 } } as any;
const result = await validatePropsForm({
config: [],
values: { a: 1 },
appContext,
services,
stage,
extendState: async () => ({ extra: true }),
});
// 直接返回 validateForm 的结果
expect(result).toBe('err-text');
const arg = vi.mocked(validateForm).mock.calls[0][0];
expect(arg.config).toEqual([]);
expect(arg.debug).toBe(true);
expect(arg.initValues).toEqual({ a: 1 });
// appContext 保留原字段,但 provides 被替换为 { services }
expect(arg.appContext).toEqual({ app: {}, provides: { services } });
// extendState 合并外部返回并注入 stage/services
const state = await arg.extendState!({} as any);
expect(state).toEqual({ extra: true, stage, services });
});
test('appContext 缺省为 null,extendState 缺省时仍注入 stage/services,可覆盖 debug', async () => {
vi.mocked(validateForm).mockResolvedValue('');
const services = {} as any;
const stage = {};
await validatePropsForm({ config: [], values: {}, services, stage, debug: false });
const arg = vi.mocked(validateForm).mock.calls[0][0];
expect(arg.appContext).toBeNull();
expect(arg.debug).toBe(false);
const state = await arg.extendState!({} as any);
expect(state).toEqual({ stage, services });
});
});

View File

@ -32,7 +32,7 @@
</template>
<script setup lang="ts">
import { provide, reactive, ref, shallowRef, toRaw, useTemplateRef, watch, watchEffect } from 'vue';
import { nextTick, provide, reactive, ref, shallowRef, toRaw, useTemplateRef, watch, watchEffect } from 'vue';
import { cloneDeep, isEqual } from 'lodash-es';
import { TMagicForm, tMagicMessage, tMagicMessageBox } from '@tmagic/design';
@ -280,6 +280,10 @@ watch(
values.value = value;
// 非对比模式,初始化完成
initialized.value = !props.isCompare;
nextTick(() => {
tMagicFormRef.value?.validate();
});
});
if (props.isCompare) {
@ -360,6 +364,27 @@ const getTextByName = (name: string, config: FormConfig = props.config): string
return findInConfig(config, nameParts);
};
/**
* 将校验返回的 invalidFields 汇总为可读的错误文案(多条以 `<br>` 拼接)。
*
* 抽离为独立方法,供 `submitForm`(提交校验)与 `validate`(返回错误文案的校验)复用,
* 保证两种校验入口产出的错误文案格式完全一致。
*/
const formatValidateError = (invalidFields: Record<string, any>): string => {
const error: string[] = [];
Object.entries(invalidFields).forEach(([prop, validateError]) => {
(validateError as ValidateError[]).forEach(({ field, message }) => {
const name = field || prop;
const text = (props.useFieldTextInError ? getTextByName(name, props.config) : undefined) || name;
error.push(`${text} -> ${message}`);
});
});
return error.join('<br>');
};
defineExpose({
values,
lastValuesProcessed,
@ -387,18 +412,36 @@ defineExpose({
} catch (invalidFields: any) {
emit('error', invalidFields);
const error: string[] = [];
throw new Error(formatValidateError(invalidFields));
}
},
Object.entries(invalidFields).forEach(([prop, ValidateError]) => {
(ValidateError as ValidateError[]).forEach(({ field, message }) => {
const name = field || prop;
const text = (props.useFieldTextInError ? getTextByName(name, props.config) : undefined) || name;
error.push(`${text} -> ${message}`);
});
});
throw new Error(error.join('<br>'));
/**
* 校验:对表单当前值执行校验,返回汇总后的错误文案。
*
* 与 `submitForm` 的区别:
* - 校验失败时不抛异常、不触发 `error` 事件,而是以返回值形式给出错误文案;
* - 不重置 `changeRecords`,不改变提交语义,仅用于「探测」当前配置是否合法。
*
* 注意:本方法只改变「校验结果的返回方式」,并不负责「不污染页面表单状态」——
* 若需对一份独立的「配置 + 值」做完全不影响页面上已渲染表单的校验,请使用 `validateForm`
* (内部会新建一个隐藏的 MForm 实例,通过 `initValues` 传入待校验值,用完即卸载)。
*
* 典型用途:作为 `validateForm` 内部复用的校验实现;也可在已渲染的表单实例上主动调用,
* 根据返回的错误文案自行决定后续处理(如记录节点错误状态)。
*
* @returns 校验通过返回空字符串 `''`,否则返回以 `<br>` 拼接的错误文案。
*/
validate: async (): Promise<string> => {
try {
const result = await tMagicFormRef.value?.validate();
// tdesign 通过返回值返回校验结果,element-plus 通过 throw error
if (result !== true) {
throw result;
}
return '';
} catch (invalidFields: any) {
return formatValidateError(invalidFields);
}
},

View File

@ -16,7 +16,7 @@
* limitations under the License.
*/
import { type AppContext, type Component, createApp, defineComponent, h, nextTick, ref, watch } from 'vue';
import { type AppContext, type Component, createApp, defineComponent, h, nextTick, type Ref, ref, watch } from 'vue';
import Form from './Form.vue';
import type { ChangeRecord, FormConfig, FormState } from './schema';
@ -79,7 +79,7 @@ export interface SubmitFormOptions {
// #region SubmitFormResult
/**
* 开启 `returnChangeRecords` 时 submitForm 的返回结果
* 开启 `returnChangeNodes` 时 submitForm 的返回结果
*/
export interface SubmitFormResult {
/** 校验通过后的表单值 */
@ -89,6 +89,310 @@ export interface SubmitFormResult {
}
// #endregion SubmitFormResult
// #region mountFormInstance
/**
* 构造 wrapper 组件的工厂:在合适时机调用 MForm 实例方法并 resolve/reject、清理实例。
*
* 由调用方决定「等待 `initialized` 后自动执行」还是「等待人工触发」(debug 模式),
* 以及「调用 `submitForm` 还是 `validate`」「结果如何包装」,从而复用公共脚手架。
*/
type FormWrapperFactory<T> = (ctx: {
/** 透传给 Form 组件的 props(由工厂统一注入,避免调用方重复闭包) */
formProps: Record<string, any>;
/** 指向挂载的 MForm 实例 */
formRef: Ref<any>;
/** 卸载实例并移除容器,resolve/reject 后必须调用以避免泄漏 */
cleanup: () => void;
/** resolve 外层 Promise */
resolve: (value: T | PromiseLike<T>) => void;
/** reject 外层 Promise */
reject: (reason?: any) => void;
}) => Component;
interface MountFormInstanceOptions<T> {
/** 透传给 Form 组件的 props */
formProps: Record<string, any>;
/** 父级应用上下文,用于继承全局组件、指令、provide 等 */
appContext?: AppContext | null;
/** 等待表单初始化的最长时间(毫秒),<=0 表示不注册超时 */
timeout: number;
/** 超时 reject 的错误文案 */
timeoutMessage: string;
/** 是否以 `display:none` 隐藏容器。调试模式需可见,应传 `false` */
hidden?: boolean;
/** 是否跳过超时注册。调试模式等待人工操作,应传 `true` */
skipTimeout?: boolean;
/** 构造 wrapper 组件 */
createWrapper: FormWrapperFactory<T>;
}
/**
* submitForm / validateForm 的公共脚手架:
*
* 创建临时容器 → 挂载一个 wrapper 组件(内含 MForm)→ 提供统一的 cleanup / timeout / appContext 继承 →
* 由 `createWrapper` 决定「何时调用 MForm 的哪个方法、如何 resolve/reject」。
*
* 调用方只需关注差异逻辑(调用 `submitForm` 还是 `validate`、结果如何包装、是否需要调试弹层),
* 容器创建、卸载、超时、上下文注入等模板代码在此统一收口。
*/
const mountFormInstance = <T>(options: MountFormInstanceOptions<T>): Promise<T> => {
const { formProps, appContext, timeout, timeoutMessage, hidden = true, skipTimeout = false, createWrapper } = options;
return new Promise<T>((resolve, reject) => {
const container = document.createElement('div');
if (hidden) {
container.style.display = 'none';
}
document.body.appendChild(container);
let cleaned = false;
let timer: ReturnType<typeof setTimeout> | null = null;
// 用 holder 持有 app,使 cleanup 可在 app 创建之前定义(const app + 无 TDZ / 无 use-before-define)
const instance: { app: ReturnType<typeof createApp> | null } = { app: null };
const cleanup = () => {
if (cleaned) return;
cleaned = true;
if (timer) {
clearTimeout(timer);
timer = null;
}
try {
instance.app?.unmount();
} catch {
// ignore
}
container.parentNode?.removeChild(container);
};
const formRef = ref<any>(null);
// 将 extendState 从 formProps 中剥离:不由 Form.vue 的 async watchEffect 异步应用,
// 而是在 wrapper 中通过 sync watch 在 formRef 就绪后直接写入 formState,
// 避免 display 等 filterFunction 在首次渲染时读到 undefined。
// 与 CompareForm / FormPanel 中「formRef.value.formState.services = ...」的做法一致。
const { extendState, ...restFormProps } = formProps;
const userWrapper = createWrapper({ formRef, formProps: restFormProps, cleanup, resolve, reject });
const wrapperComponent =
typeof extendState === 'function'
? defineComponent({
name: 'MFormExtendStateInjector',
setup() {
watch(
() => formRef.value,
(form) => {
if (!form) return;
let result: any;
try {
result = extendState(form.formState);
} catch (e) {
console.error('[MForm] extendState failed:', e);
return;
}
const apply = (state: Record<string, any> | null | undefined) => {
if (!state) return;
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(state))) {
if ('value' in descriptor) {
(form.formState as any)[key] = (state as any)[key];
} else {
descriptor.configurable = true;
Object.defineProperty(form.formState, key, descriptor);
}
}
};
if (result && typeof result.then === 'function') {
result.then(apply, (e: any) => console.error('[MForm] extendState failed:', e));
} else {
apply(result);
}
},
{ flush: 'sync', immediate: true },
);
return () => h(userWrapper);
},
})
: userWrapper;
const app = createApp(wrapperComponent);
instance.app = app;
// 继承父级应用上下文(components / directives / provides / config 等)
if (appContext) {
Object.assign(app._context, appContext);
}
if (timeout > 0 && !skipTimeout) {
timer = setTimeout(() => {
if (!cleaned) {
reject(new Error(timeoutMessage));
cleanup();
}
}, timeout);
}
try {
app.mount(container);
} catch (err) {
reject(err);
cleanup();
}
});
};
// #endregion mountFormInstance
// #region createDebugWrapper
interface DebugWrapperOptions {
/** 指向挂载的 MForm 实例 */
formRef: Ref<any>;
/** 透传给 Form 组件的 props */
formProps: Record<string, any>;
/** 弹层标题 */
title: string;
/** wrapper 组件名 */
name: string;
/**
* 点击「确定」触发;接收 `setError`,校验失败时可调用以在弹层展示错误并保留弹层供修正后重试,
* 校验通过则由调用方自行 resolve + cleanup。
*/
onConfirm: (setError: (msg: string) => void) => void | Promise<void>;
/** 点击「取消」触发(通常 reject + cleanup) */
onCancel: () => void;
}
/**
* 构造调试模式下的可见弹层 wrapper:以 fixed 遮罩居中渲染 MForm,提供「确定 / 取消」按钮与错误展示区。
*
* submitForm 与 validateForm 的调试弹层 UI 完全一致,仅「确定」时调用的实例方法、错误处理与取消文案不同,
* 这些差异通过 `onConfirm` / `onCancel` 注入,弹层结构在此统一收口。
*/
const createDebugWrapper = (options: DebugWrapperOptions): Component => {
const { formRef, formProps, title, name, onConfirm, onCancel } = options;
const btnBase = {
padding: '8px 20px',
fontSize: '14px',
lineHeight: '1',
border: '1px solid #dcdfe6',
borderRadius: '4px',
cursor: 'pointer',
};
return defineComponent({
name,
setup() {
// 校验失败信息展示区
const errorMsg = ref('');
const setError = (msg: string) => {
errorMsg.value = msg;
};
return () =>
h(
'div',
{
style: {
position: 'fixed',
inset: '0',
zIndex: '10000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0, 0, 0, 0.5)',
},
},
[
h(
'div',
{
style: {
display: 'flex',
flexDirection: 'column',
width: '600px',
maxWidth: '90vw',
maxHeight: '85vh',
background: '#fff',
borderRadius: '8px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.2)',
overflow: 'hidden',
},
},
[
h(
'div',
{
style: {
padding: '16px 20px',
fontSize: '16px',
fontWeight: '600',
borderBottom: '1px solid #ebeef5',
},
},
title,
),
h(
'div',
{
style: {
flex: '1',
padding: '20px',
overflow: 'auto',
},
},
[
h(Form as Component, { ...formProps, ref: formRef }),
errorMsg.value
? h('div', {
style: {
marginTop: '12px',
color: '#f56c6c',
fontSize: '13px',
lineHeight: '1.5',
},
innerHTML: errorMsg.value,
})
: null,
],
),
h(
'div',
{
style: {
display: 'flex',
justifyContent: 'flex-end',
gap: '12px',
padding: '12px 20px',
borderTop: '1px solid #ebeef5',
},
},
[
h('button', { type: 'button', onClick: onCancel, style: { ...btnBase } }, '取消'),
h(
'button',
{
type: 'button',
onClick: () => onConfirm(setError),
style: {
...btnBase,
color: '#fff',
background: '#409eff',
borderColor: '#409eff',
},
},
'确定',
),
],
),
],
),
],
);
},
});
};
// #endregion createDebugWrapper
/**
* 以命令式方式调用 Form.vue 完成一次表单校验/提交。
*
@ -128,214 +432,285 @@ export interface SubmitFormResult {
export const submitForm = (options: SubmitFormOptions): Promise<any> => {
const { native, appContext, timeout = 10000, returnChangeRecords, debug = false, ...formProps } = options;
return new Promise((resolve, reject) => {
const container = document.createElement('div');
// 调试模式下需要把表单展示出来,普通模式则隐藏挂载
if (!debug) {
container.style.display = 'none';
}
document.body.appendChild(container);
return mountFormInstance<any>({
formProps,
appContext,
timeout,
// 调试模式需把表单展示出来;普通模式隐藏挂载
hidden: !debug,
// 调试模式等待人工操作,不应用超时
skipTimeout: debug,
timeoutMessage: `submitForm timeout after ${timeout}ms: form is not initialized.`,
createWrapper: ({ formRef, formProps, cleanup, resolve, reject }) => {
/**
* 执行一次提交:nextTick 等待子组件渲染 → 快照 changeRecords → 调用实例 submitForm → resolve。
* 校验失败时交给 `onValidateError` 决定「保留弹层展示错误(debug)」还是「reject 并清理(普通)」,
* 从而让 debug 与普通模式共享同一份提交逻辑。
*/
const doSubmit = async (onValidateError: (err: any) => void) => {
try {
// 等待子组件(FormItem 等)完成首次渲染,确保 validate 能拿到所有字段
await nextTick();
// submitForm 校验通过后会清空 changeRecords,需在调用前先做快照
const changeRecords: ChangeRecord[] = [...(formRef.value?.changeRecords ?? [])];
const result = await formRef.value.submitForm(native);
resolve(returnChangeRecords ? { values: result, changeRecords } : result);
cleanup();
} catch (err) {
onValidateError(err);
}
};
let cleaned = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const wrapperComponent = defineComponent({
name: 'MFormSubmitWrapper',
setup() {
const formRef = ref<any>(null);
// 调试模式下用于展示校验失败信息
const errorMsg = ref('');
const doSubmit = async () => {
try {
// 等待子组件(FormItem 等)完成首次渲染,确保 validate 能拿到所有字段
await nextTick();
// submitForm 校验通过后会清空 changeRecords,需在调用前先做快照
const changeRecords: ChangeRecord[] = [...(formRef.value?.changeRecords ?? [])];
const result = await formRef.value.submitForm(native);
resolve(returnChangeRecords ? { values: result, changeRecords } : result);
cleanup();
} catch (err) {
// 调试模式下校验失败时保留弹层并展示错误,便于修正后重新提交
if (debug) {
errorMsg.value = err instanceof Error ? err.message : String(err);
return;
}
reject(err);
cleanup();
}
};
// 调试模式:可见地渲染表单,点击「确定」才提交,点击「取消」则中断
if (debug) {
const handleCancel = () => {
// 调试模式:可见地渲染表单,点击「确定」才提交,点击「取消」则中断
if (debug) {
return createDebugWrapper({
formRef,
formProps,
name: 'MFormSubmitWrapper',
title: 'submitForm 调试',
onConfirm: (setError) =>
doSubmit((err) => {
// 校验失败时保留弹层并展示错误,便于修正后重新提交
setError(err instanceof Error ? err.message : String(err));
}),
onCancel: () => {
reject(new Error('submitForm canceled in debug mode.'));
cleanup();
};
const btnBase = {
padding: '8px 20px',
fontSize: '14px',
lineHeight: '1',
border: '1px solid #dcdfe6',
borderRadius: '4px',
cursor: 'pointer',
};
return () =>
h(
'div',
{
style: {
position: 'fixed',
inset: '0',
zIndex: '10000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(0, 0, 0, 0.5)',
},
},
[
h(
'div',
{
style: {
display: 'flex',
flexDirection: 'column',
width: '600px',
maxWidth: '90vw',
maxHeight: '85vh',
background: '#fff',
borderRadius: '8px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.2)',
overflow: 'hidden',
},
},
[
h(
'div',
{
style: {
padding: '16px 20px',
fontSize: '16px',
fontWeight: '600',
borderBottom: '1px solid #ebeef5',
},
},
'submitForm 调试',
),
h(
'div',
{
style: {
flex: '1',
padding: '20px',
overflow: 'auto',
},
},
[
h(Form as Component, { ...formProps, ref: formRef }),
errorMsg.value
? h('div', {
style: {
marginTop: '12px',
color: '#f56c6c',
fontSize: '13px',
lineHeight: '1.5',
},
innerHTML: errorMsg.value,
})
: null,
],
),
h(
'div',
{
style: {
display: 'flex',
justifyContent: 'flex-end',
gap: '12px',
padding: '12px 20px',
borderTop: '1px solid #ebeef5',
},
},
[
h('button', { type: 'button', onClick: handleCancel, style: { ...btnBase } }, '取消'),
h(
'button',
{
type: 'button',
onClick: doSubmit,
style: {
...btnBase,
color: '#fff',
background: '#409eff',
borderColor: '#409eff',
},
},
'确定',
),
],
),
],
),
],
);
}
// 普通模式:表单初始化完成后自动提交
const stop = watch(
() => formRef.value?.initialized,
(initialized) => {
if (!initialized) return;
stop();
doSubmit();
},
{ flush: 'post', immediate: true },
);
return () => h(Form as Component, { ...formProps, ref: formRef });
},
});
const app = createApp(wrapperComponent);
// 继承父级应用上下文(components / directives / provides / config 等)
if (appContext) {
Object.assign(app._context, appContext);
}
const cleanup = () => {
if (cleaned) return;
cleaned = true;
if (timer) {
clearTimeout(timer);
timer = null;
});
}
try {
app.unmount();
} catch {
// ignore
}
container.parentNode?.removeChild(container);
};
// 调试模式等待人工操作,不应用超时
if (timeout > 0 && !debug) {
timer = setTimeout(() => {
if (!cleaned) {
reject(new Error(`submitForm timeout after ${timeout}ms: form is not initialized.`));
cleanup();
}
}, timeout);
}
// 普通模式:表单初始化完成后自动提交
return defineComponent({
name: 'MFormSubmitWrapper',
setup() {
const stop = watch(
() => formRef.value?.initialized,
(initialized) => {
if (!initialized) return;
stop();
doSubmit((err) => {
reject(err);
cleanup();
});
},
{ flush: 'post', immediate: true },
);
try {
app.mount(container);
} catch (err) {
reject(err);
cleanup();
}
return () => h(Form as Component, { ...formProps, ref: formRef });
},
});
},
});
};
// #region ValidateFormOptions
/**
* validateForm 函数参数(与 Form.vue 组件 props 对齐,取校验所需子集)
*/
export interface ValidateFormOptions {
/** 表单配置 */
config: FormConfig;
/** 待校验的表单值 */
initValues?: Record<string, any>;
parentValues?: Record<string, any>;
labelWidth?: string;
keyProp?: string;
/**
* 校验失败时,错误提示前缀是否使用字段的 text 文案(通过 `getTextByName` 从 config 中查找)。
* 默认 `true`,置为 `false` 时直接使用字段 name。
*/
useFieldTextInError?: boolean;
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/**
* 父级应用上下文,用于继承全局组件、指令、provide 等。
* 通常通过 `app._context` 或 `getCurrentInstance()?.appContext` 获取。
*/
appContext?: AppContext | null;
/** 等待表单初始化的最长时间(毫秒),超时将以错误 reject。默认 10000ms */
timeout?: number;
/**
* 调试模式。默认 `false`。
*
* - `false`:以隐藏方式挂载,初始化完成后自动校验并 resolve 错误文案(原有静默行为)。
* - `true`:将表单以弹层形式可见地渲染在页面上,需手动点击「确定」才会触发校验,
* 点击「取消」则以 reject 中断;校验失败时在弹层内展示错误信息并保留弹层,便于修正后重试,
* 校验通过则 resolve 空字符串。调试模式下 `timeout` 不生效(等待人工操作)。
*/
debug?: boolean;
}
// #endregion ValidateFormOptions
// #region stripTabLazy
/**
* 深拷贝配置值,保留函数(display / onTabClick 等回调)引用,避免破坏配置中的回调。
*/
const cloneConfigValue = (value: any): any => {
if (Array.isArray(value)) {
return value.map(cloneConfigValue);
}
if (value && typeof value === 'object') {
return Object.keys(value).reduce<Record<string, any>>((acc, key) => {
acc[key] = cloneConfigValue(value[key]);
return acc;
}, {});
}
return value;
};
/**
* 深度遍历配置,去掉所有 type 为 'tab' 的容器中各标签页(items)的 lazy 配置。
*/
const removeTabItemsLazy = (node: any): void => {
if (Array.isArray(node)) {
node.forEach(removeTabItemsLazy);
return;
}
if (!node || typeof node !== 'object') return;
if (node.type === 'tab' && Array.isArray(node.items)) {
node.items.forEach((pane: any) => {
if (pane && typeof pane === 'object') {
delete pane.lazy;
}
});
}
Object.keys(node).forEach((key) => {
removeTabItemsLazy(node[key]);
});
};
/**
* 返回一份去除了 tab 标签页 lazy 的配置副本。
*
* tab 容器开启 lazy 时,非激活标签页的内容不会渲染,导致 validateForm 静默挂载后
* 无法校验到这些标签页内的字段。校验场景需要一次性渲染全部字段,故在此统一去除 lazy。
* 处理基于深拷贝,不会污染调用方传入的原始 config。
*/
const stripTabItemsLazy = (config: FormConfig): FormConfig => {
const cloned = cloneConfigValue(config);
removeTabItemsLazy(cloned);
return cloned;
};
// #endregion stripTabLazy
/**
* 以命令式方式对一份「表单配置 + 值」做一次静默校验,**不依赖也不影响任何已渲染的表单**。
*
* 与 `submitForm` 类似,内部会临时挂载一个不可见的 MForm 实例,等待其初始化完成后调用
* 实例的 `validate` 方法,返回汇总后的错误文案,随后自动卸载实例。
*
* 与 `submitForm` 的区别:
* - 「静默」:校验失败不抛异常、不触发 `error` 事件、不返回表单值;
* - 仅用于「探测」配置是否合法,适合源码保存后校验、批量校验组件配置等场景。
*
* 由于每次都新建一个独立的 MForm 实例,调用方无需持有任何表单 ref,也不会污染
* 页面上正在展示的表单状态。
*
* @returns 校验通过返回空字符串 `''`,否则返回以 `<br>` 拼接的错误文案。
* 仅在初始化超时或挂载失败等异常情况下才会 reject。
*
* @example
* ```ts
* import { validateForm } from '@tmagic/form';
*
* const error = await validateForm({
* config: [...],
* initValues: { name: 'foo' },
* appContext: getCurrentInstance()?.appContext,
* });
* if (error) {
* // 配置不合法,error 为错误文案
* }
*
* // 调试模式:可见地渲染表单,点击「确定」才校验,校验失败保留弹层可修正重试:
* const error = await validateForm({
* config: [...],
* initValues: { name: 'foo' },
* debug: true,
* });
* ```
*/
export const validateForm = (options: ValidateFormOptions): Promise<string> => {
const { appContext, timeout = 10000, debug = false, config, ...rest } = options;
// 去掉 tab 容器各标签页的 lazy,确保懒加载标签页内的字段也参与校验
const formProps = { ...rest, config: stripTabItemsLazy(config) };
return mountFormInstance<string>({
formProps,
appContext,
timeout,
// 调试模式需把表单展示出来;普通模式隐藏挂载
hidden: !debug,
// 调试模式等待人工操作,不应用超时
skipTimeout: debug,
timeoutMessage: `validateForm timeout after ${timeout}ms: form is not initialized.`,
createWrapper: ({ formRef, formProps, cleanup, resolve, reject }) => {
/**
* 执行一次校验:nextTick 等待子组件渲染 → 调用实例 validate → 通过则 resolve ''。
* 校验失败(返回非空错误文案)时交给 `onInvalid` 决定「静默 resolve 错误文案(普通)」
* 还是「在弹层展示错误并保留供重试(debug)」,从而让两种模式共享同一份校验逻辑。
*/
const doValidate = async (onInvalid: (error: string) => void) => {
try {
// 等待子组件(FormItem 等)完成首次渲染,确保 validate 能拿到所有字段
await nextTick();
// 复用 Form.vue 实例的静默校验方法:校验通过返回 '',失败返回错误文案,均不抛异常
const error = await formRef.value.validate();
if (error) {
onInvalid(error);
} else {
resolve('');
cleanup();
}
} catch (err) {
reject(err);
cleanup();
}
};
// 调试模式:可见地渲染表单,点击「确定」才校验,点击「取消」则中断
if (debug) {
return createDebugWrapper({
formRef,
formProps,
name: 'MFormValidateWrapper',
title: 'validateForm 调试',
onConfirm: (setError) =>
doValidate((error) => {
// 校验失败时保留弹层并展示错误,便于修正后重新校验
setError(error);
}),
onCancel: () => {
reject(new Error('validateForm canceled in debug mode.'));
cleanup();
},
});
}
// 普通模式:表单初始化完成后自动校验(静默 resolve 错误文案)
return defineComponent({
name: 'MFormValidateWrapper',
setup() {
const stop = watch(
() => formRef.value?.initialized,
(initialized) => {
if (!initialized) return;
stop();
doValidate((error) => {
// 静默:校验失败也以错误文案 resolve(不抛异常)
resolve(error);
cleanup();
});
},
{ flush: 'post', immediate: true },
);
return () => h(Form as Component, { ...formProps, ref: formRef });
},
});
},
});
};

View File

@ -340,6 +340,56 @@ describe('Form.vue —— submitForm 实例方法', () => {
});
});
describe('Form.vue —— validate 校验实例方法(返回错误文案,不抛异常)', () => {
test('校验通过返回空字符串,且不触发 error 事件', async () => {
const wrapper = mountForm({
config: [{ text: 'text', type: 'text', name: 'text' }],
initValues: { text: 'hi' },
});
await nextTick();
const result = await wrapper.vm.validate();
expect(result).toBe('');
expect(wrapper.emitted('error')).toBeFalsy();
});
test('校验失败返回汇总后的错误文案(含字段 text),且不触发 error 事件、不抛异常', async () => {
const wrapper = mountForm({
config: [{ text: '名称', type: 'text', name: 'name' }],
initValues: { name: '' },
});
await nextTick();
const tmForm = wrapper.findComponent({ name: 'TMForm' });
const { exposed } = (tmForm.vm as any).$;
exposed.validate = vi.fn().mockRejectedValue({
name: [{ field: 'name', message: '必填' }],
});
const result = await wrapper.vm.validate();
expect(result).toBe('名称 -> 必填');
// 校验失败仅通过返回值给出错误文案,不触发 error 事件
expect(wrapper.emitted('error')).toBeFalsy();
});
test('校验返回非 true(tdesign 风格)时也返回错误文案', async () => {
const wrapper = mountForm({
config: [{ text: '账号', type: 'text', name: 'account' }],
initValues: { account: '' },
});
await nextTick();
const tmForm = wrapper.findComponent({ name: 'TMForm' });
const { exposed } = (tmForm.vm as any).$;
exposed.validate = vi.fn().mockResolvedValue({
account: [{ field: 'account', message: '不能为空' }],
});
const result = await wrapper.vm.validate();
expect(result).toContain('账号 -> 不能为空');
});
});
describe('Form.vue —— useFieldTextInError', () => {
const mountAndMockValidate = async (
props: Record<string, any>,

View File

@ -16,7 +16,7 @@
* limitations under the License.
*/
import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
import { type AppContext, createApp, defineComponent, h } from 'vue';
import { type AppContext, createApp, defineComponent, h, nextTick } from 'vue';
import MagicForm, { submitForm } from '@form/index';
import ElementPlus from 'element-plus';
@ -228,3 +228,134 @@ describe('submitForm', () => {
setTimeoutSpy.mockRestore();
});
});
describe('submitForm —— debug 模式', () => {
const findButton = (text: string) =>
Array.from(document.body.querySelectorAll('button')).find(
(b) => (b.textContent || '').trim() === text,
) as HTMLButtonElement;
// 通过 Vue 渲染留下的内部指针定位 MForm 组件实例,用于 mock 其 expose 方法。
// 真实 element-plus 校验在 jsdom 下不可靠(form-item 未注册到 form),故校验失败分支以 mock 方式验证。
const findMFormInstance = (): any => {
const formEl = document.body.querySelector('.m-form') as any;
let comp: any = formEl?.__vueParentComponent;
while (comp && comp.type?.name !== 'MForm' && comp.type?.__name !== 'MForm') comp = comp.parent;
return comp;
};
const mockExposed = (comp: any, method: string, fn: any) => {
Object.defineProperty(comp.exposed, method, { value: fn, configurable: true, writable: true });
};
test('debug 模式可见渲染弹层,点击「确定」校验通过后 resolve 表单值并清理 DOM', async () => {
const pending = submitForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
debug: true,
appContext,
});
await nextTick();
await nextTick();
// debug 模式容器未隐藏,表单可见渲染
expect(document.body.querySelector('.m-form')).not.toBeNull();
findButton('确定').click();
const values = await pending;
expect(values).toEqual({ text: 'hello' });
expect(document.body.querySelector('.m-form')).toBeNull();
});
test('点击「取消」以错误 reject 并清理 DOM', async () => {
const pending = submitForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
debug: true,
appContext,
});
await nextTick();
await nextTick();
findButton('取消').click();
let caught: any = null;
try {
await pending;
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(Error);
expect(caught.message).toContain('canceled');
expect(document.body.querySelector('.m-form')).toBeNull();
});
test('校验失败时点击「确定」在弹层展示错误并保留弹层,随后取消结束', async () => {
const pending = submitForm({
config: [{ type: 'text', name: 'name', text: '名称' }],
initValues: { name: '' },
debug: true,
appContext,
});
await nextTick();
await nextTick();
// mock MForm 实例的 submitForm 抛出汇总错误(真实 element-plus 校验在 jsdom 下不可靠)
const comp = findMFormInstance();
expect(comp).toBeTruthy();
mockExposed(comp, 'submitForm', vi.fn().mockRejectedValue(new Error('名称 -> 必填')));
findButton('确定').click();
// 等待异步校验完成并展示错误(mock submitForm 为 rejected,需等 microtask + DOM 更新)
await vi.waitFor(
() => {
const el = Array.from(document.body.querySelectorAll('div')).find((d) =>
(d.textContent || '').includes('名称 -> 必填'),
);
expect(el).toBeTruthy();
},
{ timeout: 1000 },
);
// 弹层保留
expect(document.body.querySelector('.m-form')).not.toBeNull();
// promise 仍 pending:点击取消以 reject 结束,避免悬挂
findButton('取消').click();
let caught: any = null;
try {
await pending;
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(Error);
expect(caught.message).toContain('canceled');
expect(document.body.querySelector('.m-form')).toBeNull();
});
test('debug 模式不注册超时定时器(等待人工操作)', async () => {
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
const pending = submitForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
debug: true,
timeout: 5000,
appContext,
});
await nextTick();
await nextTick();
const calledWithTimeout = setTimeoutSpy.mock.calls.some(([, delay]) => delay === 5000);
expect(calledWithTimeout).toBe(false);
findButton('确定').click();
await pending;
setTimeoutSpy.mockRestore();
});
});

View File

@ -0,0 +1,405 @@
/*
* 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, beforeAll, describe, expect, test, vi } from 'vitest';
import { type AppContext, createApp, defineComponent, h, nextTick } from 'vue';
import MagicForm, { validateForm } from '@form/index';
import ElementPlus from 'element-plus';
let appContext: AppContext;
beforeAll(() => {
// 构造一个父级 app,把 element-plus 与 m-form 插件装上,
// 之后通过 appContext 传给 validateForm 复用全局注册
const parentApp = createApp(defineComponent({ render: () => h('div') }));
parentApp.use(ElementPlus);
parentApp.use(MagicForm);
appContext = parentApp._context;
});
afterEach(() => {
document.body.innerHTML = '';
});
// 说明:validateForm 内部会新建一个独立的 MForm 实例并复用其校验方法 `validate`(返回错误文案、不抛异常),
// 校验失败时的错误文案格式由 Form.vue 实例的 `validate` 负责,已在 Form.extra.spec.ts
// 中覆盖;此处聚焦 validateForm 独有的「命令式挂载 / 卸载 / 上下文注入 / 超时」等行为。
describe('validateForm', () => {
test('校验通过时 resolve 空字符串,并自动清理 DOM', async () => {
const error = await validateForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
appContext,
});
expect(error).toBe('');
expect(document.body.querySelector('.m-form')).toBeNull();
});
test('resolve 结果始终为字符串(校验入口不抛异常)', async () => {
const error = await validateForm({
config: [{ type: 'text', name: 'name', text: '名称' }],
initValues: { name: '' },
appContext,
});
expect(typeof error).toBe('string');
expect(document.body.querySelector('.m-form')).toBeNull();
});
test('支持 extendState 扩展状态', async () => {
const extendState = vi.fn(async () => ({ extra: 'value' }));
await validateForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'foo' },
extendState,
appContext,
});
expect(extendState).toHaveBeenCalled();
});
test('tab 的 display 函数读取 extendState 注入的值时不会因竞态崩溃', async () => {
// 模拟编辑器 styleTabConfig.display 的模式:display 函数从 mForm 解构 services
// services 通过 extendState 注入。若 extendState 异步且在首次渲染前未完成,
// display 函数会读到 undefined 导致 TypeError。
const error = await validateForm({
config: [
{
type: 'tab',
items: [
{ title: '属性', items: [{ type: 'text', name: 'name', text: '名称' }] },
{
title: '样式',
display: (mForm: any) => {
const { services } = mForm || {};
return !(services?.uiService?.get('showStylePanel') ?? true);
},
items: [{ type: 'text', name: 'style', text: '样式' }],
},
],
},
],
initValues: { name: 'test' },
extendState: () => ({
services: { uiService: { get: () => false } },
}),
appContext,
});
expect(typeof error).toBe('string');
expect(document.body.querySelector('.m-form')).toBeNull();
});
test('在嵌套 items 配置下也能正确 resolve', async () => {
const error = await validateForm({
config: [
{ type: 'text', name: 'name', text: 'name' },
{
name: 'object',
items: [{ type: 'text', name: 'nested', text: 'nested' }],
},
],
initValues: {
name: 'a',
object: { nested: 'b' },
},
appContext,
});
expect(error).toBe('');
});
test('去除 type 为 tab 的容器中各标签页的 lazy,使懒加载标签页字段也参与校验', async () => {
const config: any = [
{
type: 'tab',
items: [
{ title: '属性', items: [{ type: 'text', name: 'name', text: '名称' }] },
{ title: '样式', lazy: true, items: [{ type: 'text', name: 'style', text: '样式' }] },
],
},
];
const error = await validateForm({
config,
initValues: { name: 'a', style: 'b' },
appContext,
});
expect(error).toBe('');
// 校验只使用 config 副本,不污染调用方传入的原始配置
expect(config[0].items[1].lazy).toBe(true);
expect(document.body.querySelector('.m-form')).toBeNull();
});
test('嵌套在标签页内的 tab 容器的 lazy 同样被去除', async () => {
const config: any = [
{
type: 'tab',
items: [
{
title: '外层',
items: [
{
type: 'tab',
items: [
{ title: '内层1', items: [{ type: 'text', name: 'inner1', text: '内层1' }] },
{
title: '内层2',
lazy: true,
items: [{ type: 'text', name: 'inner2', text: '内层2' }],
},
],
},
],
},
],
},
];
const error = await validateForm({
config,
initValues: { inner1: 'a', inner2: 'b' },
appContext,
});
expect(typeof error).toBe('string');
// 原始配置不被污染
expect(config[0].items[0].items[0].items[1].lazy).toBe(true);
expect(document.body.querySelector('.m-form')).toBeNull();
});
test('多次并发调用互不干扰,且结束后不在 body 残留节点', async () => {
const baseChildCount = document.body.children.length;
const results = await Promise.all([
validateForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'first' },
appContext,
}),
validateForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'second' },
appContext,
}),
]);
expect(results).toEqual(['', '']);
expect(document.body.children.length).toBe(baseChildCount);
expect(document.body.querySelector('.m-form')).toBeNull();
});
test('调用过程中临时容器会被附加到 body 上,结束后被移除', async () => {
const baseChildCount = document.body.children.length;
const pending = validateForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'in-flight' },
appContext,
});
expect(document.body.children.length).toBe(baseChildCount + 1);
await pending;
expect(document.body.children.length).toBe(baseChildCount);
});
test('未注入 DOM 环境时(document 不可用)以错误 reject', async () => {
const originalDocument = globalThis.document;
delete (globalThis as any).document;
let caught: any = null;
try {
await validateForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'no-dom' },
appContext,
});
} catch (e) {
caught = e;
} finally {
(globalThis as any).document = originalDocument;
}
expect(caught).toBeInstanceOf(Error);
});
test('timeout > 0 时会注册定时器,timeout <= 0 时不注册', async () => {
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
await validateForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'with-timeout' },
timeout: 5000,
appContext,
});
const calledWithTimeout = setTimeoutSpy.mock.calls.some(([, delay]) => delay === 5000);
expect(calledWithTimeout).toBe(true);
setTimeoutSpy.mockClear();
await validateForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'no-timeout' },
timeout: 0,
appContext,
});
const calledWithZero = setTimeoutSpy.mock.calls.some(([, delay]) => delay === 0);
expect(calledWithZero).toBe(false);
setTimeoutSpy.mockRestore();
});
});
describe('validateForm —— debug 模式', () => {
const findButton = (text: string) =>
Array.from(document.body.querySelectorAll('button')).find(
(b) => (b.textContent || '').trim() === text,
) as HTMLButtonElement;
// 通过 Vue 渲染留下的内部指针定位 MForm 组件实例,用于 mock 其 expose 方法。
// 真实 element-plus 校验在 jsdom 下不可靠(form-item 未注册到 form),故校验失败分支以 mock 方式验证。
const findMFormInstance = (): any => {
const formEl = document.body.querySelector('.m-form') as any;
let comp: any = formEl?.__vueParentComponent;
while (comp && comp.type?.name !== 'MForm' && comp.type?.__name !== 'MForm') comp = comp.parent;
return comp;
};
const mockExposed = (comp: any, method: string, fn: any) => {
Object.defineProperty(comp.exposed, method, { value: fn, configurable: true, writable: true });
};
test('debug 模式可见渲染弹层,点击「确定」校验通过后 resolve 空字符串并清理 DOM', async () => {
const pending = validateForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
debug: true,
appContext,
});
// 等待弹层与表单渲染
await nextTick();
await nextTick();
// debug 模式容器未隐藏,表单可见渲染
expect(document.body.querySelector('.m-form')).not.toBeNull();
findButton('确定').click();
const error = await pending;
expect(error).toBe('');
expect(document.body.querySelector('.m-form')).toBeNull();
});
test('点击「取消」以错误 reject 并清理 DOM', async () => {
const pending = validateForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
debug: true,
appContext,
});
await nextTick();
await nextTick();
findButton('取消').click();
let caught: any = null;
try {
await pending;
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(Error);
expect(caught.message).toContain('canceled');
expect(document.body.querySelector('.m-form')).toBeNull();
});
test('校验失败时点击「确定」在弹层展示错误并保留弹层,随后取消结束', async () => {
const pending = validateForm({
config: [{ type: 'text', name: 'name', text: '名称' }],
initValues: { name: '' },
debug: true,
appContext,
});
await nextTick();
await nextTick();
// mock MForm 实例的 validate 返回非空错误文案(真实 element-plus 校验在 jsdom 下不可靠)
const comp = findMFormInstance();
expect(comp).toBeTruthy();
mockExposed(comp, 'validate', vi.fn().mockResolvedValue('名称 -> 必填'));
findButton('确定').click();
// 等待异步校验完成并展示错误(mock validate 为 resolved,需等 microtask + DOM 更新)
await vi.waitFor(
() => {
const el = Array.from(document.body.querySelectorAll('div')).find((d) =>
(d.textContent || '').includes('名称 -> 必填'),
);
expect(el).toBeTruthy();
},
{ timeout: 1000 },
);
// 弹层保留
expect(document.body.querySelector('.m-form')).not.toBeNull();
// promise 仍 pending:点击取消以 reject 结束,避免悬挂
findButton('取消').click();
let caught: any = null;
try {
await pending;
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(Error);
expect(caught.message).toContain('canceled');
expect(document.body.querySelector('.m-form')).toBeNull();
});
test('debug 模式不注册超时定时器(等待人工操作)', async () => {
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
const pending = validateForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
debug: true,
timeout: 5000,
appContext,
});
await nextTick();
await nextTick();
const calledWithTimeout = setTimeoutSpy.mock.calls.some(([, delay]) => delay === 5000);
expect(calledWithTimeout).toBe(false);
findButton('确定').click();
await pending;
setTimeoutSpy.mockRestore();
});
});