diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index f3e9e3ec..43fa118f 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -250,6 +250,15 @@ export default defineConfig({ }, ] }, + { + text: '工具函数', + items: [ + { + text: 'submitForm', + link: '/api/form/submit-form' + }, + ] + }, ], }, { diff --git a/docs/api/form/form-methods.md b/docs/api/form/form-methods.md index fd9c0488..caaf2523 100644 --- a/docs/api/form/form-methods.md +++ b/docs/api/form/form-methods.md @@ -18,6 +18,8 @@ - **详情:** 提交表单,先执行校验,校验通过后清空 `changeRecords` 并返回当前表单值 +- **相关:** 如果你想脱离组件树以函数方式完成一次表单提交,参见 [`submitForm` 函数](./submit-form.md) + ## changeHandler - **签名:** `(prop: string, value: any, eventData?: ContainerChangeEventData) => void` diff --git a/docs/api/form/submit-form.md b/docs/api/form/submit-form.md new file mode 100644 index 00000000..5f5a23a0 --- /dev/null +++ b/docs/api/form/submit-form.md @@ -0,0 +1,192 @@ +# submitForm 函数 + +以命令式方式调用 `MForm` 组件完成一次表单校验/提交,类似 `ElMessage` 的用法。 + +调用时函数内部会临时挂载一个不可见的 `MForm` 实例,把入参作为 props 透传给它,等待初始化完成后调用其 `submitForm` 方法。校验通过则 `resolve` 表单值,校验失败则 `reject` 错误信息,最后自动卸载实例并清理 DOM。 + +适用于一些没有合适的容器、但又需要复用 `MForm` 校验逻辑的场景,例如: + +- 通过快捷菜单/命令面板触发一次性表单 +- 在脚本/服务层完成一次表单值校验后再发请求 +- 把 `config` 配置当作"可执行的校验规则"使用 + +## 签名 + +```ts +function submitForm(options: SubmitFormOptions): Promise; +``` + +## 参数 + +`options` 与 `MForm` 组件的 props 基本对齐,额外提供了 `native`、`appContext`、`timeout` 三个参数。 + +| 名称 | 类型 | 默认值 | 说明 | +| ---------------------- | ------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------- | +| `config` | `FormConfig` | — | 必填,表单配置 | +| `initValues` | `Record` | `{}` | 表单初始值 | +| `lastValues` | `Record` | `{}` | 需对比的值(开启对比模式时传入) | +| `isCompare` | `boolean` | `false` | 是否开启对比模式 | +| `parentValues` | `Record` | `{}` | 父级 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 \| Promise>` | — | 扩展 `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` resolve 当前表单值(`native` 决定是否克隆) +- `校验失败` — `Promise` reject 一个 `Error`,`message` 中包含逐条字段错误信息(格式 `${text} -> ${message}`,多条用 `
` 分隔) +- `初始化超时` — `Promise` 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 + +``` + +也可以在初始化 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) => + 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} +::: diff --git a/packages/form/src/index.ts b/packages/form/src/index.ts index 6e2a5583..9583e5e1 100644 --- a/packages/form/src/index.ts +++ b/packages/form/src/index.ts @@ -19,6 +19,7 @@ import type { FormConfig } from './schema'; export * from './schema'; +export * from './submitForm'; export * from './utils/form'; export * from './utils/useAddField'; diff --git a/packages/form/src/submitForm.ts b/packages/form/src/submitForm.ts new file mode 100644 index 00000000..77cde7cd --- /dev/null +++ b/packages/form/src/submitForm.ts @@ -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; + /** 需对比的值(开启对比模式时传入) */ + lastValues?: Record; + /** 是否开启对比模式 */ + isCompare?: boolean; + parentValues?: Record; + 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 | Promise>; + /** 透传给 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 => { + 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 | null = null; + + const wrapperComponent = defineComponent({ + name: 'MFormSubmitWrapper', + setup() { + const formRef = ref(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(); + } + }); +}; diff --git a/packages/form/tests/unit/submitForm.spec.ts b/packages/form/tests/unit/submitForm.spec.ts new file mode 100644 index 00000000..e056a30c --- /dev/null +++ b/packages/form/tests/unit/submitForm.spec.ts @@ -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(); + }); +});