mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-06-04 10:58:33 +08:00
feat(form): 新增 submitForm 命令式提交函数
提供脱离组件树以函数方式完成一次表单校验/提交的能力,类似 ElMessage 用法: 传入 config/initValues 等 props 后内部临时挂载 Form 实例, 初始化完成即调用 submitForm,校验通过 resolve 表单值、失败 reject, 最后自动卸载,并支持 appContext 继承、timeout 与 native 透传。 同步补充单元测试、API 文档及侧边栏入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2d31b3812f
commit
638c3e9f3c
@ -250,6 +250,15 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: '工具函数',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
text: 'submitForm',
|
||||||
|
link: '/api/form/submit-form'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -18,6 +18,8 @@
|
|||||||
|
|
||||||
- **详情:** 提交表单,先执行校验,校验通过后清空 `changeRecords` 并返回当前表单值
|
- **详情:** 提交表单,先执行校验,校验通过后清空 `changeRecords` 并返回当前表单值
|
||||||
|
|
||||||
|
- **相关:** 如果你想脱离组件树以函数方式完成一次表单提交,参见 [`submitForm` 函数](./submit-form.md)
|
||||||
|
|
||||||
## changeHandler
|
## changeHandler
|
||||||
|
|
||||||
- **签名:** `(prop: string, value: any, eventData?: ContainerChangeEventData) => void`
|
- **签名:** `(prop: string, value: any, eventData?: ContainerChangeEventData) => void`
|
||||||
|
|||||||
192
docs/api/form/submit-form.md
Normal file
192
docs/api/form/submit-form.md
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
# submitForm 函数
|
||||||
|
|
||||||
|
以命令式方式调用 `MForm` 组件完成一次表单校验/提交,类似 `ElMessage` 的用法。
|
||||||
|
|
||||||
|
调用时函数内部会临时挂载一个不可见的 `MForm` 实例,把入参作为 props 透传给它,等待初始化完成后调用其 `submitForm` 方法。校验通过则 `resolve` 表单值,校验失败则 `reject` 错误信息,最后自动卸载实例并清理 DOM。
|
||||||
|
|
||||||
|
适用于一些没有合适的容器、但又需要复用 `MForm` 校验逻辑的场景,例如:
|
||||||
|
|
||||||
|
- 通过快捷菜单/命令面板触发一次性表单
|
||||||
|
- 在脚本/服务层完成一次表单值校验后再发请求
|
||||||
|
- 把 `config` 配置当作"可执行的校验规则"使用
|
||||||
|
|
||||||
|
## 签名
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function submitForm(options: SubmitFormOptions): Promise<any>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 参数
|
||||||
|
|
||||||
|
`options` 与 `MForm` 组件的 props 基本对齐,额外提供了 `native`、`appContext`、`timeout` 三个参数。
|
||||||
|
|
||||||
|
| 名称 | 类型 | 默认值 | 说明 |
|
||||||
|
| ---------------------- | ------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------- |
|
||||||
|
| `config` | `FormConfig` | — | 必填,表单配置 |
|
||||||
|
| `initValues` | `Record<string, any>` | `{}` | 表单初始值 |
|
||||||
|
| `lastValues` | `Record<string, any>` | `{}` | 需对比的值(开启对比模式时传入) |
|
||||||
|
| `isCompare` | `boolean` | `false` | 是否开启对比模式 |
|
||||||
|
| `parentValues` | `Record<string, any>` | `{}` | 父级 values,透传给字段的回调 |
|
||||||
|
| `labelWidth` | `string` | `'200px'` | label 宽度 |
|
||||||
|
| `disabled` | `boolean` | `false` | 是否禁用 |
|
||||||
|
| `height` | `string` | `'auto'` | 表单高度 |
|
||||||
|
| `stepActive` | `string \| number` | `1` | 步骤表单当前激活步骤 |
|
||||||
|
| `size` | `'small' \| 'default' \| 'large'` | — | 组件尺寸 |
|
||||||
|
| `inline` | `boolean` | `false` | 是否行内表单 |
|
||||||
|
| `labelPosition` | `string` | `'right'` | label 对齐方式 |
|
||||||
|
| `keyProp` | `string` | `'__key'` | 配置项的唯一 key |
|
||||||
|
| `popperClass` | `string` | — | 弹层 className |
|
||||||
|
| `preventSubmitDefault` | `boolean` | — | 是否阻止表单原生 submit |
|
||||||
|
| `extendState` | `(state: FormState) => Record<string, any> \| Promise<Record<string, any>>` | — | 扩展 `formState` |
|
||||||
|
| `native` | `boolean` | `false` | 透传给 `Form.submitForm`。`true` 时返回内部响应式 `values`,否则返回 `cloneDeep(toRaw(values))` |
|
||||||
|
| `appContext` | `AppContext \| null` | `null` | 父级 Vue 应用上下文。需要继承全局组件、指令、provide 等时传入,常通过 `app._context` 或 `getCurrentInstance()?.appContext` 获取 |
|
||||||
|
| `timeout` | `number` | `10000` | 等待表单初始化的最长时间(毫秒)。超时将以错误 reject。设为 `<= 0` 时关闭超时兜底 |
|
||||||
|
|
||||||
|
## 返回值
|
||||||
|
|
||||||
|
- `校验通过` — `Promise<any>` resolve 当前表单值(`native` 决定是否克隆)
|
||||||
|
- `校验失败` — `Promise<any>` reject 一个 `Error`,`message` 中包含逐条字段错误信息(格式 `${text} -> ${message}`,多条用 `<br>` 分隔)
|
||||||
|
- `初始化超时` — `Promise<any>` reject `Error('submitForm timeout after ${timeout}ms: form is not initialized.')`
|
||||||
|
|
||||||
|
无论成功或失败,函数都会在最后自动 `unmount` 内部 app 并移除挂载用的 DOM 容器,无需调用方手动清理。
|
||||||
|
|
||||||
|
## 基础用法
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { submitForm } from '@tmagic/form';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const values = await submitForm({
|
||||||
|
config: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
name: 'username',
|
||||||
|
text: '用户名',
|
||||||
|
rules: [{ required: true, message: '请输入用户名' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
initValues: { username: '' },
|
||||||
|
});
|
||||||
|
console.log('提交成功', values);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('校验失败', e);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 在组件中继承父级应用上下文
|
||||||
|
|
||||||
|
`MForm` 内部使用 `@tmagic/design` 的组件(背后可能是 `element-plus` 或 `tdesign`),需要宿主应用先完成相应的 `app.use(...)` 安装。在 `submitForm` 这种脱离常规组件树的命令式调用中,可通过 `appContext` 把父级应用上下文带过去:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { getCurrentInstance } from 'vue';
|
||||||
|
|
||||||
|
import { submitForm } from '@tmagic/form';
|
||||||
|
|
||||||
|
const { appContext } = getCurrentInstance()!;
|
||||||
|
|
||||||
|
const onClick = async () => {
|
||||||
|
const values = await submitForm({
|
||||||
|
config: [{ type: 'text', name: 'text', text: '文本' }],
|
||||||
|
initValues: { text: 'hello' },
|
||||||
|
appContext,
|
||||||
|
});
|
||||||
|
console.log(values);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以在初始化 app 时把上下文缓存下来,再在任意位置复用:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createApp } from 'vue';
|
||||||
|
import ElementPlus from 'element-plus';
|
||||||
|
import MagicForm, { type SubmitFormOptions, submitForm as rawSubmitForm } from '@tmagic/form';
|
||||||
|
|
||||||
|
import App from './App.vue';
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
app.use(ElementPlus);
|
||||||
|
app.use(MagicForm);
|
||||||
|
app.mount('#app');
|
||||||
|
|
||||||
|
export const submitForm = (options: Omit<SubmitFormOptions, 'appContext'>) =>
|
||||||
|
rawSubmitForm({ ...options, appContext: app._context });
|
||||||
|
```
|
||||||
|
|
||||||
|
## 处理校验错误
|
||||||
|
|
||||||
|
校验失败时 reject 的 `Error.message` 已经把出错字段拼好,可以直接展示到用户:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { tMagicMessage } from '@tmagic/design';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const values = await submitForm({ config, initValues });
|
||||||
|
await save(values);
|
||||||
|
} catch (e: any) {
|
||||||
|
tMagicMessage.error({
|
||||||
|
dangerouslyUseHTMLString: true,
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行环境
|
||||||
|
|
||||||
|
`submitForm` 内部依赖 `document` / `window` 来挂载临时 Vue 实例,因此**只能在浏览器或具备 DOM 环境的运行时中使用**。
|
||||||
|
|
||||||
|
| 环境 | 是否可用 | 说明 |
|
||||||
|
| ----------------------------------------------- | -------- | --------------------------------------------------------------------------------- |
|
||||||
|
| 浏览器 / Electron 渲染进程 / 浏览器扩展 | ✅ | 直接可用 |
|
||||||
|
| Vitest / Jest + `happy-dom` / `jsdom` | ✅ | 项目自身的单测就跑在这种环境下 |
|
||||||
|
| 纯 Node.js / Bun / Deno(无 DOM polyfill) | ❌ | 模块顶层就会读 `document`,会抛 `document is not defined` |
|
||||||
|
| Node.js + 手动注入 `happy-dom` / `jsdom` | ⚠️ | 可用,需要在 import `@tmagic/form` **之前**完成全局变量注入;校验行为不一定与浏览器完全一致 |
|
||||||
|
|
||||||
|
### 在 Node.js 中使用(需要先准备 DOM)
|
||||||
|
|
||||||
|
下面是一个在 Node 脚本里调用 `submitForm` 的完整例子,使用 [`happy-dom`](https://github.com/capricorn86/happy-dom) 作为 DOM polyfill:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// scripts/check-form.ts
|
||||||
|
import { Window } from 'happy-dom';
|
||||||
|
|
||||||
|
const window = new Window();
|
||||||
|
Object.assign(globalThis, {
|
||||||
|
window,
|
||||||
|
document: window.document,
|
||||||
|
navigator: window.navigator,
|
||||||
|
HTMLElement: window.HTMLElement,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 注意:DOM polyfill 必须先注入到 globalThis,再用动态 import
|
||||||
|
// 加载业务模块,否则 @tmagic/design 等模块顶层执行时就会读 document
|
||||||
|
const { createApp } = await import('vue');
|
||||||
|
const ElementPlus = (await import('element-plus')).default;
|
||||||
|
const MagicForm = (await import('@tmagic/form')).default;
|
||||||
|
const { submitForm } = await import('@tmagic/form');
|
||||||
|
|
||||||
|
const parentApp = createApp({ render: () => null });
|
||||||
|
parentApp.use(ElementPlus);
|
||||||
|
parentApp.use(MagicForm);
|
||||||
|
|
||||||
|
const values = await submitForm({
|
||||||
|
config: [{ type: 'text', name: 'username', text: '用户名' }],
|
||||||
|
initValues: { username: 'foo' },
|
||||||
|
appContext: parentApp._context,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(values);
|
||||||
|
```
|
||||||
|
|
||||||
|
::: warning 注意
|
||||||
|
- DOM polyfill 必须在 **import 业务模块之前** 注入到 `globalThis`,否则模块顶层执行时仍会失败
|
||||||
|
- 在 `happy-dom` / `jsdom` 中,`element-plus` 的部分 `validate()` 行为不一定能 1:1 复现真实浏览器(例如某些场景下必填规则可能不触发),建议关键校验使用自定义 `validator` 函数确保稳定
|
||||||
|
- 如果只是想在 Node 端做一次纯校验,更稳妥的做法是直接复用 [`async-validator`](https://github.com/yiminghe/async-validator)(element-plus 内部用的就是它),绕开整个 Vue 渲染层
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 类型定义
|
||||||
|
|
||||||
|
::: details 查看 `SubmitFormOptions` 类型定义
|
||||||
|
<<< @/../packages/form/src/submitForm.ts#SubmitFormOptions{ts}
|
||||||
|
:::
|
||||||
@ -19,6 +19,7 @@
|
|||||||
import type { FormConfig } from './schema';
|
import type { FormConfig } from './schema';
|
||||||
|
|
||||||
export * from './schema';
|
export * from './schema';
|
||||||
|
export * from './submitForm';
|
||||||
export * from './utils/form';
|
export * from './utils/form';
|
||||||
export * from './utils/useAddField';
|
export * from './utils/useAddField';
|
||||||
|
|
||||||
|
|||||||
161
packages/form/src/submitForm.ts
Normal file
161
packages/form/src/submitForm.ts
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
/*
|
||||||
|
* 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 { type AppContext, type Component, createApp, defineComponent, h, nextTick, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import Form from './Form.vue';
|
||||||
|
import type { FormConfig, FormState } from './schema';
|
||||||
|
|
||||||
|
// #region SubmitFormOptions
|
||||||
|
/**
|
||||||
|
* submitForm 函数参数(与 Form.vue 组件 props 对齐)
|
||||||
|
*/
|
||||||
|
export interface SubmitFormOptions {
|
||||||
|
/** 表单配置 */
|
||||||
|
config: FormConfig;
|
||||||
|
/** 表单初始值 */
|
||||||
|
initValues?: Record<string, any>;
|
||||||
|
/** 需对比的值(开启对比模式时传入) */
|
||||||
|
lastValues?: Record<string, any>;
|
||||||
|
/** 是否开启对比模式 */
|
||||||
|
isCompare?: boolean;
|
||||||
|
parentValues?: Record<string, any>;
|
||||||
|
labelWidth?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
height?: string;
|
||||||
|
stepActive?: string | number;
|
||||||
|
size?: 'small' | 'default' | 'large';
|
||||||
|
inline?: boolean;
|
||||||
|
labelPosition?: string;
|
||||||
|
keyProp?: string;
|
||||||
|
popperClass?: string;
|
||||||
|
preventSubmitDefault?: boolean;
|
||||||
|
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
|
||||||
|
/** 透传给 Form.submitForm 的参数:是否直接返回原始响应式 values */
|
||||||
|
native?: boolean;
|
||||||
|
/**
|
||||||
|
* 父级应用上下文,用于继承全局组件、指令、provide 等。
|
||||||
|
* 通常通过 `app._context` 或 `getCurrentInstance()?.appContext` 获取。
|
||||||
|
*/
|
||||||
|
appContext?: AppContext | null;
|
||||||
|
/** 等待表单初始化的最长时间(毫秒),超时将以错误 reject。默认 10000ms */
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
// #endregion SubmitFormOptions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 以命令式方式调用 Form.vue 完成一次表单校验/提交。
|
||||||
|
*
|
||||||
|
* 类似 ElMessage 的用法:传入 props(包含 `config`/`initValues` 等),函数内部会临时挂载
|
||||||
|
* 一个不可见的 Form 组件实例,等待初始化完成后调用其 `submitForm` 方法,
|
||||||
|
* 校验通过则 resolve 表单值,校验失败则 reject 错误信息,最后自动卸载实例。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* import { submitForm } from '@tmagic/form';
|
||||||
|
*
|
||||||
|
* try {
|
||||||
|
* const values = await submitForm({
|
||||||
|
* config: [...],
|
||||||
|
* initValues: { name: 'foo' },
|
||||||
|
* });
|
||||||
|
* console.log(values);
|
||||||
|
* } catch (e) {
|
||||||
|
* console.error(e);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const submitForm = (options: SubmitFormOptions): Promise<any> => {
|
||||||
|
const { native, appContext, timeout = 10000, ...formProps } = options;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.style.display = 'none';
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
let cleaned = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const wrapperComponent = defineComponent({
|
||||||
|
name: 'MFormSubmitWrapper',
|
||||||
|
setup() {
|
||||||
|
const formRef = ref<any>(null);
|
||||||
|
|
||||||
|
const stop = watch(
|
||||||
|
() => formRef.value?.initialized,
|
||||||
|
async (initialized) => {
|
||||||
|
if (!initialized) return;
|
||||||
|
stop();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 等待子组件(FormItem 等)完成首次渲染,确保 validate 能拿到所有字段
|
||||||
|
await nextTick();
|
||||||
|
const result = await formRef.value.submitForm(native);
|
||||||
|
resolve(result);
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ 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) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
if (!cleaned) {
|
||||||
|
reject(new Error(`submitForm timeout after ${timeout}ms: form is not initialized.`));
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
app.mount(container);
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
205
packages/form/tests/unit/submitForm.spec.ts
Normal file
205
packages/form/tests/unit/submitForm.spec.ts
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
/*
|
||||||
|
* 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 } from 'vue';
|
||||||
|
import MagicForm, { submitForm } from '@form/index';
|
||||||
|
import ElementPlus from 'element-plus';
|
||||||
|
|
||||||
|
let appContext: AppContext;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// 构造一个父级 app,把 element-plus 与 m-form 插件装上,
|
||||||
|
// 之后通过 appContext 传给 submitForm 复用全局注册
|
||||||
|
const parentApp = createApp(defineComponent({ render: () => h('div') }));
|
||||||
|
parentApp.use(ElementPlus);
|
||||||
|
parentApp.use(MagicForm);
|
||||||
|
appContext = parentApp._context;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('submitForm', () => {
|
||||||
|
test('校验通过时 resolve 表单值,并自动清理 DOM', async () => {
|
||||||
|
const values = await submitForm({
|
||||||
|
config: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
name: 'text',
|
||||||
|
text: 'text',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
initValues: { text: 'hello' },
|
||||||
|
appContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(values).toEqual({ text: 'hello' });
|
||||||
|
expect(document.body.querySelector('.m-form')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('native=true 时返回原始(未 clone)的 values', async () => {
|
||||||
|
const initValues = { text: 'origin' };
|
||||||
|
|
||||||
|
const values = await submitForm({
|
||||||
|
config: [{ type: 'text', name: 'text', text: 'text' }],
|
||||||
|
initValues,
|
||||||
|
native: true,
|
||||||
|
appContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(values).toEqual({ text: 'origin' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('支持 extendState 扩展状态', async () => {
|
||||||
|
const extendState = vi.fn(async () => ({ extra: 'value' }));
|
||||||
|
|
||||||
|
await submitForm({
|
||||||
|
config: [{ type: 'text', name: 'text', text: 'text' }],
|
||||||
|
initValues: { text: 'foo' },
|
||||||
|
extendState,
|
||||||
|
appContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(extendState).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('在嵌套 items 配置下也能正确 resolve', async () => {
|
||||||
|
const values = await submitForm({
|
||||||
|
config: [
|
||||||
|
{ type: 'text', name: 'name', text: 'name' },
|
||||||
|
{
|
||||||
|
name: 'object',
|
||||||
|
items: [{ type: 'text', name: 'nested', text: 'nested' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
initValues: {
|
||||||
|
name: 'a',
|
||||||
|
object: { nested: 'b' },
|
||||||
|
},
|
||||||
|
appContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(values).toEqual({
|
||||||
|
name: 'a',
|
||||||
|
object: { nested: 'b' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('多次连续调用不会相互干扰', async () => {
|
||||||
|
const [v1, v2] = await Promise.all([
|
||||||
|
submitForm({
|
||||||
|
config: [{ type: 'text', name: 'text', text: 'text' }],
|
||||||
|
initValues: { text: 'first' },
|
||||||
|
appContext,
|
||||||
|
}),
|
||||||
|
submitForm({
|
||||||
|
config: [{ type: 'text', name: 'text', text: 'text' }],
|
||||||
|
initValues: { text: 'second' },
|
||||||
|
appContext,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(v1).toEqual({ text: 'first' });
|
||||||
|
expect(v2).toEqual({ text: 'second' });
|
||||||
|
expect(document.body.querySelector('.m-form')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('多次串行调用后 document.body 不留下任何节点', async () => {
|
||||||
|
const baseChildCount = document.body.children.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await submitForm({
|
||||||
|
config: [{ type: 'text', name: 'text', text: 'text' }],
|
||||||
|
initValues: { text: `value-${i}` },
|
||||||
|
appContext,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 反复调用后,body 下不应残留任何挂载容器
|
||||||
|
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 = submitForm({
|
||||||
|
config: [{ type: 'text', name: 'text', text: 'text' }],
|
||||||
|
initValues: { text: 'in-flight' },
|
||||||
|
appContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 此时容器应已加入 body
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 模拟纯 Node 环境
|
||||||
|
delete (globalThis as any).document;
|
||||||
|
|
||||||
|
let caught: any = null;
|
||||||
|
try {
|
||||||
|
await submitForm({
|
||||||
|
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 submitForm({
|
||||||
|
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 submitForm({
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user