mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-07-22 05:59:45 +00:00
feat(form,editor): 新增 validateForm 校验能力并接入源码保存校验联动
This commit is contained in:
parent
4a15da2108
commit
d07e48bb10
@ -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();
|
||||
|
||||
@ -131,6 +131,7 @@ const eventNameConfig = computed(() => {
|
||||
}
|
||||
callback();
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
@ -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' }]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
405
packages/form/tests/unit/validateForm.spec.ts
Normal file
405
packages/form/tests/unit/validateForm.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user