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 使 clearValidateelement-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 并合并外部 extendStatedebug 默认 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 缺省为 nullextendState 缺省时仍注入 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` debugreject
* 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('校验返回非 truetdesign 风格)时也返回错误文案', 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();
});
});