feat(form): 新增无渲染校验入口,支持 Node/CI 环境执行表单校验

将 submitForm/validateForm 改为无 DOM 的 headless 实现,并抽取字段登记与编辑器复合字段配置,便于脚本与 CI 批量校验。
This commit is contained in:
roymondchen 2026-08-28 15:33:54 +08:00
parent 4247cfdaf5
commit 93dc0d2187
94 changed files with 6326 additions and 3067 deletions

View File

@ -589,9 +589,11 @@ export default defineConfig({
resolve: {
alias:[
{ find: /^@tmagic\/form-schema/, replacement: path.join(__dirname, '../../packages/form-schema/src/index.ts') },
{ find: /^@tmagic\/form\/headless$/, replacement: path.join(__dirname, '../../packages/form/src/headless.ts') },
{ find: /^@tmagic\/form/, replacement: path.join(__dirname, '../../packages/form/src/index.ts') },
{ find: /^@tmagic\/utils/, replacement: path.join(__dirname, '../../packages/utils/src/index.ts') },
{ find: /^@tmagic\/schema/, replacement: path.join(__dirname, '../../packages/schema/src/index.ts') },
{ find: /^@tmagic\/design\/headless$/, replacement: path.join(__dirname, '../../packages/design/src/headless.ts') },
{ find: /^@tmagic\/design/, replacement: path.join(__dirname, '../../packages/design/src/index.ts') },
{ find: /^@tmagic\/element-plus-adapter/, replacement: path.join(__dirname, '../../packages/element-plus-adapter/src/index.ts') },
]

View File

@ -1,14 +1,104 @@
# submitForm 函数
以命令式方式调用 `MForm` 组件完成一次表单校验/提交,类似 `ElMessage` 的用法。
以命令式方式对一份「表单配置 + 表单值」执行一次校验并取回表单值,类似 `ElMessage` 的用法。
调用时函数内部会临时挂载一个不可见的 `MForm` 实例,把入参作为 props 透传给它,等待初始化完成后调用其 `submitForm` 方法。校验通过则 `resolve` 表单值,校验失败则 `reject` 错误信息,最后自动卸载实例并清理 DOM
走**无渲染**实现:不创建任何 DOM 容器、不实例化任何组件,而是直接遍历 `config` 树收集带规则的字段,交给 [`async-validator`](https://github.com/yiminghe/async-validator)`element-plus` 内部用的也是它)执行。因此它可以在 Node / CI 等没有 DOM 的环境中使用,也省去了挂载整棵表单的开销。校验通过则 `resolve` 表单值,失败则 `reject` 错误信息。纯 Node 请从 `@tmagic/form/headless` 引入,避免加载 Vue 组件和样式
适用于一些没有合适的容器、但又需要复用 `MForm` 校验逻辑的场景,例如:
- 通过快捷菜单/命令面板触发一次性表单
- 在脚本/服务层完成一次表单值校验后再发请求
- 把 `config` 配置当作"可执行的校验规则"使用
- 在 Node 脚本 / CI 中批量校验组件配置
## 无渲染校验与自定义字段登记
无渲染实现按 `Container.vue` 及各容器组件的模板规则遍历配置树,产出的字段 `prop` 与规则与「挂载 `MForm` 后调用 `validate()`」等价。需要 UI 时传入 `dialog: true`,会把表单以弹层渲染出来供填写/确认。
字段只要带了 `rules`(会包 FormItem就会校验自身不必先登记为叶子。配置里有 `items` 会下钻子项。内部再渲染 `MContainer` 的复合字段需要 `registerField(type, { nested })`把内部会挂到父表单上的配置交出来。nested 回调自身抛错时,会以 `FieldNestedConfigError``code: 'FIELD_NESTED_CONFIG'`reject。
自定义字段的渲染组件和无渲染校验都通过 `registerField` / `registerFields` 一次登记。`component` 会写入字段注册表(`getFormField`);传入 `app` 时同时 `app.component('m-fields-*')`。容器组件用 `container`,对应 `m-form-*`
| 字段形态 | 登记方式 |
| --------------------------------------------------------------------- | ------------------------------------------- |
| 自身带 `rules`,内部没有嵌套的父表单 FormItem | 无需登记,直接校验 |
| 内部只渲染叶子 UI或把子表单渲染在独立的 `MForm` / `MFormBox` 实例里 | `registerField('my-field')`(配置里有 `items` 但不属于父表单时,避免被当下钻) |
| 同时需要渲染组件 | `registerField('my-field', { component })` |
| 容器组件(`m-form-*` | `registerField('my-box', { container, walk })` |
| 叶子字段,但挂载时会改写 model类似 `display``initValue` | `registerField('my-field', { effect })` |
| 内部再渲染 `MContainer` / `MPanel` / `MGroupList`,向父表单注册字段 | `registerField('my-field', { nested })` |
| 自定义 `typeMatch` 类型校验 | `registerField('my-field', { typeMatch })` |
```ts
import { registerField, registerFields } from '@tmagic/form';
import MyColorPicker from './MyColorPicker.vue';
// 叶子字段:内部没有嵌套的表单项;带 component 时即可渲染
registerFields({ 'my-color-picker': { component: MyColorPicker } });
// 需要挂到当前 app 时传入第二个参数
registerFields({ 'my-color-picker': { component: MyColorPicker } }, app);
// 叶子字段但挂载setup时会改写 model传入 effect 让无渲染校验复刻这份写入,
// 否则无渲染校验拿到的值会与渲染式校验不一致
registerField('my-status', {
effect: ({ config, model }) => {
if ((config as any).initValue && model) {
model[(config as any).name] = (config as any).initValue;
}
},
});
// 复合字段:把组件内部渲染的 MContainer 配置交出来
registerField('my-composite', {
nested: ({ config, model, prop }) => ({
// 对应组件内部 <MContainer :config="innerConfig" :model="model[name]" :prop="prop">
config: innerConfig,
model: model[config.name],
prop,
}),
});
// typeMatch覆盖或扩展该 type 的类型匹配校验,可与 nested / effect 同时登记
registerField('my-status', {
typeMatch: (value, { message }) => (typeof value === 'string' ? undefined : message || '应为字符串'),
});
```
返回的 `config``name` 会被追加到返回的 `prop` 上。因此当嵌套配置复用了字段自身的 `name`(例如内部渲染 `<MGroupList :config="{ name, items }" :model="model" :prop="prop">`)时,要返回 `parentProp` 而非 `prop`,否则 `name` 会被拼两次:
```ts
registerField('my-list', {
nested: ({ config, parentProp }) => ({
config: { type: 'group-list', name: config.name, items: innerItems },
prop: parentProp,
}),
});
```
编辑器侧四个复合字段(`code-select` / `display-conds` / `event-select` / `style-setter`)的登记可参考 `packages/editor/src/fields/headless-validation.ts`nested 与组件共用同一份配置工厂(`packages/editor/src/fields/configs/`),避免两条链路各写一份而逐渐跑偏。
`type: 'component'` 会把 `config.component` 当任意 Vue 组件渲染。无渲染校验把它视为叶子,**不会**遍历内部结构。因此该组件不得再向父表单注册 FormItem需要嵌套表单项时应对该具体组件 `registerField(type, { nested })`
### 重复登记与撤销
同一个 type 多次登记按字段浅合并,后一次只覆盖自己传入的 key
```ts
registerField('my-composite', { nested });
registerField('my-composite', { component: MyComposite }); // nested 仍在
```
登记分「内置」与「业务」两层。`app.use(MagicForm)` / `registerBuiltInFields` 写内置层,`registerField` / `registerFields` 写业务层;读取时业务层优先,`unregisterField` / `clearFields` 只清业务层,内置字段不受影响(单测里 `clearFields` 之后仍能校验 `text``tab` 等内置 type
因为是合并语义,把一个已登记 `nested` 的 type 改成普通叶子,不能靠再传一次空对象,要先撤销:
```ts
registerField('my-composite', {}); // ✗ 合并后 nested 还在,仍会下钻
unregisterField('my-composite'); // ✓ 先清掉业务层登记
registerField('my-composite', { component: MyComposite });
```
一次登记里同时传多个形态时的优先级:`walk` > `nested` > `effect`(叶子),命中低优先级的那份会被忽略并在控制台给出告警。
## 签名
@ -18,7 +108,7 @@ function submitForm(options: SubmitFormOptions): Promise<any>;
## 参数
`options``MForm` 组件的 props 基本对齐,额外提供了 `native``returnChangeRecords``appContext`、`timeout` 等参数
`options``MForm` 组件的 props 基本对齐,额外提供了 `native``returnChangeRecords``dialog`、`signal` 等参数。`appContext``dialog: true` 时生效
| 名称 | 类型 | 默认值 | 说明 |
| ---------------------- | ------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------- |
@ -41,19 +131,20 @@ function submitForm(options: SubmitFormOptions): Promise<any>;
| `extendState` | `(state: FormState) => Record<string, any> \| Promise<Record<string, any>>` | — | 扩展 `formState` |
| `native` | `boolean` | `false` | 透传给 `Form.submitForm``true` 时返回内部响应式 `values`,否则返回 `cloneDeep(toRaw(values))` |
| `returnChangeRecords` | `boolean` | `false` | `true` 时 resolve 结果为 `{ values, changeRecords }`,携带表单变更记录;否则仅 resolve `values` |
| `appContext` | `AppContext \| null` | `null` | 父级 Vue 应用上下文。需要继承全局组件、指令、provide 等时传入,常通过 `app._context``getCurrentInstance()?.appContext` 获取 |
| `timeout` | `number` | `10000` | 等待表单初始化的最长时间(毫秒)。超时将以错误 reject。设为 `<= 0` 时关闭超时兜底 |
| `appContext` | `AppContext \| null` | `null` | 父级 Vue 应用上下文。仅 `dialog: true` 时生效用于继承全局组件、指令、provide 等,常通过 `app._context``getCurrentInstance()?.appContext` 获取 |
| `dialog` | `boolean` | `false` | `true` 时把表单以弹层形式渲染出来,点击「确定」才提交,「取消」则以 reject 中断;校验失败会保留弹层并展示错误,便于修正后重试。等待人工操作,可用 `signal` 中断 |
| `title` | `string` | `'submitForm'` / `'validateForm'` | 弹层标题,仅 `dialog: true` 时生效 |
| `signal` | `AbortSignal` | — | 外部中断信号。abort 时立即以 `signal.reason` reject并卸载 `dialog` 模式下已挂载的临时表单实例 |
## 返回值
- `校验通过``Promise<any>` resolve 当前表单值(`native` 决定是否克隆);当 `returnChangeRecords``true`resolve `{ values, changeRecords }`
- `校验失败``Promise<any>` reject 一个 `Error``message` 中包含逐条字段错误信息(格式 `${text} -> ${message}`,多条用 `<br>` 分隔)
- `初始化超时``Promise<any>` reject `Error('submitForm timeout after ${timeout}ms: form is not initialized.')`
无论成功或失败,函数都会在最后自动 `unmount` 内部 app 并移除挂载用的 DOM 容器,无需调用方手动清理。
`dialog: true`无论成功或失败,函数都会在最后自动 `unmount` 内部 app 并移除挂载用的 DOM 容器,无需调用方手动清理。
::: tip 关于 changeRecords
`changeRecords` 记录的是表单挂载后发生的字段变更(由各字段的 `change` 事件累积而来)。`submitForm` 这种命令式、无用户交互的场景下,通常为空数组;只有在 `extendState` 或字段联动等逻辑中触发了变更时才会有内容。`MForm` 内部的 `submitForm` 在校验通过后会清空变更记录,因此本函数会在调用前先对其做快照再返回
`changeRecords` 记录的是表单挂载后发生的字段变更(由各字段的 `change` 事件累积而来)。无渲染校验没有用户交互,因此固定返回空数组;只有 `dialog: true` 时才可能有内容(`MForm` 内部的 `submitForm` 在校验通过后会清空变更记录,因此本函数会在调用前先做快照)
:::
## 基础用法
@ -99,9 +190,9 @@ console.log(values); // { username: 'foo' }
console.log(changeRecords); // ChangeRecord[]
```
## 在组件中继承父级应用上下文
## 弹层模式(`dialog: true`)下继承父级应用上下文
`MForm` 内部使用 `@tmagic/design` 的组件(背后可能是 `element-plus``tdesign`),需要宿主应用先完成相应 `app.use(...)` 安装。在 `submitForm` 这种脱离常规组件树的命令式调用中,可通过 `appContext` 把父级应用上下文带过去:
默认路径不挂载组件,不需要 `appContext`。只有 `dialog: true` 会渲染弹层,此时 `MForm` 要用到 `@tmagic/design` 的组件(背后可能是 `element-plus``tdesign`),需要宿主应用的上下文带过去:
```vue
<script setup lang="ts">
@ -115,6 +206,8 @@ const onClick = async () => {
const values = await submitForm({
config: [{ type: 'text', name: 'text', text: '文本' }],
initValues: { text: 'hello' },
dialog: true,
title: '编辑配置',
appContext,
});
console.log(values);
@ -158,57 +251,69 @@ try {
}
```
## 运行环境
## validateForm 函数
`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
`validateForm``submitForm` 共用同一套无渲染校验实现,区别在于它是**静默**的:校验失败不抛异常、不返回表单值,而是以返回值形式给出错误文案。适合「只想探测这份配置是否合法」的场景,例如源码编辑器保存后校验、批量校验组件配置。
```ts
// scripts/check-form.ts
import { Window } from 'happy-dom';
function validateForm(options: ValidateFormOptions): Promise<string>;
```
const window = new Window();
Object.assign(globalThis, {
window,
document: window.document,
navigator: window.navigator,
HTMLElement: window.HTMLElement,
`options``SubmitFormOptions` 中与校验相关的子集(`config``initValues``parentValues``labelWidth``keyProp``useFieldTextInError``extendState``typeMatchValid``appContext``dialog``title``signal`)。
```ts
import { validateForm } from '@tmagic/form';
const error = await validateForm({
config: [{ type: 'text', name: 'username', text: '用户名', rules: [{ required: true, message: '请输入用户名' }] }],
initValues: { username: '' },
});
// 注意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');
if (error) {
// '用户名 -> 请输入用户名'
}
```
const parentApp = createApp({ render: () => null });
parentApp.use(ElementPlus);
parentApp.use(MagicForm);
校验通过返回空字符串 `''`,否则返回以 `<br>` 拼接的错误文案。无法完成校验时才会 reject例如嵌套配置回调失败抛出 `FieldNestedConfigError`)。
const values = await submitForm({
config: [{ type: 'text', name: 'username', text: '用户名' }],
initValues: { username: 'foo' },
appContext: parentApp._context,
## 运行环境
无渲染实现不接触 `document` / `window`,因此在任何 JS 运行时中都可用:
| 环境 | 是否可用 | 说明 |
| ------------------------------------------ | -------- | ------------------------------------------------------------------------------------- |
| 浏览器 / Electron 渲染进程 / 浏览器扩展 | ✅ | 直接可用 |
| Vitest / Jest + `happy-dom` / `jsdom` | ✅ | 项目自身的单测就跑在这种环境下 |
| 纯 Node.js / Bun / Deno无 DOM polyfill | ✅ | 从 `@tmagic/form/headless` 引入,不要用 `@tmagic/form` 主入口 |
```ts
// scripts/check-form.ts —— 纯 Node 环境,无需任何 DOM polyfill
import { builtInFields, registerBuiltInFields, registerFields, validateForm } from '@tmagic/form/headless';
import { editorFields } from '@tmagic/editor/headless';
// `builtInFields` 只是数据;未 `app.use(MagicForm)` 时要自己登记
registerBuiltInFields(builtInFields);
registerFields(editorFields);
const error = await validateForm({
config: [{ type: 'text', name: 'username', text: '用户名', rules: [{ required: true }] }],
initValues: { username: '' },
});
console.log(values);
if (error) {
console.error(error);
process.exit(1);
}
```
::: 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 渲染层
`dialog: true` 依赖 DOM 与已安装的 UI 库(`element-plus` / `tdesign`),在纯 Node 环境中不可用。
:::
::: warning ESM 与 CJS 不要混用
`@tmagic/form/headless` 的 ESM 产物与 `@tmagic/form` 共用同一批模块文件,字段注册表是同一份,两个入口可以混着 `import`
CJS 产物是两个各自独立的 bundle注册表不共享。所以同一进程里不要同时 `require('@tmagic/form')``require('@tmagic/form/headless')`——在一边 `registerField` 另一边读不到,校验会因为「没登记过这个 type」而静默放过。`@tmagic/editor``@tmagic/design` 的 headless 子路径同理。
:::
## 类型定义

View File

@ -78,35 +78,34 @@
### 运行时注册
```ts
import {
registerTypeMatchRule,
registerTypeMatchRules,
deleteTypeMatchRule,
clearTypeMatchRules,
} from '@tmagic/form';
import { registerField, registerFields, unregisterField, clearFields } from '@tmagic/form';
// 覆盖内置 text
registerTypeMatchRule('text', (value, { message }) => {
if (typeof value !== 'string') {
return message || '值类型应为字符串';
}
registerField('text', {
typeMatch: (value, { message }) => {
if (typeof value !== 'string') {
return message || '值类型应为字符串';
}
},
});
// 扩展业务字段
registerTypeMatchRule('vs-code', (value, { message }) => {
if (typeof value !== 'string') {
return message || '代码字段应为字符串';
}
registerField('vs-code', {
typeMatch: (value, { message }) => {
if (typeof value !== 'string') {
return message || '代码字段应为字符串';
}
},
});
// 批量注册
registerTypeMatchRules({
foo: (value) => (Array.isArray(value) ? undefined : '应为数组'),
registerFields({
foo: { typeMatch: (value) => (Array.isArray(value) ? undefined : '应为数组') },
});
// 删除 / 清空
deleteTypeMatchRule('foo');
clearTypeMatchRules();
// 删除 / 清空该 type 的全部登记(含 typeMatch
unregisterField('foo');
clearFields();
```
自定义校验器签名:`(value, context) => string | undefined | Promise<string | undefined>`。返回错误文案表示失败,返回 `undefined` 表示通过。`context` 包含 `fieldType``mForm``props``message`
@ -116,11 +115,15 @@ clearTypeMatchRules();
自定义校验器可以返回 `Promise`,用于需要异步确认取值是否合法的场景(如请求接口校验 id 是否存在)。内置规则均为同步。
```ts
registerTypeMatchRule('mod-select', async (value, { message }) => {
const exists = await checkModExists(value);
if (!exists) {
return message || `模块(${value})不存在`;
}
import { registerField } from '@tmagic/form';
registerField('mod-select', {
typeMatch: async (value, { message }) => {
const exists = await checkModExists(value);
if (!exists) {
return message || `模块(${value})不存在`;
}
},
});
```
@ -134,13 +137,17 @@ registerTypeMatchRule('mod-select', async (value, { message }) => {
```ts
import MagicForm from '@tmagic/form';
import MyField from './MyField.vue';
app.use(MagicForm, {
typeMatchRules: {
'my-field': (value, { message }) => {
if (typeof value !== 'string') {
return message || 'my-field 应为字符串';
}
fields: {
'my-field': {
component: MyField,
typeMatch: (value, { message }) => {
if (typeof value !== 'string') {
return message || 'my-field 应为字符串';
}
},
},
},
});
@ -148,7 +155,7 @@ app.use(MagicForm, {
### Editor 字段内置规则
安装 `@tmagic/editor` 时会自动 `registerTypeMatchRules` 注册编辑器自定义字段规则。服务数据(数据源 / 代码块 / 节点树)未就绪时,只做基础形态校验,不做枚举或存在性失败。
安装 `@tmagic/editor` 时会`editorFields`(无 Vue 组件)叠上字段组件后作为 `fields` 传给 `@tmagic/form`。Node 里从 `@tmagic/form/headless``@tmagic/editor/headless` 引入即可。若安装时也传了 `fields`,会与编辑器字段按 type 浅合并:调用方传入的 key 覆盖对应项,未传的 key`nested` / `typeMatch`)保留。服务数据(数据源 / 代码块 / 节点树)未就绪时,只做基础形态校验,不做枚举或存在性失败。
| 字段 type | 期望值 |
| --- | --- |
@ -167,7 +174,7 @@ app.use(MagicForm, {
> 容器类字段(`event-select` / `code-select` / `display-conds`)遵循同一约定:容器级 typeMatch 只做结构校验,「枚举 / 存在性」下沉到内部单元格各自的 typeMatch/rules避免单个子项非法导致整块表单标红。
业务仍可用 `registerTypeMatchRule` 覆盖上述任一 type
业务仍可用 `registerField(type, { typeMatch })` 覆盖上述任一 type 的类型校验;多次 `registerField` 按字段浅合并,不会丢掉已登记的 `nested` / `walk` / `effect`
## 示例
@ -222,6 +229,10 @@ app.use(MagicForm, {
<<< @/../packages/form/src/utils/typeMatch.ts#TypeMatchValidateContext{ts}
:::
::: details 查看 FieldOptions 类型定义
<<< @/../packages/form/src/utils/registerField.ts#FieldOptions{ts}
:::
::: details 查看 FormInstallOptions 类型定义
<<< @/../packages/form/src/plugin.ts#FormInstallOptions{ts}
:::

View File

@ -45,15 +45,20 @@ app.use(MagicForm);
app.mount("#app");
```
也可在安装时传入自定义 `typeMatch` 规则,详见[表单校验 - 扩展自定义 type 规则](../../form-config/rules.md#扩展自定义-type-规则)
也可在安装时传入自定义字段登记(叶子 / nested / typeMatch / component,详见[表单校验 - 扩展自定义 type 规则](../../form-config/rules.md#扩展自定义-type-规则)
```javascript
import MyField from './MyField.vue';
app.use(MagicForm, {
typeMatchRules: {
'my-field': (value, { message }) => {
if (typeof value !== 'string') {
return message || 'my-field 应为字符串';
}
fields: {
'my-field': {
component: MyField,
typeMatch: (value, { message }) => {
if (typeof value !== 'string') {
return message || 'my-field 应为字符串';
}
},
},
},
});

View File

@ -15,7 +15,8 @@
"pg": "pnpm playground",
"playground:react": "pnpm --filter \"runtime-react\" build:libs && pnpm --filter \"runtime-react\" --filter \"tmagic-playground\" dev:react",
"pg:react": "pnpm playground:react",
"build": "pnpm build:dts && node scripts/build.mjs",
"build": "pnpm build:dts && node scripts/build.mjs && pnpm check:headless",
"check:headless": "node scripts/check-headless-dist.mjs",
"build:dts": "pnpm --filter \"@tmagic/cli\" build && tsc -p tsconfig.build-browser.json && vue-tsc --declaration --emitDeclarationOnly --project tsconfig.build-vue.json && rolldown -c rolldown.dts.config.mjs && rimraf temp",
"check:type": "node scripts/check-type.mjs",
"build:playground": "pnpm --filter \"runtime-vue\" build && pnpm --filter \"tmagic-playground\" build",

View File

@ -16,6 +16,11 @@
"import": "./dist/es/index.js",
"require": "./dist/tmagic-design.umd.cjs"
},
"./headless": {
"types": "./types/headless.d.ts",
"import": "./dist/es/headless.js",
"require": "./dist/tmagic-design-headless.umd.cjs"
},
"./*": "./*"
},
"files": [

View File

@ -0,0 +1,35 @@
/*
* 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.
*/
/**
* @fileoverview `@tmagic/design/headless` design
*
* `@tmagic/form/headless`
* ES `@tmagic/design`
* `config` `setDesignConfig`
*
* @module @tmagic/design/headless
*/
export { getDesignConfig, setDesignConfig } from './config';
export {
appendValidateSuggestion,
stripValidateSuggestion,
VALIDATE_SUGGESTION_SEPARATOR,
} from './formValidateMessage';
export type { DesignPluginOptions } from './types';

View File

@ -17,6 +17,11 @@
"import": "./dist/es/index.js",
"require": "./dist/tmagic-editor.umd.cjs"
},
"./headless": {
"types": "./types/headless.d.ts",
"import": "./dist/es/headless.js",
"require": "./dist/tmagic-editor-headless.umd.cjs"
},
"./dist/style.css": {
"import": "./dist/style.css",
"require": "./dist/style.css"

View File

@ -1,6 +1,5 @@
<template>
<MagicCodeEditor
v-if="!silentMode"
:height="config.height"
:type="diffMode ? 'diff' : undefined"
:init-values="diffMode ? (lastValues || {})[name] : model[name]"
@ -18,9 +17,9 @@
</template>
<script lang="ts" setup>
import { computed, inject } from 'vue';
import { computed } from 'vue';
import { type CodeConfig, type FieldProps, FORM_SILENT_MODE_KEY } from '@tmagic/form';
import { type CodeConfig, type FieldProps } from '@tmagic/form';
import MagicCodeEditor from '@editor/layouts/CodeEditor.vue';
@ -28,13 +27,6 @@ defineOptions({
name: 'MFieldsVsCode',
});
/**
* 静默模式submitForm/validateForm 隐藏挂载下跳过 monaco 渲染
* 校验由 FormItem 针对 model 中的值完成与编辑器实例无关本组件挂载无值副作用
* save 仅由用户操作/编辑器内容变更触发跳过可省去 monaco worker/model 的无谓实例化
*/
const silentMode = inject(FORM_SILENT_MODE_KEY, false);
const emit = defineEmits<{
change: [value: string | any];
}>();

View File

@ -23,14 +23,13 @@
<script lang="ts" setup>
import { computed, watch } from 'vue';
import { Plus } from '@element-plus/icons-vue';
import { isEmpty } from 'lodash-es';
import { HookCodeType, HookType } from '@tmagic/core';
import { HookCodeType } from '@tmagic/core';
import { TMagicButton, TMagicCard } from '@tmagic/design';
import type { CodeSelectConfig, ContainerChangeEventData, FieldProps, GroupListConfig } from '@tmagic/form';
import type { CodeSelectConfig, ContainerChangeEventData, FieldProps } from '@tmagic/form';
import { MContainer } from '@tmagic/form';
import { useServices } from '@editor/hooks/use-services';
import { createCodeSelectConfig, normalizeCodeSelectValue } from '@editor/fields/configs/codeSelect';
defineOptions({
name: 'MFieldsCodeSelect',
@ -40,8 +39,6 @@ const emit = defineEmits<{
change: [v: any, eventData: ContainerChangeEventData];
}>();
const { dataSourceService, codeBlockService } = useServices();
const props = withDefaults(defineProps<FieldProps<CodeSelectConfig>>(), {});
/**
@ -69,87 +66,13 @@ const newHandler = () => {
modifyKey: `hookData.${hookData.length}`,
});
};
const codeConfig = computed<GroupListConfig>(() => ({
type: 'group-list',
name: 'hookData',
enableToggleMode: false,
expandAll: true,
addable: () => false,
title: (mForm, { model, index }: any) => {
if (model.codeType === HookCodeType.DATA_SOURCE_METHOD) {
if (Array.isArray(model.codeId)) {
if (model.codeId.length < 2) {
return index;
}
const ds = dataSourceService.getDataSourceById(model.codeId[0]);
return `${ds?.title} / ${model.codeId[1]}`;
}
return Array.isArray(model.codeId) ? model.codeId.join('/') : index;
}
const codeContent = codeBlockService.getCodeContentById(model.codeId);
if (codeContent) {
return codeContent.name;
}
return model.codeId || index;
},
titlePrefix: props.config.name === undefined ? undefined : String(props.config.name),
items: [
{
text: '代码类型',
type: 'select',
name: 'codeType',
labelPosition: 'right',
rules: [{ typeMatch: true, trigger: 'change' }],
options: [
{ value: HookCodeType.CODE, text: '代码块' },
{ value: HookCodeType.DATA_SOURCE_METHOD, text: '数据源方法' },
],
defaultValue: HookCodeType.CODE,
onChange: (_mForm, v: HookCodeType, { setModel }) => {
if (v === HookCodeType.DATA_SOURCE_METHOD) {
setModel('codeId', []);
} else {
setModel('codeId', '');
}
return v;
},
},
{
type: 'code-select-col',
name: 'codeId',
text: '代码块',
rules: [{ typeMatch: true, trigger: 'change' }],
display: (_mForm, { model }) => model.codeType !== HookCodeType.DATA_SOURCE_METHOD,
notEditable: () => !codeBlockService.getEditStatus(),
},
{
type: 'data-source-method-select',
name: 'codeId',
text: '数据源字段',
rules: [{ typeMatch: true, trigger: 'change' }],
display: (_mForm, { model }) => model.codeType === HookCodeType.DATA_SOURCE_METHOD,
notEditable: () => !dataSourceService.get('editable'),
},
],
}));
const codeConfig = computed(() => createCodeSelectConfig(props.config));
watch(
() => props.model[props.name],
(value) => {
() => {
//
if (isEmpty(value)) {
//
props.model[props.name] = {
hookType: HookType.CODE,
hookData: [],
};
}
normalizeCodeSelectValue(props.model, props.name);
},
{
immediate: true,

View File

@ -15,7 +15,7 @@
:prop="prop"
></MFormContainer>
<MSelect
v-else-if="!silentMode"
v-else
class="select"
:config="selectConfig"
:name="name"
@ -27,7 +27,7 @@
<!-- 查看/编辑按钮对比模式为只读不展示 -->
<TMagicButton
v-if="!isCompareMode && !silentMode && model[name] && hasCodeBlockSidePanel"
v-if="!isCompareMode && model[name] && hasCodeBlockSidePanel"
class="m-fields-select-action-button"
:size="size"
@click="editCode(model[name])"
@ -65,7 +65,6 @@ import {
createValues,
type FieldProps,
filterFunction,
FORM_SILENT_MODE_KEY,
type FormItemConfig,
type FormState,
MContainer as MFormContainer,
@ -84,7 +83,6 @@ defineOptions({
});
const mForm = inject<FormState | undefined>('mForm');
const silentMode = inject(FORM_SILENT_MODE_KEY, false);
const { codeBlockService, uiService } = useServices();
const eventBus = inject<EventBus>('eventBus');
const emit = defineEmits<{

View File

@ -6,7 +6,7 @@
}"
>
<FieldSelect
v-if="isSelectValid && !silentMode"
v-if="isSelectValid"
:model-value="model[name]"
:disabled="disabled"
:size="size"
@ -18,7 +18,7 @@
></FieldSelect>
<component
v-else-if="!silentMode"
v-else
:is="tagName"
:config="config.fieldConfig"
:model="model"
@ -33,7 +33,7 @@
></component>
<TMagicTooltip
v-if="!silentMode && config.fieldConfig && !disabledDataSource && !mForm?.isCompare"
v-if="config.fieldConfig && !disabledDataSource && !mForm?.isCompare"
:disabled="showDataSourceFieldSelect"
content="选择数据源"
>
@ -57,7 +57,6 @@ import {
type ContainerChangeEventData,
type DataSourceFieldSelectConfig,
type FieldProps,
FORM_SILENT_MODE_KEY,
type FormState,
getFormField,
} from '@tmagic/form';
@ -105,7 +104,6 @@ watch(
const { dataSourceService, propsService } = useServices();
const mForm = inject<FormState | undefined>('mForm');
const silentMode = inject(FORM_SILENT_MODE_KEY, false);
const dataSources = computed(() => dataSourceService.get('dataSources') || []);
const disabledDataSource = computed(() => propsService.getDisabledDataSource());

View File

@ -8,7 +8,6 @@
</div>
<FloatingBox
v-if="!silentMode"
:body-style="{ padding: '0 16px' }"
v-model:visible="addDialogVisible"
v-model:width="width"
@ -31,7 +30,6 @@
</FloatingBox>
<FloatingBox
v-if="!silentMode"
:body-style="{ padding: '0 16px' }"
v-model:visible="addFromJsonDialogVisible"
v-model:width="width"
@ -62,7 +60,6 @@ import {
type ContainerChangeEventData,
type DataSourceFieldsConfig,
type FieldProps,
FORM_SILENT_MODE_KEY,
type FormConfig,
type FormState,
MFormBox,
@ -92,8 +89,6 @@ const emit = defineEmits<{
const { uiService } = useServices();
const mForm = inject<FormState | undefined>('mForm');
const silentMode = inject(FORM_SILENT_MODE_KEY, false);
/** 对比模式下隐藏新增/编辑/删除等操作按钮,仅保留只读展示。 */
const isCompare = computed(() => Boolean(mForm?.isCompare));
@ -383,8 +378,6 @@ provide(
);
onMounted(() => {
if (silentMode) return;
const path = editingFieldPath.value;
if (!path.length) return;

View File

@ -2,7 +2,6 @@
<div class="m-fields-data-source-method-select">
<div class="data-source-method-select-container">
<MCascader
v-if="!silentMode"
class="select"
:config="cascaderConfig"
:model="model"
@ -14,7 +13,7 @@
></MCascader>
<TMagicTooltip
v-if="!silentMode && model[name] && isCustomMethod && dataSourceSidePanel && !isCompare"
v-if="model[name] && isCustomMethod && dataSourceSidePanel && !isCompare"
:content="notEditable ? '查看' : '编辑'"
>
<TMagicButton class="m-fields-select-action-button" :size="size" @click="editCodeHandler">
@ -49,7 +48,6 @@ import {
type DataSourceMethodSelectConfig,
type FieldProps,
filterFunction,
FORM_SILENT_MODE_KEY,
type FormItemConfig,
type FormState,
MCascader,
@ -69,7 +67,6 @@ defineOptions({
const { dataSourceService, uiService } = useServices();
const mForm = inject<FormState | undefined>('mForm');
const silentMode = inject(FORM_SILENT_MODE_KEY, false);
const eventBus = inject<EventBus>('eventBus');
const emit = defineEmits(['change']);

View File

@ -9,7 +9,7 @@
</div>
<CodeBlockEditor
v-if="codeConfig && !silentMode"
v-if="codeConfig"
ref="codeBlockEditor"
:disabled="disabled"
:content="codeConfig"
@ -30,7 +30,6 @@ import {
type ContainerChangeEventData,
type DataSourceMethodsConfig,
type FieldProps,
FORM_SILENT_MODE_KEY,
type FormState,
} from '@tmagic/form';
import { type ColumnConfig, MagicTable } from '@tmagic/table';
@ -49,8 +48,6 @@ const props = withDefaults(defineProps<FieldProps<DataSourceMethodsConfig>>(), {
const emit = defineEmits(['change']);
const mForm = inject<FormState | undefined>('mForm');
const silentMode = inject(FORM_SILENT_MODE_KEY, false);
/** 对比模式下隐藏新增/编辑/删除等操作按钮,仅保留只读展示。 */
const isCompare = computed(() => Boolean(mForm?.isCompare));
@ -177,8 +174,6 @@ const editingMethodName = inject<ComputedRef<string | undefined>>(
);
onMounted(() => {
if (silentMode) return;
const methodName = editingMethodName.value;
if (!methodName) return;

View File

@ -7,7 +7,6 @@
</div>
<FloatingBox
v-if="!silentMode"
:body-style="{ padding: '0 16px' }"
v-model:visible="addDialogVisible"
v-model:width="width"
@ -35,14 +34,7 @@ import { computed, inject, Ref, ref } from 'vue';
import type { MockSchema } from '@tmagic/core';
import { TMagicButton, tMagicMessageBox, TMagicSwitch } from '@tmagic/design';
import {
type DataSourceMocksConfig,
type FieldProps,
FORM_SILENT_MODE_KEY,
type FormConfig,
type FormState,
MFormBox,
} from '@tmagic/form';
import { type DataSourceMocksConfig, type FieldProps, type FormConfig, type FormState, MFormBox } from '@tmagic/form';
import { type ColumnConfig, MagicTable } from '@tmagic/table';
import { getDefaultValueFromFields } from '@tmagic/utils';
@ -65,7 +57,6 @@ const emit = defineEmits(['change']);
const { uiService } = useServices();
const mForm = inject<FormState | undefined>('mForm');
const silentMode = inject(FORM_SILENT_MODE_KEY, false);
/** 对比模式下隐藏新增/编辑/删除等操作按钮,仅保留只读展示。 */
const isCompare = computed(() => Boolean(mForm?.isCompare));

View File

@ -22,13 +22,10 @@ import {
type FieldProps,
filterFunction,
type FormState,
type GroupListConfig,
MGroupList,
} from '@tmagic/form';
import { removeDataSourceFieldPrefix } from '@tmagic/utils';
import { useServices } from '@editor/hooks/use-services';
import { getCascaderOptionsFromFields, getFieldType } from '@editor/utils';
import { createDisplayCondsConfig } from '@editor/fields/configs/displayConds';
defineOptions({
name: 'm-fields-display-conds',
@ -42,148 +39,11 @@ const props = withDefaults(defineProps<FieldProps<DisplayCondsConfig>>(), {
disabled: false,
});
const { dataSourceService } = useServices();
const mForm = inject<FormState | undefined>('mForm');
const parentFields = computed(() => filterFunction<string[]>(mForm, props.config.parentFields, props) || []);
const resolveFieldPath = (path: string[]) => {
const [id, ...fieldNames] = path;
const ds = id ? dataSourceService.getDataSourceById(removeDataSourceFieldPrefix(`${id}`)) : undefined;
return { ds, fieldNames };
};
const fieldOnChange = (_formState: FormState | undefined, v: string[], { model }: { model: Record<string, any> }) => {
const { ds, fieldNames } = resolveFieldPath([...parentFields.value, ...v]);
const type = getFieldType(ds, fieldNames);
if (type === 'number') {
model.value = Number(model.value);
} else if (type === 'boolean') {
model.value = Boolean(model.value);
} else if (type === 'null') {
model.value = null;
} else {
model.value = `${model.value}`;
}
return v;
};
const config = computed<GroupListConfig>(() => ({
type: 'groupList',
name: props.name,
titlePrefix: props.config.titlePrefix,
expandAll: true,
enableToggleMode: false,
flat: props.config.flat,
items: [
{
type: 'table',
name: 'cond',
operateColWidth: props.config.operateColWidth,
enableToggleMode: false,
fixed: props.config.fixed,
flat: props.config.flat,
items: [
parentFields.value.length
? {
type: 'cascader',
options: () => {
const { ds, fieldNames } = resolveFieldPath(parentFields.value);
if (!ds) {
return [];
}
let fields = ds.fields || [];
fieldNames.forEach((key) => {
const field = fields.find((f) => f.name === key);
fields = field?.fields || [];
});
return getCascaderOptionsFromFields(fields, ['string', 'number', 'boolean', 'any']);
},
name: 'field',
value: 'key',
label: '字段',
checkStrictly: false,
onChange: fieldOnChange,
defaultValue: () => [],
rules: [
{ required: true, trigger: 'blur', message: '请选择字段' },
{ typeMatch: true, trigger: 'change' },
],
}
: {
type: 'data-source-field-select',
name: 'field',
value: 'key',
label: '字段',
checkStrictly: false,
dataSourceFieldType: ['string', 'number', 'boolean', 'any'],
onChange: fieldOnChange,
defaultValue: () => [],
rules: [
{ required: true, trigger: 'blur', message: '请选择字段' },
{ typeMatch: true, trigger: 'change' },
],
},
{
type: 'cond-op-select',
parentFields: parentFields.value,
label: '条件',
width: 140,
name: 'op',
rules: [
{ required: true, trigger: 'blur', message: '请选择条件' },
{ typeMatch: true, trigger: 'change' },
],
},
{
label: '值',
width: 160,
items: [
{
name: 'value',
type: (_mForm, { model }) => {
const { ds, fieldNames } = resolveFieldPath([...parentFields.value, ...(model.field || [])]);
const type = getFieldType(ds, fieldNames);
if (type === 'number') {
return 'number';
}
if (type === 'boolean') {
return 'select';
}
if (type === 'null') {
return 'display';
}
return 'text';
},
options: [
{ text: 'true', value: true },
{ text: 'false', value: false },
],
display: (_mForm, { model }) => !['between', 'not_between'].includes(model.op),
displayText: (_mForm: FormState | undefined, { model }: any) => {
if (model.value === null) {
return 'null';
}
return model.value;
},
},
{
name: 'range',
type: 'number-range',
display: (vm, { model }) => ['between', 'not_between'].includes(model.op),
},
],
},
],
},
],
}));
const config = computed(() => createDisplayCondsConfig(props.config, props.name, parentFields.value));
const changeHandler = (v: DisplayCond[], eventData?: ContainerChangeEventData) => {
if (!Array.isArray(props.model[props.name])) {

View File

@ -83,32 +83,17 @@
import { computed } from 'vue';
import { Delete } from '@element-plus/icons-vue';
import { Plus } from '@element-plus/icons-vue';
import { has } from 'lodash-es';
import { ActionType, MNode } from '@tmagic/core';
import { TMagicButton } from '@tmagic/design';
import type {
CodeSelectColConfig,
ContainerChangeEventData,
DataSourceMethodSelectConfig,
DynamicTypeConfig,
EventSelectConfig,
FieldProps,
FormState,
PanelConfig,
TableConfig,
UISelectConfig,
} from '@tmagic/form';
import { defineFormItem, MContainer as MFormContainer, MPanel, MTable } from '@tmagic/form';
import type { ContainerChangeEventData, EventSelectConfig, FieldProps } from '@tmagic/form';
import { MContainer as MFormContainer, MPanel, MTable } from '@tmagic/form';
import { useServices } from '@editor/hooks/use-services';
import {
getCompActionAllowedValues,
getCompActionOptions,
getEventNameAllowedValues,
getEventNameOptions,
normalizeCompActionValue,
} from '@editor/utils';
createActionsConfig,
createEventNameConfig,
createLegacyTableConfig,
isLegacyEventValue,
} from '@editor/fields/configs/eventSelect';
defineOptions({
name: 'MFieldsEventSelect',
@ -120,258 +105,17 @@ const emit = defineEmits<{
change: [v: any, eventData?: ContainerChangeEventData];
}>();
const { editorService, dataSourceService, eventsService, codeBlockService, propsService } = useServices();
//
const eventNameConfig = computed(() => {
const defaultEventNameConfig = {
name: 'name',
text: '事件类型',
type: (mForm: FormState | undefined, { formValue }: any) => {
if (
props.config.src !== 'component' ||
(formValue.type === 'page-fragment-container' && formValue.pageFragmentId)
) {
return 'cascader';
}
return 'select';
},
labelWidth: '70px',
checkStrictly: () => props.config.src !== 'component',
valueSeparator: '.',
options: (_mForm: FormState, { formValue }: any) => getEventNameOptions(props.config.src, formValue),
rules: [
{
validator: ({ value, callback }: any, { formValue }: any) => {
const allowedNames = getEventNameAllowedValues(props.config as any, formValue);
if (allowedNames && allowedNames.size > 0 && value && !allowedNames.has(value)) {
return callback(`事件名(${value})不存在`);
}
callback();
},
trigger: 'blur',
},
],
};
return { ...defaultEventNameConfig, ...props.config.eventNameConfig };
});
const actionTypeOptions = computed(() => {
const o: {
text: string;
label: string;
value: string;
disabled?: boolean;
}[] = [
{
text: '组件',
label: '组件',
value: ActionType.COMP,
},
];
if (!propsService.getDisabledCodeBlock()) {
o.push({
text: '代码',
label: '代码',
disabled: !Object.keys(codeBlockService.getCodeDsl() || {}).length,
value: ActionType.CODE,
});
}
if (!propsService.getDisabledDataSource()) {
o.push({
text: '数据源',
label: '数据源',
value: ActionType.DATA_SOURCE,
});
}
return o;
});
//
const actionTypeConfig = computed(() => {
const defaultActionTypeConfig = {
name: 'actionType',
text: '联动类型',
type: 'select',
labelPosition: 'left',
defaultValue: ActionType.COMP,
options: actionTypeOptions.value,
rules: [
{
required: true,
message: '联动类型不能为空',
},
{
typeMatch: true,
trigger: 'blur',
},
],
onChange: (_mForm: FormState, _v: string, { setModel }: any) => {
setModel('to', '');
setModel('method', '');
setModel('codeId', '');
setModel('dataSourceMethod', []);
},
};
return { ...defaultActionTypeConfig, ...props.config.actionTypeConfig };
});
//
const targetCompConfig = computed(() => {
const defaultTargetCompConfig: UISelectConfig = {
name: 'to',
text: '联动组件',
type: 'ui-select',
labelPosition: 'left',
display: (_mForm, { model }) => model.actionType === ActionType.COMP,
onChange: (_mForm, _v, { setModel }) => {
setModel('method', '');
},
rules: [
{
typeMatch: true,
trigger: 'blur',
},
],
};
return { ...defaultTargetCompConfig, ...props.config.targetCompConfig };
});
//
const compActionConfig = computed(() => {
const defaultCompActionConfig: DynamicTypeConfig = {
name: 'method',
text: '动作',
labelPosition: 'left',
type: (mForm: FormState | undefined, { model }: any) => {
const to = editorService.getNodeById(model.to);
if (to && to.type === 'page-fragment-container' && to.pageFragmentId) {
return 'cascader';
}
return 'select';
},
checkStrictly: () => props.config.src !== 'component',
display: (mForm: FormState | undefined, { model }: any) => model.actionType === ActionType.COMP,
options: (_mForm: FormState, { model }: any) => getCompActionOptions(model.to),
rules: [
{
trigger: 'blur',
validator: ({ value, callback }: any, { model }: any) => {
const allowedMethods = getCompActionAllowedValues(props.config as any, model);
const normalized = normalizeCompActionValue(value);
if (allowedMethods && allowedMethods.size > 0 && normalized && !allowedMethods.has(normalized)) {
return callback(`动作名(${normalized})不存在`);
}
callback();
},
},
],
};
return { ...defaultCompActionConfig, ...props.config.compActionConfig };
});
//
const codeActionConfig = computed(() => {
const defaultCodeActionConfig: CodeSelectColConfig = {
type: 'code-select-col',
text: '代码块',
name: 'codeId',
notEditable: () => !codeBlockService.getEditStatus(),
display: (mForm, { model }) => model.actionType === ActionType.CODE,
};
return { ...defaultCodeActionConfig, ...props.config.codeActionConfig };
});
//
const dataSourceActionConfig = computed(() => {
const defaultDataSourceActionConfig: DataSourceMethodSelectConfig = {
type: 'data-source-method-select',
text: '数据源方法',
name: 'dataSourceMethod',
notEditable: () => !dataSourceService.get('editable'),
display: (mForm, { model }) => model.actionType === ActionType.DATA_SOURCE,
};
return { ...defaultDataSourceActionConfig, ...props.config.dataSourceActionConfig };
});
const eventNameConfig = computed(() => createEventNameConfig(props.config));
//
const tableConfig = computed(
() =>
defineFormItem({
type: 'table',
name: 'events',
items: [
{
name: 'name',
label: '事件名',
type: eventNameConfig.value.type,
options: (mForm: FormState, { formValue }: any) =>
eventsService
.getEvent(formValue.type, { node: editorService.getNodeById(formValue.id) })
.map((option: any) => ({
text: option.label,
value: option.value,
})),
},
{
name: 'to',
label: '联动组件',
type: 'ui-select',
},
{
name: 'method',
label: '动作',
type: compActionConfig.value.type,
options: (mForm: FormState, { model, formValue }: any) => {
const node = editorService.getNodeById(model.to) || (formValue as MNode);
if (!node?.type) return [];
return eventsService.getMethod(node.type, { targetId: model.to, node }).map((option: any) => ({
text: option.label,
value: option.value,
}));
},
},
],
}) as TableConfig,
);
const tableConfig = computed(() => createLegacyTableConfig(props.config));
//
const actionsConfig = computed(
() =>
defineFormItem({
type: 'panel',
labelPosition: 'left',
items: [
{
type: 'group-list',
name: 'actions',
expandAll: true,
enableToggleMode: false,
titlePrefix: '动作',
labelPosition: 'left',
items: [
actionTypeConfig.value,
targetCompConfig.value,
compActionConfig.value,
codeActionConfig.value,
dataSourceActionConfig.value,
],
},
],
}) as PanelConfig,
);
const actionsConfig = computed(() => createActionsConfig(props.config));
//
const isOldVersion = computed(() => {
if (props.model[props.name].length === 0) return false;
return !has(props.model[props.name][0], 'actions');
});
const isOldVersion = computed(() => isLegacyEventValue(props.model[props.name]));
/**
* 对比模式判定

View File

@ -44,7 +44,7 @@
</div>
<MagicCodeEditor
v-if="config.advanced && showCode && !silentMode"
v-if="config.advanced && showCode"
editor-custom-type="m-fields-key-value"
language="javascript"
:init-values="model[name]"
@ -60,7 +60,7 @@
></MagicCodeEditor>
<TMagicButton
v-if="config.advanced && !isCompare && !silentMode"
v-if="config.advanced && !isCompare"
size="default"
:disabled="disabled"
link
@ -75,7 +75,7 @@ import { computed, inject, ref, watchEffect } from 'vue';
import { Delete, Plus } from '@element-plus/icons-vue';
import { TMagicButton, TMagicInput } from '@tmagic/design';
import { type FieldProps, FORM_SILENT_MODE_KEY, type FormState, type KeyValueConfig } from '@tmagic/form';
import { type FieldProps, type FormState, type KeyValueConfig } from '@tmagic/form';
import CodeIcon from '@editor/icons/CodeIcon.vue';
import MagicCodeEditor from '@editor/layouts/CodeEditor.vue';
@ -93,7 +93,6 @@ const emit = defineEmits<{
}>();
const mForm = inject<FormState | undefined>('mForm');
const silentMode = inject(FORM_SILENT_MODE_KEY, false);
/** 对比模式下隐藏增删/代码切换等操作按钮,仅保留只读展示。 */
const isCompare = computed(() => Boolean(mForm?.isCompare));

View File

@ -52,48 +52,14 @@
import { computed, ref } from 'vue';
import type { ContainerChangeEventData, FormValue } from '@tmagic/form';
import { defineFormItem, MContainer } from '@tmagic/form';
import { MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import { createBorderDirectionConfig } from '../configs';
const direction = ref('');
const config = computed(() =>
defineFormItem({
items: [
{
name: `border${direction.value}Width`,
text: '边框宽度',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
},
{
name: `border${direction.value}Color`,
text: '边框颜色',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'colorPicker',
},
},
{
name: `border${direction.value}Style`,
text: '边框样式',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'select',
options: ['solid', 'dashed', 'dotted'].map((item) => ({
value: item,
text: item,
})),
},
},
],
}),
);
const config = computed(() => createBorderDirectionConfig(direction.value));
const selectDirection = (d?: string) => (direction.value = d || '');

View File

@ -0,0 +1,690 @@
import { markRaw } from 'vue';
import { appendValidateSuggestion } from '@tmagic/design/headless';
import { defineFormItem, type FormConfig } from '@tmagic/form/headless';
import type { StyleSchema } from '@tmagic/schema';
import type { validateDataSourceFieldSelect } from '@editor/utils/type-match-rules';
import BackgroundPosition from './components/BackgroundPosition.vue';
import {
AlignItemsCenter,
AlignItemsFlexEnd,
AlignItemsFlexStart,
AlignItemsSpaceAround,
AlignItemsSpaceBetween,
} from './icons/align-items';
import { BackgroundNoRepeat, BackgroundRepeat, BackgroundRepeatX, BackgroundRepeatY } from './icons/background-repeat';
import { DisplayBlock, DisplayFlex, DisplayInline, DisplayInlineBlock, DisplayNone } from './icons/display';
import {
FlexDirectionColumn,
FlexDirectionColumnReverse,
FlexDirectionRow,
FlexDirectionRowReverse,
} from './icons/flex-direction';
import {
JustifyContentCenter,
JustifyContentFlexEnd,
JustifyContentFlexStart,
JustifyContentSpaceAround,
JustifyContentSpaceBetween,
} from './icons/justify-content';
import { AlignCenter, AlignLeft, AlignRight } from './icons/text-align';
/**
*
*
* `theme` `useTheme()` magic-admin
* radio button
*/
export const createLayoutConfig = (theme: string): FormConfig => [
defineFormItem({
name: 'display',
text: '模式',
type: 'radioGroup',
childType: 'button',
labelWidth: '90px',
iconSize: '24px',
options: [
{
value: 'inline',
icon: markRaw(DisplayInline),
tooltip: '内联布局 inline',
},
{
value: 'flex',
icon: markRaw(DisplayFlex),
tooltip: '弹性布局 flex',
},
{
value: 'block',
icon: markRaw(DisplayBlock),
tooltip: '块级布局 block',
},
{
value: 'inline-block',
icon: markRaw(DisplayInlineBlock),
tooltip: '内联块布局 inline-block',
},
{
value: 'none',
icon: markRaw(DisplayNone),
tooltip: '隐藏 none',
},
],
}),
defineFormItem({
name: 'flexDirection',
text: '主轴方向',
type: 'radioGroup',
childType: 'button',
labelWidth: '90px',
iconSize: '24px',
options: [
{ value: 'row', icon: markRaw(FlexDirectionRow), tooltip: '水平方向 起点在左侧 row' },
{
value: 'row-reverse',
icon: markRaw(FlexDirectionRowReverse),
tooltip: '水平方向 起点在右侧 row-reverse',
},
{
value: 'column',
icon: markRaw(FlexDirectionColumn),
tooltip: '垂直方向 起点在上沿 column',
},
{
value: 'column-reverse',
icon: markRaw(FlexDirectionColumnReverse),
tooltip: '垂直方向 起点在下沿 column-reverse',
},
],
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
}),
defineFormItem({
name: 'justifyContent',
text: '主轴对齐',
type: 'radioGroup',
childType: 'button',
labelWidth: '90px',
iconSize: '24px',
options: [
{ value: 'flex-start', icon: markRaw(JustifyContentFlexStart), tooltip: '左对齐 flex-start' },
{ value: 'flex-end', icon: markRaw(JustifyContentFlexEnd), tooltip: '右对齐 flex-end' },
{ value: 'center', icon: markRaw(JustifyContentCenter), tooltip: '居中 center' },
{
value: 'space-between',
icon: markRaw(JustifyContentSpaceBetween),
tooltip: '两端对齐 space-between',
},
{
value: 'space-around',
icon: markRaw(JustifyContentSpaceAround),
tooltip: '横向平分 space-around',
},
],
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
}),
defineFormItem({
name: 'alignItems',
text: '辅轴对齐',
type: 'radioGroup',
childType: 'button',
labelWidth: '90px',
iconSize: '24px',
options: [
{ value: 'flex-start', icon: markRaw(AlignItemsFlexStart), tooltip: '左对齐 flex-start' },
{ value: 'flex-end', icon: markRaw(AlignItemsFlexEnd), tooltip: '右对齐 flex-end' },
{ value: 'center', icon: markRaw(AlignItemsCenter), tooltip: '居中 center' },
{
value: 'space-between',
icon: markRaw(AlignItemsSpaceBetween),
tooltip: '两端对齐 space-between',
},
{
value: 'space-around',
icon: markRaw(AlignItemsSpaceAround),
tooltip: '横向平分 space-around',
},
],
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
}),
defineFormItem({
name: 'flexWrap',
text: '换行',
type: 'radioGroup',
childType: theme !== 'magic-admin' ? 'button' : 'default',
labelWidth: '90px',
iconSize: '24px',
options: [
{ value: 'nowrap', text: '不换行', tooltip: '不换行 nowrap' },
{ value: 'wrap', text: '正换行', tooltip: '第一行在上方 wrap' },
{ value: 'wrap-reverse', text: '逆换行', tooltip: '第一行在下方 wrap-reverse' },
],
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
}),
defineFormItem({
type: 'row',
items: [
{
name: 'width',
text: '宽度px',
labelWidth: '90px',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
},
],
}),
defineFormItem({
type: 'row',
items: [
{
name: 'height',
text: '高度px',
labelWidth: '90px',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
},
],
}),
defineFormItem({
type: 'row',
items: [
{
type: 'data-source-field-select',
text: 'overflow',
name: 'overflow',
labelWidth: '90px',
checkStrictly: false,
dataSourceFieldType: ['string'],
fieldConfig: {
type: 'select',
clearable: true,
allowCreate: true,
options: [
{ text: 'visible', value: 'visible' },
{ text: 'hidden', value: 'hidden' },
{ text: 'clip', value: 'clip' },
{ text: 'scroll', value: 'scroll' },
{ text: 'auto', value: 'auto' },
{ text: 'overlay', value: 'overlay' },
{ text: 'initial', value: 'initial' },
],
},
},
],
}),
defineFormItem({
type: 'row',
items: [
{
type: 'data-source-field-select',
text: '透明度(%',
name: 'opacity',
labelWidth: '90px',
dataSourceFieldType: ['string', 'number'],
fieldConfig: {
type: 'text',
},
},
],
}),
];
const positionText: Record<string, string> = {
static: '不定位',
relative: '相对定位',
absolute: '绝对定位',
fixed: '固定定位',
sticky: '粘性定位',
};
/**
*
*
* `values` style modelleft/top/right/bottom display
* `values.position`
*/
export const createPositionConfig = (values: Partial<StyleSchema>): FormConfig => [
defineFormItem({
name: 'position',
text: '定位',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'select',
options: Object.keys(positionText).map((item) => ({
value: item,
text: `${item}(${positionText[item]})`,
})),
},
}),
defineFormItem({
type: 'row',
labelWidth: '68px',
display: () => values.position !== 'static',
items: [
{
name: 'left',
type: 'data-source-field-select',
text: 'left',
fieldConfig: {
type: 'text',
},
rules: [
{
typeMatch: true,
message: appendValidateSuggestion('left 应为字符串', '请参考以下示例值:"10"'),
},
],
},
{
name: 'top',
type: 'data-source-field-select',
text: 'top',
fieldConfig: {
type: 'text',
},
rules: [
{
typeMatch: true,
message: appendValidateSuggestion('top 应为字符串', '请参考以下示例值:"10"'),
},
],
},
],
}),
defineFormItem({
type: 'row',
labelWidth: '68px',
display: () => values.position !== 'static',
items: [
{
name: 'right',
type: 'data-source-field-select',
text: 'right',
fieldConfig: {
type: 'text',
},
rules: [
{
typeMatch: true,
message: appendValidateSuggestion('right 应为字符串', '请参考以下示例值:"10"'),
},
],
},
{
name: 'bottom',
type: 'data-source-field-select',
text: 'bottom',
fieldConfig: {
type: 'text',
},
rules: [
{
typeMatch: true,
message: appendValidateSuggestion('bottom 应为字符串', '请参考以下示例值:"10"'),
},
],
},
],
}),
defineFormItem({
labelWidth: '68px',
name: 'zIndex',
text: 'zIndex',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
rules: [
{
typeMatch: true,
message: appendValidateSuggestion('zIndex 应为数字', '请参考以下示例值10'),
},
],
}),
];
/**
*
*
* `@tmagic/design` `appendValidateSuggestion`
* design
*
*/
export const createBackgroundConfig = (): FormConfig => [
defineFormItem({
name: 'backgroundColor',
text: '背景色',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'colorPicker',
},
rules: [
{
typeMatch: true,
message: appendValidateSuggestion('背景色应为字符串', '请参考以下示例值:"#000000"'),
},
],
}),
defineFormItem({
name: 'backgroundImage',
text: '背景图',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'img-upload',
} as any,
}),
defineFormItem({
name: 'backgroundSize',
text: '背景尺寸',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
options: [
{ value: 'auto', text: '默认', tooltip: '默认 auto' },
{ value: 'contain', text: '等比填充', tooltip: '等比填充 contain' },
{ value: 'cover', text: '等比覆盖', tooltip: '等比覆盖 cover' },
],
rules: [
{
typeMatch: false,
},
{
validator: ({ value, callback }) => {
if (value === '' || value === null || value === undefined) {
return callback();
}
const keywords = ['auto', 'cover', 'contain', 'inherit', 'initial', 'revert', 'unset'];
// 单值:关键字 或 长度/百分比
const lengthPercent = /^-?\d+(\.\d+)?(px|em|rem|ex|ch|vw|vh|vmin|vmax|cm|mm|in|pt|pc|%)$/;
const singleValue = (v: string) => keywords.includes(v) || lengthPercent.test(v);
const str = String(value).trim();
const parts = str.split(/\s+/);
// cover / contain 不能与其他值组合
if (parts.length > 1 && (parts.includes('cover') || parts.includes('contain'))) {
return callback('cover/contain 不能与其他值组合');
}
// 多值最多两个
if (parts.length > 2) {
return callback('backgroundSize 最多支持两个值');
}
// 关键字 auto 在多值场景中允许与其他长度/百分比组合
if (parts.every((part) => singleValue(part))) {
return callback();
}
return callback('backgroundSize 值不合法');
},
},
],
}),
defineFormItem({
name: 'backgroundRepeat',
text: '重复显示',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
options: [
{ value: 'no-repeat', icon: markRaw(BackgroundNoRepeat), tooltip: '不重复 no-repeat' },
{ value: 'repeat-x', icon: markRaw(BackgroundRepeatX), tooltip: '水平方向重复 repeat-x' },
{ value: 'repeat-y', icon: markRaw(BackgroundRepeatY), tooltip: '垂直方向重复 repeat-y' },
{
value: 'repeat',
icon: markRaw(BackgroundRepeat),
tooltip: '垂直和水平方向重复 repeat',
},
],
}),
defineFormItem({
name: 'backgroundPosition',
text: '背景定位',
type: 'component',
component: BackgroundPosition,
labelWidth: '68px',
}),
];
/**
*
*
* / `type-match-rules`
* `validateDataSourceFieldSelect`
*
*/
export const createFontConfig = (validateDataSourceField: typeof validateDataSourceFieldSelect): FormConfig => [
defineFormItem({
type: 'row',
items: [
{
labelWidth: '68px',
name: 'fontSize',
text: '字号',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
rules: [
{
typeMatch: true,
message: appendValidateSuggestion('字号应为字符串或数字', '请参考以下示例值24 或 "24"'),
},
],
},
{
labelWidth: '68px',
name: 'lineHeight',
text: '行高',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
},
],
}),
defineFormItem({
name: 'fontWeight',
text: '字重',
labelWidth: '68px',
type: 'data-source-field-select',
dataSourceFieldType: ['string', 'number'],
fieldConfig: {
type: 'select',
allowCreate: true,
options: ['normal', 'bold']
.concat(
Array(7)
.fill(1)
.map((x, i) => `${i + 1}00`),
)
.map((item) => ({
value: item,
text: item,
})),
},
rules: [
{
typeMatch: false,
},
{
validator: ({ value, callback }, { config, model, prop }, mForm) => {
if (value === '' || value === null || value === undefined) {
return callback();
}
const result = validateDataSourceField(
value,
{
fieldType: 'data-source-field-select',
mForm,
props: { config, model, prop },
},
{
// 字重允许 string含可创建项与 number如 700
validatePlainValue: (plainValue) => {
if (typeof plainValue === 'string' || (typeof plainValue === 'number' && !Number.isNaN(plainValue))) {
return undefined;
}
return '字重应为字符串或数字';
},
},
);
if (result && typeof (result as Promise<string | undefined>).then === 'function') {
(result as Promise<string | undefined>).then(
(error) => callback(error),
(error) => callback(error),
);
return;
}
return callback(result);
},
},
],
}),
defineFormItem({
labelWidth: '68px',
name: 'color',
text: '颜色',
type: 'data-source-field-select',
fieldConfig: {
type: 'colorPicker',
},
rules: [
{
typeMatch: true,
message: appendValidateSuggestion('颜色应为字符串', '请参考以下示例值:"#000000"'),
},
],
}),
defineFormItem({
name: 'textAlign',
text: '对齐',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
options: [
{ value: 'left', icon: markRaw(AlignLeft), tooltip: '左对齐 row', text: '左对齐' },
{ value: 'center', icon: markRaw(AlignCenter), tooltip: '居中对齐 center', text: '居中对齐' },
{ value: 'right', icon: markRaw(AlignRight), tooltip: '右对齐 right', text: '右对齐' },
],
}),
];
/** 「边框与圆角」面板中圆角部分的配置,边框四向配置见 `createBorderDirectionConfig`。 */
export const borderRadiusConfig = defineFormItem({
labelWidth: '68px',
name: 'borderRadius',
text: '圆角',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
});
/** 「变形」面板配置。 */
export const transformConfig = defineFormItem({
name: 'transform',
items: [
{
name: 'rotate',
text: '旋转角度',
labelWidth: '68px',
type: 'data-source-field-select',
checkStrictly: false,
dataSourceFieldType: ['string', 'number'],
fieldConfig: {
type: 'text',
},
},
{
name: 'scale',
text: '缩放',
labelWidth: '68px',
type: 'data-source-field-select',
checkStrictly: false,
dataSourceFieldType: ['string', 'number'],
fieldConfig: {
type: 'text',
},
},
],
});
/**
* 6 FormItem `Index.vue`
*
*
*
* - Layout Boxmargin/padding input MContainer
* - Border MContainer `:prop`
*
* `theme` flexWrap UI `childType`
*/
export const createStyleSetterConfig = (
values: Partial<StyleSchema>,
theme: string,
validateDataSourceField: typeof validateDataSourceFieldSelect,
): FormConfig => [
...createLayoutConfig(theme),
...createPositionConfig(values),
...createBackgroundConfig(),
...createFontConfig(validateDataSourceField),
borderRadiusConfig,
transformConfig,
];
/**
*
*
* `direction` ''() / 'Top' / 'Right' / 'Bottom' / 'Left'
* `borderWidth`
*/
export const createBorderDirectionConfig = (direction: string) =>
defineFormItem({
items: [
{
name: `border${direction}Width`,
text: '边框宽度',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
},
{
name: `border${direction}Color`,
text: '边框颜色',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'colorPicker',
},
},
{
name: `border${direction}Style`,
text: '边框样式',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'select',
options: ['solid', 'dashed', 'dotted'].map((item) => ({
value: item,
text: item,
})),
},
},
],
});

View File

@ -15,14 +15,12 @@
</template>
<script lang="ts" setup>
import { markRaw } from 'vue';
import { computed } from 'vue';
import { appendValidateSuggestion } from '@tmagic/design';
import { type ContainerChangeEventData, defineFormConfig, MContainer } from '@tmagic/form';
import { type ContainerChangeEventData, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import BackgroundPosition from '../components/BackgroundPosition.vue';
import { BackgroundNoRepeat, BackgroundRepeat, BackgroundRepeatX, BackgroundRepeatY } from '../icons/background-repeat';
import { createBackgroundConfig } from '../configs';
defineProps<{
values: Partial<StyleSchema>;
@ -39,105 +37,7 @@ const emit = defineEmits<{
addDiffCount: [];
}>();
const formConfig = defineFormConfig([
{
name: 'backgroundColor',
text: '背景色',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'colorPicker',
},
rules: [
{
typeMatch: true,
message: appendValidateSuggestion('背景色应为字符串', '请参考以下示例值:"#000000"'),
},
],
},
{
name: 'backgroundImage',
text: '背景图',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'img-upload',
} as any,
},
{
name: 'backgroundSize',
text: '背景尺寸',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
options: [
{ value: 'auto', text: '默认', tooltip: '默认 auto' },
{ value: 'contain', text: '等比填充', tooltip: '等比填充 contain' },
{ value: 'cover', text: '等比覆盖', tooltip: '等比覆盖 cover' },
],
rules: [
{
typeMatch: false,
},
{
validator: ({ value, callback }) => {
if (value === '' || value === null || value === undefined) {
return callback();
}
const keywords = ['auto', 'cover', 'contain', 'inherit', 'initial', 'revert', 'unset'];
// /
const lengthPercent = /^-?\d+(\.\d+)?(px|em|rem|ex|ch|vw|vh|vmin|vmax|cm|mm|in|pt|pc|%)$/;
const singleValue = (v: string) => keywords.includes(v) || lengthPercent.test(v);
const str = String(value).trim();
const parts = str.split(/\s+/);
// cover / contain
if (parts.length > 1 && (parts.includes('cover') || parts.includes('contain'))) {
return callback('cover/contain 不能与其他值组合');
}
//
if (parts.length > 2) {
return callback('backgroundSize 最多支持两个值');
}
// auto /
if (parts.every((part) => singleValue(part))) {
return callback();
}
return callback('backgroundSize 值不合法');
},
},
],
},
{
name: 'backgroundRepeat',
text: '重复显示',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
options: [
{ value: 'no-repeat', icon: markRaw(BackgroundNoRepeat), tooltip: '不重复 no-repeat' },
{ value: 'repeat-x', icon: markRaw(BackgroundRepeatX), tooltip: '水平方向重复 repeat-x' },
{ value: 'repeat-y', icon: markRaw(BackgroundRepeatY), tooltip: '垂直方向重复 repeat-y' },
{
value: 'repeat',
icon: markRaw(BackgroundRepeat),
tooltip: '垂直和水平方向重复 repeat',
},
],
},
{
name: 'backgroundPosition',
text: '背景定位',
type: 'component',
component: BackgroundPosition,
labelWidth: '68px',
},
]);
const formConfig = computed(() => createBackgroundConfig());
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData);

View File

@ -1,7 +1,7 @@
<template>
<MContainer
:prop="prop"
:config="config"
:config="borderRadiusConfig"
:model="values"
:last-values="lastValues"
:is-compare="isCompare"
@ -22,10 +22,11 @@
</template>
<script lang="ts" setup>
import { type ContainerChangeEventData, defineFormItem, MContainer } from '@tmagic/form';
import { type ContainerChangeEventData, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import Border from '../components/Border.vue';
import { borderRadiusConfig } from '../configs';
defineProps<{
values: Partial<StyleSchema>;
@ -41,16 +42,6 @@ const emit = defineEmits<{
addDiffCount: [];
}>();
const config = defineFormItem({
labelWidth: '68px',
name: 'borderRadius',
text: '圆角',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
});
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData);
};

View File

@ -15,15 +15,14 @@
</template>
<script lang="ts" setup>
import { markRaw } from 'vue';
import { computed } from 'vue';
import { appendValidateSuggestion } from '@tmagic/design';
import { type ContainerChangeEventData, defineFormConfig, MContainer } from '@tmagic/form';
import { type ContainerChangeEventData, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import { validateDataSourceFieldSelect } from '@editor/utils/type-match-rules';
import { AlignCenter, AlignLeft, AlignRight } from '../icons/text-align';
import { createFontConfig } from '../configs';
defineProps<{
values: Partial<StyleSchema>;
@ -39,125 +38,7 @@ const emit = defineEmits<{
addDiffCount: [];
}>();
const formConfig = defineFormConfig([
{
type: 'row',
items: [
{
labelWidth: '68px',
name: 'fontSize',
text: '字号',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
rules: [
{
typeMatch: true,
message: appendValidateSuggestion('字号应为字符串或数字', '请参考以下示例值24 或 "24"'),
},
],
},
{
labelWidth: '68px',
name: 'lineHeight',
text: '行高',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
},
],
},
{
name: 'fontWeight',
text: '字重',
labelWidth: '68px',
type: 'data-source-field-select',
dataSourceFieldType: ['string', 'number'],
fieldConfig: {
type: 'select',
allowCreate: true,
options: ['normal', 'bold']
.concat(
Array(7)
.fill(1)
.map((x, i) => `${i + 1}00`),
)
.map((item) => ({
value: item,
text: item,
})),
},
rules: [
{
typeMatch: false,
},
{
validator: ({ value, callback }, { config, model, prop }, mForm) => {
if (value === '' || value === null || value === undefined) {
return callback();
}
const result = validateDataSourceFieldSelect(
value,
{
fieldType: 'data-source-field-select',
mForm,
props: { config, model, prop },
},
{
// string number 700
validatePlainValue: (plainValue) => {
if (typeof plainValue === 'string' || (typeof plainValue === 'number' && !Number.isNaN(plainValue))) {
return undefined;
}
return '字重应为字符串或数字';
},
},
);
if (result && typeof (result as Promise<string | undefined>).then === 'function') {
(result as Promise<string | undefined>).then(
(error) => callback(error),
(error) => callback(error),
);
return;
}
return callback(result);
},
},
],
},
{
labelWidth: '68px',
name: 'color',
text: '颜色',
type: 'data-source-field-select',
fieldConfig: {
type: 'colorPicker',
},
rules: [
{
typeMatch: true,
message: appendValidateSuggestion('颜色应为字符串', '请参考以下示例值:"#000000"'),
},
],
},
{
name: 'textAlign',
text: '对齐',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
options: [
{ value: 'left', icon: markRaw(AlignLeft), tooltip: '左对齐 row', text: '左对齐' },
{ value: 'center', icon: markRaw(AlignCenter), tooltip: '居中对齐 center', text: '居中对齐' },
{ value: 'right', icon: markRaw(AlignRight), tooltip: '右对齐 right', text: '右对齐' },
],
},
]);
const formConfig = computed(() => createFontConfig(validateDataSourceFieldSelect));
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData);

View File

@ -24,35 +24,15 @@
</template>
<script lang="ts" setup>
import { markRaw } from 'vue';
import { computed } from 'vue';
import { useTheme } from '@tmagic/design';
import type { ContainerChangeEventData } from '@tmagic/form';
import { defineFormConfig, MContainer } from '@tmagic/form';
import { MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import Box from '../components/Box.vue';
import {
AlignItemsCenter,
AlignItemsFlexEnd,
AlignItemsFlexStart,
AlignItemsSpaceAround,
AlignItemsSpaceBetween,
} from '../icons/align-items';
import { DisplayBlock, DisplayFlex, DisplayInline, DisplayInlineBlock, DisplayNone } from '../icons/display';
import {
FlexDirectionColumn,
FlexDirectionColumnReverse,
FlexDirectionRow,
FlexDirectionRowReverse,
} from '../icons/flex-direction';
import {
JustifyContentCenter,
JustifyContentFlexEnd,
JustifyContentFlexStart,
JustifyContentSpaceAround,
JustifyContentSpaceBetween,
} from '../icons/justify-content';
import { createLayoutConfig } from '../configs';
const props = defineProps<{
values: Partial<StyleSchema>;
@ -71,202 +51,7 @@ const emit = defineEmits<{
const displayTheme = useTheme(props);
const formConfig = defineFormConfig([
{
name: 'display',
text: '模式',
type: 'radioGroup',
childType: 'button',
labelWidth: '90px',
iconSize: '24px',
options: [
{
value: 'inline',
icon: markRaw(DisplayInline),
tooltip: '内联布局 inline',
},
{
value: 'flex',
icon: markRaw(DisplayFlex),
tooltip: '弹性布局 flex',
},
{
value: 'block',
icon: markRaw(DisplayBlock),
tooltip: '块级布局 block',
},
{
value: 'inline-block',
icon: markRaw(DisplayInlineBlock),
tooltip: '内联块布局 inline-block',
},
{
value: 'none',
icon: markRaw(DisplayNone),
tooltip: '隐藏 none',
},
],
},
{
name: 'flexDirection',
text: '主轴方向',
type: 'radioGroup',
childType: 'button',
labelWidth: '90px',
iconSize: '24px',
options: [
{ value: 'row', icon: markRaw(FlexDirectionRow), tooltip: '水平方向 起点在左侧 row' },
{
value: 'row-reverse',
icon: markRaw(FlexDirectionRowReverse),
tooltip: '水平方向 起点在右侧 row-reverse',
},
{
value: 'column',
icon: markRaw(FlexDirectionColumn),
tooltip: '垂直方向 起点在上沿 column',
},
{
value: 'column-reverse',
icon: markRaw(FlexDirectionColumnReverse),
tooltip: '垂直方向 起点在下沿 column-reverse',
},
],
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
},
{
name: 'justifyContent',
text: '主轴对齐',
type: 'radioGroup',
childType: 'button',
labelWidth: '90px',
iconSize: '24px',
options: [
{ value: 'flex-start', icon: markRaw(JustifyContentFlexStart), tooltip: '左对齐 flex-start' },
{ value: 'flex-end', icon: markRaw(JustifyContentFlexEnd), tooltip: '右对齐 flex-end' },
{ value: 'center', icon: markRaw(JustifyContentCenter), tooltip: '居中 center' },
{
value: 'space-between',
icon: markRaw(JustifyContentSpaceBetween),
tooltip: '两端对齐 space-between',
},
{
value: 'space-around',
icon: markRaw(JustifyContentSpaceAround),
tooltip: '横向平分 space-around',
},
],
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
},
{
name: 'alignItems',
text: '辅轴对齐',
type: 'radioGroup',
childType: 'button',
labelWidth: '90px',
iconSize: '24px',
options: [
{ value: 'flex-start', icon: markRaw(AlignItemsFlexStart), tooltip: '左对齐 flex-start' },
{ value: 'flex-end', icon: markRaw(AlignItemsFlexEnd), tooltip: '右对齐 flex-end' },
{ value: 'center', icon: markRaw(AlignItemsCenter), tooltip: '居中 center' },
{
value: 'space-between',
icon: markRaw(AlignItemsSpaceBetween),
tooltip: '两端对齐 space-between',
},
{
value: 'space-around',
icon: markRaw(AlignItemsSpaceAround),
tooltip: '横向平分 space-around',
},
],
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
},
{
name: 'flexWrap',
text: '换行',
type: 'radioGroup',
childType: displayTheme.value !== 'magic-admin' ? 'button' : 'default',
labelWidth: '90px',
iconSize: '24px',
options: [
{ value: 'nowrap', text: '不换行', tooltip: '不换行 nowrap' },
{ value: 'wrap', text: '正换行', tooltip: '第一行在上方 wrap' },
{ value: 'wrap-reverse', text: '逆换行', tooltip: '第一行在下方 wrap-reverse' },
],
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
},
{
type: 'row',
items: [
{
name: 'width',
text: '宽度px',
labelWidth: '90px',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
},
],
},
{
type: 'row',
items: [
{
name: 'height',
text: '高度px',
labelWidth: '90px',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
},
],
},
{
type: 'row',
items: [
{
type: 'data-source-field-select',
text: 'overflow',
name: 'overflow',
labelWidth: '90px',
checkStrictly: false,
dataSourceFieldType: ['string'],
fieldConfig: {
type: 'select',
clearable: true,
allowCreate: true,
options: [
{ text: 'visible', value: 'visible' },
{ text: 'hidden', value: 'hidden' },
{ text: 'clip', value: 'clip' },
{ text: 'scroll', value: 'scroll' },
{ text: 'auto', value: 'auto' },
{ text: 'overlay', value: 'overlay' },
{ text: 'initial', value: 'initial' },
],
},
},
],
},
{
type: 'row',
items: [
{
type: 'data-source-field-select',
text: '透明度(%',
name: 'opacity',
labelWidth: '90px',
dataSourceFieldType: ['string', 'number'],
fieldConfig: {
type: 'text',
},
},
],
},
]);
const formConfig = computed(() => createLayoutConfig(displayTheme.value));
const change = (value: string | StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData);

View File

@ -15,10 +15,13 @@
</template>
<script lang="ts" setup>
import { appendValidateSuggestion } from '@tmagic/design';
import { type ContainerChangeEventData, defineFormConfig, MContainer } from '@tmagic/form';
import { computed } from 'vue';
import { type ContainerChangeEventData, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import { createPositionConfig } from '../configs';
const props = defineProps<{
values: Partial<StyleSchema>;
lastValues?: Partial<StyleSchema>;
@ -33,114 +36,7 @@ const emit = defineEmits<{
addDiffCount: [];
}>();
const positionText: Record<string, string> = {
static: '不定位',
relative: '相对定位',
absolute: '绝对定位',
fixed: '固定定位',
sticky: '粘性定位',
};
const formConfig = defineFormConfig([
{
name: 'position',
text: '定位',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'select',
options: Object.keys(positionText).map((item) => ({
value: item,
text: `${item}(${positionText[item]})`,
})),
},
},
{
type: 'row',
labelWidth: '68px',
display: () => props.values.position !== 'static',
items: [
{
name: 'left',
type: 'data-source-field-select',
text: 'left',
fieldConfig: {
type: 'text',
},
rules: [
{
typeMatch: true,
message: appendValidateSuggestion('left 应为字符串', '请参考以下示例值:"10"'),
},
],
},
{
name: 'top',
type: 'data-source-field-select',
text: 'top',
fieldConfig: {
type: 'text',
},
rules: [
{
typeMatch: true,
message: appendValidateSuggestion('top 应为字符串', '请参考以下示例值:"10"'),
},
],
},
],
},
{
type: 'row',
labelWidth: '68px',
display: () => props.values.position !== 'static',
items: [
{
name: 'right',
type: 'data-source-field-select',
text: 'right',
fieldConfig: {
type: 'text',
},
rules: [
{
typeMatch: true,
message: appendValidateSuggestion('right 应为字符串', '请参考以下示例值:"10"'),
},
],
},
{
name: 'bottom',
type: 'data-source-field-select',
text: 'bottom',
fieldConfig: {
type: 'text',
},
rules: [
{
typeMatch: true,
message: appendValidateSuggestion('bottom 应为字符串', '请参考以下示例值:"10"'),
},
],
},
],
},
{
labelWidth: '68px',
name: 'zIndex',
text: 'zIndex',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
rules: [
{
typeMatch: true,
message: appendValidateSuggestion('zIndex 应为数字', '请参考以下示例值10'),
},
],
},
]);
const formConfig = computed(() => createPositionConfig(props.values));
const change = (value: string | StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData);

View File

@ -1,7 +1,7 @@
<template>
<MContainer
:prop="prop"
:config="config"
:config="transformConfig"
:model="values"
:last-values="lastValues"
:is-compare="isCompare"
@ -13,9 +13,11 @@
</template>
<script lang="ts" setup>
import { type ContainerChangeEventData, defineFormItem, MContainer } from '@tmagic/form';
import { type ContainerChangeEventData, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import { transformConfig } from '../configs';
defineProps<{
values: Partial<StyleSchema>;
lastValues?: Partial<StyleSchema>;
@ -30,34 +32,6 @@ const emit = defineEmits<{
addDiffCount: [];
}>();
const config = defineFormItem({
name: 'transform',
items: [
{
name: 'rotate',
text: '旋转角度',
labelWidth: '68px',
type: 'data-source-field-select',
checkStrictly: false,
dataSourceFieldType: ['string', 'number'],
fieldConfig: {
type: 'text',
},
},
{
name: 'scale',
text: '缩放',
labelWidth: '68px',
type: 'data-source-field-select',
checkStrictly: false,
dataSourceFieldType: ['string', 'number'],
fieldConfig: {
type: 'text',
},
},
],
});
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData);
};

View File

@ -0,0 +1,122 @@
/*
* 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 { isEmpty } from 'lodash-es';
import { HookCodeType, HookType } from '@tmagic/core';
import type { CodeSelectConfig, FormValue, GroupListConfig } from '@tmagic/form/headless';
import codeBlockService from '@editor/services/codeBlock';
import dataSourceService from '@editor/services/dataSource';
/**
* `fields/CodeSelect.vue`
*
*
*/
export const createCodeSelectConfig = (config: CodeSelectConfig): GroupListConfig => {
const groupListConfig: GroupListConfig = {
type: 'group-list',
name: 'hookData',
enableToggleMode: false,
expandAll: true,
addable: () => false,
title: (_mForm: any, { model, index }: any) => {
if (model.codeType === HookCodeType.DATA_SOURCE_METHOD) {
if (Array.isArray(model.codeId)) {
if (model.codeId.length < 2) {
return index;
}
const ds = dataSourceService.getDataSourceById(model.codeId[0]);
return `${ds?.title} / ${model.codeId[1]}`;
}
return Array.isArray(model.codeId) ? model.codeId.join('/') : index;
}
const codeContent = codeBlockService.getCodeContentById(model.codeId);
if (codeContent) {
return codeContent.name;
}
return model.codeId || index;
},
titlePrefix: config.name === undefined ? undefined : String(config.name),
items: [
{
text: '代码类型',
type: 'select',
name: 'codeType',
labelPosition: 'right',
rules: [{ typeMatch: true, trigger: 'change' }],
options: [
{ value: HookCodeType.CODE, text: '代码块' },
{ value: HookCodeType.DATA_SOURCE_METHOD, text: '数据源方法' },
],
defaultValue: HookCodeType.CODE,
onChange: (_mForm: any, v: HookCodeType, { setModel }: any) => {
if (v === HookCodeType.DATA_SOURCE_METHOD) {
setModel('codeId', []);
} else {
setModel('codeId', '');
}
return v;
},
},
{
type: 'code-select-col',
name: 'codeId',
text: '代码块',
rules: [{ typeMatch: true, trigger: 'change' }],
display: (_mForm: any, { model }: any) => model.codeType !== HookCodeType.DATA_SOURCE_METHOD,
notEditable: () => !codeBlockService.getEditStatus(),
},
{
type: 'data-source-method-select',
name: 'codeId',
text: '数据源字段',
rules: [{ typeMatch: true, trigger: 'change' }],
display: (_mForm: any, { model }: any) => model.codeType === HookCodeType.DATA_SOURCE_METHOD,
notEditable: () => !dataSourceService.get('editable'),
},
],
} as any as GroupListConfig;
return groupListConfig;
};
/**
* `fields/CodeSelect.vue`
* `watch(immediate)` `{ hookType, hookData }`
*
*
*/
export const normalizeCodeSelectValue = (model: FormValue | undefined, name: string): void => {
if (!model) return;
// 空值或者空数组
if (isEmpty(model[name])) {
model[name] = {
hookType: HookType.CODE,
hookData: [],
};
}
};

View File

@ -0,0 +1,178 @@
/*
* 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 { DisplayCondsConfig, FormState, GroupListConfig } from '@tmagic/form/headless';
import { removeDataSourceFieldPrefix } from '@tmagic/utils';
import dataSourceService from '@editor/services/dataSource';
import { getCascaderOptionsFromFields, getFieldType } from '@editor/utils';
/**
* `fields/DisplayConds.vue`
*
*
*
* `parentFields` `filterFunction(mForm, config.parentFields, props)`
* cascader data-source-field-select
*/
export const createDisplayCondsConfig = (
config: DisplayCondsConfig,
name: string,
parentFields: string[],
): GroupListConfig => {
const resolveFieldPath = (path: string[]) => {
const [id, ...fieldNames] = path;
const ds = id ? dataSourceService.getDataSourceById(removeDataSourceFieldPrefix(`${id}`)) : undefined;
return { ds, fieldNames };
};
// 字段变更后按新字段的类型把已填的值转成对应类型,避免类型校验与运行期取值不一致
const fieldOnChange = (_formState: FormState | undefined, v: string[], { model }: { model: Record<string, any> }) => {
const { ds, fieldNames } = resolveFieldPath([...parentFields, ...v]);
const type = getFieldType(ds, fieldNames);
if (type === 'number') {
model.value = Number(model.value);
} else if (type === 'boolean') {
model.value = Boolean(model.value);
} else if (type === 'null') {
model.value = null;
} else {
model.value = `${model.value}`;
}
return v;
};
return {
type: 'groupList',
name,
titlePrefix: config.titlePrefix,
expandAll: true,
enableToggleMode: false,
flat: config.flat,
items: [
{
type: 'table',
name: 'cond',
operateColWidth: config.operateColWidth,
enableToggleMode: false,
fixed: config.fixed,
flat: config.flat,
items: [
parentFields.length
? {
type: 'cascader',
options: () => {
const { ds, fieldNames } = resolveFieldPath(parentFields);
if (!ds) {
return [];
}
let fields = ds.fields || [];
fieldNames.forEach((key) => {
const field = fields.find((f) => f.name === key);
fields = field?.fields || [];
});
return getCascaderOptionsFromFields(fields, ['string', 'number', 'boolean', 'any']);
},
name: 'field',
value: 'key',
label: '字段',
checkStrictly: false,
onChange: fieldOnChange,
defaultValue: () => [],
rules: [
{ required: true, trigger: 'blur', message: '请选择字段' },
{ typeMatch: true, trigger: 'change' },
],
}
: {
type: 'data-source-field-select',
name: 'field',
value: 'key',
label: '字段',
checkStrictly: false,
dataSourceFieldType: ['string', 'number', 'boolean', 'any'],
onChange: fieldOnChange,
defaultValue: () => [],
rules: [
{ required: true, trigger: 'blur', message: '请选择字段' },
{ typeMatch: true, trigger: 'change' },
],
},
{
type: 'cond-op-select',
parentFields,
label: '条件',
width: 140,
name: 'op',
rules: [
{ required: true, trigger: 'blur', message: '请选择条件' },
{ typeMatch: true, trigger: 'change' },
],
},
{
label: '值',
width: 160,
items: [
{
name: 'value',
type: (_mForm: FormState | undefined, { model }: any) => {
const { ds, fieldNames } = resolveFieldPath([...parentFields, ...(model.field || [])]);
const type = getFieldType(ds, fieldNames);
if (type === 'number') {
return 'number';
}
if (type === 'boolean') {
return 'select';
}
if (type === 'null') {
return 'display';
}
return 'text';
},
options: [
{ text: 'true', value: true },
{ text: 'false', value: false },
],
display: (_mForm: FormState | undefined, { model }: any) =>
!['between', 'not_between'].includes(model.op),
displayText: (_mForm: FormState | undefined, { model }: any) => {
if (model.value === null) {
return 'null';
}
return model.value;
},
},
{
name: 'range',
type: 'number-range',
display: (_mForm: FormState | undefined, { model }: any) =>
['between', 'not_between'].includes(model.op),
},
],
},
],
},
],
} as any as GroupListConfig;
};

View File

@ -0,0 +1,296 @@
/*
* 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 { has } from 'lodash-es';
import { ActionType, type MNode } from '@tmagic/core';
import type {
CodeSelectColConfig,
DataSourceMethodSelectConfig,
DynamicTypeConfig,
EventSelectConfig,
FormState,
PanelConfig,
TableConfig,
UISelectConfig,
} from '@tmagic/form/headless';
import { defineFormItem } from '@tmagic/form/headless';
import codeBlockService from '@editor/services/codeBlock';
import dataSourceService from '@editor/services/dataSource';
import editorService from '@editor/services/editor';
import eventsService from '@editor/services/events';
import propsService from '@editor/services/props';
import {
getCompActionAllowedValues,
getCompActionOptions,
getEventNameAllowedValues,
getEventNameOptions,
normalizeCompActionValue,
} from '@editor/utils';
/**
* `fields/EventSelect.vue`
*
*
* `config.xxxConfig`
*/
/** 事件名称下拉框配置,渲染在每张事件卡片的头部 */
export const createEventNameConfig = (config: EventSelectConfig) => {
const defaultEventNameConfig = {
name: 'name',
text: '事件类型',
type: (_mForm: FormState | undefined, { formValue }: any) => {
if (config.src !== 'component' || (formValue.type === 'page-fragment-container' && formValue.pageFragmentId)) {
return 'cascader';
}
return 'select';
},
labelWidth: '70px',
checkStrictly: () => config.src !== 'component',
valueSeparator: '.',
options: (_mForm: FormState, { formValue }: any) => getEventNameOptions(config.src, formValue),
rules: [
{
validator: ({ value, callback }: any, { formValue }: any) => {
const allowedNames = getEventNameAllowedValues(config as any, formValue);
if (allowedNames && allowedNames.size > 0 && value && !allowedNames.has(value)) {
return callback(`事件名(${value})不存在`);
}
callback();
},
trigger: 'blur',
},
],
};
return { ...defaultEventNameConfig, ...config.eventNameConfig };
};
const createActionTypeOptions = () => {
const o: {
text: string;
label: string;
value: string;
disabled?: boolean;
}[] = [
{
text: '组件',
label: '组件',
value: ActionType.COMP,
},
];
if (!propsService.getDisabledCodeBlock()) {
o.push({
text: '代码',
label: '代码',
disabled: !Object.keys(codeBlockService.getCodeDsl() || {}).length,
value: ActionType.CODE,
});
}
if (!propsService.getDisabledDataSource()) {
o.push({
text: '数据源',
label: '数据源',
value: ActionType.DATA_SOURCE,
});
}
return o;
};
/** 联动类型 */
const createActionTypeConfig = (config: EventSelectConfig) => {
const defaultActionTypeConfig = {
name: 'actionType',
text: '联动类型',
type: 'select',
labelPosition: 'left',
defaultValue: ActionType.COMP,
options: createActionTypeOptions(),
rules: [
{
required: true,
message: '联动类型不能为空',
},
{
typeMatch: true,
trigger: 'blur',
},
],
onChange: (_mForm: FormState, _v: string, { setModel }: any) => {
setModel('to', '');
setModel('method', '');
setModel('codeId', '');
setModel('dataSourceMethod', []);
},
};
return { ...defaultActionTypeConfig, ...config.actionTypeConfig };
};
/** 联动组件 */
const createTargetCompConfig = (config: EventSelectConfig) => {
const defaultTargetCompConfig: UISelectConfig = {
name: 'to',
text: '联动组件',
type: 'ui-select',
labelPosition: 'left',
display: (_mForm, { model }) => model.actionType === ActionType.COMP,
onChange: (_mForm, _v, { setModel }) => {
setModel('method', '');
},
rules: [
{
typeMatch: true,
trigger: 'blur',
},
],
};
return { ...defaultTargetCompConfig, ...config.targetCompConfig };
};
/** 联动组件动作 */
const createCompActionConfig = (config: EventSelectConfig) => {
const defaultCompActionConfig: DynamicTypeConfig = {
name: 'method',
text: '动作',
labelPosition: 'left',
type: (_mForm: FormState | undefined, { model }: any) => {
const to = editorService.getNodeById(model.to);
if (to?.type === 'page-fragment-container' && to.pageFragmentId) {
return 'cascader';
}
return 'select';
},
checkStrictly: () => config.src !== 'component',
display: (_mForm: FormState | undefined, { model }: any) => model.actionType === ActionType.COMP,
options: (_mForm: FormState, { model }: any) => getCompActionOptions(model.to),
rules: [
{
trigger: 'blur',
validator: ({ value, callback }: any, { model }: any) => {
const allowedMethods = getCompActionAllowedValues(config as any, model);
const normalized = normalizeCompActionValue(value);
if (allowedMethods && allowedMethods.size > 0 && normalized && !allowedMethods.has(normalized)) {
return callback(`动作名(${normalized})不存在`);
}
callback();
},
},
],
};
return { ...defaultCompActionConfig, ...config.compActionConfig };
};
/** 代码联动 */
const createCodeActionConfig = (config: EventSelectConfig) => {
const defaultCodeActionConfig: CodeSelectColConfig = {
type: 'code-select-col',
text: '代码块',
name: 'codeId',
notEditable: () => !codeBlockService.getEditStatus(),
display: (_mForm, { model }) => model.actionType === ActionType.CODE,
};
return { ...defaultCodeActionConfig, ...config.codeActionConfig };
};
/** 数据源联动 */
const createDataSourceActionConfig = (config: EventSelectConfig) => {
const defaultDataSourceActionConfig: DataSourceMethodSelectConfig = {
type: 'data-source-method-select',
text: '数据源方法',
name: 'dataSourceMethod',
notEditable: () => !dataSourceService.get('editable'),
display: (_mForm, { model }) => model.actionType === ActionType.DATA_SOURCE,
};
return { ...defaultDataSourceActionConfig, ...config.dataSourceActionConfig };
};
/** 单张事件卡片里的动作组配置 */
export const createActionsConfig = (config: EventSelectConfig): PanelConfig =>
defineFormItem({
type: 'panel',
labelPosition: 'left',
items: [
{
type: 'group-list',
name: 'actions',
expandAll: true,
enableToggleMode: false,
titlePrefix: '动作',
labelPosition: 'left',
items: [
createActionTypeConfig(config),
createTargetCompConfig(config),
createCompActionConfig(config),
createCodeActionConfig(config),
createDataSourceActionConfig(config),
],
},
],
}) as PanelConfig;
/** 兼容旧数据格式(事件列表里没有 actions时渲染的表格配置本身不带校验规则 */
export const createLegacyTableConfig = (config: EventSelectConfig): TableConfig =>
defineFormItem({
type: 'table',
name: 'events',
items: [
{
name: 'name',
label: '事件名',
type: createEventNameConfig(config).type,
options: (_mForm: FormState, { formValue }: any) =>
eventsService
.getEvent(formValue.type, { node: editorService.getNodeById(formValue.id) })
.map((option: any) => ({
text: option.label,
value: option.value,
})),
},
{
name: 'to',
label: '联动组件',
type: 'ui-select',
},
{
name: 'method',
label: '动作',
type: createCompActionConfig(config).type,
options: (_mForm: FormState, { model, formValue }: any) => {
const node = editorService.getNodeById(model.to) || (formValue as MNode);
if (!node?.type) return [];
return eventsService.getMethod(node.type, { targetId: model.to, node }).map((option: any) => ({
text: option.label,
value: option.value,
}));
},
},
],
}) as TableConfig;
/** 事件列表是否为旧数据格式(列表项里没有 actions */
export const isLegacyEventValue = (events: any): boolean => {
if (!Array.isArray(events) || events.length === 0) return false;
return !has(events[0], 'actions');
};

View File

@ -0,0 +1,163 @@
/*
* 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 DisplayCondsConfig,
type EventSelectConfig,
type FieldNestedConfig,
filterFunction,
type FormItemConfig,
type HeadlessFieldOptions,
} from '@tmagic/form/headless';
import type { StyleSchema } from '@tmagic/schema';
import { editorTypeMatchRules, validateDataSourceFieldSelect } from '@editor/utils/type-match-rules';
import { createCodeSelectConfig, normalizeCodeSelectValue } from './configs/codeSelect';
import { createDisplayCondsConfig } from './configs/displayConds';
import { createActionsConfig, createEventNameConfig, isLegacyEventValue } from './configs/eventSelect';
import { createStyleSetterConfig } from './StyleSetter/configs';
const getName = (config: FormItemConfig): string => `${(config as any).name ?? ''}`;
/**
* `code-select`
*
* `fields/CodeSelect.vue`
* `<MContainer :config="codeConfig" :model="model[name]" :prop="prop">`
*
* @param ctx -
* @returns config / model / prop
*/
const codeSelectNestedConfig: FieldNestedConfig = ({ config, model, prop }) => {
const name = getName(config);
// 组件在 watch(immediate) 里做的旧数据兼容,发生在校验之前
normalizeCodeSelectValue(model, name);
return {
config: createCodeSelectConfig(config as any),
model: model?.[name],
prop,
};
};
/**
* `display-conds`
*
* `fields/DisplayConds.vue`
* `<MGroupList :config="config" :name="name" :model="model" :prop="prop">`
*
* @param ctx -
* @returns group-list prop parentProp name
*/
const displayCondsNestedConfig: FieldNestedConfig = ({ config, model, prop, parentProp, mForm }) => {
const name = getName(config);
const parentFields =
filterFunction<string[]>(mForm, (config as DisplayCondsConfig).parentFields, {
model,
config,
prop,
}) || [];
return {
config: createDisplayCondsConfig(config as DisplayCondsConfig, name, parentFields),
// 内部 group-list 复用了字段自身的 nameprop 基准要退回父级,否则 name 会被拼两次
prop: parentProp,
};
};
/**
* `event-select`
*
* `fields/EventSelect.vue` `v-for`
* `<MFormContainer>` `<MPanel>``:prop` `${prop}.${index}`
* group-list `v-for`
*
* @param ctx -
* @returns group-list null
*/
const eventSelectNestedConfig: FieldNestedConfig = ({ config, model, parentProp }) => {
const name = getName(config);
const events = model?.[name];
// 旧数据格式走的是另一套表格配置,其中不含任何 rules不参与校验
if (!Array.isArray(events) || isLegacyEventValue(events)) return null;
return {
config: {
type: 'group-list',
name,
items: [createEventNameConfig(config as EventSelectConfig), createActionsConfig(config as EventSelectConfig)],
} as any as FormItemConfig,
prop: parentProp,
};
};
/**
* `style-setter`
*
* `fields/StyleSetter/Index.vue`6 `:values="model[name]"``:prop="prop || name"`
* `theme` `useTheme` flexWrap UI childType
*
* @param ctx -
* @returns style styleModel
*/
const styleSetterNestedConfig: FieldNestedConfig = ({ config, model, prop }) => {
const name = getName(config);
const styleModel = (model?.[name] ?? {}) as Partial<StyleSchema>;
return {
config: createStyleSetterConfig(styleModel, '', validateDataSourceFieldSelect),
model: styleModel,
prop,
};
};
/**
* Vue
*
* Node `registerFields(editorFields)` `validateForm` / `submitForm`
* plugin `component` `@tmagic/form`
*
* - UI MForm / MFormBox
* - nested MContainer / MPanel / MGroupList
* - typeMatch type
*
* nested config / model / prop
* `fields/configs/`
*/
export const editorFields: Record<string, HeadlessFieldOptions> = {
'vs-code': {},
'code-link': {},
'ui-select': { typeMatch: editorTypeMatchRules['ui-select'] },
'cond-op-select': { typeMatch: editorTypeMatchRules['cond-op-select'] },
'page-fragment-select': { typeMatch: editorTypeMatchRules['page-fragment-select'] },
'data-source-select': { typeMatch: editorTypeMatchRules['data-source-select'] },
'data-source-input': { typeMatch: editorTypeMatchRules['data-source-input'] },
'key-value': { typeMatch: editorTypeMatchRules['key-value'] },
'code-select-col': { typeMatch: editorTypeMatchRules['code-select-col'] },
'data-source-fields': { typeMatch: editorTypeMatchRules['data-source-fields'] },
'data-source-mocks': { typeMatch: editorTypeMatchRules['data-source-mocks'] },
'data-source-methods': { typeMatch: editorTypeMatchRules['data-source-methods'] },
'data-source-method-select': { typeMatch: editorTypeMatchRules['data-source-method-select'] },
'data-source-field-select': { typeMatch: editorTypeMatchRules['data-source-field-select'] },
'code-select': { nested: codeSelectNestedConfig, typeMatch: editorTypeMatchRules['code-select'] },
'display-conds': { nested: displayCondsNestedConfig, typeMatch: editorTypeMatchRules['display-conds'] },
'event-select': { nested: eventSelectNestedConfig, typeMatch: editorTypeMatchRules['event-select'] },
'style-setter': { nested: styleSetterNestedConfig, typeMatch: editorTypeMatchRules['style-setter'] },
};

View File

@ -0,0 +1,33 @@
/*
* 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.
*/
/**
* @fileoverview `@tmagic/editor/headless`
*
* Editor.vuemonaco DOM Node import
* `@tmagic/form/headless``registerBuiltInFields(builtInFields); registerFields(editorFields);`
*
* Vue `style-setter` UI
* `fields/StyleSetter/configs.ts` `icon` / `component`
* SFCtypeMatch editor services
* import 访 DOM `@tmagic/form/headless`
*
* @module @tmagic/editor/headless
*/
export { editorFields } from './fields/headless-validation';

View File

@ -91,4 +91,6 @@ export { default as DisplayConds } from './fields/DisplayConds.vue';
export { default as CondOpSelect } from './fields/CondOpSelect.vue';
export { default as StyleSetter } from './fields/StyleSetter/Index.vue';
export { editorFields } from './fields/headless-validation';
export { default } from './plugin';

View File

@ -158,17 +158,16 @@ const saveCode = async (values: any) => {
return;
}
// MForm
//
//
//
// 使
//
// display / rules formState services extendState
try {
const error = await validateForm({
config: props.config,
typeMatchValid: true,
initValues: newValues,
// provides Editor services / codeOptions provide
// appContext使 MForm DataSourceInput inject
appContext: internalInstance?.appContext ? { ...internalInstance?.appContext, provides: { services } } : null,
extendState: (state) => {
if (configFormRef.value?.formState) {
return { ...(configFormRef.value?.formState || {}) };
@ -187,9 +186,10 @@ const saveCode = async (values: any) => {
emit('submit', newValues, undefined, error ? new Error(error) : undefined);
} catch (e: any) {
console.log('validateForm error', e);
// 退
emit('submit', newValues);
console.error('validateForm error', e);
// / initValue
// error
emit('submit', newValues, undefined, e instanceof Error ? e : new Error(String(e)));
}
};

View File

@ -20,8 +20,8 @@ import { type App } from 'vue';
import type { DesignPluginOptions } from '@tmagic/design';
import designPlugin from '@tmagic/design';
import type { FormInstallOptions } from '@tmagic/form';
import formPlugin, { registerSilentLeafFieldTypes, registerTypeMatchRules } from '@tmagic/form';
import type { FieldOptions, FormInstallOptions } from '@tmagic/form';
import formPlugin, { mergeFieldOptions } from '@tmagic/form';
import tablePlugin from '@tmagic/table';
import Code from './fields/Code.vue';
@ -38,13 +38,13 @@ import DataSourceMocks from './fields/DataSourceMocks.vue';
import DataSourceSelect from './fields/DataSourceSelect.vue';
import DisplayConds from './fields/DisplayConds.vue';
import EventSelect from './fields/EventSelect.vue';
import { editorFields } from './fields/headless-validation';
import KeyValue from './fields/KeyValue.vue';
import PageFragmentSelect from './fields/PageFragmentSelect.vue';
import StyleSetter from './fields/StyleSetter/Index.vue';
import uiSelect from './fields/UISelect.vue';
import CodeEditor from './layouts/CodeEditor.vue';
import { setEditorConfig } from './utils/config';
import { editorTypeMatchRules } from './utils/type-match-rules';
import Editor from './Editor.vue';
import type { EditorInstallOptions } from './type';
@ -60,44 +60,43 @@ const defaultInstallOpt: EditorInstallOptions = {
flat: false,
};
const editorFieldVue: Record<string, Pick<FieldOptions, 'component' | 'container'>> = {
'vs-code': { component: Code },
'ui-select': { component: uiSelect },
'cond-op-select': { component: CondOpSelect },
'page-fragment-select': { component: PageFragmentSelect },
'data-source-select': { component: DataSourceSelect },
'data-source-input': { component: DataSourceInput },
'code-link': { component: CodeLink },
'key-value': { component: KeyValue },
'code-select-col': { component: CodeSelectCol },
'data-source-fields': { component: DataSourceFields },
'data-source-mocks': { component: DataSourceMocks },
'data-source-methods': { component: DataSourceMethods },
'data-source-method-select': { component: DataSourceMethodSelect },
'data-source-field-select': { component: DataSourceFieldSelect },
'code-select': { component: CodeSelect },
'display-conds': { component: DisplayConds },
'event-select': { component: EventSelect },
'style-setter': { container: StyleSetter },
};
export default {
install: (app: App, opt?: Partial<EditorInstallOptions | DesignPluginOptions | FormInstallOptions>): void => {
const option = Object.assign(defaultInstallOpt, opt || {});
const incoming = opt || {};
const option = { ...defaultInstallOpt, ...incoming };
const formOpt = incoming as FormInstallOptions;
app.use(designPlugin, opt || {});
app.use(formPlugin, opt || {});
app.use(designPlugin, incoming);
app.use(formPlugin, {
...formOpt,
fields: mergeFieldOptions(editorFields, editorFieldVue, formOpt.fields),
});
app.use(tablePlugin);
registerTypeMatchRules(editorTypeMatchRules);
registerSilentLeafFieldTypes([
'vs-code',
'ui-select',
'cond-op-select',
'page-fragment-select',
'data-source-select',
'data-source-input',
]);
app.config.globalProperties.$TMAGIC_EDITOR = option;
setEditorConfig(option);
app.component(`${Editor.name || 'MEditor'}`, Editor);
app.component('magic-code-editor', CodeEditor);
app.component('m-fields-ui-select', uiSelect);
app.component('m-fields-code-link', CodeLink);
app.component('m-fields-vs-code', Code);
app.component('m-fields-code-select', CodeSelect);
app.component('m-fields-code-select-col', CodeSelectCol);
app.component('m-fields-event-select', EventSelect);
app.component('m-fields-data-source-fields', DataSourceFields);
app.component('m-fields-data-source-mocks', DataSourceMocks);
app.component('m-fields-key-value', KeyValue);
app.component('m-fields-data-source-input', DataSourceInput);
app.component('m-fields-data-source-select', DataSourceSelect);
app.component('m-fields-data-source-methods', DataSourceMethods);
app.component('m-fields-data-source-method-select', DataSourceMethodSelect);
app.component('m-fields-data-source-field-select', DataSourceFieldSelect);
app.component('m-fields-page-fragment-select', PageFragmentSelect);
app.component('m-fields-display-conds', DisplayConds);
app.component('m-fields-cond-op-select', CondOpSelect);
app.component('m-form-style-setter', StyleSetter);
},
};

View File

@ -18,9 +18,14 @@
import type { DataSourceFieldType, DataSourceSchema, Id } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import { appendValidateSuggestion } from '@tmagic/design';
import type { TypeMatchValidateContext, TypeMatchValidator } from '@tmagic/form';
import { MAX_SUGGESTION_OPTIONS, optionSuggestion, stringifyExampleValue, validateTypeMatch } from '@tmagic/form';
import { appendValidateSuggestion } from '@tmagic/design/headless';
import type { TypeMatchValidateContext, TypeMatchValidator } from '@tmagic/form/headless';
import {
MAX_SUGGESTION_OPTIONS,
optionSuggestion,
stringifyExampleValue,
validateTypeMatch,
} from '@tmagic/form/headless';
import {
DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX,
DATA_SOURCE_SET_DATA_METHOD_NAME,

View File

@ -7,8 +7,6 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
import { FORM_SILENT_MODE_KEY } from '@tmagic/form';
import Code from '@editor/fields/Code.vue';
// 用一个简单的桩组件代替 MagicCodeEditor把所有 props 原样渲染到 data-* 属性上,
@ -171,28 +169,6 @@ describe('Code', () => {
});
});
describe('静默模式', () => {
test('注入 FORM_SILENT_MODE_KEY=true 时不渲染 CodeEditor', () => {
const wrapper = mountCode(
{},
{
provide: { [FORM_SILENT_MODE_KEY as symbol]: true },
},
);
expect(wrapper.find('.fake-code-editor').exists()).toBe(false);
});
test('注入 FORM_SILENT_MODE_KEY=false 时正常渲染 CodeEditor', () => {
const wrapper = mountCode(
{},
{
provide: { [FORM_SILENT_MODE_KEY as symbol]: false },
},
);
expect(wrapper.find('.fake-code-editor').exists()).toBe(true);
});
});
describe('diff 模式 (对比)', () => {
test('isCompare=true 且有 lastValues 时切换为 diff 模式', () => {
const wrapper = mountCode({

View File

@ -9,19 +9,22 @@ import { mount } from '@vue/test-utils';
import CodeSelect from '@editor/fields/CodeSelect.vue';
const dataSourceService = {
get: vi.fn(() => true),
getDataSourceById: vi.fn(() => ({ title: 'DS1' })),
};
const codeBlockService = {
getCodeContentById: vi.fn(() => ({ name: 'code-name' })),
getEditStatus: vi.fn(() => true),
};
vi.mock('@editor/hooks/use-services', () => ({
useServices: () => ({ dataSourceService, codeBlockService }),
// 表单配置由 fields/configs/codeSelect.ts 产出(组件与无渲染校验的嵌套配置共用),
// 那里直接用服务单例,因此这里 mock 服务模块本身
const { dataSourceService, codeBlockService } = vi.hoisted(() => ({
dataSourceService: {
get: vi.fn(() => true),
getDataSourceById: vi.fn(() => ({ title: 'DS1' })),
},
codeBlockService: {
getCodeContentById: vi.fn(() => ({ name: 'code-name' })),
getEditStatus: vi.fn(() => true),
},
}));
vi.mock('@editor/services/dataSource', () => ({ default: dataSourceService }));
vi.mock('@editor/services/codeBlock', () => ({ default: codeBlockService }));
vi.mock('@tmagic/form', async (importOriginal) => {
const actual = await importOriginal<any>();
return {

View File

@ -7,8 +7,6 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
import { FORM_SILENT_MODE_KEY } from '@tmagic/form';
import CodeSelectCol from '@editor/fields/CodeSelectCol.vue';
const codeBlockService = {
@ -115,16 +113,6 @@ describe('CodeSelectCol', () => {
expect(wrapper.find('.fake-params').exists()).toBe(true);
});
test('静默模式跳过选择器和编辑按钮但保留 CodeParams', () => {
const wrapper = mount(CodeSelectCol, {
props: baseProps() as any,
global: { provide: { [FORM_SILENT_MODE_KEY as symbol]: true } },
});
expect(wrapper.findComponent({ name: 'MSelect' }).exists()).toBe(false);
expect(wrapper.find('button').exists()).toBe(false);
expect(wrapper.find('.fake-params').exists()).toBe(true);
});
test('选择无 params 的代码块不渲染 CodeParams', () => {
const wrapper = mount(CodeSelectCol, { props: baseProps({ model: { codeId: 'c2', params: {} } }) as any });
expect(wrapper.find('.fake-params').exists()).toBe(false);

View File

@ -7,8 +7,6 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
import { FORM_SILENT_MODE_KEY } from '@tmagic/form';
import FieldSelect from '@editor/fields/DataSourceFieldSelect/FieldSelect.vue';
import DSFSIndex from '@editor/fields/DataSourceFieldSelect/Index.vue';
@ -198,16 +196,6 @@ describe('DataSourceFieldSelect Index', () => {
expect(wrapper.findAll('.fake-cascader').length).toBeGreaterThanOrEqual(1);
});
test('静默模式跳过内部 FieldSelect', () => {
const wrapper = mount(DSFSIndex, {
props: { config: {}, model: { v: [] }, name: 'v' } as any,
global: { provide: { [FORM_SILENT_MODE_KEY as symbol]: true } },
});
expect(wrapper.findAll('.fake-cascader').length).toBe(0);
expect(wrapper.find('.fake-btn').exists()).toBe(false);
expect(wrapper.find('fake-form-field').exists()).toBe(false);
});
test('toggle showDataSourceFieldSelect', async () => {
const wrapper = mount(DSFSIndex, {
props: {

View File

@ -7,8 +7,6 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { FORM_SILENT_MODE_KEY } from '@tmagic/form';
import DataSourceFields from '@editor/fields/DataSourceFields.vue';
const { messageBoxConfirm, messageError } = vi.hoisted(() => ({
@ -134,14 +132,6 @@ describe('DataSourceFields', () => {
expect(wrapper.findAll('.fake-btn').length).toBeGreaterThanOrEqual(2);
});
test('静默模式不渲染编辑浮窗', () => {
const wrapper = mount(DataSourceFields, {
props: { config: {}, model: { fields: [] }, name: 'fields', prop: 'fields' } as any,
global: { provide: { [FORM_SILENT_MODE_KEY as symbol]: true } },
});
expect(wrapper.findAll('.fake-floating')).toHaveLength(0);
});
test('点击新增字段添加', async () => {
const model: any = { fields: [] };
const wrapper = mount(DataSourceFields, {

View File

@ -7,8 +7,6 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
import { FORM_SILENT_MODE_KEY } from '@tmagic/form';
import DataSourceMethodSelect from '@editor/fields/DataSourceMethodSelect.vue';
const dataSourceService = {
@ -139,16 +137,6 @@ describe('DataSourceMethodSelect', () => {
expect(wrapper.find('.fake-params').exists()).toBe(true);
});
test('静默模式跳过级联选择器和编辑按钮但保留 CodeParams', () => {
const wrapper = mount(DataSourceMethodSelect, {
props: baseProps() as any,
global: { provide: { [FORM_SILENT_MODE_KEY as symbol]: true } },
});
expect(wrapper.findComponent({ name: 'MCascader' }).exists()).toBe(false);
expect(wrapper.find('button').exists()).toBe(false);
expect(wrapper.find('.fake-params').exists()).toBe(true);
});
test('onChangeHandler emit change 包含 changeRecords', async () => {
dataSourceService.getDataSourceById.mockReturnValue({ id: 'ds1', methods: [] });
const wrapper = mount(DataSourceMethodSelect, { props: baseProps() as any });

View File

@ -7,8 +7,6 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import { FORM_SILENT_MODE_KEY } from '@tmagic/form';
import DataSourceMethods from '@editor/fields/DataSourceMethods.vue';
const { messageBoxConfirm, codeBlockEditorShow, codeBlockEditorHide } = vi.hoisted(() => ({
@ -111,26 +109,6 @@ describe('DataSourceMethods.vue', () => {
expect(codeBlockEditorShow).toHaveBeenCalled();
});
test('静默模式不根据注入的方法名打开编辑器', async () => {
const wrapper = mount(DataSourceMethods, {
props: {
name: 'methods',
prop: 'methods',
config: {} as any,
model: { methods: [{ name: 'm1', content: 'function () {}' }] } as any,
} as any,
global: {
provide: {
[FORM_SILENT_MODE_KEY as symbol]: true,
editingDataSourceMethodName: { value: 'm1' },
},
},
});
await nextTick();
expect(wrapper.find('.fake-code-block-editor').exists()).toBe(false);
expect(codeBlockEditorShow).not.toHaveBeenCalled();
});
test('编辑 action - method.content 是 string', async () => {
const wrapper = mount(DataSourceMethods, {
props: {

View File

@ -7,8 +7,6 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { FORM_SILENT_MODE_KEY } from '@tmagic/form';
import DataSourceMocks from '@editor/fields/DataSourceMocks.vue';
const { messageBoxConfirm } = vi.hoisted(() => ({ messageBoxConfirm: vi.fn(async () => true) }));
@ -116,14 +114,6 @@ describe('DataSourceMocks', () => {
expect(wrapper.find('.fake-add-btn').exists()).toBe(true);
});
test('静默模式不渲染编辑浮窗', () => {
const wrapper = mount(DataSourceMocks, {
props: { config: {}, model: { mocks: [] }, name: 'mocks' } as any,
global: { provide: { [FORM_SILENT_MODE_KEY as symbol]: true } },
});
expect(wrapper.find('.fake-floating').exists()).toBe(false);
});
test('点击添加按钮显示 dialog', async () => {
const wrapper = mount(DataSourceMocks, {
props: { config: {}, model: { mocks: [] }, name: 'mocks' } as any,

View File

@ -9,14 +9,14 @@ import { mount } from '@vue/test-utils';
import DisplayConds from '@editor/fields/DisplayConds.vue';
const dataSourceService = {
getDataSourceById: vi.fn(),
};
vi.mock('@editor/hooks/use-services', () => ({
useServices: () => ({ dataSourceService }),
// 表单配置由 fields/configs/displayConds.ts 产出(组件与无渲染校验的嵌套配置共用),
// 那里直接用服务单例,因此这里 mock 服务模块本身
const { dataSourceService } = vi.hoisted(() => ({
dataSourceService: { getDataSourceById: vi.fn() },
}));
vi.mock('@editor/services/dataSource', () => ({ default: dataSourceService }));
const { fieldTypeMock } = vi.hoisted(() => ({
fieldTypeMock: vi.fn((_ds: any, names: string[]) => {
const key = names?.[0];

View File

@ -7,8 +7,6 @@ import { describe, expect, test, vi } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import { FORM_SILENT_MODE_KEY } from '@tmagic/form';
import KeyValue from '@editor/fields/KeyValue.vue';
vi.mock('@tmagic/design', () => ({
@ -126,17 +124,6 @@ describe('KeyValue', () => {
expect(wrapper.find('.code-editor').exists()).toBe(true);
});
test('静默模式跳过高级代码编辑器', () => {
const wrapper = mount(KeyValue, {
props: baseProps({
config: { advanced: true, type: 'key-value' },
model: { kv: () => null },
}) as any,
global: { provide: { [FORM_SILENT_MODE_KEY as symbol]: true } },
});
expect(wrapper.find('.code-editor').exists()).toBe(false);
});
test('CodeEditor save emit change', async () => {
const wrapper = mount(KeyValue, {
props: baseProps({

View File

@ -0,0 +1,307 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent.
*/
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
import { computed } from 'vue';
// 走 @form 源码别名而非 @tmagic/form编辑器包在单测里解析到的是 form 的构建产物
import {
builtInFields,
clearFields,
collectValidatableFields,
createHeadlessFormState,
registerBuiltInFields,
registerFields,
} from '@form/index';
import { NODE_CONDS_KEY } from '@tmagic/core';
import { editorFields } from '@editor/fields/headless-validation';
import { fillConfig } from '@editor/utils/props';
/**
* config
* prop FormItem
* async-validator
*/
const collect = (config: any[], values: any, typeMatchValid = true) => {
const formState = createHeadlessFormState({ config, initValues: values });
formState.values = values;
const fields = collectValidatableFields(
formState,
config,
values,
computed(() => typeMatchValid),
);
return { props: fields.map((field) => field.prop) };
};
beforeAll(() => {
registerBuiltInFields(builtInFields);
registerFields(editorFields);
});
afterAll(() => {
clearFields();
});
describe('editorFields', () => {
test('不含 Vue 组件,供 Node 侧 registerFields', () => {
for (const [type, options] of Object.entries(editorFields)) {
expect(Object.keys(options), type).not.toContain('component');
}
});
});
describe('code-select', () => {
test('遍历内部钩子列表,路径为 <prop>.hookData.<index>.<name>', () => {
const config = [{ type: 'code-select', name: 'created', text: '钩子' }];
const values = {
created: {
hookType: 'code',
hookData: [
{ codeType: 'code', codeId: 'c1' },
{ codeType: 'dataSourceMethod', codeId: ['ds1', 'doFetch'] },
],
},
};
const { props } = collect(config, values);
expect(props).toEqual([
// typeMatchValid 会给字段自身补一条 typeMatch 规则,与渲染式一样有一个 FormItem
'created',
'created.hookData.0.codeType',
// codeType 决定第二列走代码块还是数据源方法,两者共用 codeId
'created.hookData.0.codeId',
'created.hookData.1.codeType',
'created.hookData.1.codeId',
]);
});
test('空值按组件的旧数据兼容改写为 { hookType, hookData }', () => {
const config = [{ type: 'code-select', name: 'created' }];
const values: any = { created: [] };
collect(config, values);
expect(values.created).toEqual({ hookType: 'code', hookData: [] });
});
test('嵌套在容器里时带上父级路径', () => {
const config = [
{
type: 'panel',
name: 'hooks',
items: [{ type: 'code-select', name: 'created' }],
},
];
const values = { hooks: { created: { hookType: 'code', hookData: [{ codeType: 'code', codeId: 'c1' }] } } };
expect(collect(config, values).props).toEqual([
'hooks.created',
'hooks.created.hookData.0.codeType',
'hooks.created.hookData.0.codeId',
]);
});
});
describe('display-conds', () => {
test('展开条件列表,路径为 <prop>.<index>.cond.<index>.<name>', () => {
const config = [{ type: 'display-conds', name: 'displayConds' }];
const values = {
displayConds: [
{
cond: [
{ field: [], op: '', value: '' },
{ field: ['a'], op: 'between', range: [] },
],
},
],
};
const { props } = collect(config, values);
expect(props).toEqual([
'displayConds',
'displayConds.0.cond.0.field',
'displayConds.0.cond.0.op',
'displayConds.0.cond.0.value',
'displayConds.0.cond.1.field',
'displayConds.0.cond.1.op',
// op 为 between 时值列切到区间字段
'displayConds.0.cond.1.range',
]);
});
test('内部 group-list 复用字段自身的 name路径不会把 name 拼两次', () => {
const config = [
{
type: 'panel',
name: 'cond',
items: [{ type: 'display-conds', name: 'displayConds' }],
},
];
const values = { cond: { displayConds: [{ cond: [{ field: [], op: '' }] }] } };
expect(collect(config, values).props).toEqual([
'cond.displayConds',
'cond.displayConds.0.cond.0.field',
'cond.displayConds.0.cond.0.op',
'cond.displayConds.0.cond.0.value',
]);
});
});
describe('event-select', () => {
test('展开事件卡片与动作组,路径为 <prop>.<index>.actions.<index>.<name>', () => {
const config = [{ type: 'event-select', name: 'events' }];
const values = {
events: [
{
name: 'click',
actions: [{ actionType: 'comp', to: 'node_1', method: 'show' }],
},
],
};
const { props } = collect(config, values);
expect(props).toEqual([
'events',
'events.0.name',
'events.0.actions.0.actionType',
'events.0.actions.0.to',
'events.0.actions.0.method',
]);
});
test('动作类型为代码 / 数据源时按 display 切换到对应字段', () => {
const config = [{ type: 'event-select', name: 'events' }];
const values = {
events: [
{ name: 'click', actions: [{ actionType: 'code', codeId: '' }] },
{ name: 'click', actions: [{ actionType: 'data-source', dataSourceMethod: [] }] },
],
};
expect(collect(config, values).props).toEqual([
'events',
'events.0.name',
'events.0.actions.0.actionType',
'events.0.actions.0.codeId',
'events.1.name',
'events.1.actions.0.actionType',
'events.1.actions.0.dataSourceMethod',
]);
});
test('旧数据格式(列表项没有 actions不含校验规则不产出字段', () => {
const config = [{ type: 'event-select', name: 'events' }];
const values = { events: [{ name: 'click', to: 'node_1', method: 'show' }] };
const { props } = collect(config, values);
// 只剩字段自身那条自动补上的 typeMatch 规则
expect(props).toEqual(['events']);
});
});
describe('style-setter', () => {
const styleField = { type: 'style-setter', name: 'style' };
test('按面板顺序遍历内部字段,路径挂在 style 下', () => {
const values = { style: { transform: {} } };
const { props } = collect([styleField], values);
expect(props).toEqual([
'style',
'style.display',
'style.width',
'style.height',
'style.overflow',
'style.opacity',
'style.position',
'style.left',
'style.top',
'style.right',
'style.bottom',
'style.zIndex',
'style.backgroundColor',
'style.backgroundImage',
'style.backgroundSize',
'style.backgroundRepeat',
'style.backgroundPosition',
'style.fontSize',
'style.lineHeight',
'style.fontWeight',
'style.color',
'style.textAlign',
'style.borderRadius',
'style.transform.rotate',
'style.transform.scale',
]);
});
test('display 为 flex 时展开主轴/辅轴/换行字段', () => {
const { props } = collect([styleField], { style: { display: 'flex' } });
expect(props).toEqual(
expect.arrayContaining(['style.flexDirection', 'style.justifyContent', 'style.alignItems', 'style.flexWrap']),
);
expect(props.indexOf('style.flexDirection')).toBeGreaterThan(props.indexOf('style.display'));
expect(props.indexOf('style.flexWrap')).toBeLessThan(props.indexOf('style.width'));
});
test('position 为 static 时不展开 left/top/right/bottom', () => {
const { props } = collect([styleField], { style: { position: 'static' } });
expect(props).toContain('style.position');
expect(props).not.toContain('style.left');
expect(props).not.toContain('style.top');
expect(props).not.toContain('style.right');
expect(props).not.toContain('style.bottom');
});
test('边框四向字段不展开Border 子组件未把 prop 传给 MContainer', () => {
const { props } = collect([styleField], { style: {} });
expect(props).not.toContain('style.borderWidth');
expect(props).not.toContain('style.borderTopWidth');
expect(props).not.toContain('style.marginTop');
});
});
describe('fillConfig 通用属性表单', () => {
test('默认注入的 tab 配置没有未知 type', () => {
const config = fillConfig([]);
const values = {
type: 'text',
id: '1',
name: '',
style: { transform: {} },
events: [],
created: { hookType: 'code', hookData: [] },
mounted: { hookType: 'code', hookData: [] },
display: { hookType: 'code', hookData: [] },
[NODE_CONDS_KEY]: [],
};
const formState = createHeadlessFormState({ config, initValues: values });
formState.values = values;
// 关掉独立样式面板,让 fillConfig 注入的 style tab 走 display从而覆盖 style-setter
(formState as any).services = { uiService: { get: (key: string) => key !== 'showStylePanel' } };
expect(() =>
collectValidatableFields(
formState,
config,
values,
computed(() => true),
),
).not.toThrow();
});
});

View File

@ -60,7 +60,7 @@ vi.mock('@tmagic/design', () => ({
return () => h('button', { class: 'fake-btn' }, slots.default?.());
},
}),
tMagicMessage: () => {},
tMagicMessage: vi.fn(),
}));
// 可控的 submitForm 实现:默认校验成功,测试可将其改为 reject 以模拟校验失败
@ -72,7 +72,7 @@ vi.mock('@tmagic/form', async () => {
const actual = await vi.importActual<any>('@tmagic/form');
return {
...actual,
// 源码保存后的静默校验走独立的 validateForm内部新建 MForm 实例),此处 mock 便于断言
// 源码保存后的静默校验走独立的 validateForm无渲染校验,不挂载组件),此处 mock 便于断言
validateForm: vi.fn((options?: any) => validateFormImpl(options)),
MForm: defineComponent({
name: 'MForm',
@ -218,7 +218,7 @@ describe('FormPanel', () => {
expect(wrapper.emitted('submit')?.[0]).toEqual([{ foo: 'bar' }]);
});
test('启用 enablePropsFormValidate 时源码保存通过新建的 MForm 做静默校验(携带 config/initValues', async () => {
test('启用 enablePropsFormValidate 时源码保存走 validateForm 静默校验(携带 config/initValues', async () => {
const validateSpy = vi.fn(async () => '');
validateFormImpl = validateSpy;
const wrapper = mount(FormPanel, {
@ -286,7 +286,9 @@ describe('FormPanel', () => {
expect((submitEvents?.[0]?.[2] as Error).message).toBe('字段A -> 必填');
});
test('启用 enablePropsFormValidate 时静默校验抛异常则退回普通提交', async () => {
test('启用 enablePropsFormValidate 时静默校验抛异常仍提交,并携带 error', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const { tMagicMessage } = await import('@tmagic/design');
validateFormImpl = async () => {
throw new Error('validate 异常');
};
@ -299,7 +301,12 @@ describe('FormPanel', () => {
await new Promise((r) => setTimeout(r, 0));
const submitEvents = wrapper.emitted('submit');
// 退回到仅携带值的提交
expect(submitEvents?.[0]).toEqual([{ foo: 'bar' }]);
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('validate 异常');
expect(consoleError).toHaveBeenCalledWith('validateForm error', expect.any(Error));
expect(tMagicMessage).not.toHaveBeenCalled();
consoleError.mockRestore();
});
});

View File

@ -15,8 +15,6 @@ vi.mock('@tmagic/form', async (importOriginal) => {
return {
...actual,
default: { install: vi.fn() },
registerSilentLeafFieldTypes: vi.fn(),
registerTypeMatchRules: vi.fn(),
};
});
vi.mock('@tmagic/table', () => ({
@ -77,22 +75,73 @@ describe('plugin install', () => {
};
test('install 调用 design/form/table 插件并注册全局组件', async () => {
const { registerSilentLeafFieldTypes, registerTypeMatchRules } = await import('@tmagic/form');
const { editorTypeMatchRules } = await import('@editor/utils/type-match-rules');
const formPlugin = (await import('@tmagic/form')).default;
const { app, components } = buildApp();
editorPlugin.install(app, { someOption: true } as any);
expect(app.use).toHaveBeenCalledTimes(3);
expect(registerTypeMatchRules).toHaveBeenCalledWith(editorTypeMatchRules);
expect(registerSilentLeafFieldTypes).toHaveBeenCalledWith([
'vs-code',
'ui-select',
'cond-op-select',
'page-fragment-select',
'data-source-select',
'data-source-input',
]);
expect(Object.keys(components).length).toBeGreaterThan(10);
const formInstall = (app.use as any).mock.calls.find((call: any[]) => call[0] === formPlugin);
expect(formInstall).toBeDefined();
const fields = formInstall[1].fields as Record<
string,
{ component?: unknown; container?: unknown; nested?: unknown; typeMatch?: unknown }
>;
expect(formInstall[1].someOption).toBe(true);
expect(Object.keys(fields)).toEqual(
expect.arrayContaining([
'vs-code',
'ui-select',
'cond-op-select',
'page-fragment-select',
'data-source-select',
'data-source-input',
'code-link',
'key-value',
'code-select-col',
'data-source-fields',
'data-source-mocks',
'data-source-methods',
'data-source-method-select',
'data-source-field-select',
'code-select',
'display-conds',
'event-select',
'style-setter',
]),
);
expect(fields['vs-code'].component).toBeDefined();
for (const type of Object.keys(fields)) {
if (type === 'style-setter') {
expect(fields[type].container, `${type} 缺少 container`).toBeDefined();
expect(fields[type].component).toBeUndefined();
continue;
}
expect(fields[type].component, `${type} 缺少 component`).toBeDefined();
}
expect(fields['code-select'].nested).toEqual(expect.any(Function));
expect(fields['code-select'].typeMatch).toEqual(expect.any(Function));
expect(fields['style-setter'].nested).toEqual(expect.any(Function));
expect(fields['ui-select'].typeMatch).toEqual(expect.any(Function));
expect(fields['vs-code'].nested).toBeUndefined();
expect(components.MEditor).toBeDefined();
expect(components['magic-code-editor']).toBeDefined();
expect(Object.keys(components)).toEqual(['MEditor', 'magic-code-editor']);
});
test('install 时调用方 fields 与 editorFields 按 type 浅合并', async () => {
const formPlugin = (await import('@tmagic/form')).default;
const { app } = buildApp();
editorPlugin.install(app, {
fields: {
'code-select': { component: { name: 'CustomCodeSelect' } },
'my-field': { component: { name: 'MyField' } },
},
} as any);
const formOpt = (app.use as any).mock.calls.find((call: any[]) => call[0] === formPlugin)[1];
expect(formOpt.fields['code-select'].component).toEqual({ name: 'CustomCodeSelect' });
expect(formOpt.fields['code-select'].nested).toEqual(expect.any(Function));
expect(formOpt.fields['code-select'].typeMatch).toEqual(expect.any(Function));
expect(formOpt.fields['my-field'].component).toEqual({ name: 'MyField' });
expect(formOpt.fields['ui-select'].component).toBeDefined();
});
test('install 不传 opt 时使用默认配置', () => {

View File

@ -17,6 +17,11 @@
"import": "./dist/es/index.js",
"require": "./dist/tmagic-form.umd.cjs"
},
"./headless": {
"types": "./types/headless.d.ts",
"import": "./dist/es/headless.js",
"require": "./dist/tmagic-form-headless.umd.cjs"
},
"./dist/style.css": {
"import": "./dist/style.css",
"require": "./dist/style.css"
@ -40,6 +45,7 @@
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@popperjs/core": "^2.11.8",
"async-validator": "^4.2.5",
"dayjs": "^1.11.21",
"lodash-es": "^4.18.1",
"sortablejs": "^1.15.7"

View File

@ -55,17 +55,9 @@ import { M_THEME_KEY, TMagicForm, tMagicMessage, tMagicMessageBox } from '@tmagi
import { setValueByKeyPath } from '@tmagic/utils';
import Container from './containers/Container.vue';
import { getConfig } from './utils/config';
import { applyExtendState, initValue } from './utils/form';
import type {
ChangeRecord,
ContainerChangeEventData,
FormConfig,
FormSlots,
FormState,
FormValue,
ValidateError,
} from './schema';
import { applyExtendState, createFormStateBase, initValue } from './utils/form';
import { formatValidateError as formatError, getTextByName as findTextByName } from './utils/validateError';
import type { ChangeRecord, ContainerChangeEventData, FormConfig, FormSlots, FormState, FormValue } from './schema';
import { FORM_DIFF_CONFIG_KEY, FORM_TYPE_MATCH_VALID_KEY } from './schema';
defineOptions({
@ -178,9 +170,6 @@ const tMagicFormRef = useTemplateRef('tMagicForm');
const initialized = ref(false);
const values = ref<FormValue>({});
const lastValuesProcessed = ref<FormValue>({});
const fields = new Map<string, any>();
const requestFuc = getConfig('request') as Function;
/**
* 当前表单生效的主题名称
@ -253,20 +242,7 @@ const formState: FormState = reactive<FormState>({
values,
lastValuesProcessed,
$emit: emit as (_event: string, ..._args: any[]) => void,
fields,
setField: (prop: string, field: any) => fields.set(prop, field),
getField: (prop: string) => fields.get(prop),
deleteField: (prop: string) => fields.delete(prop),
$messageBox: tMagicMessageBox,
$message: tMagicMessage,
post: (options: any) => {
if (requestFuc) {
return requestFuc({
method: 'POST',
...options,
});
}
},
...createFormStateBase({ $message: tMagicMessage, $messageBox: tMagicMessageBox }),
});
/**
@ -406,66 +382,17 @@ const submitHandler = (e: SubmitEvent) => {
}
};
/**
* 通过 name config 中查找对应的 text
* @param name - 字段名支持点分隔的路径格式 'a.b.c'
* @param config - 表单配置数组
* @returns 找到的 text 如果未找到则返回 undefined
*/
const getTextByName = (name: string, config: FormConfig = props.config): string | undefined => {
if (!name || !Array.isArray(config)) return undefined;
const nameParts = name.split('.');
const findInConfig = (configs: FormConfig, parts: string[]): string | undefined => {
if (parts.length === 0) return undefined;
const [currentPart, ...remainingParts] = parts;
for (const item of configs) {
if (item.name === currentPart) {
if (remainingParts.length === 0) {
return typeof item.text === 'string' ? item.text : undefined;
}
if ('items' in item && Array.isArray(item.items)) {
const result = findInConfig(item.items, remainingParts);
if (result !== undefined) return result;
}
}
if ('items' in item && Array.isArray(item.items)) {
const result = findInConfig(item.items, parts);
if (result !== undefined) return result;
}
}
return undefined;
};
return findInConfig(config, nameParts);
};
const getTextByName = (name: string, config: FormConfig = props.config): string | undefined =>
findTextByName(name, config);
/**
* 将校验返回的 invalidFields 汇总为可读的错误文案多条以 `<br>` 拼接
*
* 抽离为独立方法 `submitForm`提交校验 `validate`返回错误文案的校验复用
* 保证两种校验入口产出的错误文案格式完全一致
* 实现收口在 `utils/validateError`供渲染式校验本组件的 `submitForm` / `validate`
* 无渲染校验`validateValues`共用保证两条链路产出的错误文案格式完全一致
*/
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>');
};
const formatValidateError = (invalidFields: Record<string, any>): string =>
formatError(invalidFields, { config: props.config, useFieldTextInError: props.useFieldTextInError });
defineExpose({
values,

View File

@ -27,15 +27,7 @@
<template v-else-if="type && display && !showDiff">
<TMagicFormItem v-bind="formItemProps" :class="{ 'tmagic-form-hidden': `${itemLabelWidth}` === '0' || !text }">
<template #label>
<slot
v-if="shouldRenderLeafField"
name="label"
:config="config"
:type="type"
:text="text"
:prop="itemProp"
:disabled="disabled"
>
<slot name="label" :config="config" :type="type" :text="text" :prop="itemProp" :disabled="disabled">
<FormLabel
:tip="config.tip"
:type="type"
@ -49,25 +41,8 @@
</slot>
</template>
<!-- 静默校验时只保留 FormItem校验依赖其 prop / rules model 与字段组件实例无关 -->
<template v-if="shouldRenderLeafField">
<TMagicTooltip v-if="tooltip.text" :placement="tooltip.placement">
<component
v-bind="fieldsProps"
:is="tagName"
:model="model"
:last-values="lastValues"
:is-compare="isCompare"
@change="onChangeHandler"
@addDiffCount="onAddDiffCount"
></component>
<template #content>
<div v-html="tooltip.text"></div>
</template>
</TMagicTooltip>
<TMagicTooltip v-if="tooltip.text" :placement="tooltip.placement">
<component
v-else
v-bind="fieldsProps"
:is="tagName"
:model="model"
@ -76,13 +51,24 @@
@change="onChangeHandler"
@addDiffCount="onAddDiffCount"
></component>
</template>
<template #content>
<div v-html="tooltip.text"></div>
</template>
</TMagicTooltip>
<component
v-else
v-bind="fieldsProps"
:is="tagName"
:model="model"
:last-values="lastValues"
:is-compare="isCompare"
@change="onChangeHandler"
@addDiffCount="onAddDiffCount"
></component>
</TMagicFormItem>
<TMagicTooltip
v-if="shouldRenderLeafField && config.tip && type === 'checkbox' && !(config as CheckboxConfig).useLabel"
placement="top"
>
<TMagicTooltip v-if="config.tip && type === 'checkbox' && !(config as CheckboxConfig).useLabel" placement="top">
<TMagicIcon style="line-height: 40px; margin-left: 5px"><warning-filled /></TMagicIcon>
<template #content>
<div v-html="config.tip"></div>
@ -102,15 +88,7 @@
}"
>
<template #label>
<slot
v-if="shouldRenderLeafField"
name="label"
:config="config"
:type="type"
:text="text"
:prop="itemProp"
:disabled="disabled"
>
<slot name="label" :config="config" :type="type" :text="text" :prop="itemProp" :disabled="disabled">
<FormLabel
:tip="config.tip"
:type="type"
@ -120,23 +98,9 @@
></FormLabel>
</slot>
</template>
<template v-if="shouldRenderLeafField">
<TMagicTooltip v-if="tooltip.text" :placement="tooltip.placement">
<component
v-bind="fieldsProps"
:is="tagName"
:model="model"
:last-values="lastValues"
:is-compare="isCompare"
@change="onChangeHandler"
></component>
<template #content>
<div v-html="tooltip.text"></div>
</template>
</TMagicTooltip>
<TMagicTooltip v-if="tooltip.text" :placement="tooltip.placement">
<component
v-else
v-bind="fieldsProps"
:is="tagName"
:model="model"
@ -144,7 +108,20 @@
:is-compare="isCompare"
@change="onChangeHandler"
></component>
</template>
<template #content>
<div v-html="tooltip.text"></div>
</template>
</TMagicTooltip>
<component
v-else
v-bind="fieldsProps"
:is="tagName"
:model="model"
:last-values="lastValues"
:is-compare="isCompare"
@change="onChangeHandler"
></component>
</TMagicFormItem>
<!-- 普通字段渲染前后两份独立的组件用于对比 -->
@ -155,15 +132,7 @@
:class="{ 'tmagic-form-hidden': `${itemLabelWidth}` === '0' || !text, 'show-before-diff': true }"
>
<template #label>
<slot
v-if="shouldRenderLeafField"
name="label"
:config="config"
:type="type"
:text="text"
:prop="itemProp"
:disabled="disabled"
>
<slot name="label" :config="config" :type="type" :text="text" :prop="itemProp" :disabled="disabled">
<FormLabel
:tip="config.tip"
:type="type"
@ -173,28 +142,24 @@
></FormLabel>
</slot>
</template>
<template v-if="shouldRenderLeafField">
<TMagicTooltip v-if="tooltip.text" :placement="tooltip.placement">
<component v-bind="fieldsProps" :is="tagName" :model="lastValues" @change="onChangeHandler"></component>
<template #content>
<div v-html="tooltip.text"></div>
</template>
</TMagicTooltip>
<component
v-else
v-bind="fieldsProps"
:is="tagName"
:model="lastValues"
@change="onChangeHandler"
></component>
</template>
<TMagicTooltip v-if="tooltip.text" :placement="tooltip.placement">
<component v-bind="fieldsProps" :is="tagName" :model="lastValues" @change="onChangeHandler"></component>
<template #content>
<div v-html="tooltip.text"></div>
</template>
</TMagicTooltip>
<component
v-else
v-bind="fieldsProps"
:is="tagName"
:model="lastValues"
@change="onChangeHandler"
></component>
</TMagicFormItem>
<TMagicTooltip
v-if="shouldRenderLeafField && config.tip && type === 'checkbox' && !(config as CheckboxConfig).useLabel"
placement="top"
>
<TMagicTooltip v-if="config.tip && type === 'checkbox' && !(config as CheckboxConfig).useLabel" placement="top">
<TMagicIcon style="line-height: 40px; margin-left: 5px"><warning-filled /></TMagicIcon>
<template #content>
<div v-html="config.tip"></div>
@ -208,15 +173,7 @@
:class="{ 'tmagic-form-hidden': `${itemLabelWidth}` === '0' || !text, 'show-after-diff': true }"
>
<template #label>
<slot
v-if="shouldRenderLeafField"
name="label"
:config="config"
:type="type"
:text="text"
:prop="itemProp"
:disabled="disabled"
>
<slot name="label" :config="config" :type="type" :text="text" :prop="itemProp" :disabled="disabled">
<FormLabel
:tip="config.tip"
:type="type"
@ -226,22 +183,18 @@
></FormLabel>
</slot>
</template>
<template v-if="shouldRenderLeafField">
<TMagicTooltip v-if="tooltip.text" :placement="tooltip.placement">
<component v-bind="fieldsProps" :is="tagName" :model="model" @change="onChangeHandler"></component>
<template #content>
<div v-html="tooltip.text"></div>
</template>
</TMagicTooltip>
<component v-else v-bind="fieldsProps" :is="tagName" :model="model" @change="onChangeHandler"></component>
</template>
<TMagicTooltip v-if="tooltip.text" :placement="tooltip.placement">
<component v-bind="fieldsProps" :is="tagName" :model="model" @change="onChangeHandler"></component>
<template #content>
<div v-html="tooltip.text"></div>
</template>
</TMagicTooltip>
<component v-else v-bind="fieldsProps" :is="tagName" :model="model" @change="onChangeHandler"></component>
</TMagicFormItem>
<TMagicTooltip
v-if="shouldRenderLeafField && config.tip && type === 'checkbox' && !(config as CheckboxConfig).useLabel"
placement="top"
>
<TMagicTooltip v-if="config.tip && type === 'checkbox' && !(config as CheckboxConfig).useLabel" placement="top">
<TMagicIcon style="line-height: 40px; margin-left: 5px"><warning-filled /></TMagicIcon>
<template #content>
<div v-html="config.tip"></div>
@ -310,10 +263,17 @@ import type {
FormValue,
ToolTipConfigType,
} from '../schema';
import { FORM_DIFF_CONFIG_KEY, FORM_SILENT_MODE_KEY, FORM_TYPE_MATCH_VALID_KEY } from '../schema';
import { getField } from '../utils/config';
import { createObjectProp, display as displayFunction, filterFunction, getRules } from '../utils/form';
import { getSilentLeafFieldTypes } from '../utils/silentLeafFieldTypes';
import { FORM_DIFF_CONFIG_KEY, FORM_TYPE_MATCH_VALID_KEY } from '../schema';
import {
createObjectProp,
display as displayFunction,
filterFunction,
getItemProp,
getRules,
isValidName as isValidNameOf,
resolveItemType,
} from '../utils/form';
import { getField } from '../utils/registerField';
import FormLabel from './FormLabel.vue';
@ -388,30 +348,14 @@ const showDiff = computed(() => {
const items = computed(() => (props.config as ContainerCommonConfig).items);
const itemProp = computed(() => {
let n: string | number = '';
if (name.value) {
n = name.value;
} else {
return props.prop;
}
const itemProp = computed(() => getItemProp(props.prop, name.value));
if (typeof props.prop !== 'undefined' && props.prop !== '') {
return `${props.prop}.${n}`;
}
return `${n}`;
});
const type = computed((): string => {
let type = 'type' in props.config ? props.config.type : '';
type = type && filterFunction<string>(mForm, type, props);
if (type === 'form') return '';
if (type === 'container') return '';
return type?.replace(/([A-Z])/g, '-$1').toLowerCase() || (items.value ? '' : 'text');
});
const type = computed((): string => resolveItemType(mForm, props.config, props));
const tagName = computed(() => {
// `type: 'component'` Vue
// FormItem addField MContainer
// registerField(type, { nested })
if (type.value === 'component' && (props.config as ComponentConfig).component) {
return (props.config as ComponentConfig).component;
}
@ -458,11 +402,6 @@ const effectiveSelfDiffFieldTypes = computed<Set<string>>(() => {
const isSelfDiffField = computed(() => effectiveSelfDiffFieldTypes.value.has(type.value));
// registerSilentLeafFieldTypes
const silentMode = inject(FORM_SILENT_MODE_KEY, false);
const shouldRenderLeafField = computed(() => !silentMode || !getSilentLeafFieldTypes().has(type.value));
const disabled = computed(() => props.disabled || filterFunction(mForm, props.config.disabled, props));
const text = computed(() => filterFunction(mForm, props.config.text, props));
@ -571,22 +510,7 @@ const onAddDiffCount = () => emit('addDiffCount');
const hasModifyKey = (eventDataItem: ContainerChangeEventData) =>
typeof eventDataItem?.modifyKey !== 'undefined' && eventDataItem.modifyKey !== '';
const isValidName = () => {
const valueType = typeof name.value;
if (valueType !== 'string' && valueType !== 'symbol' && valueType !== 'number') {
return false;
}
if (name.value === '') {
return false;
}
if (typeof name.value === 'number') {
return name.value >= 0;
}
return true;
};
const isValidName = () => isValidNameOf(name.value);
const createModelProxy = (
target: any,

View File

@ -99,7 +99,7 @@
:lastValues="lastValues"
:is-compare="isCompare"
:labelWidth="labelWidth"
:prop="`${prop}${prop ? '.' : ''}${String(index)}`"
:prop="rowProp"
:size="size"
:disabled="disabled"
@change="changeHandler"
@ -115,7 +115,8 @@ import { ArrowDown, ArrowRight, Bottom, Delete, DocumentCopy, Position, Top } fr
import { TMagicButton, TMagicCard, TMagicIcon, TMagicInputNumber, TMagicPopover, TMagicTooltip } from '@tmagic/design';
import type { ContainerChangeEventData, FormState, GroupListConfig } from '../schema';
import { filterFunction } from '../utils/form';
import { appendProp, filterFunction } from '../utils/form';
import { getGroupListRowConfig } from '../utils/tableGroupList';
import Container from './Container.vue';
@ -143,15 +144,9 @@ const mForm = inject<FormState | undefined>('mForm');
const defaultExpandQuantity = props.config.defaultExpandQuantity ?? 7;
const expand = ref(props.config.expandAll || defaultExpandQuantity > props.index);
const rowConfig = computed(() => ({
type: 'row',
span: props.config.span || 24,
items: props.config.items,
labelWidth: props.config.labelWidth,
[mForm?.keyProp || '__key']: `${(props.config as Record<string, any>)[mForm?.keyProp || '__key']}${String(
props.index,
)}`,
}));
const rowConfig = computed(() => getGroupListRowConfig(props.config, props.index, mForm?.keyProp));
const rowProp = computed(() => appendProp(props.prop, props.index));
const title = computed(() => {
if (props.config.titleKey && props.model[props.config.titleKey]) {

View File

@ -61,6 +61,7 @@ import { TMagicButton } from '@tmagic/design';
import type { GroupListConfig, TableConfig } from '@tmagic/form-schema';
import type { ContainerChangeEventData } from '../../schema';
import { isGroupListType, toGroupListConfig, toTableConfig } from '../../utils/tableGroupList';
import MFormGroupList from '../GroupList.vue';
import MFormTable from '../table/Table.vue';
@ -91,63 +92,13 @@ const emit = defineEmits(['change', 'select', 'addDiffCount']);
const { addable, newHandler } = useAdd(props, emit);
const isGroupListType = (type: string | undefined) => type === 'groupList' || type === 'group-list';
const displayMode = ref<'table' | 'groupList'>(isGroupListType(props.config.type) ? 'groupList' : 'table');
const calcLabelWidth = (label: string) => {
if (!label) return '0px';
const zhLength = label.match(/[^\x00-\xff]/g)?.length || 0;
const chLength = label.length - zhLength;
return `${Math.max(chLength * 8 + zhLength * 20, 80)}px`;
};
// config table table
// groupList table config
const tableConfig = computed<TableConfig>(() => {
if (!isGroupListType(props.config.type)) {
return props.config as TableConfig;
}
// groupList table config
const tableConfig = computed<TableConfig>(() => toTableConfig(props.config));
const source = props.config as GroupListConfig;
return {
...props.config,
type: 'table',
groupItems: source.items,
items:
source.tableItems ||
(source.items as any[]).map((item: any) => ({
...item,
label: item.label || item.text,
text: null,
})),
} as any as TableConfig;
});
// groupList config
const groupListConfig = computed<GroupListConfig>(() => {
if (isGroupListType(props.config.type)) {
return props.config as GroupListConfig;
}
const source = props.config as TableConfig;
return {
...props.config,
type: 'groupList',
tableItems: source.items,
items:
source.groupItems ||
(source.items as any[]).map((item: any) => {
const text = item.text || item.label;
return {
...item,
text,
labelWidth: calcLabelWidth(text),
span: item.span || 12,
};
}),
} as any as GroupListConfig;
});
const groupListConfig = computed<GroupListConfig>(() => toGroupListConfig(props.config));
// displayMode `<component :is>` any
const currentConfig = computed<any>(() => (displayMode.value === 'table' ? tableConfig.value : groupListConfig.value));

View File

@ -1,13 +1,13 @@
import { computed, h, inject, type Ref } from 'vue';
import { WarningFilled } from '@element-plus/icons-vue';
import { cloneDeep } from 'lodash-es';
import { type TableColumnOptions, TMagicIcon, TMagicTooltip } from '@tmagic/design';
import type { FormItemConfig, FormState, TableColumnConfig } from '@tmagic/form-schema';
import type { FormItemConfig, FormState } from '@tmagic/form-schema';
import type { ContainerChangeEventData } from '../../schema';
import { isGlobalFlat } from '../../utils/config';
import { display as displayFunc, getDataByPage, sortArray } from '../../utils/form';
import { appendProp, display as displayFunc, getDataByPage, sortArray } from '../../utils/form';
import { isTableColumnRendered, makeTableColumnConfig } from '../../utils/tableGroupList';
import Container from '../Container.vue';
import ActionsColumn from './ActionsColumn.vue';
@ -65,18 +65,7 @@ export const useTableColumns = (
return props.config.selection;
});
const getProp = (index: number) => {
return `${props.prop}${props.prop ? '.' : ''}${index + 1 + currentPage.value * pageSize.value - 1}`;
};
const makeConfig = (config: TableColumnConfig, row: any): TableColumnConfig => {
const newConfig = cloneDeep(config);
if (typeof config.itemsFunction === 'function') {
newConfig.items = config.itemsFunction(row);
}
delete newConfig.display;
return newConfig;
};
const getProp = (index: number) => appendProp(props.prop, index + currentPage.value * pageSize.value);
const changeHandler = (v: any, eventData: ContainerChangeEventData) => {
emit('change', props.model, eventData);
@ -185,7 +174,7 @@ export const useTableColumns = (
}
for (const column of props.config.items) {
if (column.type !== 'hidden' && display(column.display)) {
if (isTableColumnRendered(column, display)) {
const titleTipValue = titleTip(column.titleTip);
columns.push({
@ -203,7 +192,7 @@ export const useTableColumns = (
disabled: props.disabled,
prop: getProp($index),
rules: column.rules,
config: makeConfig(column, row) as FormItemConfig,
config: makeTableColumnConfig(column, row) as FormItemConfig,
model: row,
lastValues: lastData.value[$index],
isCompare: props.isCompare,

View File

@ -12,6 +12,7 @@ import { computed, inject } from 'vue';
import { TMagicCheckbox, TMagicCheckboxGroup } from '@tmagic/design';
import type { CheckboxGroupConfig, CheckboxGroupOption, FieldProps, FormState } from '../schema';
import { initCheckboxGroupValue } from '../utils/fieldValueEffects';
import { filterFunction } from '../utils/form';
import { useAddField } from '../utils/useAddField';
@ -25,10 +26,7 @@ const emit = defineEmits(['change']);
useAddField(props.prop);
//
if (props.model && !props.model[props.name]) {
props.model[props.name] = [];
}
initCheckboxGroupValue(props.model, props.name);
const changeHandler = (v: Array<string | number | boolean>) => {
emit('change', v);

View File

@ -15,7 +15,7 @@
import { TMagicDatePicker } from '@tmagic/design';
import type { DateConfig, FieldProps } from '../schema';
import { datetimeFormatter } from '../utils/form';
import { normalizeDateValue } from '../utils/fieldValueEffects';
import { useAddField } from '../utils/useAddField';
defineOptions({
@ -30,7 +30,7 @@ const emit = defineEmits<{
useAddField(props.prop);
props.model[props.name] = datetimeFormatter(props.model[props.name], '', props.config.valueFormat || 'YYYY/MM/DD');
normalizeDateValue(props.config, props.model, props.name);
const changeHandler = (v: string) => {
emit('change', v);

View File

@ -17,7 +17,7 @@
import { TMagicDatePicker } from '@tmagic/design';
import type { DateTimeConfig, FieldProps } from '../schema';
import { datetimeFormatter } from '../utils/form';
import { normalizeDateTimeValue } from '../utils/fieldValueEffects';
import { useAddField } from '../utils/useAddField';
defineOptions({
@ -32,18 +32,7 @@ const emit = defineEmits<{
useAddField(props.prop);
const value = props.model?.[props.name]?.toString();
if (props.model) {
if (!value || value === 'Invalid Date') {
props.model[props.name] = '';
} else {
props.model[props.name] = datetimeFormatter(
props.model[props.name],
'',
props.config.valueFormat || 'YYYY/MM/DD HH:mm:ss',
);
}
}
normalizeDateTimeValue(props.config, props.model, props.name);
const changeHandler = (v: string) => {
emit('change', v);

View File

@ -6,6 +6,7 @@
import { computed, inject } from 'vue';
import type { DisplayConfig, FieldProps, FormState } from '../schema';
import { applyDisplayInitValue } from '../utils/fieldValueEffects';
import { filterFunction } from '../utils/form';
import { useAddField } from '../utils/useAddField';
@ -17,9 +18,7 @@ const props = defineProps<FieldProps<DisplayConfig>>();
const mForm = inject<FormState | undefined>('mForm');
if (props.config.initValue && props.model) {
props.model[props.name] = props.config.initValue;
}
applyDisplayInitValue(props.config, props.model, props.name);
const text = computed(() => {
if (props.config.displayText) {

View File

@ -29,6 +29,7 @@ import { TMagicForm, TMagicFormItem, TMagicInput } from '@tmagic/design';
import type { DynamicFieldConfig, FieldProps } from '../schema';
import { getConfig } from '../utils/config';
import { eachDynamicField } from '../utils/fieldValueEffects';
import { useAddField } from '../utils/useAddField';
defineOptions({
@ -54,15 +55,13 @@ const changeFieldMap = async () => {
const fields = await props.config.returnFields(props.config, props.model, request);
fieldMap.value = {};
fieldLabelMap.value = {};
fields.forEach((v) => {
if (typeof v !== 'object' || v.name === undefined) return;
let oldVal = props.model?.[v.name] || '';
if (!oldVal && v.defaultValue !== undefined) {
oldVal = v.defaultValue;
emit('change', oldVal, { modifyKey: v.name });
eachDynamicField(fields, props.model, (field, value, isDefaultApplied) => {
// defaultValue
if (isDefaultApplied) {
emit('change', value, { modifyKey: field.name });
}
fieldMap.value[v.name] = oldVal;
fieldLabelMap.value[v.name] = v.label || '';
fieldMap.value[field.name] = value;
fieldLabelMap.value[field.name] = field.label || '';
});
};

View File

@ -24,6 +24,7 @@ import { ref, watch } from 'vue';
import { TMagicInput } from '@tmagic/design';
import type { FieldProps, NumberRangeConfig } from '../schema';
import { normalizeNumberRangeValue } from '../utils/fieldValueEffects';
import { useAddField } from '../utils/useAddField';
defineOptions({
@ -53,9 +54,7 @@ watch(
useAddField(props.prop);
if (!Array.isArray(props.model[props.name])) {
props.model[props.name] = [];
}
normalizeNumberRangeValue(props.model, props.name);
const minChangeHandler = (v: string) => {
emit('change', [Number(v), props.model[props.name][1]]);

View File

@ -0,0 +1,73 @@
/*
* 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.
*/
/**
* @fileoverview `@tmagic/form/headless` Vue
*
* Node / CI `submitForm` / `validateForm` / `registerField`
* `dialog: true` DOM `@tmagic/form`
*
* ESM `@tmagic/form`
* CJS`require` UMD bundle
* `require('@tmagic/form')` `require('@tmagic/form/headless')`
*
* @module @tmagic/form/headless
*/
export * from './schema';
export * from './utils/form';
export { builtInFields } from './utils/builtInFields';
export {
clearFields,
getField as getFormField,
mergeFieldOptions,
registerBuiltInFields,
registerField,
registerFields,
unregisterField,
} from './utils/registerField';
export type { FieldOptions, HeadlessFieldOptions } from './utils/registerField';
export type { FieldNestedConfig, FieldNestedConfigContext, FieldNestedConfigResult } from './utils/fieldNestedConfig';
export { isLeafFieldType } from './utils/fieldValueEffects';
export type { FieldMountValueEffect, FieldMountValueEffectContext } from './utils/fieldValueEffects';
export { collectValidatableFields, FieldNestedConfigError, isFieldNestedConfigError } from './utils/collectFields';
export type { CollectedField } from './utils/collectFields';
export { createHeadlessFormState, validateValues } from './utils/validateValues';
export type { HeadlessFormStateOptions, ValidateValuesOptions, ValidateValuesResult } from './utils/validateValues';
export { formatValidateError, getTextByName } from './utils/validateError';
export {
clearTypeMatchRules,
deleteTypeMatchRule,
getTypeMatchRule,
MAX_SUGGESTION_OPTIONS,
optionSuggestion,
stringifyExampleValue,
validateTypeMatch,
} from './utils/typeMatch';
export type { TypeMatchValidateContext, TypeMatchValidator } from './utils/typeMatch';
export { submitForm, validateForm } from './utils/submitHeadless';
export type { SubmitFormOptions, SubmitFormResult, ValidateFormOptions } from './utils/submitHeadless';

View File

@ -57,17 +57,31 @@ export { default as MSelect } from './fields/Select.vue';
export { default as MCascader } from './fields/Cascader.vue';
export { default as MDynamicField } from './fields/DynamicField.vue';
export {
deleteField as deleteFormField,
getField as getFormField,
registerField as registerFormField,
} from './utils/config';
export { builtInFields } from './utils/builtInFields';
export {
clearSilentLeafFieldTypes,
getSilentLeafFieldTypes,
registerSilentLeafFieldTypes,
} from './utils/silentLeafFieldTypes';
clearFields,
getField as getFormField,
mergeFieldOptions,
registerBuiltInFields,
registerField,
registerFields,
unregisterField,
} from './utils/registerField';
export type { FieldOptions, HeadlessFieldOptions } from './utils/registerField';
export type { FieldNestedConfig, FieldNestedConfigContext, FieldNestedConfigResult } from './utils/fieldNestedConfig';
export { isLeafFieldType } from './utils/fieldValueEffects';
export type { FieldMountValueEffect, FieldMountValueEffectContext } from './utils/fieldValueEffects';
export { collectValidatableFields, FieldNestedConfigError, isFieldNestedConfigError } from './utils/collectFields';
export type { CollectedField } from './utils/collectFields';
export { createHeadlessFormState, validateValues } from './utils/validateValues';
export type { HeadlessFormStateOptions, ValidateValuesOptions, ValidateValuesResult } from './utils/validateValues';
export { formatValidateError, getTextByName } from './utils/validateError';
export {
clearTypeMatchRules,
@ -75,8 +89,6 @@ export {
getTypeMatchRule,
MAX_SUGGESTION_OPTIONS,
optionSuggestion,
registerTypeMatchRule,
registerTypeMatchRules,
stringifyExampleValue,
validateTypeMatch,
} from './utils/typeMatch';

View File

@ -46,8 +46,9 @@ import Text from './fields/Text.vue';
import Textarea from './fields/Textarea.vue';
import Time from './fields/Time.vue';
import Timerange from './fields/Timerange.vue';
import { builtInFields } from './utils/builtInFields';
import { setConfig } from './utils/config';
import { registerTypeMatchRules, type TypeMatchValidator } from './utils/typeMatch';
import { type FieldOptions, registerBuiltInFields, registerFields } from './utils/registerField';
import Form from './Form.vue';
import FormDialog from './FormDialog.vue';
import FormDrawer from './FormDrawer.vue';
@ -55,59 +56,79 @@ import FormDrawer from './FormDrawer.vue';
import './theme/index.scss';
// #region FormInstallOptions
/**
* `@tmagic/form`
*/
export interface FormInstallOptions {
/** 是否启用全局 flat 模式。 */
flat?: boolean;
/** 自定义字段 type 的 typeMatch 校验规则,可覆盖内置规则或扩展业务字段 */
typeMatchRules?: Record<string, TypeMatchValidator>;
/**
* type / nested / walk / typeMatch / component / container
* `registerFields`
*/
fields?: Record<string, FieldOptions>;
[key: string]: any;
}
// #endregion FormInstallOptions
const builtInFieldVue: Record<string, Pick<FieldOptions, 'component' | 'container'>> = {
text: { component: Text },
'img-upload': { component: Text },
number: { component: Number },
'number-range': { component: NumberRange },
textarea: { component: Textarea },
hidden: { component: Hidden },
date: { component: Date },
datetime: { component: DateTime },
daterange: { component: Daterange },
timerange: { component: Timerange },
time: { component: Time },
checkbox: { component: Checkbox },
switch: { component: Switch },
'color-picker': { component: ColorPicker },
'checkbox-group': { component: CheckboxGroup },
'radio-group': { component: RadioGroup },
display: { component: Display },
link: { component: Link },
select: { component: Select },
cascader: { component: Cascader },
'dynamic-field': { component: DynamicField },
container: { container: Container },
tab: { container: Tabs },
row: { container: Row },
'flex-layout': { container: FlexLayout },
fieldset: { container: Fieldset },
panel: { container: Panel },
step: { container: MStep },
table: { container: TableGroupList },
'group-list': { container: TableGroupList },
'table-group-list': { container: TableGroupList },
};
const defaultInstallOpt: FormInstallOptions = {};
export default {
/**
* `@tmagic/form` `m-form` / `m-form-dialog` / `m-form-drawer`
*
* @param app - Vue
* @param [opt] -
*/
install(app: App, opt: FormInstallOptions = {}) {
const option = Object.assign(defaultInstallOpt, opt);
const option = { ...defaultInstallOpt, ...opt };
app.config.globalProperties.$MAGIC_FORM = option;
setConfig(option);
if (option.typeMatchRules) {
registerTypeMatchRules(option.typeMatchRules);
registerBuiltInFields(builtInFields);
registerBuiltInFields(builtInFieldVue, app);
if (option.fields) {
registerFields(option.fields, app);
}
app.component('m-form', Form);
app.component('m-form-dialog', FormDialog);
app.component('m-form-drawer', FormDrawer);
app.component('m-form-container', Container);
app.component('m-form-fieldset', Fieldset);
app.component('m-form-group-list', TableGroupList);
app.component('m-form-panel', Panel);
app.component('m-form-row', Row);
app.component('m-form-step', MStep);
app.component('m-form-table', TableGroupList);
app.component('m-form-tab', Tabs);
app.component('m-form-flex-layout', FlexLayout);
app.component('m-fields-text', Text);
app.component('m-fields-img-upload', Text);
app.component('m-fields-number', Number);
app.component('m-fields-number-range', NumberRange);
app.component('m-fields-textarea', Textarea);
app.component('m-fields-hidden', Hidden);
app.component('m-fields-date', Date);
app.component('m-fields-datetime', DateTime);
app.component('m-fields-daterange', Daterange);
app.component('m-fields-timerange', Timerange);
app.component('m-fields-time', Time);
app.component('m-fields-checkbox', Checkbox);
app.component('m-fields-switch', Switch);
app.component('m-fields-color-picker', ColorPicker);
app.component('m-fields-checkbox-group', CheckboxGroup);
app.component('m-fields-radio-group', RadioGroup);
app.component('m-fields-display', Display);
app.component('m-fields-link', Link);
app.component('m-fields-select', Select);
app.component('m-fields-cascader', Cascader);
app.component('m-fields-dynamic-field', DynamicField);
},
};

View File

@ -29,18 +29,6 @@ export interface FormDiffConfig {
export const FORM_DIFF_CONFIG_KEY: InjectionKey<FormDiffConfig> = Symbol('mFormDiffConfig');
export const FORM_TYPE_MATCH_VALID_KEY: InjectionKey<ComputedRef<boolean>> = Symbol('mFormTypeMatchValid');
/**
* `submitForm` / `validateForm` providedebug
*
* Container `getSilentLeafFieldTypes()` `LEAF_FIELD_TYPES`
* FormItem FormItem model UI
* vs-code inject
*
* onMounted/watch immediate emit change DOM
* 使 `registerSilentLeafFieldTypes`
*/
export const FORM_SILENT_MODE_KEY: InjectionKey<boolean> = Symbol('mFormSilentMode');
export interface ValidateError {
message: string;
field: string;

View File

@ -16,102 +16,26 @@
* limitations under the License.
*/
import {
type AppContext,
type Component,
createApp,
defineComponent,
h,
nextTick,
provide,
type Ref,
ref,
watch,
} from 'vue';
import { type AppContext, type Component, createApp, defineComponent, h, nextTick, type Ref, ref, watch } from 'vue';
import { applyExtendState } from './utils/form';
import {
submitForm as submitFormHeadless,
type SubmitFormOptions,
type SubmitFormResult,
validateForm as validateFormHeadless,
type ValidateFormOptions,
} from './utils/submitHeadless';
import Form from './Form.vue';
import { type ChangeRecord, FORM_SILENT_MODE_KEY, type FormConfig, type FormState } from './schema';
import { type ChangeRecord, type FormConfig } 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;
/**
* 使 text `getTextByName` config
* `true` `false` 使 name
*/
useFieldTextInError?: boolean;
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/** 透传给 Form.submitForm 的参数:是否直接返回原始响应式 values */
native?: boolean;
/**
* resolve changeRecords
* resolve `{ values, changeRecords }` resolve values
*/
returnChangeRecords?: boolean;
/**
* provide
* `app._context` `getCurrentInstance()?.appContext`
*/
appContext?: AppContext | null;
/** 等待表单初始化的最长时间(毫秒),超时将以错误 reject。默认 10000ms */
timeout?: number;
/**
* `false`
*
* - `false`
* - `true`/
* reject 便
* `timeout`
*/
debug?: boolean;
typeMatchValid?: boolean;
/**
* abort `signal.reason` reject
* `debug`
*/
signal?: AbortSignal;
}
// #endregion SubmitFormOptions
// #region SubmitFormResult
/**
* `returnChangeNodes` submitForm
*/
export interface SubmitFormResult {
/** 校验通过后的表单值 */
values: any;
/** 表单变更记录 */
changeRecords: ChangeRecord[];
}
// #endregion SubmitFormResult
export type { SubmitFormOptions, SubmitFormResult, ValidateFormOptions };
// #region mountFormInstance
/**
* wrapper MForm resolve/reject
*
* `initialized` debug
* `initialized` `dialog: true`
* `submitForm` `validate`
*/
type FormWrapperFactory<T> = (ctx: {
@ -132,43 +56,22 @@ interface MountFormInstanceOptions<T> {
formProps: Record<string, any>;
/** 父级应用上下文用于继承全局组件、指令、provide 等 */
appContext?: AppContext | null;
/** 等待表单初始化的最长时间(毫秒),<=0 时回退到默认超时以保证兜底清理生效 */
timeout: number;
/** 超时 reject 的错误文案 */
timeoutMessage: string;
/** 是否以 `display:none` 隐藏容器。调试模式需可见,应传 `false` */
hidden?: boolean;
/** 是否跳过超时注册。调试模式等待人工操作,应传 `true` */
skipTimeout?: boolean;
/** 外部中断信号abort 时会 reject 并卸载实例、移除容器,用于取消无超时(如 debug的挂载 */
/** 外部中断信号abort 时会 reject 并卸载实例、移除容器,用于取消等待人工操作的挂载 */
signal?: AbortSignal;
/** 构造 wrapper 组件 */
createWrapper: FormWrapperFactory<T>;
}
/** 未指定或传入非正数 timeout 时的兜底超时(毫秒),保证非 debug 挂载始终能被清理 */
const DEFAULT_MOUNT_TIMEOUT = 10000;
/**
* submitForm / validateForm
* `dialog: true` submitForm / validateForm
*
* wrapper MForm cleanup / timeout / appContext
* wrapper MForm cleanup / appContext
* `createWrapper` MForm resolve/reject
*
* `submitForm` `validate`
*
* //`signal`
*/
const mountFormInstance = <T>(options: MountFormInstanceOptions<T>): Promise<T> => {
const {
formProps,
appContext,
timeout,
timeoutMessage,
hidden = true,
skipTimeout = false,
signal,
createWrapper,
} = options;
const { formProps, appContext, signal, createWrapper } = options;
return new Promise<T>((resolve, reject) => {
// 已中断则直接 reject不创建任何容器/实例
@ -178,24 +81,16 @@ const mountFormInstance = <T>(options: MountFormInstanceOptions<T>): Promise<T>
}
let cleaned = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let onAbort: (() => void) | null = null;
// 用 holder 持有 app使 cleanup 可在 app 创建之前定义const app + 无 TDZ / 无 use-before-define
const instance: { app: ReturnType<typeof createApp> | null } = { app: null };
const container = document.createElement('div');
if (hidden) {
container.style.display = 'none';
}
document.body.appendChild(container);
const cleanup = () => {
if (cleaned) return;
cleaned = true;
if (timer) {
clearTimeout(timer);
timer = null;
}
if (signal && onAbort) {
signal.removeEventListener('abort', onAbort);
onAbort = null;
@ -208,7 +103,7 @@ const mountFormInstance = <T>(options: MountFormInstanceOptions<T>): Promise<T>
container.parentNode?.removeChild(container);
};
// 支持外部通过 AbortSignal 主动中断:debug 模式无超时兜底,若调用方放弃了该 Promise
// 支持外部通过 AbortSignal 主动中断:弹层无超时兜底,若调用方放弃了该 Promise
// 可通过 abort 卸载实例、移除遮罩/容器,避免无限驻留在 DOM 中。
if (signal) {
onAbort = () => {
@ -269,21 +164,7 @@ const mountFormInstance = <T>(options: MountFormInstanceOptions<T>): Promise<T>
})
: userWrapper;
// 静默隐藏挂载模式下注入静默标记vs-code 等重型字段组件可据此跳过自身渲染,
// 校验/取值依赖 FormItem 与 model 值,与叶子 UI 组件无关(见 FORM_SILENT_MODE_KEY 注释)。
// 用组件级 provide 而非 app.provideappContext 合并后 app._context.provides 与父级应用
// 共享引用app.provide 会把标记泄漏到父级应用。
const rootComponent = hidden
? defineComponent({
name: 'MFormSilentProvider',
setup() {
provide(FORM_SILENT_MODE_KEY, true);
return () => h(wrapperComponent);
},
})
: wrapperComponent;
const app = createApp(rootComponent);
const app = createApp(wrapperComponent);
instance.app = app;
// 继承父级应用上下文components / directives / provides / config 等)
@ -291,18 +172,6 @@ const mountFormInstance = <T>(options: MountFormInstanceOptions<T>): Promise<T>
Object.assign(app._context, appContext);
}
// 非 debug未跳过超时场景始终注册超时兜底timeout 为非正数时回退到默认值,
// 避免表单永不初始化时实例/容器/watcher 无限驻留而泄漏。
if (!skipTimeout) {
const effectiveTimeout = timeout > 0 ? timeout : DEFAULT_MOUNT_TIMEOUT;
timer = setTimeout(() => {
if (!cleaned) {
reject(new Error(timeoutMessage));
cleanup();
}
}, effectiveTimeout);
}
app.mount(container);
} catch (err) {
reject(err);
@ -312,8 +181,8 @@ const mountFormInstance = <T>(options: MountFormInstanceOptions<T>): Promise<T>
};
// #endregion mountFormInstance
// #region createDebugWrapper
interface DebugWrapperOptions {
// #region createDialogWrapper
interface DialogWrapperOptions {
/** 指向挂载的 MForm 实例 */
formRef: Ref<any>;
/** 透传给 Form 组件的 props */
@ -332,12 +201,12 @@ interface DebugWrapperOptions {
}
/**
* wrapper fixed MForm /
* wrapper fixed MForm /
*
* submitForm validateForm UI
* submitForm validateForm UI
* `onConfirm` / `onCancel`
*/
const createDebugWrapper = (options: DebugWrapperOptions): Component => {
const createDialogWrapper = (options: DialogWrapperOptions): Component => {
const { formRef, formProps, title, name, onConfirm, onCancel } = options;
const btnBase = {
@ -461,63 +330,22 @@ const createDebugWrapper = (options: DebugWrapperOptions): Component => {
},
});
};
// #endregion createDebugWrapper
// #endregion createDialogWrapper
/**
* Form.vue /
* `submitForm`
*
* 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);
* }
*
* // 需要同时获取变更记录时:
* const { values, changeRecords } = await submitForm({
* config: [...],
* initValues: { name: 'foo' },
* returnChangeRecords: true,
* });
*
* // 调试模式:可见地渲染表单,点击「确定」才提交:
* const values = await submitForm({
* config: [...],
* initValues: { name: 'foo' },
* debug: true,
* });
* ```
* / `submitForm`
*/
export const submitForm = (options: SubmitFormOptions): Promise<any> => {
const { native, appContext, timeout = 10000, returnChangeRecords, debug = false, signal, ...formProps } = options;
const submitFormByDialogRender = (options: SubmitFormOptions): Promise<any> => {
const { native, appContext, returnChangeRecords, signal, dialog, title, ...formProps } = options;
return mountFormInstance<any>({
formProps,
appContext,
timeout,
signal,
// 调试模式需把表单展示出来;普通模式隐藏挂载
hidden: !debug,
// 调试模式等待人工操作,不应用超时
skipTimeout: debug,
timeoutMessage: `submitForm timeout after ${timeout}ms: form is not initialized.`,
createWrapper: ({ formRef, formProps, cleanup, resolve, reject }) => {
/**
* nextTick changeRecords submitForm resolve
* `onValidateError` debugreject
* debug
*/
// 执行一次提交nextTick 等待子组件渲染 → 快照 changeRecords → 调用实例 submitForm → resolve
const doSubmit = async (onValidateError: (err: any) => void) => {
try {
// 等待子组件FormItem 等)完成首次渲染,确保 validate 能拿到所有字段
@ -532,92 +360,25 @@ export const submitForm = (options: SubmitFormOptions): Promise<any> => {
}
};
// 调试模式:可见地渲染表单,点击「确定」才提交,点击「取消」则中断
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();
},
});
}
// 普通模式:表单初始化完成后自动提交
return defineComponent({
return createDialogWrapper({
formRef,
formProps,
name: 'MFormSubmitWrapper',
setup() {
const stop = watch(
() => formRef.value?.initialized,
(initialized) => {
if (!initialized) return;
stop();
doSubmit((err) => {
reject(err);
cleanup();
});
},
{ flush: 'post', immediate: true },
);
return () => h(Form as Component, { ...formProps, ref: formRef });
title: title ?? 'submitForm',
onConfirm: (setError) =>
doSubmit((err) => {
// 校验失败时保留弹层并展示错误,便于修正后重新提交
setError(err instanceof Error ? err.message : String(err));
}),
onCancel: () => {
reject(new Error('submitForm canceled.'));
cleanup();
},
});
},
});
};
// #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;
typeMatchValid?: boolean;
/**
* abort `signal.reason` reject
* `debug`
*/
signal?: AbortSignal;
}
// #endregion ValidateFormOptions
// #region stripTabLazy
/**
* display / onTabClick
@ -673,44 +434,12 @@ export const stripTabItemsLazy = (config: FormConfig): FormConfig => {
// #endregion stripTabLazy
/**
* + ****
* `validate`
*
* `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,
* });
* ```
* / `validateForm`
*/
export const validateForm = (options: ValidateFormOptions): Promise<string> => {
const { appContext, timeout = 10000, debug = false, config, signal, ...rest } = options;
const validateFormByDialogRender = (options: ValidateFormOptions): Promise<string> => {
const { appContext, config, signal, dialog, title, ...rest } = options;
// 去掉 tab 容器各标签页的 lazy确保懒加载标签页内的字段也参与校验
const formProps = { ...rest, config: stripTabItemsLazy(config) };
@ -718,19 +447,9 @@ export const validateForm = (options: ValidateFormOptions): Promise<string> => {
return mountFormInstance<string>({
formProps,
appContext,
timeout,
signal,
// 调试模式需把表单展示出来;普通模式隐藏挂载
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
*/
// 执行一次校验nextTick 等待子组件渲染 → 调用实例 validate → 通过则 resolve '',失败则在弹层展示错误
const doValidate = async (onInvalid: (error: string) => void) => {
try {
// 等待子组件FormItem 等)完成首次渲染,确保 validate 能拿到所有字段
@ -749,46 +468,43 @@ export const validateForm = (options: ValidateFormOptions): Promise<string> => {
}
};
// 调试模式:可见地渲染表单,点击「确定」才校验,点击「取消」则中断
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({
return createDialogWrapper({
formRef,
formProps,
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 });
title: title ?? 'validateForm',
onConfirm: (setError) =>
doValidate((error) => {
// 校验失败时保留弹层并展示错误,便于修正后重新校验
setError(error);
}),
onCancel: () => {
reject(new Error('validateForm canceled.'));
cleanup();
},
});
},
});
};
// #region headless
/**
* +
*
* `@tmagic/form/headless``dialog: true`
*/
export const submitForm = async (options: SubmitFormOptions): Promise<any> => {
if (options.dialog) return submitFormByDialogRender(options);
return submitFormHeadless(options);
};
/**
* +
*
* `dialog: true` `submitForm`
*/
export const validateForm = async (options: ValidateFormOptions): Promise<string> => {
if (options.dialog) return validateFormByDialogRender(options);
return validateFormHeadless(options);
};
// #endregion headless

View File

@ -0,0 +1,70 @@
/*
* 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 { expandFieldset, expandPanel, expandRow, expandStep, expandTab, expandTableGroupList } from './collectFields';
import {
checkboxGroupEffect,
dateEffect,
dateTimeEffect,
displayEffect,
dynamicFieldEffect,
numberRangeEffect,
} from './fieldValueEffects';
import { type HeadlessFieldOptions } from './registerField';
/**
* Vue
*
* `registerBuiltInFields(builtInFields)`
* `app.use(MagicForm)`install Vue
*/
export const builtInFields: Record<string, HeadlessFieldOptions> = {
text: {},
'img-upload': {},
number: {},
'number-range': { effect: numberRangeEffect },
textarea: {},
hidden: {},
date: { effect: dateEffect },
datetime: { effect: dateTimeEffect },
daterange: {},
timerange: {},
time: {},
checkbox: {},
switch: {},
'color-picker': {},
'checkbox-group': { effect: checkboxGroupEffect },
'radio-group': {},
display: { effect: displayEffect },
link: {},
select: {},
cascader: {},
'dynamic-field': { effect: dynamicFieldEffect },
// Container 对 `type: 'component'` 直接渲染 `config.component`,没有独立的 m-fields-component
component: {},
// `type: 'container'` 不登记为叶子:未登记且带 items 时 dispatch 会按普通容器下钻
tab: { walk: expandTab },
row: { walk: expandRow },
'flex-layout': { walk: expandRow },
fieldset: { walk: expandFieldset },
panel: { walk: expandPanel },
step: { walk: expandStep },
table: { walk: expandTableGroupList },
'group-list': { walk: expandTableGroupList },
'table-group-list': { walk: expandTableGroupList },
};

View File

@ -0,0 +1,408 @@
/*
* 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 { ComputedRef } from 'vue';
import { toLine } from '@tmagic/utils';
import type { FormConfig, FormItemConfig, FormState, FormValue, Rule } from '../schema';
import { getFieldNestedConfig } from './fieldNestedConfig';
import { getFieldMountValueEffect, isLeafFieldType } from './fieldValueEffects';
import {
appendProp,
display as displayFunction,
filterFunction,
getItemProp,
getNativeRules,
isValidName,
resolveItemType,
} from './form';
import {
getGroupListRowConfig,
isGroupListType,
isTableColumnRendered,
makeTableColumnConfig,
toGroupListConfig,
toTableConfig,
} from './tableGroupList';
// #region CollectedField
/** 一个参与校验的字段:与渲染式校验中「一个带 rules 的 TMagicFormItem」一一对应 */
export interface CollectedField {
/** 字段的完整 prop 路径(从表单根 values 起算,与 FormItem 的 prop 一致) */
prop: string;
/** 经 `getNativeRules` 处理后的规则validator 为 async-validator 原生签名) */
rules: Rule[];
/** 字段配置 */
config: FormItemConfig;
/** 字段所在层级的 model 切片 */
model: FormValue;
}
// #endregion CollectedField
/** 已登记的嵌套配置回调自身抛错时抛出(机制故障,不是漏登记) */
export class FieldNestedConfigError extends Error {
readonly code = 'FIELD_NESTED_CONFIG';
readonly type: string;
readonly prop: string;
constructor(type: string, prop: string, cause: unknown) {
const reason = cause instanceof Error ? cause.message : String(cause);
super(`[MForm] nested config for "${type}" at "${prop}" failed: ${reason}`);
this.name = 'FieldNestedConfigError';
this.type = type;
this.prop = prop;
if (cause instanceof Error) {
(this as Error & { cause?: unknown }).cause = cause;
}
}
}
export const isFieldNestedConfigError = (error: unknown): error is FieldNestedConfigError =>
error instanceof FieldNestedConfigError ||
(typeof error === 'object' && error !== null && (error as { code?: string }).code === 'FIELD_NESTED_CONFIG');
interface WalkContext {
mForm: FormState | undefined;
typeMatchValid: ComputedRef<boolean> | undefined;
fields: CollectedField[];
}
interface WalkNode {
config: FormItemConfig;
/** 所在层级的 model 切片(对应 Container 的 `props.model` */
model: FormValue;
/** 父级 prop对应 Container 的 `props.prop` */
prop: string;
}
export type ContainerWalker = (_ctx: WalkContext, _node: WalkNode, _itemProp: string) => void;
const extraContainerWalkers = new Map<string, ContainerWalker>();
const builtInContainerWalkers = new Map<string, ContainerWalker>();
export const registerContainerWalker = (type: string, walker: ContainerWalker, builtIn = false): void => {
if (typeof type !== 'string' || !type || typeof walker !== 'function') return;
const key = toLine(type);
if (builtIn) {
builtInContainerWalkers.set(key, walker);
return;
}
extraContainerWalkers.set(key, walker);
};
export const getContainerWalker = (type: string): ContainerWalker | undefined => {
const key = toLine(type);
return extraContainerWalkers.get(key) ?? builtInContainerWalkers.get(key);
};
export const deleteContainerWalker = (type: string): boolean => extraContainerWalkers.delete(toLine(type));
export const clearContainerWalkers = (): void => extraContainerWalkers.clear();
const getItems = (config: any): FormItemConfig[] | undefined => config?.items;
/**
* `Container.vue` `display`
*
* `display: 'expand'`
*
*/
const resolveDisplay = (ctx: WalkContext, config: any, nodeProps: any): boolean => {
const value = displayFunction(ctx.mForm, config?.display, nodeProps);
if (value === 'expand') return true;
return Boolean(value);
};
const addField = (ctx: WalkContext, node: WalkNode, itemProp: string, nodeProps: any): void => {
const rules = getNativeRules(ctx.mForm, (node.config as any).rules, nodeProps, ctx.typeMatchValid) as Rule[];
if (!rules.length) return;
ctx.fields.push({ prop: itemProp, rules, config: node.config, model: node.model });
};
const walkChildren = (ctx: WalkContext, items: FormItemConfig[] | undefined, model: FormValue, prop: string): void => {
if (!Array.isArray(items)) return;
for (const item of items) {
if (!item) continue;
walkNode(ctx, { config: item, model, prop });
}
};
const getContainerScope = (node: WalkNode) => {
const { config, model } = node;
const name = (config as any).name || '';
return {
config,
model,
name,
items: getItems(config),
// 容器组件收到的是 Container 自身的 model父级切片再由组件内部按 name 下钻
childModel: (name ? model?.[name] : model) as FormValue,
};
};
/** containers/Tabs.vue */
export const expandTab = (ctx: WalkContext, node: WalkNode, itemProp: string): void => {
const { config, model, name, items, childModel } = getContainerScope(node);
const tabsProps = { model, config, prop: itemProp };
if ((config as any).dynamic) {
if (!name) return;
const tabs = model?.[name] || [];
tabs.forEach((_tab: any, tabIndex: number) => {
walkChildren(ctx, items, childModel?.[tabIndex], appendProp(itemProp, tabIndex));
});
return;
}
const tabs = (items || []).filter((item: any) => displayFunction(ctx.mForm, item?.display, tabsProps));
for (const tab of tabs) {
const tabName = (tab as any).name;
// tab.lazy 只影响渲染时机,不影响该标签页是否属于这份配置,无渲染校验一律遍历
walkChildren(
ctx,
getItems(tab),
tabName ? childModel?.[tabName] : childModel,
tabName ? appendProp(itemProp, tabName) : itemProp,
);
}
};
/** containers/Row.vue → Col.vuecol 用 v-show 控制显隐,始终渲染 */
export const expandRow = (ctx: WalkContext, node: WalkNode, itemProp: string): void => {
const { items, childModel } = getContainerScope(node);
walkChildren(ctx, items, childModel, itemProp);
};
/** containers/Fieldset.vue */
export const expandFieldset = (ctx: WalkContext, node: WalkNode, itemProp: string): void => {
const { config, items, childModel } = getContainerScope(node);
if (!childModel) return;
const { checkbox } = config as any;
const checkboxName = typeof checkbox === 'object' && typeof checkbox.name === 'string' ? checkbox.name : 'value';
const checkboxTrueValue =
typeof checkbox === 'object' && typeof checkbox.trueValue !== 'undefined' ? checkbox.trueValue : 1;
// 勾选框关闭时整个 fieldset 的子项不渲染,语义上等于「该段配置未启用」,不参与校验
if ((config as any).expand && childModel?.[checkboxName] !== checkboxTrueValue) return;
walkChildren(ctx, items, childModel, itemProp);
};
/** containers/Panel.vue折叠仅 display:none子项照常渲染 */
export const expandPanel = (ctx: WalkContext, node: WalkNode, itemProp: string): void => {
const { items, childModel } = getContainerScope(node);
if (!items?.length) return;
walkChildren(ctx, items, childModel, itemProp);
};
/** containers/Step.vue非当前步仅 v-show 隐藏子项照常渲染prop 基准被重置为 step.name */
export const expandStep = (ctx: WalkContext, node: WalkNode, _itemProp: string): void => {
const { model, items } = getContainerScope(node);
for (const step of items || []) {
const stepName = (step as any)?.name;
walkChildren(ctx, getItems(step), stepName ? model?.[stepName] : model, `${stepName}`);
}
};
/**
* table / group-list
*
* `TableGroupList.vue` `config.type` table groupList
* `utils/tableGroupList`
*
* - table hidden display × Container prop `${prop}.${index}`
* - groupList row `items`
*
* tablegroupList
*
*/
export const expandTableGroupList = (ctx: WalkContext, node: WalkNode, itemProp: string): void => {
const { config, model } = node;
const name = (config as any).name || '';
const rows = model?.[name];
if (!Array.isArray(rows)) return;
if (isGroupListType((config as any).type)) {
const groupListConfig = toGroupListConfig(config as any);
rows.forEach((row, index) => {
walkNode(ctx, {
config: getGroupListRowConfig(groupListConfig, index, ctx.mForm?.keyProp) as FormItemConfig,
model: row,
prop: appendProp(itemProp, index),
});
});
return;
}
const tableItems = toTableConfig(config as any).items;
if (!Array.isArray(tableItems)) return;
// 列的 display 在 Table 层用「表格自身的 props」求值随后 makeTableColumnConfig 会删掉 display
const tableProps = { model, config, prop: itemProp };
const evalDisplay = (display: any) => displayFunction(ctx.mForm, display, tableProps);
rows.forEach((row, index) => {
for (const column of tableItems) {
if (!column || !isTableColumnRendered(column, evalDisplay)) continue;
walkNode(ctx, {
config: makeTableColumnConfig(column, row) as FormItemConfig,
model: row,
prop: appendProp(itemProp, index),
});
}
});
};
/** 遍历已登记嵌套配置的复合字段 */
const walkNestedConfig = (ctx: WalkContext, type: string, node: WalkNode, itemProp: string): boolean => {
const resolve = getFieldNestedConfig(type);
if (!resolve) return false;
let result;
try {
result = resolve({
config: node.config,
model: node.model,
prop: itemProp,
parentProp: node.prop,
mForm: ctx.mForm,
});
} catch (e) {
throw new FieldNestedConfigError(type, itemProp, e);
}
if (!result) return true;
const nestedModel = result.model ?? node.model;
const nestedProp = result.prop ?? itemProp;
const nestedConfig = Array.isArray(result.config) ? result.config : [result.config];
walkChildren(ctx, nestedConfig, nestedModel, nestedProp);
return true;
};
/**
* Container `Container.vue`
*
* `isCompare` prop FormItem
*
*/
const walkNode = (ctx: WalkContext, node: WalkNode): void => {
const { config, prop } = node;
const model = node.model ?? {};
// 与 Container 的 props 对齐:`prop` 是父级 prop供 display / rules 的上下文使用
const nodeProps = { model, config, prop };
const name = (config as any).name || '';
const items = getItems(config);
const type = resolveItemType(ctx.mForm, config, nodeProps);
const itemProp = getItemProp(prop, name);
const text = filterFunction(ctx.mForm, (config as any).text, nodeProps);
// hidden 不看 display始终渲染一个隐藏的 FormItem
if (type === 'hidden') {
addField(ctx, { ...node, model }, itemProp, nodeProps);
return;
}
const display = resolveDisplay(ctx, config, nodeProps);
// 容器分支:不渲染自身的 FormItem只渲染容器组件。
// 容器自身的 rules 只有在带 text、会包一层 FormItem 时才会生效(见下一分支),与 Container.vue 一致。
if (items && !text && type && display) {
dispatchByType(ctx, type, { ...node, model }, itemProp);
return;
}
// FormItem 分支:只要会包 FormItem有 rules 就会校验,不要求 type 已登记为叶子。
if (type && display) {
addField(ctx, { ...node, model }, itemProp, nodeProps);
dispatchByType(ctx, type, { ...node, model }, itemProp);
return;
}
// 无 type 的纯嵌套配置:直接递归子项
if (items && display) {
const childModel = isValidName(name) ? model[name] : model;
if (!childModel) return;
walkChildren(ctx, items, childModel, itemProp);
}
};
/**
* type walk
*
*
* type `items`
* rules FormItem rules
*/
const dispatchByType = (ctx: WalkContext, type: string, node: WalkNode, itemProp: string): void => {
const walkContainer = getContainerWalker(type);
if (walkContainer) {
walkContainer(ctx, node, itemProp);
return;
}
if (walkNestedConfig(ctx, type, node, itemProp)) return;
if (isLeafFieldType(type)) {
getFieldMountValueEffect(type)?.({ config: node.config, model: node.model, prop: itemProp, mForm: ctx.mForm });
return;
}
const items = getItems(node.config);
if (items?.length) {
const { childModel } = getContainerScope(node);
walkChildren(ctx, items, childModel, itemProp);
}
};
/**
* config + values
*
* `Container.vue` prop / rules
* MForm `validate()` DOM
*
* `values`
*
*
* @param mForm - `createHeadlessFormState`
* @param config -
* @param values -
* @param [typeMatchValid] - typeMatch
* @returns
*/
export const collectValidatableFields = (
mForm: FormState | undefined,
config: FormConfig,
values: FormValue,
typeMatchValid?: ComputedRef<boolean>,
): CollectedField[] => {
const ctx: WalkContext = {
mForm,
typeMatchValid,
fields: [],
};
if (Array.isArray(config)) {
walkChildren(ctx, config as FormItemConfig[], values, '');
}
return ctx.fields;
};

View File

@ -16,7 +16,6 @@
* limitations under the License.
*/
import type { Component } from 'vue';
import { ref } from 'vue';
let $MAGIC_FORM = {} as any;
@ -32,17 +31,4 @@ const setConfig = (option: any): void => {
const getConfig = <T = unknown>(key: string): T => $MAGIC_FORM[key];
const fieldRegistry = new Map<string, Component>();
const registerField = (tagName: string, component: Component): void => {
if (fieldRegistry.has(tagName)) {
return;
}
fieldRegistry.set(tagName, component);
};
const getField = (tagName: string): Component | undefined => fieldRegistry.get(tagName);
const deleteField = (tagName: string): boolean => fieldRegistry.delete(tagName);
export { deleteField, getConfig, getField, isGlobalFlat, registerField, setConfig };
export { getConfig, isGlobalFlat, setConfig };

View File

@ -0,0 +1,127 @@
/*
* 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 { toLine } from '@tmagic/utils';
import type { FormItemConfig, FormState, FormValue } from '../schema';
// #region FieldNestedConfig
/** 嵌套配置回调的入参:字段自身的配置、所在层级的 model、完整字段路径与表单状态 */
export interface FieldNestedConfigContext {
/** 字段自身的配置(已经过 filterFunction 之外的原样配置) */
config: FormItemConfig;
/** 字段所在层级的 model 切片 */
model: FormValue;
/** 字段的完整 prop 路径(含父级前缀),即字段组件拿到的 `props.prop` */
prop: string;
/** 字段所在层级的 prop 基准(父级 Container 的 `props.prop`),即 `prop` 去掉自身 name 的部分 */
parentProp: string;
/** 表单状态 */
mForm: FormState | undefined;
}
/** 嵌套配置回调的返回值:需要继续遍历的嵌套配置及其 model / prop 基准 */
export interface FieldNestedConfigResult {
/** 嵌套配置(对应字段组件内部渲染的 `MContainer` 的 `config` */
config: FormItemConfig | FormItemConfig[];
/**
* model 沿 `model`
*
* `code-select` `:model="model[name]"` `model[config.name]`
*/
model?: FormValue;
/**
* prop 沿 `prop`
*
* config `name` `name`
* `display-conds` group-list `props.name` `parentProp`
* name
*/
prop?: string;
}
/**
*
*
* `MContainer` config
* `code-select` / `event-select` / `style-setter`
* FormItem`validateValues`
* config
* `registerField(type, { nested })`
*
* `null` / `undefined`
*
* type `items`
* `rules`
*/
export type FieldNestedConfig = (_ctx: FieldNestedConfigContext) => FieldNestedConfigResult | null | undefined | void;
// #endregion FieldNestedConfig
/** 内置嵌套配置(由 `registerBuiltInFields` 写入;`clearFields` 不会清掉) */
const builtInNestedConfigs = new Map<string, FieldNestedConfig>();
/** 业务侧登记的嵌套配置 */
const extraNestedConfigs = new Map<string, FieldNestedConfig>();
/**
* type
*
* `type` Container 线`codeSelect` `code-select`
* 便
* `builtIn` `deleteFieldNestedConfig` / `clearFieldNestedConfigs`
*
* @param type - type
* @param resolve -
* @param [builtIn=false] -
*/
export const registerFieldNestedConfig = (type: string, resolve: FieldNestedConfig, builtIn = false): void => {
if (typeof type !== 'string' || !type || typeof resolve !== 'function') return;
(builtIn ? builtInNestedConfigs : extraNestedConfigs).set(toLine(type), resolve);
};
/**
* type
*
* @param type - type
* @returns `undefined`
*/
export const getFieldNestedConfig = (type: string): FieldNestedConfig | undefined => {
const key = toLine(type);
return extraNestedConfigs.get(key) ?? builtInNestedConfigs.get(key);
};
/**
* type
*
* @param type - type
* @returns
*/
export const hasFieldNestedConfig = (type: string): boolean => {
const key = toLine(type);
return extraNestedConfigs.has(key) || builtInNestedConfigs.has(key);
};
/**
*
*
* @param type - type
* @returns
*/
export const deleteFieldNestedConfig = (type: string): boolean => extraNestedConfigs.delete(toLine(type));
/** 清空业务侧登记的嵌套配置(不影响内置;主要用于单测)。 */
export const clearFieldNestedConfigs = (): void => extraNestedConfigs.clear();

View File

@ -0,0 +1,287 @@
/*
* 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.
*/
/**
* @fileoverview type model
*
* type setup
* `registerField` / `registerFields`
* `MagicForm.install` `registerBuiltInFields`
* type `items` `rules`
*
* @module fieldValueEffects
*/
import { setValueByKeyPath, toLine } from '@tmagic/utils';
import type {
DateConfig,
DateTimeConfig,
DisplayConfig,
DynamicFieldConfig,
FormItemConfig,
FormState,
FormValue,
} from '../schema';
import { getConfig } from './config';
import { datetimeFormatter } from './form';
/** `dynamic-field` 的 `returnFields` 返回的单个字段描述 */
type DynamicFieldItem = ReturnType<DynamicFieldConfig['returnFields']>[number];
// #region FieldMountValueEffect
/** mount effect 的入参:字段自身的配置、所在层级的 model、完整字段路径与表单状态 */
export interface FieldMountValueEffectContext {
/** 字段自身的配置 */
config: FormItemConfig;
/** 字段所在层级的 model 切片 */
model: FormValue;
/** 字段的完整 prop 路径(含父级前缀),对应 Container 的 `itemProp` */
prop: string;
/** 表单状态 */
mForm: FormState | undefined;
}
/**
* model
*
* effect type setup
*/
export type FieldMountValueEffect = (_ctx: FieldMountValueEffectContext) => void;
// #endregion FieldMountValueEffect
/**
* `fields/Display.vue` `initValue` model
*
* @param config - `initValue`
* @param model - model
* @param name - name
*/
export const applyDisplayInitValue = (
config: Pick<DisplayConfig, 'initValue'>,
model: FormValue | undefined,
name: string,
): void => {
if (config.initValue && model) {
model[name] = config.initValue;
}
};
/**
* `fields/NumberRange.vue`
*
* @param model - model
* @param name - name
*/
export const normalizeNumberRangeValue = (model: FormValue | undefined, name: string): void => {
if (model && !Array.isArray(model[name])) {
model[name] = [];
}
};
/**
* `fields/CheckboxGroup.vue`
*
* @param model - model
* @param name - name
*/
export const initCheckboxGroupValue = (model: FormValue | undefined, name: string): void => {
if (model && !model[name]) {
model[name] = [];
}
};
/**
* `fields/Date.vue` `valueFormat`
*
* @param config - `valueFormat`
* @param model - model
* @param name - name
*/
export const normalizeDateValue = (
config: Pick<DateConfig, 'valueFormat'>,
model: FormValue | undefined,
name: string,
): void => {
if (!model) return;
model[name] = datetimeFormatter(model[name], '', config.valueFormat || 'YYYY/MM/DD');
};
/**
* `fields/DateTime.vue` `valueFormat`
*
* @param config - `valueFormat`
* @param model - model
* @param name - name
*/
export const normalizeDateTimeValue = (
config: Pick<DateTimeConfig, 'valueFormat'>,
model: FormValue | undefined,
name: string,
): void => {
if (!model) return;
const value = model[name]?.toString();
if (!value || value === 'Invalid Date') {
model[name] = '';
return;
}
model[name] = datetimeFormatter(model[name], '', config.valueFormat || 'YYYY/MM/DD HH:mm:ss');
};
/**
* `fields/DynamicField.vue` defaultValue
*
*
* `isDefaultApplied` `defaultValue`
* emit change model
*
* @param fields - `returnFields`
* @param model - model
* @param onField -
*/
export const eachDynamicField = (
fields: DynamicFieldItem[],
model: FormValue | undefined,
onField: (_field: DynamicFieldItem, _value: any, _isDefaultApplied: boolean) => void,
): void => {
for (const field of fields) {
if (typeof field !== 'object' || field?.name === undefined) continue;
let value = model?.[field.name] || '';
let isDefaultApplied = false;
if (!value && field.defaultValue !== undefined) {
value = field.defaultValue;
isDefaultApplied = true;
}
onField(field, value, isDefaultApplied);
}
};
export const displayEffect: FieldMountValueEffect = ({ config, model }) =>
applyDisplayInitValue(config as DisplayConfig, model, (config as any).name);
export const numberRangeEffect: FieldMountValueEffect = ({ config, model }) =>
normalizeNumberRangeValue(model, (config as any).name);
export const checkboxGroupEffect: FieldMountValueEffect = ({ config, model }) =>
initCheckboxGroupValue(model, (config as any).name);
export const dateEffect: FieldMountValueEffect = ({ config, model }) =>
normalizeDateValue(config as DateConfig, model, (config as any).name);
export const dateTimeEffect: FieldMountValueEffect = ({ config, model }) =>
normalizeDateTimeValue(config as DateTimeConfig, model, (config as any).name);
export const dynamicFieldEffect: FieldMountValueEffect = ({ config, model, prop, mForm }) => {
// 该组件读取的是同级 model但写入走 Container 的 modifyKey 分支,落在 `${prop}.${key}`
// 这里保持与渲染一致(含这层不对称),避免两条链路产出不同的值。
const { returnFields, dynamicKey } = config as DynamicFieldConfig;
if (typeof returnFields !== 'function' || !model) return;
if (model[dynamicKey] === '') return;
const result = returnFields(config as DynamicFieldConfig, model, getConfig<Function>('request'));
// 同步返回才能在校验前生效;异步 returnFields 与渲染式校验一样存在时序不确定性,此处不等待
if (!result || typeof (result as any).then === 'function' || !Array.isArray(result)) return;
eachDynamicField(result, model, (field, value, isDefaultApplied) => {
if (isDefaultApplied) {
setValueByKeyPath(`${prop}.${field.name}`, value, mForm?.values || model);
}
});
};
/** 内置叶子字段(由 `MagicForm.install` 写入clearFields 不会清掉) */
const builtInLeafFieldTypes = new Set<string>();
const builtInMountValueEffects = new Map<string, FieldMountValueEffect>();
/** 业务侧登记的叶子字段与其挂载副作用 */
const extraLeafFieldTypes = new Set<string>();
const extraMountValueEffects = new Map<string, FieldMountValueEffect>();
/**
* type `registerField` `registerField`
*
* `effect` type extra effect extra effect
* effect
*
* @param type - type
* @param [effect] - model
* @param [builtIn=false] -
*/
export const registerLeafFieldType = (type: string, effect?: FieldMountValueEffect, builtIn = false): void => {
if (typeof type !== 'string' || !type) return;
const key = toLine(type);
const types = builtIn ? builtInLeafFieldTypes : extraLeafFieldTypes;
const effects = builtIn ? builtInMountValueEffects : extraMountValueEffects;
types.add(key);
if (typeof effect === 'function') {
effects.set(key, effect);
} else if (!builtIn) {
effects.delete(key);
}
};
/**
* type
*
* @param type - type
* @returns
*/
export const isLeafFieldType = (type: string): boolean => {
const key = toLine(type);
return extraLeafFieldTypes.has(key) || builtInLeafFieldTypes.has(key);
};
/**
* type model
*
* @param type - type
* @returns `undefined`
*/
export const getFieldMountValueEffect = (type: string): FieldMountValueEffect | undefined => {
const key = toLine(type);
return extraMountValueEffects.get(key) ?? builtInMountValueEffects.get(key);
};
/**
*
*
* @param type - type
* @returns
*/
export const deleteLeafFieldType = (type: string): boolean => {
const key = toLine(type);
extraMountValueEffects.delete(key);
return extraLeafFieldTypes.delete(key);
};
/** 清空业务侧登记的叶子字段(不影响内置;主要用于单测)。 */
export const clearLeafFieldTypes = (): void => {
extraLeafFieldTypes.clear();
extraMountValueEffects.clear();
};

View File

@ -18,10 +18,11 @@
import { ComputedRef, readonly } from 'vue';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
// dayjs 没有 exports 映射,原生 Node ESM 不会补扩展名,深路径必须写全 .js
import utc from 'dayjs/plugin/utc.js';
import { cloneDeep } from 'lodash-es';
import { getDesignConfig } from '@tmagic/design';
import { getDesignConfig } from '@tmagic/design/headless';
import { getValueByKeyPath } from '@tmagic/utils';
import type {
@ -40,6 +41,7 @@ import type {
TypeFunction,
} from '../schema';
import { getConfig } from './config';
import { createTypeMatchValidator } from './typeMatch';
type AsyncValidatorFn = (rule: any, value: any, callback: Function, source?: any, options?: any) => any;
@ -272,6 +274,92 @@ const getDefaultValue = function (
return '';
};
/**
* formState props post
*
* `Form.vue``createHeadlessFormState`
* `onChange` / `validator` / options
*/
const fallbackMessage = {
error: (msg: string) => console.error(msg),
success: (msg: string) => console.log(msg),
warning: (msg: string) => console.warn(msg),
info: (msg: string) => console.info(msg),
closeAll: () => undefined,
};
const fallbackMessageBox = {
alert: (msg: string) => console.log(msg),
confirm: (msg: string) => console.log(msg),
close: (msg: string) => console.log(msg),
};
export const createFormStateBase = (ui?: { $message?: any; $messageBox?: any }) => {
const fields = new Map<string, any>();
const requestFuc = getConfig('request') as Function;
return {
fields,
setField: (prop: string, field: any) => fields.set(prop, field),
getField: (prop: string) => fields.get(prop),
deleteField: (prop: string) => fields.delete(prop),
$messageBox: ui?.$messageBox ?? fallbackMessageBox,
$message: ui?.$message ?? fallbackMessage,
post: (options: any) => {
if (requestFuc) {
return requestFuc({
method: 'POST',
...options,
});
}
},
};
};
/**
* `name` model
*
* `Container.vue` `collectValidatableFields`
*
*/
export const isValidName = (name: unknown): boolean => {
const valueType = typeof name;
if (valueType !== 'string' && valueType !== 'symbol' && valueType !== 'number') return false;
if (name === '') return false;
if (valueType === 'number') return (name as number) >= 0;
return true;
};
/**
* `prop`
*
* `prop` / key group-list table
*/
export const appendProp = (prop: string | undefined = '', key: string | number): string =>
`${prop}${prop ? '.' : ''}${key}`;
/**
* `prop` `name` `prop` FormItem prop
*
* name `config.name || ''` name
* 沿 `prop`
*/
export const getItemProp = (prop: string | undefined = '', name: string | number | undefined = ''): string =>
name === '' ? (prop ?? '') : appendProp(prop, name);
/**
* `type` type type线
*
* `Container.vue` type
*/
export const resolveItemType = (mForm: FormState | undefined, config: any, props: any): string => {
let type = 'type' in (config || {}) ? config.type : '';
type = type && filterFunction<string>(mForm, type, props);
// form / container 都表示「仅嵌套,不渲染字段」
if (type === 'form' || type === 'container') return '';
return type?.replace(/([A-Z])/g, '-$1').toLowerCase() || (config?.items ? '' : 'text');
};
export const filterFunction = <T = any>(
mForm: FormState | undefined,
config: T | FilterFunction<T> | undefined,
@ -309,11 +397,12 @@ export const display = function (mForm: FormState | undefined, config: any, prop
return true;
};
export const getRules = function (
const buildRules = function (
mForm: FormState | undefined,
r: Rule[] | Rule = [],
props: any,
typeMatchValid?: ComputedRef<boolean>,
typeMatchValid: ComputedRef<boolean> | undefined,
adapt: (_validator: AsyncValidatorFn) => AsyncValidatorFn = (validator) => validator,
) {
let rules = cloneDeep(r);
@ -330,33 +419,32 @@ export const getRules = function (
return rules
.map((item) => {
if (item.typeMatch) {
(item as any).validator = adaptFormValidator(createTypeMatchValidator(mForm, props, item));
(item as any).validator = adapt(createTypeMatchValidator(mForm, props, item));
return item;
}
if (typeof item.validator === 'function') {
const fnc = item.validator;
(item as any).validator = adaptFormValidator(
(rule: any, value: any, callback: Function, source: any, options: any) =>
fnc(
{
rule,
value: props.config.names ? props.model : value,
callback,
source,
options,
},
{
values: mForm?.initValues || {},
model: props.model,
parent: mForm?.parentValues || {},
formValue: mForm?.values || props.model,
prop: props.prop,
config: props.config,
},
mForm,
),
(item as any).validator = adapt((rule: any, value: any, callback: Function, source: any, options: any) =>
fnc(
{
rule,
value: props.config.names ? props.model : value,
callback,
source,
options,
},
{
values: mForm?.initValues || {},
model: props.model,
parent: mForm?.parentValues || {},
formValue: mForm?.values || props.model,
prop: props.prop,
config: props.config,
},
mForm,
),
);
}
return item;
@ -371,6 +459,30 @@ export const getRules = function (
});
};
export const getRules = function (
mForm: FormState | undefined,
r: Rule[] | Rule = [],
props: any,
typeMatchValid?: ComputedRef<boolean>,
) {
return buildRules(mForm, r, props, typeMatchValid, adaptFormValidator);
};
/**
* `getRules` validator async-validatorElement Plus UI
*
* `validateValues`使 async-validator
* TDesign `validator(val)` `adaptFormValidator`
*/
export const getNativeRules = function (
mForm: FormState | undefined,
r: Rule[] | Rule = [],
props: any,
typeMatchValid?: ComputedRef<boolean>,
) {
return buildRules(mForm, r, props, typeMatchValid);
};
export const initValue = async (
mForm: FormState | undefined,
{ initValues, config }: { initValues: FormValue; config: FormConfig },

View File

@ -0,0 +1,309 @@
/*
* 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 { App, Component } from 'vue';
import { toLine } from '@tmagic/utils';
import {
clearContainerWalkers,
type ContainerWalker,
deleteContainerWalker,
registerContainerWalker,
} from './collectFields';
import {
clearFieldNestedConfigs,
deleteFieldNestedConfig,
type FieldNestedConfig,
registerFieldNestedConfig,
} from './fieldNestedConfig';
import {
clearLeafFieldTypes,
deleteLeafFieldType,
type FieldMountValueEffect,
registerLeafFieldType,
} from './fieldValueEffects';
import { clearTypeMatchRules, deleteTypeMatchRule, registerTypeMatchRule, type TypeMatchValidator } from './typeMatch';
// #region FieldOptions
/**
* type
*
* `items`
*/
export interface FieldOptions {
/**
* Vue
* `app` `app.component('m-fields-*')`
*/
component?: Component;
/**
*
* `app` `app.component('m-form-*')`
*/
container?: Component;
/** 叶子字段挂载时改写 model 的副作用。 */
effect?: FieldMountValueEffect;
/**
* tab / table
* `nested` / `effect` `walk`
*/
walk?: ContainerWalker;
/**
*
* `effect` `effect`
*/
nested?: FieldNestedConfig;
/** 该 type 的 typeMatch 校验可与叶子、walk 或 nested 同时登记。 */
typeMatch?: TypeMatchValidator;
}
/**
* Vue
*
* Node / `validateForm` / `submitForm`
*/
export type HeadlessFieldOptions = Omit<FieldOptions, 'component' | 'container'>;
// #endregion FieldOptions
const extraComponents = new Map<string, Component>();
const builtInComponents = new Map<string, Component>();
const applyVueComponent = (
type: string,
component: Component,
app: App | undefined,
builtIn: boolean,
kind: 'fields' | 'form',
): void => {
const key = toLine(type);
if (builtIn) {
builtInComponents.set(key, component);
} else {
extraComponents.set(key, component);
}
app?.component(`m-${kind}-${key}`, component);
};
/**
* Vue `app` `m-fields-*` / `m-form-*`
*
* @param fields -
* @param [app] - Vue
* @param [builtIn=false] - `clearFields`
*/
export const bindFieldApp = (fields: Record<string, FieldOptions>, app?: App, builtIn = false): void => {
for (const [type, options] of Object.entries(fields)) {
if (options?.component) {
applyVueComponent(type, options.component, app, builtIn, 'fields');
}
if (options?.container) {
applyVueComponent(type, options.container, app, builtIn, 'form');
}
}
};
/**
* type key key
*
* Node `headless` `component` / `container`
*
* @param tables - `undefined`
* @returns
*/
export const mergeFieldOptions = (
...tables: Array<Record<string, HeadlessFieldOptions | FieldOptions> | undefined>
): Record<string, FieldOptions> => {
const result: Record<string, FieldOptions> = {};
for (const table of tables) {
if (!table) continue;
for (const [type, options] of Object.entries(table)) {
result[type] = { ...result[type], ...pickDefinedFieldOptions(options) };
}
}
return result;
};
const FIELD_OPTION_KEYS = ['component', 'container', 'effect', 'walk', 'nested', 'typeMatch'] as const;
const pickDefinedFieldOptions = (options?: FieldOptions): FieldOptions => {
if (!options) return {};
const next: FieldOptions = {};
for (const key of FIELD_OPTION_KEYS) {
if (options[key] !== undefined) {
(next as any)[key] = options[key];
}
}
return next;
};
const extraFieldOptions = new Map<string, FieldOptions>();
const builtInFieldOptions = new Map<string, FieldOptions>();
const removeFormComponent = (type: string): void => {
extraComponents.delete(toLine(type));
};
const clearFormComponents = (): void => {
extraComponents.clear();
};
/**
* type `codeSelect` `code-select`
*
* @param type - type
* @returns Vue `undefined`
*/
export const getField = (type: string): Component | undefined => {
const key = toLine(type);
return extraComponents.get(key) ?? builtInComponents.get(key);
};
const registerFieldImpl = (type: string, options: FieldOptions | undefined, app: App | undefined, builtIn: boolean) => {
if (typeof type !== 'string' || !type) return;
const key = toLine(type);
const store = builtIn ? builtInFieldOptions : extraFieldOptions;
const incoming = pickDefinedFieldOptions(options);
const merged: FieldOptions = { ...store.get(key), ...incoming };
store.set(key, merged);
if (incoming.walk && (incoming.nested || typeof incoming.effect === 'function')) {
console.warn(
`[MForm] registerField("${key}"): walk is set together with nested/effect; ` +
'headless validation will use walk and nested/effect will be ignored.',
);
} else if (incoming.nested && typeof incoming.effect === 'function') {
console.warn(
`[MForm] registerField("${key}"): nested and effect are both set; ` +
'headless validation will use nested and the mount value effect will be ignored.',
);
}
if (incoming.component && incoming.container) {
console.warn(
`[MForm] registerField("${key}"): component and container are both set; ` +
'getField will use container, and both m-fields-* / m-form-* will be registered.',
);
}
if (merged.component) {
applyVueComponent(type, merged.component, app, builtIn, 'fields');
}
if (merged.container) {
applyVueComponent(type, merged.container, app, builtIn, 'form');
}
if (merged.typeMatch) {
registerTypeMatchRule(type, merged.typeMatch, builtIn);
}
if (merged.walk) {
registerContainerWalker(type, merged.walk, builtIn);
if (!builtIn) {
deleteLeafFieldType(type);
deleteFieldNestedConfig(type);
}
return;
}
if (!builtIn) {
deleteContainerWalker(type);
}
if (merged.nested) {
if (!builtIn) deleteLeafFieldType(type);
registerFieldNestedConfig(type, merged.nested, builtIn);
return;
}
// 只登记了容器组件不当叶子dispatch 会按 items 下钻
if (merged.container && !merged.component && typeof merged.effect !== 'function') {
if (!builtIn) deleteLeafFieldType(type);
return;
}
if (!builtIn) {
deleteFieldNestedConfig(type);
}
registerLeafFieldType(type, merged.effect, builtIn);
};
/**
* type
*
* `type` Container 线`codeSelect` `code-select`
* type key key
* `app` `component` / `container` `app.component('m-fields-*'` / `'m-form-*')`
*
* @param type - type
* @param [options] -
* @param [app] - Vue `app.component`
*/
export const registerField = (type: string, options?: FieldOptions, app?: App): void => {
registerFieldImpl(type, options, app, false);
};
/**
*
*
* @param fields - type
* @param [app] - Vue `app.component`
*/
export const registerFields = (fields: Record<string, FieldOptions>, app?: App): void => {
for (const [type, options] of Object.entries(fields)) {
registerFieldImpl(type, options, app, false);
}
};
/**
* `clearFields` / `unregisterField`
*
* @param fields - type
* @param [app] - Vue `app.component`
*/
export const registerBuiltInFields = (fields: Record<string, FieldOptions>, app?: App): void => {
for (const [type, options] of Object.entries(fields)) {
registerFieldImpl(type, options, app, true);
}
};
/**
* type
*
* @param type - type
*/
export const unregisterField = (type: string): void => {
extraFieldOptions.delete(toLine(type));
deleteLeafFieldType(type);
deleteFieldNestedConfig(type);
deleteTypeMatchRule(type);
deleteContainerWalker(type);
removeFormComponent(type);
};
/** 清空业务侧登记(不影响内置;主要用于单测)。 */
export const clearFields = (): void => {
extraFieldOptions.clear();
clearLeafFieldTypes();
clearFieldNestedConfigs();
clearTypeMatchRules();
clearContainerWalkers();
clearFormComponents();
};

View File

@ -1,71 +0,0 @@
/*
* 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 { toLine } from '@tmagic/utils';
/**
*
*
* FormItem model
* defaultValue
*
* `registerSilentLeafFieldTypes`
*/
export const LEAF_FIELD_TYPES: ReadonlySet<string> = new Set([
'text',
'textarea',
'number',
'time',
'daterange',
'timerange',
'checkbox',
'radio-group',
'switch',
'select',
'cascader',
'color-picker',
'link',
]);
/** 业务侧追加的静默叶子字段(不含内置默认集合) */
const extraSilentLeafFieldTypes = new Set<string>();
/**
* type `LEAF_FIELD_TYPES`
*
* FormItem /
*/
export const registerSilentLeafFieldTypes = (types: Iterable<string>): void => {
for (const type of types) {
if (typeof type !== 'string' || !type) continue;
extraSilentLeafFieldTypes.add(toLine(type));
}
};
/** 当前生效的静默叶子字段集合:内置默认 已注册扩展 */
export const getSilentLeafFieldTypes = (): ReadonlySet<string> => {
if (extraSilentLeafFieldTypes.size === 0) {
return LEAF_FIELD_TYPES;
}
return new Set([...LEAF_FIELD_TYPES, ...extraSilentLeafFieldTypes]);
};
/** 清空业务侧追加的静默叶子字段(不影响内置默认集合;主要用于单测) */
export const clearSilentLeafFieldTypes = (): void => {
extraSilentLeafFieldTypes.clear();
};

View 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 type { AppContext } from 'vue';
import { cloneDeep } from 'lodash-es';
import type { ChangeRecord, FormConfig, FormState } from '../schema';
import { validateValues, type ValidateValuesResult } from './validateValues';
// #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;
/**
* 使 text `getTextByName` config
* `true` `false` 使 name
*/
useFieldTextInError?: boolean;
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/** 透传给 Form.submitForm 的参数:是否直接返回原始响应式 values */
native?: boolean;
/**
* resolve changeRecords
* resolve `{ values, changeRecords }` resolve values
*/
returnChangeRecords?: boolean;
/**
* provide
* `dialog: true` `@tmagic/form/headless`
*/
appContext?: AppContext | null;
/**
* `false`
*
* `@tmagic/form/headless` `dialog: true` `@tmagic/form`
*/
dialog?: boolean;
/**
* `dialog: true`
*/
title?: string;
typeMatchValid?: boolean;
/**
* abort `signal.reason` reject
*/
signal?: AbortSignal;
}
// #endregion SubmitFormOptions
// #region SubmitFormResult
/**
* `returnChangeRecords` submitForm
*/
export interface SubmitFormResult {
/** 校验通过后的表单值 */
values: any;
/** 表单变更记录 */
changeRecords: ChangeRecord[];
}
// #endregion SubmitFormResult
// #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>>;
/**
* `dialog: true` `@tmagic/form/headless`
*/
appContext?: AppContext | null;
/**
* `false`
*
* `@tmagic/form/headless` `dialog: true` `@tmagic/form`
*/
dialog?: boolean;
/**
* `dialog: true`
*/
title?: string;
typeMatchValid?: boolean;
/**
* abort `signal.reason` reject
*/
signal?: AbortSignal;
}
// #endregion ValidateFormOptions
const throwIfAborted = (signal: AbortSignal | undefined, fnName: string) => {
if (signal?.aborted) {
throw signal.reason ?? new Error(`${fnName} aborted`);
}
};
const throwIfDialog = (dialog: boolean | undefined, fnName: string) => {
if (dialog) {
throw new Error(
`[MForm] ${fnName}({ dialog: true }) is not available from @tmagic/form/headless. Import from @tmagic/form instead.`,
);
}
};
/**
* `submitForm` `validateForm` `validateValues`
*/
export const validateWithoutRender = async (
fnName: 'submitForm' | 'validateForm',
options: SubmitFormOptions | ValidateFormOptions,
): Promise<ValidateValuesResult> => {
const { signal, config, initValues, parentValues, keyProp, typeMatchValid, useFieldTextInError, extendState } =
options;
throwIfAborted(signal, fnName);
const result = await validateValues({
config,
initValues,
parentValues,
keyProp,
popperClass: (options as SubmitFormOptions).popperClass,
typeMatchValid,
useFieldTextInError,
extendState,
});
throwIfAborted(signal, fnName);
return result;
};
/**
* +
*
* `@tmagic/form/headless` `dialog: true`
*/
export const submitForm = async (options: SubmitFormOptions): Promise<any> => {
throwIfDialog(options.dialog, 'submitForm');
const validated = await validateWithoutRender('submitForm', options);
if (validated.error) throw new Error(validated.error);
const values = options.native ? validated.values : cloneDeep(validated.values);
return options.returnChangeRecords ? { values, changeRecords: [] as ChangeRecord[] } : values;
};
/**
* +
*
* `@tmagic/form/headless` `dialog: true`
*/
export const validateForm = async (options: ValidateFormOptions): Promise<string> => {
throwIfDialog(options.dialog, 'validateForm');
return (await validateWithoutRender('validateForm', options)).error;
};

View File

@ -0,0 +1,119 @@
/*
* 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 { cloneDeep } from 'lodash-es';
import type { GroupListConfig, TableColumnConfig, TableConfig } from '../schema';
/**
* table / group-list `TableGroupList.vue`
*
*
* `TableGroupList.vue` / `GroupListItem.vue` / `useTableColumns.ts`
* `collectValidatableFields`
*/
/** group-list 形态的 type兼容驼峰与中划线两种写法 */
export const isGroupListType = (type: unknown): boolean => type === 'groupList' || type === 'group-list';
/** 按 label 文案长度估算 label 宽度(中文按 20px、其他按 8px最小 80px */
export const calcLabelWidth = (label: string): string => {
if (!label) return '0px';
const zhLength = label.match(/[^\x00-\xff]/g)?.length || 0;
const chLength = label.length - zhLength;
return `${Math.max(chLength * 8 + zhLength * 20, 80)}px`;
};
/** 由 group-list 形态的配置派生出 table 形态所需的配置;本身已是 table 形态则原样返回 */
export const toTableConfig = (config: TableConfig | GroupListConfig): TableConfig => {
if (!isGroupListType(config.type)) return config as TableConfig;
const source = config as GroupListConfig;
return {
...config,
type: 'table',
groupItems: source.items,
items:
source.tableItems ||
(source.items as any[]).map((item: any) => ({
...item,
label: item.label || item.text,
text: null,
})),
} as any as TableConfig;
};
/** 由 table 形态的配置派生出 group-list 形态所需的配置;本身已是 group-list 形态则原样返回 */
export const toGroupListConfig = (config: TableConfig | GroupListConfig): GroupListConfig => {
if (isGroupListType(config.type)) return config as GroupListConfig;
const source = config as TableConfig;
return {
...config,
type: 'groupList',
tableItems: source.items,
items:
source.groupItems ||
(source.items as any[]).map((item: any) => {
const text = item.text || item.label;
return {
...item,
text,
labelWidth: calcLabelWidth(text),
span: item.span || 12,
};
}),
} as any as GroupListConfig;
};
/**
* group-list row `GroupListItem.vue` `Container` config
*
* `keyProp` `mForm.keyProp` `__key`
*/
export const getGroupListRowConfig = (config: GroupListConfig, index: number, keyProp?: string) => {
const key = keyProp || '__key';
return {
type: 'row',
span: config.span || 24,
items: config.items,
labelWidth: config.labelWidth,
[key]: `${(config as Record<string, any>)[key]}${String(index)}`,
};
};
/**
* `hidden` `display`
*
* `display` props `evalDisplay`
*/
export const isTableColumnRendered = (column: TableColumnConfig, evalDisplay: (_display: any) => any): boolean =>
column.type !== 'hidden' && Boolean(evalDisplay(column.display));
/**
* `Container` `itemsFunction` `display`
*/
export const makeTableColumnConfig = (column: TableColumnConfig, row: any): TableColumnConfig => {
const newConfig = cloneDeep(column);
if (typeof column.itemsFunction === 'function') {
newConfig.items = column.itemsFunction(row);
}
delete newConfig.display;
return newConfig;
};

View File

@ -18,9 +18,10 @@
import { readonly } from 'vue';
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
// dayjs 没有 exports 映射,原生 Node ESM 不会补扩展名,深路径必须写全 .js
import customParseFormat from 'dayjs/plugin/customParseFormat.js';
import { appendValidateSuggestion } from '@tmagic/design';
import { appendValidateSuggestion } from '@tmagic/design/headless';
import { getValueByKeyPath, toLine } from '@tmagic/utils';
import type { CascaderOption, FormState, Rule } from '../schema';
@ -49,33 +50,36 @@ export type TypeMatchValidator = (
) => string | undefined | Promise<string | undefined>;
// #endregion TypeMatchValidator
const typeMatchRuleRegistry = new Map<string, TypeMatchValidator>();
const extraTypeMatchRules = new Map<string, TypeMatchValidator>();
const builtInTypeMatchRules = new Map<string, TypeMatchValidator>();
const isPromise = (value: any): value is Promise<unknown> =>
typeof value === 'object' && value !== null && typeof value.then === 'function';
/** 注册或覆盖某个字段 type 的 typeMatch 校验规则 */
export const registerTypeMatchRule = (type: string, validator: TypeMatchValidator): void => {
typeMatchRuleRegistry.set(toLine(type), validator);
/** 注册或覆盖某个字段 type 的 typeMatch 校验规则。`builtIn` 登记不受 `clearTypeMatchRules` / `deleteTypeMatchRule` 影响。 */
export const registerTypeMatchRule = (type: string, validator: TypeMatchValidator, builtIn = false): void => {
(builtIn ? builtInTypeMatchRules : extraTypeMatchRules).set(toLine(type), validator);
};
/** 批量注册 typeMatch 校验规则 */
export const registerTypeMatchRules = (rules: Record<string, TypeMatchValidator>): void => {
export const registerTypeMatchRules = (rules: Record<string, TypeMatchValidator>, builtIn = false): void => {
Object.entries(rules).forEach(([type, validator]) => {
registerTypeMatchRule(type, validator);
registerTypeMatchRule(type, validator, builtIn);
});
};
/** 获取某个字段 type 的自定义 typeMatch 校验规则 */
export const getTypeMatchRule = (type: string): TypeMatchValidator | undefined =>
typeMatchRuleRegistry.get(toLine(type));
/** 获取某个字段 type 的 typeMatch 校验规则(业务侧优先于内置) */
export const getTypeMatchRule = (type: string): TypeMatchValidator | undefined => {
const key = toLine(type);
return extraTypeMatchRules.get(key) ?? builtInTypeMatchRules.get(key);
};
/** 删除某个字段 type 的自定义 typeMatch 校验规则 */
export const deleteTypeMatchRule = (type: string): boolean => typeMatchRuleRegistry.delete(toLine(type));
/** 删除业务侧 typeMatch 校验规则(不影响内置) */
export const deleteTypeMatchRule = (type: string): boolean => extraTypeMatchRules.delete(toLine(type));
/** 清空所有自定义 typeMatch 校验规则 */
/** 清空业务侧 typeMatch 校验规则(不影响内置) */
export const clearTypeMatchRules = (): void => {
typeMatchRuleRegistry.clear();
extraTypeMatchRules.clear();
};
/** 本地解析配置函数,避免与 form.ts 循环依赖 */

View File

@ -0,0 +1,92 @@
/*
* 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 { FormConfig, ValidateError } from '../schema';
/**
* name config text
*
* @param name - 'a.b.c'
* @param config -
* @returns text undefined
*/
export const getTextByName = (name: string, config: FormConfig = []): string | undefined => {
if (!name || !Array.isArray(config)) return undefined;
const nameParts = name.split('.');
const findInConfig = (configs: FormConfig, parts: string[]): string | undefined => {
if (parts.length === 0) return undefined;
const [currentPart, ...remainingParts] = parts;
for (const item of configs) {
if (item.name === currentPart) {
if (remainingParts.length === 0) {
return typeof item.text === 'string' ? item.text : undefined;
}
if ('items' in item && Array.isArray(item.items)) {
const result = findInConfig(item.items, remainingParts);
if (result !== undefined) return result;
}
}
if ('items' in item && Array.isArray(item.items)) {
const result = findInConfig(item.items, parts);
if (result !== undefined) return result;
}
}
return undefined;
};
return findInConfig(config, nameParts);
};
/**
* invalidFields `<br>`
*
* `useFieldTextInError` `true` text 退 name
*
* Form.vue `submitForm` / `validate``validateValues`
*
*
* @param invalidFields - async-validator
* @param [options] -
* @param [options.config] - text
* @param [options.useFieldTextInError=true] - 使 text
* @returns
*/
export const formatValidateError = (
invalidFields: Record<string, any>,
{ config = [], useFieldTextInError = true }: { config?: FormConfig; useFieldTextInError?: boolean } = {},
): string => {
const error: string[] = [];
Object.entries(invalidFields).forEach(([prop, validateError]) => {
(validateError as ValidateError[]).forEach(({ field, message }) => {
const name = field || prop;
const text = (useFieldTextInError ? getTextByName(name, config) : undefined) || name;
error.push(`${text} -> ${message}`);
});
});
return error.join('<br>');
};

View File

@ -0,0 +1,189 @@
/*
* 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 { computed, reactive } from 'vue';
import Schema from 'async-validator';
import type { FormConfig, FormState, FormValue } from '../schema';
import { type CollectedField, collectValidatableFields } from './collectFields';
import { applyExtendState, createFormStateBase, initValue } from './form';
import { formatValidateError } from './validateError';
// #region HeadlessFormStateOptions
/** 构造无渲染 formState 所需的参数(与 MForm 的同名 props 对齐) */
export interface HeadlessFormStateOptions {
config: FormConfig;
initValues?: FormValue;
parentValues?: FormValue;
keyProp?: string;
popperClass?: string;
}
// #endregion HeadlessFormStateOptions
/**
* `formState`
*
* `Form.vue` provide `mForm` 使
* `display` / `rules.validator` / `filter`
*
* `$emit` `fields`
*
*/
export const createHeadlessFormState = (options: HeadlessFormStateOptions): FormState => {
const state: FormState = {
keyProp: options.keyProp ?? '__key',
popperClass: options.popperClass ?? '',
config: options.config,
initValues: options.initValues ?? {},
isCompare: false,
lastValues: {},
parentValues: options.parentValues ?? {},
values: {},
lastValuesProcessed: {},
$emit: () => undefined,
...createFormStateBase(),
};
return reactive(state);
};
/**
* prop `undefined`
*
* `@tmagic/utils` `getValueByKeyPath`
* Element Plus FormItem `undefined`
*/
const getFieldValue = (prop: string, values: FormValue): any => {
if (!prop) return undefined;
return prop.split('.').reduce<any>((acc, key) => {
if (acc === null || typeof acc !== 'object') return undefined;
return acc[key];
}, values);
};
/**
* async-validator `fields` `null`
*
* Element Plus FormItem
* `Schema({ [prop]: rules })` source
* `firstFields` `trigger` FormItem
* async-validator
*/
const validateField = async (field: CollectedField, values: FormValue): Promise<Record<string, any> | null> => {
const rules = field.rules.map(({ trigger, ...rule }: any) => rule);
if (!rules.length) return null;
const value = getFieldValue(field.prop, values);
try {
await new Schema({ [field.prop]: rules } as any).validate({ [field.prop]: value }, { firstFields: true });
return null;
} catch (err: any) {
// async-validator reject 的形态为 { errors, fields }
if (err?.fields) return err.fields;
return { [field.prop]: [{ field: field.prop, message: err?.message ?? `${err}` }] };
}
};
// #region ValidateValuesOptions
/** `validateValues` 参数 */
export interface ValidateValuesOptions extends HeadlessFormStateOptions {
/** 是否开启类型匹配校验 */
typeMatchValid?: boolean;
/**
* 使 text `true`
*/
useFieldTextInError?: boolean;
/** 扩展 formState与 MForm 的同名 prop 语义一致(只能新增字段,不能覆盖内置字段) */
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
}
// #endregion ValidateValuesOptions
// #region ValidateValuesResult
/** `validateValues` 结果 */
export interface ValidateValuesResult {
/** 经 `initValue` 初始化并复刻挂载副作用后的表单值 */
values: FormValue;
/** 汇总后的错误文案(多条以 `<br>` 拼接),校验通过为空字符串 */
error: string;
/** 原始错误映射,形如 `{ [prop]: [{ field, message }] }` */
invalidFields: Record<string, any>;
}
// #endregion ValidateValuesResult
/**
* config +
*
* DOM
*
* 1. headless `formState` `extendState`
* 2. `initValue` `onInitValue`
* 3. config FormItem
* 4. async-validator
*
* @example
* ```ts
* const { error, values } = await validateValues({
* config: [...],
* initValues: { name: '' },
* typeMatchValid: true,
* });
* ```
*/
export const validateValues = async (options: ValidateValuesOptions): Promise<ValidateValuesResult> => {
const { config, initValues = {}, typeMatchValid, useFieldTextInError = true, extendState } = options;
const formState = createHeadlessFormState(options);
// formState 的内置 key 快照extendState 只能新增字段,不能覆盖这些字段,与 Form.vue 语义一致
const reservedStateKeys = new Set<string | symbol>(Reflect.ownKeys(formState));
if (typeof extendState === 'function') {
try {
applyExtendState(formState, await extendState(formState), reservedStateKeys);
} catch (e) {
console.error('[MForm] extendState failed:', e);
}
}
const values = await initValue(formState, { initValues, config });
formState.values = values;
const fields = collectValidatableFields(
formState,
config,
values,
computed(() => Boolean(typeMatchValid)),
);
const invalidFields: Record<string, any> = {};
for (const field of fields) {
const fieldErrors = await validateField(field, values);
if (fieldErrors) {
Object.assign(invalidFields, fieldErrors);
}
}
return {
values,
invalidFields,
error: formatValidateError(invalidFields, { config, useFieldTextInError }),
};
};

View File

@ -0,0 +1,53 @@
/*
* 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, describe, expect, test } from 'vitest';
import { builtInFields, clearFields, registerBuiltInFields, submitForm, validateForm } from '@form/headless';
afterEach(() => {
clearFields();
});
describe('@tmagic/form/headless', () => {
test('纯 Node 环境可登记内置字段并校验', async () => {
registerBuiltInFields(builtInFields);
const error = await validateForm({
config: [{ type: 'text', name: 'username', text: '用户名', rules: [{ required: true, message: '必填' }] }],
initValues: { username: '' },
});
expect(error).toContain('用户名');
expect(error).toContain('必填');
const values = await submitForm({
config: [{ type: 'text', name: 'username', text: '用户名', rules: [{ required: true, message: '必填' }] }],
initValues: { username: 'ok' },
});
expect(values.username).toBe('ok');
});
test('dialog: true 在 headless 入口直接拒绝', async () => {
await expect(
validateForm({
config: [{ type: 'text', name: 'username' }],
dialog: true,
}),
).rejects.toThrow('@tmagic/form/headless');
});
});

View File

@ -0,0 +1,89 @@
/*
* 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.
*/
/**
* validateValues / submitForm / validateForm
*
* submitForm validateForm dialog
* mock MForm DOM
*
*/
import { type AppContext, createApp, defineComponent, h } from 'vue';
import MagicForm from '@form/index';
import ElementPlus from 'element-plus';
/** 必填规则 */
export const required = (message = '必填') => [{ required: true, message }] as any;
/**
* element-plus m-form app appContext
*
* dialog
*/
export const createFormAppContext = (register?: (_app: ReturnType<typeof createApp>) => void): AppContext => {
const parentApp = createApp(defineComponent({ render: () => h('div') }));
parentApp.use(ElementPlus);
parentApp.use(MagicForm);
register?.(parentApp);
return parentApp._context;
};
/** 按文案定位调试弹层的按钮(「确定」/「取消」) */
export const findButton = (text: string): HTMLButtonElement =>
Array.from(document.body.querySelectorAll('button')).find(
(b) => (b.textContent || '').trim() === text,
) as HTMLButtonElement;
/**
* Vue MForm
*
* element-plus jsdom form-item form
* dialog mock
*/
export 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;
};
/** 替换 MForm 实例 expose 出来的方法 */
export const mockExposed = (comp: any, method: string, fn: any): void => {
Object.defineProperty(comp.exposed, method, { value: fn, configurable: true, writable: true });
};
/** 在「没有 document」的环境下执行 fn用于模拟纯 Node 运行时 */
export const withoutDocument = async <T>(fn: () => Promise<T>): Promise<T> => {
const originalDocument = globalThis.document;
delete (globalThis as any).document;
try {
return await fn();
} finally {
(globalThis as any).document = originalDocument;
}
};
/** 捕获 fn 抛出的异常(用于断言 reject 的具体内容) */
export const captureError = async (fn: () => Promise<unknown>): Promise<any> => {
try {
await fn();
} catch (e) {
return e;
}
return null;
};

View File

@ -16,67 +16,108 @@
* limitations under the License.
*/
import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
import { type AppContext, createApp, defineComponent, h, inject, nextTick } from 'vue';
import MagicForm, { FORM_SILENT_MODE_KEY, submitForm, validateForm } from '@form/index';
import ElementPlus from 'element-plus';
import { type AppContext, defineComponent, h, nextTick } from 'vue';
import { clearFields, registerFields, submitForm } from '@form/index';
import {
captureError,
createFormAppContext,
findButton,
findMFormInstance,
mockExposed,
required,
withoutDocument,
} from './helpers/formValidation';
let appContext: AppContext;
// 探针字段:挂载时注入静默标记并记录,用于验证 submitForm/validateForm 的 provide 行为
const silentProbeValues: (boolean | undefined)[] = [];
const SilentProbe = defineComponent({
name: 'MFieldsSilentProbe',
// 探针字段:被真实实例化时记一次用于区分「纯逻辑校验」与「dialog 弹层真实渲染」
const probeMountCount = { value: 0 };
const MountProbe = defineComponent({
name: 'MFieldsMountProbe',
setup() {
silentProbeValues.push(inject(FORM_SILENT_MODE_KEY, undefined));
probeMountCount.value += 1;
return () => h('div');
},
});
beforeAll(() => {
// 构造一个父级 app把 element-plus 与 m-form 插件装上,
// 之后通过 appContext 传给 submitForm 复用全局注册
const parentApp = createApp(defineComponent({ render: () => h('div') }));
parentApp.use(ElementPlus);
parentApp.use(MagicForm);
parentApp.component('m-fields-silent-probe', SilentProbe);
appContext = parentApp._context;
appContext = createFormAppContext((app) => app.component('m-fields-mount-probe', MountProbe));
});
afterEach(() => {
document.body.innerHTML = '';
probeMountCount.value = 0;
clearFields();
});
/** 探针配置mount-probe 不是内置 type无渲染路径不会实例化该组件 */
const probeConfig = [{ type: 'mount-probe', name: 'text', text: 'text' }];
// submitForm 走无渲染实现:不挂载组件、不需要 DOM。
// 校验引擎本身的行为在 utils/validateValues.spec.ts 中覆盖;
// 此处聚焦 submitForm 这一层:返回值形态、无 DOM 可用、未登记 type 不挂载、dialog 弹层。
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' };
test('校验通过时 resolve 表单值,且不产生任何 DOM', async () => {
const baseChildCount = document.body.children.length;
const values = await submitForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues,
initValues: { text: 'hello' },
});
expect(values).toEqual({ text: 'hello' });
expect(document.body.children.length).toBe(baseChildCount);
expect(document.body.querySelector('.m-form')).toBeNull();
});
test('校验失败时以错误文案 reject', async () => {
const caught = await captureError(() =>
submitForm({
config: [{ type: 'text', name: 'name', text: '名称', rules: required() }],
initValues: { name: '' },
}),
);
expect(caught).toBeInstanceOf(Error);
expect(caught.message).toBe('名称 -> 必填');
});
test('config 中的 defaultValue 会被填入结果', async () => {
const values = await submitForm({
config: [
{ type: 'text', name: 'text', text: 'text' },
{ type: 'text', name: 'withDefault', text: 'withDefault', defaultValue: 'fallback' },
] as any,
initValues: { text: 'hello' },
});
expect(values).toEqual({ text: 'hello', withDefault: 'fallback' });
});
test('native=true 时返回未经 clone 的 values', async () => {
const values = await submitForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'origin' },
native: true,
appContext,
});
expect(values).toEqual({ text: 'origin' });
});
test('默认native 未开启)返回的是深拷贝,不与调用方入参共享引用', async () => {
const initValues = { object: { nested: 'b' } };
const values = await submitForm({
config: [{ name: 'object', items: [{ type: 'text', name: 'nested', text: 'nested' }] }],
initValues,
});
expect(values).toEqual({ object: { nested: 'b' } });
values.object.nested = 'mutated';
expect(initValues.object.nested).toBe('b');
});
test('支持 extendState 扩展状态', async () => {
const extendState = vi.fn(async () => ({ extra: 'value' }));
@ -84,7 +125,6 @@ describe('submitForm', () => {
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'foo' },
extendState,
appContext,
});
expect(extendState).toHaveBeenCalled();
@ -95,7 +135,6 @@ describe('submitForm', () => {
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'foo' },
extendState: () => ({ keyProp: 'custom', extra: 'value' }),
appContext,
});
// keyProp 属于内置保留字段,被静默跳过,不污染最终 values
@ -106,66 +145,46 @@ describe('submitForm', () => {
const values = await submitForm({
config: [
{ type: 'text', name: 'name', text: 'name' },
{
name: 'object',
items: [{ type: 'text', name: 'nested', text: 'nested' }],
},
{ name: 'object', items: [{ type: 'text', name: 'nested', text: 'nested' }] },
],
initValues: {
name: 'a',
object: { nested: 'b' },
},
appContext,
initValues: { name: 'a', object: { nested: 'b' } },
});
expect(values).toEqual({
name: 'a',
object: { nested: 'b' },
});
expect(values).toEqual({ name: 'a', object: { nested: 'b' } });
});
test('returnChangeRecords=true 时返回 { values, changeRecords }', async () => {
test('returnChangeRecords=true 时返回 { values, changeRecords },无渲染下变更记录为空', async () => {
const result = await submitForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
returnChangeRecords: true,
appContext,
});
expect(result).toHaveProperty('values');
expect(result).toHaveProperty('changeRecords');
expect(result.values).toEqual({ text: 'hello' });
expect(Array.isArray(result.changeRecords)).toBe(true);
// 无渲染校验没有用户交互,因此不存在变更记录
expect(result.changeRecords).toEqual([]);
});
test('未设置 returnChangeRecords 时仅返回 values不包裹', async () => {
const result = await submitForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
appContext,
});
expect(result).toEqual({ text: 'hello' });
expect(result).not.toHaveProperty('changeRecords');
});
test('多次连续调用不会相互干扰', async () => {
test('多次并发调用互不干扰', async () => {
const config = [{ type: 'text', name: 'text', text: 'text' }];
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,
}),
submitForm({ config, initValues: { text: 'first' } }),
submitForm({ config, initValues: { text: 'second' } }),
]);
expect(v1).toEqual({ text: 'first' });
expect(v2).toEqual({ text: 'second' });
expect(document.body.querySelector('.m-form')).toBeNull();
});
test('多次串行调用后 document.body 不留下任何节点', async () => {
@ -175,152 +194,109 @@ describe('submitForm', () => {
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;
test('signal 已中断时立即以 reason 抛错', async () => {
const controller = new AbortController();
const reason = new Error('canceled by caller');
controller.abort(reason);
// 模拟纯 Node 环境
delete (globalThis as any).document;
let caught: any = null;
try {
await submitForm({
await expect(
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('静默(隐藏挂载)模式下向字段 provide FORM_SILENT_MODE_KEY=true', async () => {
silentProbeValues.length = 0;
const values = await submitForm({
config: [{ type: 'silent-probe', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
appContext,
});
expect(values).toEqual({ text: 'hello' });
expect(silentProbeValues).toEqual([true]);
});
test('静默标记不会泄漏到父级应用的 provides', async () => {
silentProbeValues.length = 0;
await submitForm({
config: [{ type: 'silent-probe', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
appContext,
});
expect((appContext as any).provides[FORM_SILENT_MODE_KEY as symbol]).toBeUndefined();
});
test('validateForm 静默模式下同样 provide FORM_SILENT_MODE_KEY=true', async () => {
silentProbeValues.length = 0;
const error = await validateForm({
config: [{ type: 'silent-probe', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
appContext,
});
expect(error).toBe('');
expect(silentProbeValues).toEqual([true]);
});
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();
initValues: { text: 'a' },
signal: controller.signal,
}),
).rejects.toBe(reason);
});
});
describe('submitForm —— debug 模式', () => {
const findButton = (text: string) =>
Array.from(document.body.querySelectorAll('button')).find(
(b) => (b.textContent || '').trim() === text,
) as HTMLButtonElement;
describe('submitForm —— 无 DOM 环境', () => {
test('全为内置类型时,无 document 也能取回表单值', async () => {
const values = await withoutDocument(() =>
submitForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'no-dom' },
}),
);
// 通过 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;
};
expect(values).toEqual({ text: 'no-dom' });
});
const mockExposed = (comp: any, method: string, fn: any) => {
Object.defineProperty(comp.exposed, method, { value: fn, configurable: true, writable: true });
};
test('无 document 时校验规则依然生效', async () => {
const caught = await withoutDocument(() =>
captureError(() =>
submitForm({
config: [{ type: 'text', name: 'name', text: '名称', rules: required() }],
initValues: { name: '' },
}),
),
);
test('debug 模式可见渲染弹层,点击「确定」校验通过后 resolve 表单值并清理 DOM', async () => {
expect(caught).toBeInstanceOf(Error);
expect(caught.message).toBe('名称 -> 必填');
});
test('无 document 时未登记 type 也能提交', async () => {
const values = await withoutDocument(() =>
submitForm({ config: probeConfig, initValues: { text: 'hello' }, appContext }),
);
expect(values).toEqual({ text: 'hello' });
});
});
describe('submitForm —— 未登记字段 type', () => {
test('即使有 DOM 也不挂载字段组件,无 rules 时直接 resolve', async () => {
const baseChildCount = document.body.children.length;
const values = await submitForm({ config: probeConfig, initValues: { text: 'hello' }, appContext });
expect(values).toEqual({ text: 'hello' });
expect(probeMountCount.value).toBe(0);
expect(document.body.children.length).toBe(baseChildCount);
});
test('登记为叶子字段后提交仍不渲染任何组件', async () => {
registerFields({ 'mount-probe': {} });
const values = await submitForm({ config: probeConfig, initValues: { text: 'hello' }, appContext });
expect(values).toEqual({ text: 'hello' });
expect(probeMountCount.value).toBe(0);
});
test('登记为叶子字段后,无 DOM 也能提交', async () => {
registerFields({ 'totally-unknown': {} });
const values = await withoutDocument(() =>
submitForm({
config: [{ type: 'totally-unknown', name: 'x', text: 'X' }] as any,
initValues: { x: 'v' },
}),
);
expect(values).toEqual({ x: 'v' });
});
});
describe('submitForm —— dialog 弹层', () => {
test('可见渲染弹层,点击「确定」校验通过后 resolve 表单值并清理 DOM', async () => {
const pending = submitForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
debug: true,
dialog: true,
appContext,
});
await nextTick();
await nextTick();
// debug 模式容器未隐藏,表单可见渲染
// 弹层容器未隐藏,表单可见渲染
expect(document.body.querySelector('.m-form')).not.toBeNull();
findButton('确定').click();
@ -330,11 +306,29 @@ describe('submitForm —— debug 模式', () => {
expect(document.body.querySelector('.m-form')).toBeNull();
});
test('弹层标题可配置', async () => {
const pending = submitForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
dialog: true,
title: '编辑配置',
appContext,
});
await nextTick();
await nextTick();
expect(document.body.textContent).toContain('编辑配置');
expect(document.body.textContent).not.toContain('submitForm');
findButton('取消').click();
await captureError(() => pending);
});
test('点击「取消」以错误 reject 并清理 DOM', async () => {
const pending = submitForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
debug: true,
dialog: true,
appContext,
});
await nextTick();
@ -342,12 +336,7 @@ describe('submitForm —— debug 模式', () => {
findButton('取消').click();
let caught: any = null;
try {
await pending;
} catch (e) {
caught = e;
}
const caught = await captureError(() => pending);
expect(caught).toBeInstanceOf(Error);
expect(caught.message).toContain('canceled');
@ -358,20 +347,18 @@ describe('submitForm —— debug 模式', () => {
const pending = submitForm({
config: [{ type: 'text', name: 'name', text: '名称' }],
initValues: { name: '' },
debug: true,
dialog: 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) =>
@ -388,53 +375,20 @@ describe('submitForm —— debug 模式', () => {
// promise 仍 pending点击取消以 reject 结束,避免悬挂
findButton('取消').click();
let caught: any = null;
try {
await pending;
} catch (e) {
caught = e;
}
const caught = await captureError(() => pending);
expect(caught).toBeInstanceOf(Error);
expect(caught.message).toContain('canceled');
expect(document.body.querySelector('.m-form')).toBeNull();
});
test('debug 模式不提供静默标记(表单可见,字段应正常渲染)', async () => {
silentProbeValues.length = 0;
const pending = submitForm({
config: [{ type: 'silent-probe', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
debug: true,
appContext,
});
test('字段被真实实例化dialog 是唯一会渲染的路径)', async () => {
const pending = submitForm({ config: probeConfig, initValues: { text: 'hello' }, dialog: true, appContext });
await nextTick();
await nextTick();
expect(silentProbeValues).toEqual([undefined]);
expect(probeMountCount.value).toBe(1);
findButton('确定').click();
await pending;
});
test('debug 模式不注册超时定时器(等待人工操作)', async () => {
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
const pending = submitForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
debug: true,
timeout: 5000,
appContext,
});
await nextTick();
await nextTick();
const calledWithTimeout = setTimeoutSpy.mock.calls.some(([, delay]) => delay === 5000);
expect(calledWithTimeout).toBe(false);
findButton('确定').click();
await pending;
setTimeoutSpy.mockRestore();
});
});

View File

@ -16,7 +16,7 @@
* limitations under the License.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { deleteField, getConfig, getField, registerField, setConfig } from '@form/utils/config';
import { getConfig, setConfig } from '@form/utils/config';
describe('config.ts', () => {
beforeEach(() => {
@ -50,17 +50,4 @@ describe('config.ts', () => {
test('在未设置时获取Config', () => {
expect(getConfig('model')).toBeUndefined();
});
test('registerField/getField/deleteField 完整流程', () => {
const fakeComp: any = { name: 'fake' };
registerField('fake-field', fakeComp);
expect(getField('fake-field')).toBe(fakeComp);
registerField('fake-field', { name: 'other' } as any);
expect(getField('fake-field')).toBe(fakeComp);
expect(deleteField('fake-field')).toBe(true);
expect(getField('fake-field')).toBeUndefined();
expect(deleteField('fake-field')).toBe(false);
});
});

View File

@ -0,0 +1,243 @@
/*
* 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, describe, expect, test } from 'vitest';
import { createApp, defineComponent } from 'vue';
import {
builtInFields,
clearFields,
collectValidatableFields,
getFormField,
getTypeMatchRule,
isLeafFieldType,
mergeFieldOptions,
registerBuiltInFields,
registerField,
registerFields,
unregisterField,
} from '@form/index';
const FakeA = defineComponent({ name: 'FakeA', render: () => null });
const FakeB = defineComponent({ name: 'FakeB', render: () => null });
const requiredText = (name: string) => ({
type: 'text',
name,
text: '内部',
rules: [{ required: true, message: '必填' }],
});
/** 内部渲染一个名为 inner 的必填文本 */
const innerTextNested = ({ config, model, prop }: any) => ({
config: [requiredText('inner')],
model: model[config.name],
prop,
});
/** 与 innerTextNested 同形,但内部字段名不同,用来区分生效的是哪一份登记 */
const renameInnerNested = ({ config, model, prop }: any) => ({
config: [requiredText('renamed')],
model: model[config.name],
prop,
});
afterEach(() => {
clearFields();
});
describe('registerField component', () => {
test('写入字段注册表Container 可通过 getFormField 取到组件', () => {
registerField('my-color-picker', { component: FakeA });
expect(getFormField('my-color-picker')).toBe(FakeA);
});
test('type 名支持驼峰与中划线互通', () => {
registerField('myColorPicker', { component: FakeA });
expect(getFormField('my-color-picker')).toBe(FakeA);
});
test('后一次 component 覆盖前一次', () => {
registerField('my-field', { component: FakeA });
registerField('my-field', { component: FakeB });
expect(getFormField('my-field')).toBe(FakeB);
});
test('省略 component 时不改动已登记的 Vue 组件', () => {
registerField('my-field', { component: FakeA });
registerField('my-field', {
typeMatch: () => undefined,
});
expect(getFormField('my-field')).toBe(FakeA);
});
test('unregisterField / clearFields 移除已登记的组件', () => {
registerFields({ 'my-field': { component: FakeA } });
unregisterField('my-field');
expect(getFormField('my-field')).toBeUndefined();
registerField('keep', { component: FakeB });
clearFields();
expect(getFormField('keep')).toBeUndefined();
});
test('不传 app 时不调用 app.component', () => {
const app = createApp({});
registerField('my-field', { component: FakeA });
expect(getFormField('my-field')).toBe(FakeA);
expect(app.component('m-fields-my-field')).toBeUndefined();
});
test('传入 app 时同步 app.component', () => {
const app = createApp({});
registerField('my-field', { component: FakeA }, app);
expect(getFormField('my-field')).toBe(FakeA);
expect(app.component('m-fields-my-field')).toBe(FakeA);
});
test('container 写入注册表,传入 app 时登记 m-form-*', () => {
const app = createApp({});
registerField('my-box', { container: FakeA }, app);
expect(getFormField('my-box')).toBe(FakeA);
expect(app.component('m-form-my-box')).toBe(FakeA);
expect(app.component('m-fields-my-box')).toBeUndefined();
});
test('install 时内置 container 登记 m-form-*', async () => {
const plugin = (await import('@form/plugin')).default;
const app = createApp({});
plugin.install(app, {});
expect(getFormField('tab')).toBeTruthy();
expect(app.component('m-form-tab')).toBeTruthy();
expect(app.component('m-form-container')).toBeTruthy();
expect(app.component('m-fields-tab')).toBeUndefined();
});
test('install 时 fields.component 把 app 传给 registerFields', async () => {
const plugin = (await import('@form/plugin')).default;
const app = createApp({});
plugin.install(app, {
fields: {
'install-comp': { component: FakeA },
},
});
expect(getFormField('install-comp')).toBe(FakeA);
expect(app.component('m-fields-install-comp')).toBe(FakeA);
});
});
describe('builtInFields', () => {
test('不含 Vue 组件,供 Node 侧无渲染校验使用', () => {
for (const [type, options] of Object.entries(builtInFields)) {
expect(options.component, type).toBeUndefined();
expect(options.container, type).toBeUndefined();
}
});
test('registerBuiltInFields(builtInFields) 后 clearFields 清不掉内置叶子', () => {
registerBuiltInFields(builtInFields);
clearFields();
expect(isLeafFieldType('text')).toBe(true);
expect(isLeafFieldType('tab')).toBe(false);
});
test('mergeFieldOptions 把 component / container 叠到无渲染表上', () => {
const merged = mergeFieldOptions(
{ text: {}, tab: { walk: () => undefined } },
{ text: { component: FakeA }, tab: { container: FakeB } },
);
expect(merged.text.component).toBe(FakeA);
expect(merged.tab.container).toBe(FakeB);
expect(merged.tab.walk).toEqual(expect.any(Function));
});
test('mergeFieldOptions 后一份只覆盖自己带的 key', () => {
const nested = () => undefined;
const typeMatch = () => undefined;
const merged = mergeFieldOptions(
{ 'code-select': { nested, typeMatch } },
{ 'code-select': { component: FakeA } },
{ 'code-select': { component: FakeB }, 'my-field': { component: FakeA } },
);
expect(merged['code-select'].component).toBe(FakeB);
expect(merged['code-select'].nested).toBe(nested);
expect(merged['code-select'].typeMatch).toBe(typeMatch);
expect(merged['my-field'].component).toBe(FakeA);
});
test('多次 registerField 按字段合并typeMatch 不会丢掉 nested', () => {
registerField('my-composite', { nested: innerTextNested });
registerField('my-composite', { typeMatch: () => undefined });
expect(getTypeMatchRule('my-composite')).toBeTypeOf('function');
expect(
collectValidatableFields(undefined, [{ type: 'my-composite', name: 'outer' }] as any, {
outer: { inner: '' },
}).map((field) => field.prop),
).toEqual(['outer.inner']);
});
test('registerBuiltInFields 的 typeMatch 不会被 clearFields 清掉', () => {
registerBuiltInFields({
'built-in-match': { typeMatch: () => 'built-in' },
});
clearFields();
expect(getTypeMatchRule('built-in-match')).toBeTypeOf('function');
});
test('registerBuiltInFields 的 nested 不会被 clearFields / unregisterField 清掉', () => {
registerBuiltInFields({ 'built-in-nested': { nested: innerTextNested } });
const collect = () =>
collectValidatableFields(undefined, [{ type: 'built-in-nested', name: 'outer' }] as any, {
outer: { inner: '' },
}).map((field) => field.prop);
expect(collect()).toEqual(['outer.inner']);
unregisterField('built-in-nested');
expect(collect()).toEqual(['outer.inner']);
clearFields();
expect(collect()).toEqual(['outer.inner']);
});
test('业务侧 nested 覆盖内置unregisterField 后回落到内置', () => {
registerBuiltInFields({ 'both-nested': { nested: innerTextNested } });
registerField('both-nested', { nested: renameInnerNested });
const collect = () =>
collectValidatableFields(undefined, [{ type: 'both-nested', name: 'outer' }] as any, {
outer: { inner: '', renamed: '' },
}).map((field) => field.prop);
expect(collect()).toEqual(['outer.renamed']);
unregisterField('both-nested');
expect(collect()).toEqual(['outer.inner']);
});
test('内置登记 nested 不会清掉业务侧已登记的叶子', () => {
registerField('leaf-then-built-in', {});
expect(isLeafFieldType('leaf-then-built-in')).toBe(true);
registerBuiltInFields({ 'leaf-then-built-in': { nested: innerTextNested } });
expect(isLeafFieldType('leaf-then-built-in')).toBe(true);
});
});

View File

@ -19,6 +19,7 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import type { FormState } from '@form/index';
import { getRules } from '@form/utils/form';
import { clearFields } from '@form/utils/registerField';
import {
clearTypeMatchRules,
createTypeMatchValidator,
@ -30,6 +31,7 @@ import {
} from '@form/utils/typeMatch';
import { setDesignConfig } from '@tmagic/design';
import { getDesignConfig } from '@tmagic/design/headless';
const mForm: FormState = {
config: [],
@ -621,6 +623,18 @@ describe('getRules typeMatch', () => {
});
});
describe('design 配置入口', () => {
// 无渲染链路读的是 @tmagic/design/headless窄入口不带组件而 app.use(designPlugin)
// 写的是 @tmagic/design。两个入口必须落在同一份 design 配置上,否则适配器判定会静默失效。
test('@tmagic/design 与 @tmagic/design/headless 共用同一份配置', () => {
setDesignConfig({ adapterType: 'tdesign-vue-next' });
expect(getDesignConfig('adapterType')).toBe('tdesign-vue-next');
setDesignConfig({});
expect(getDesignConfig('adapterType')).toBeUndefined();
});
});
describe('getRules tdesign validator', () => {
beforeEach(() => {
setDesignConfig({ adapterType: 'tdesign-vue-next' });
@ -816,25 +830,40 @@ describe('typeMatch 扩展注册', () => {
});
expect(validateTypeMatch(1, mForm, propsOf({ type: 'batch' }))).toBe('batch error');
});
test('clearTypeMatchRules / deleteTypeMatchRule 不清内置规则', () => {
registerTypeMatchRule('built-in-keep', () => 'built-in', true);
registerTypeMatchRule('built-in-keep', () => 'extra');
expect(validateTypeMatch(1, mForm, propsOf({ type: 'built-in-keep' }))).toBe('extra');
expect(deleteTypeMatchRule('built-in-keep')).toBe(true);
expect(validateTypeMatch(1, mForm, propsOf({ type: 'built-in-keep' }))).toBe('built-in');
registerTypeMatchRule('built-in-keep', () => 'extra-again');
clearTypeMatchRules();
expect(validateTypeMatch(1, mForm, propsOf({ type: 'built-in-keep' }))).toBe('built-in');
});
});
describe('plugin typeMatchRules', () => {
describe('plugin fields', () => {
beforeEach(() => {
clearTypeMatchRules();
clearFields();
});
afterEach(() => {
clearTypeMatchRules();
clearFields();
});
test('install 时注册 typeMatchRules', async () => {
test('install 时注册 fields', async () => {
const { createApp } = await import('vue');
const plugin = (await import('@form/plugin')).default;
const app = createApp({});
plugin.install(app, {
typeMatchRules: {
'install-type': (value) => (value === 'ok' ? undefined : 'install error'),
fields: {
'install-type': {
typeMatch: (value) => (value === 'ok' ? undefined : 'install error'),
},
},
});

View File

@ -0,0 +1,764 @@
/*
* 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 { createApp, defineComponent } from 'vue';
import MagicForm, {
builtInFields,
clearFields,
collectValidatableFields,
createHeadlessFormState,
getTypeMatchRule,
isFieldNestedConfigError,
isLeafFieldType,
registerBuiltInFields,
registerField,
registerFields,
unregisterField,
validateValues,
} from '@form/index';
import { required } from '../helpers/formValidation';
/** plugin.ts 注册的内置字段 type */
const BUILT_IN_FIELD_TYPES = [
'text',
'img-upload',
'number',
'number-range',
'textarea',
'hidden',
'date',
'datetime',
'daterange',
'timerange',
'time',
'checkbox',
'switch',
'color-picker',
'checkbox-group',
'radio-group',
'display',
'link',
'select',
'cascader',
'dynamic-field',
'component',
];
/** 收集字段 prop 列表,用于断言「哪些字段参与了校验」 */
const collectProps = (config: any, values: any) => {
const formState = createHeadlessFormState({ config, initValues: values });
formState.values = values;
const fields = collectValidatableFields(formState, config, values);
return { props: fields.map((f) => f.prop) };
};
beforeAll(() => {
registerBuiltInFields(builtInFields);
});
afterEach(() => {
clearFields();
});
describe('validateValues —— 基础校验', () => {
test('required 规则生效,错误文案使用字段 text', async () => {
const { error, invalidFields } = await validateValues({
config: [{ type: 'text', name: 'name', text: '名称', rules: required() }] as any,
initValues: { name: '' },
});
expect(error).toBe('名称 -> 必填');
expect(invalidFields).toHaveProperty('name');
});
test('校验通过时 error 为空字符串', async () => {
const { error, invalidFields } = await validateValues({
config: [{ type: 'text', name: 'name', text: '名称', rules: required() }] as any,
initValues: { name: 'a' },
});
expect(error).toBe('');
expect(invalidFields).toEqual({});
});
test('useFieldTextInError=false 时用字段 name 作为前缀', async () => {
const { error } = await validateValues({
config: [{ type: 'text', name: 'name', text: '名称', rules: required() }] as any,
initValues: { name: '' },
useFieldTextInError: false,
});
expect(error).toBe('name -> 必填');
});
test('多条错误以 <br> 拼接', async () => {
const { error } = await validateValues({
config: [
{ type: 'text', name: 'a', text: 'A', rules: required('A必填') },
{ type: 'text', name: 'b', text: 'B', rules: required('B必填') },
] as any,
initValues: { a: '', b: '' },
});
expect(error).toBe('A -> A必填<br>B -> B必填');
});
test('无 rules 的字段不参与校验', async () => {
const { props } = collectProps([{ type: 'text', name: 'a', text: 'A' }], { a: '' });
expect(props).toEqual([]);
});
test('trigger 只是 FormItem 的元信息,不影响规则匹配方式', async () => {
// 若把 trigger 一起交给 async-validator{ required: true } 会从 required 校验器
// 退化为 string 校验器,行为随之改变,这里断言两者结果一致
const withTrigger = await validateValues({
config: [
{ type: 'text', name: 'a', text: 'A', rules: [{ required: true, message: '必填', trigger: 'blur' }] },
] as any,
initValues: { a: '' },
});
const withoutTrigger = await validateValues({
config: [{ type: 'text', name: 'a', text: 'A', rules: required() }] as any,
initValues: { a: '' },
});
expect(withTrigger.error).toBe(withoutTrigger.error);
});
test('自定义 validator 可拿到 mForm 与 model 上下文', async () => {
const validator = vi.fn(({ value, callback }: any, ctx: any, mForm: any) => {
expect(ctx.model).toMatchObject({ a: 'x' });
expect(mForm.initValues).toMatchObject({ a: 'x' });
callback(value === 'x' ? new Error('不能是 x') : undefined);
});
const { error } = await validateValues({
config: [{ type: 'text', name: 'a', text: 'A', rules: [{ validator }] }] as any,
initValues: { a: 'x' },
});
expect(validator).toHaveBeenCalled();
expect(error).toBe('A -> 不能是 x');
});
test('typeMatchValid=true 时自动注入类型匹配校验', async () => {
const { error } = await validateValues({
config: [{ type: 'number', name: 'n', text: '数字' }] as any,
initValues: { n: 'not-a-number' },
typeMatchValid: true,
});
expect(error).not.toBe('');
expect(error).toContain('数字');
});
test('extendState 注入的字段可被 display 函数读到', async () => {
const { props } = await (async () => {
const config: any = [
{
type: 'text',
name: 'a',
text: 'A',
rules: required(),
display: (mForm: any) => mForm?.custom === 'on',
},
];
const { error } = await validateValues({
config,
initValues: { a: '' },
extendState: () => ({ custom: 'on' }),
});
return { props: error };
})();
expect(props).toBe('A -> 必填');
});
test('extendState 抛错时不影响校验流程', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const { error } = await validateValues({
config: [{ type: 'text', name: 'a', text: 'A', rules: required() }] as any,
initValues: { a: '' },
extendState: () => {
throw new Error('boom');
},
});
expect(error).toBe('A -> 必填');
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test('extendState 不能覆盖 formState 内置字段', async () => {
const { values } = await validateValues({
config: [{ type: 'text', name: 'a', text: 'A' }] as any,
initValues: { a: 'origin' },
extendState: () => ({ keyProp: 'hacked', initValues: { a: 'hacked' } }),
});
expect(values).toEqual({ a: 'origin' });
});
});
describe('validateValues —— display 判定', () => {
test('display: false 的字段不参与校验', async () => {
const { props } = collectProps([{ type: 'text', name: 'a', text: 'A', display: false, rules: required() }], {
a: '',
});
expect(props).toEqual([]);
});
test('display 函数返回 false 的字段不参与校验', async () => {
const { props } = collectProps([{ type: 'text', name: 'a', text: 'A', display: () => false, rules: required() }], {
a: '',
});
expect(props).toEqual([]);
});
test("display: 'expand' 的字段参与校验(展开与否是交互状态)", async () => {
const { props } = collectProps([{ type: 'text', name: 'a', text: 'A', display: 'expand', rules: required() }], {
a: '',
});
expect(props).toEqual(['a']);
});
test('hidden 字段不看 display始终参与校验', async () => {
const { props } = collectProps([{ type: 'hidden', name: 'a', display: false, rules: required() }], { a: '' });
expect(props).toEqual(['a']);
});
});
describe('validateValues —— 容器的 prop 路径', () => {
test('无 type 的嵌套 items 按 name 下钻', () => {
const { props } = collectProps(
[{ name: 'obj', items: [{ type: 'text', name: 'inner', text: 'I', rules: required() }] }],
{ obj: { inner: '' } },
);
expect(props).toEqual(['obj.inner']);
});
test('无 name 的嵌套 items 不增加层级', () => {
const { props } = collectProps([{ items: [{ type: 'text', name: 'a', text: 'A', rules: required() }] }], { a: '' });
expect(props).toEqual(['a']);
});
test('row / flex-layout 透传 prop', () => {
const row = collectProps([{ type: 'row', items: [{ type: 'text', name: 'a', text: 'A', rules: required() }] }], {
a: '',
});
expect(row.props).toEqual(['a']);
const flex = collectProps(
[{ type: 'flexLayout', items: [{ type: 'text', name: 'a', text: 'A', rules: required() }] }],
{ a: '' },
);
expect(flex.props).toEqual(['a']);
});
test('panel 折叠不影响校验', () => {
const { props } = collectProps(
[
{
type: 'panel',
name: 'p',
expand: false,
items: [{ type: 'text', name: 'a', text: 'A', rules: required() }],
},
],
{ p: { a: '' } },
);
expect(props).toEqual(['p.a']);
});
test('tab按 tab.name 拼接 proplazy 标签页也参与校验', () => {
const { props } = collectProps(
[
{
type: 'tab',
items: [
{ title: 'T1', name: 't1', items: [{ type: 'text', name: 'a', text: 'A', rules: required() }] },
{ title: 'T2', name: 't2', lazy: true, items: [{ type: 'text', name: 'b', text: 'B', rules: required() }] },
],
},
],
{ t1: { a: '' }, t2: { b: '' } },
);
expect(props).toEqual(['t1.a', 't2.b']);
});
test('tabdisplay 为假的标签页不参与校验', () => {
const { props } = collectProps(
[
{
type: 'tab',
items: [
{ title: 'T1', name: 't1', items: [{ type: 'text', name: 'a', text: 'A', rules: required() }] },
{
title: 'T2',
name: 't2',
display: () => false,
items: [{ type: 'text', name: 'b', text: 'B', rules: required() }],
},
],
},
],
{ t1: { a: '' }, t2: { b: '' } },
);
expect(props).toEqual(['t1.a']);
});
test('dynamic tab按下标拼接 prop', () => {
const { props } = collectProps(
[
{
type: 'tab',
dynamic: true,
name: 'tabs',
items: [{ type: 'text', name: 'a', text: 'A', rules: required() }],
},
],
{ tabs: [{ a: '' }, { a: '' }] },
);
expect(props).toEqual(['tabs.0.a', 'tabs.1.a']);
});
test('fieldset勾选框关闭时子项不参与校验', () => {
const config = [
{
type: 'fieldset',
name: 'fs',
expand: true,
checkbox: true,
items: [{ type: 'text', name: 'a', text: 'A', rules: required() }],
},
];
expect(collectProps(config, { fs: { value: 0, a: '' } }).props).toEqual([]);
expect(collectProps(config, { fs: { value: 1, a: '' } }).props).toEqual(['fs.a']);
});
test('stepprop 基准被重置为 step.name', () => {
const { props } = collectProps(
[
{
type: 'step',
items: [
{ title: 'S1', name: 's1', items: [{ type: 'text', name: 'a', text: 'A', rules: required() }] },
{ title: 'S2', name: 's2', items: [{ type: 'text', name: 'b', text: 'B', rules: required() }] },
],
},
],
{ s1: { a: '' }, s2: { b: '' } },
);
expect(props).toEqual(['s1.a', 's2.b']);
});
test('group-list每一行都参与校验包括默认折叠的行', () => {
const rows = Array.from({ length: 9 }, (_, i) => ({ title: `${i}` }));
const { props } = collectProps(
[
{
type: 'group-list',
name: 'list',
items: [{ type: 'text', name: 'title', text: '标题', rules: required() }],
},
],
{ list: rows },
);
// defaultExpandQuantity 默认为 7渲染式校验会漏掉第 8 行起的字段
expect(props).toHaveLength(9);
expect(props[8]).toBe('list.8.title');
});
test('table逐行逐列拼接 prop跳过 hidden 列与 display 为假的列', () => {
const { props } = collectProps(
[
{
type: 'table',
name: 'rows',
items: [
{ type: 'text', name: 'a', label: 'A', rules: required() },
{ type: 'hidden', name: 'h', rules: required() },
{ type: 'text', name: 'c', label: 'C', display: () => false, rules: required() },
],
},
],
{
rows: [
{ a: '', h: '', c: '' },
{ a: '', h: '', c: '' },
],
},
);
expect(props).toEqual(['rows.0.a', 'rows.1.a']);
});
test('table分页配置不会截断校验范围', () => {
const rows = Array.from({ length: 12 }, () => ({ a: '' }));
const { props } = collectProps(
[
{
type: 'table',
name: 'rows',
pagination: true,
items: [{ type: 'text', name: 'a', label: 'A', rules: required() }],
},
],
{ rows },
);
// 渲染式校验只会覆盖当前页(默认 10 条)
expect(props).toHaveLength(12);
});
test('带 text 的容器:容器自身与子项都参与校验', () => {
const { props } = collectProps(
[
{
type: 'group-list',
name: 'list',
text: '列表',
rules: required('列表必填'),
items: [{ type: 'text', name: 'title', text: '标题', rules: required() }],
},
],
{ list: [{ title: '' }] },
);
expect(props).toEqual(['list', 'list.0.title']);
});
});
describe('validateValues —— 挂载副作用复刻', () => {
test('display 字段的 initValue 会写入表单值', async () => {
const { values, error } = await validateValues({
config: [{ type: 'display', name: 'status', text: '状态', initValue: 'ready', rules: required() }] as any,
initValues: {},
});
expect(values.status).toBe('ready');
expect(error).toBe('');
});
test('number-range 字段的非数组值被修正为空数组', async () => {
const { values } = await validateValues({
config: [{ type: 'number-range', name: 'range', text: '区间' }] as any,
initValues: { range: 'not-an-array' },
});
expect(values.range).toEqual([]);
});
test('checkbox-group 字段的空值被初始化为空数组', async () => {
const { values, error } = await validateValues({
config: [{ type: 'checkbox-group', name: 'tags', text: '标签', rules: required() }] as any,
initValues: {},
});
// 与渲染式一致:初始化成空数组后 required 仍然不通过
expect(values.tags).toEqual([]);
expect(error).toBe('标签 -> 必填');
});
test('date 字段按 valueFormat 归一化', async () => {
const { values } = await validateValues({
config: [{ type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' }] as any,
initValues: { start: '2021/07/17 15:37:00' },
});
expect(values.start).toBe('2021-07-17');
});
test('datetime 字段的非法值被归一化为空字符串', async () => {
const { values, error } = await validateValues({
config: [{ type: 'datetime', name: 'start', text: '开始', rules: required() }] as any,
initValues: { start: new Date('nonsense') },
});
expect(values.start).toBe('');
expect(error).toBe('开始 -> 必填');
});
test('datetime 字段按默认 valueFormat 归一化', async () => {
const { values } = await validateValues({
config: [{ type: 'datetime', name: 'start', text: '开始' }] as any,
initValues: { start: '2021/07/17 15:37:00' },
});
expect(values.start).toBe('2021/07/17 15:37:00');
});
});
describe('validateValues —— 未登记 type 与扩展登记', () => {
test('未登记的字段 type 只要有 rules 就校验自身,不必登记为叶子', () => {
const { props } = collectProps([{ type: 'my-custom', name: 'a', text: 'A', rules: required() }], { a: '' });
expect(props).toEqual(['a']);
});
test('未登记且没有 rules / items 的 type 不抛错,也不收集字段', () => {
const { props } = collectProps(
[
{ type: 'my-custom', name: 'a', text: 'A' },
{ type: 'text', name: 'ok', text: 'OK', rules: required() },
],
{ a: '', ok: '' },
);
expect(props).toEqual(['ok']);
});
test('未登记的 type 若配置了 items会下钻校验子项', () => {
const { props } = collectProps(
[
{
type: 'my-layout',
items: [{ type: 'text', name: 'a', text: 'A', rules: required() }],
},
],
{ a: '' },
);
expect(props).toEqual(['a']);
});
test('多个未登记 type 不阻塞遍历,只收集带 rules 的字段', () => {
const { props } = collectProps(
[
{ type: 'alpha-unknown', name: 'a', text: 'A' },
{ type: 'text', name: 'ok', text: 'OK', rules: required() },
{ type: 'beta-unknown', name: 'b', text: 'B' },
{
type: 'panel',
name: 'wrap',
items: [{ type: 'gamma-unknown', name: 'c', text: 'C', rules: required() }],
},
],
{ a: '', ok: '', b: '', wrap: { c: '' } },
);
expect(props).toEqual(['ok', 'wrap.c']);
});
test('内置 type: component 视为叶子:收集自身 rules不下钻 items', () => {
const { props } = collectProps(
[
{
type: 'component',
name: 'a',
text: 'A',
rules: required(),
items: [{ type: 'text', name: 'nested', text: 'Nested', rules: required() }],
},
],
{ a: '', nested: '' },
);
expect(props).toEqual(['a']);
});
test('clearFields 不会清掉内置叶子字段', () => {
registerFields({ 'temp-leaf': {} });
clearFields();
expect(isLeafFieldType('temp-leaf')).toBe(false);
expect(isLeafFieldType('text')).toBe(true);
expect(isLeafFieldType('tab')).toBe(false);
});
test('内置叶子字段都不抛错', () => {
for (const type of BUILT_IN_FIELD_TYPES) {
expect(() => collectProps([{ type, name: 'a', text: 'A' }], { a: '' }), type).not.toThrow();
}
});
test('内置叶子字段表覆盖 plugin 注册的全部 m-fields-* 组件', () => {
const app = createApp(defineComponent({ render: () => null }));
app.use(MagicForm);
const registeredTypes = Object.keys((app._context as any).components)
.filter((name) => name.startsWith('m-fields-'))
.map((name) => name.replace('m-fields-', ''));
expect(registeredTypes.length).toBeGreaterThan(0);
for (const type of registeredTypes) {
expect(isLeafFieldType(type), `${type} 未登记为叶子字段`).toBe(true);
}
});
test('registerFields 登记为叶子后仍收集自身 rules且不下钻 items', () => {
registerFields({ 'my-custom': {} });
const { props } = collectProps(
[
{
type: 'my-custom',
name: 'a',
text: 'A',
rules: required(),
items: [{ type: 'text', name: 'nested', text: 'Nested', rules: required() }],
},
],
{ a: '', nested: '' },
);
expect(props).toEqual(['a']);
});
test('registerField 带 effect 时会复刻写入', async () => {
registerField('my-status', {
effect: ({ config, model }) => {
model[(config as any).name] = 'from-effect';
},
});
const { values, error } = await validateValues({
config: [{ type: 'my-status', name: 'status', text: '状态', rules: required() }] as any,
initValues: {},
});
expect(values.status).toBe('from-effect');
expect(error).toBe('');
});
test('叶子字段 type 名支持驼峰与中划线互通', () => {
registerField('myStatus');
const { props } = collectProps(
[
{
type: 'my-status',
name: 'a',
items: [{ type: 'text', name: 'nested', text: 'Nested', rules: required() }],
},
],
{ a: { nested: '' } },
);
expect(props).toEqual([]);
});
test('登记的叶子字段可覆盖内置字段的 mount effect', async () => {
registerField('display', {
effect: ({ config, model }) => {
model[(config as any).name] = 'overridden';
},
});
const { values } = await validateValues({
config: [{ type: 'display', name: 'status', text: '状态', initValue: 'ready' }] as any,
initValues: {},
});
expect(values.status).toBe('overridden');
});
test('unregisterField 后不再锁死子树,配置里的 items 会下钻', () => {
registerFields({ 'my-custom': {} });
unregisterField('my-custom');
const { props } = collectProps(
[
{
type: 'my-custom',
name: 'wrap',
items: [{ type: 'text', name: 'inner', text: 'Inner', rules: required() }],
},
],
{ wrap: { inner: '' } },
);
expect(props).toEqual(['wrap.inner']);
});
test('registerField nested 可遍历复合字段的内部配置', async () => {
registerField('my-composite', {
nested: ({ config, model }) => ({
config: { type: 'text', name: 'inner', text: '内部', rules: required('内部必填') },
model: model[(config as any).name],
}),
});
const { error } = await validateValues({
config: [{ type: 'my-composite', name: 'wrap', text: '包裹' }] as any,
initValues: { wrap: { inner: '' } },
});
// 嵌套配置不在调用方传入的 config 树上getTextByName 找不到 text回退为 prop 路径
expect(error).toBe('wrap.inner -> 内部必填');
});
test('nested 返回 null 表示该字段没有内部字段', () => {
registerField('my-composite', { nested: () => null });
expect(() => collectProps([{ type: 'my-composite', name: 'a', text: 'A' }], { a: '' })).not.toThrow();
});
test('nested 抛错时把失败原因带出去', () => {
registerField('my-composite', {
nested: () => {
throw new Error('boom');
},
});
expect(() => collectProps([{ type: 'my-composite', name: 'a', text: 'A' }], { a: '' })).toThrow(
/\[MForm\] nested config for "my-composite" at "a" failed: boom/,
);
});
test('nested 抛错时抛出 FieldNestedConfigError可按 code 判别', () => {
registerField('my-composite', {
nested: () => {
throw new Error('boom');
},
});
try {
collectProps([{ type: 'my-composite', name: 'a', text: 'A' }], { a: '' });
expect.unreachable('should throw');
} catch (e) {
expect(isFieldNestedConfigError(e)).toBe(true);
expect((e as { code?: string }).code).toBe('FIELD_NESTED_CONFIG');
expect((e as { type?: string; prop?: string }).type).toBe('my-composite');
expect((e as { type?: string; prop?: string }).prop).toBe('a');
}
});
test('nested 的 type 名支持驼峰与中划线互通', () => {
registerField('myComposite', { nested: () => null });
expect(() => collectProps([{ type: 'my-composite', name: 'a', text: 'A' }], { a: '' })).not.toThrow();
});
test('同时传 nested 与 effect 时告警 effect 会被忽略', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
registerField('my-both', { nested: () => null, effect: () => undefined });
expect(spy).toHaveBeenCalledWith(expect.stringContaining('[MForm] registerField("my-both")'));
expect(spy.mock.calls[0][0]).toContain('mount value effect will be ignored');
spy.mockRestore();
});
test('后一次 registerField 覆盖前一次,不告警', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
registerField('my-both', { effect: () => undefined });
registerField('my-both', { nested: () => null });
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
test('registerField typeMatch 会写入规则表unregisterField 后清除', () => {
registerField('my-custom', {
typeMatch: (value) => (value === 'ok' ? undefined : 'not ok'),
});
expect(getTypeMatchRule('my-custom')).toBeTypeOf('function');
unregisterField('my-custom');
expect(getTypeMatchRule('my-custom')).toBeUndefined();
});
});

View File

@ -16,50 +16,66 @@
* limitations under the License.
*/
import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
import { type AppContext, createApp, defineComponent, h, nextTick } from 'vue';
import MagicForm, { clearSilentLeafFieldTypes, registerSilentLeafFieldTypes, validateForm } from '@form/index';
import ElementPlus from 'element-plus';
import { type AppContext, defineComponent, h, nextTick } from 'vue';
import { clearFields, registerFields, validateForm } from '@form/index';
import {
captureError,
createFormAppContext,
findButton,
findMFormInstance,
mockExposed,
required,
withoutDocument,
} from './helpers/formValidation';
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;
appContext = createFormAppContext();
});
afterEach(() => {
document.body.innerHTML = '';
clearSilentLeafFieldTypes();
clearFields();
});
// 说明validateForm 内部会新建一个独立的 MForm 实例并复用其校验方法 `validate`(返回错误文案、不抛异常
// 校验失败时的错误文案格式由 Form.vue 实例的 `validate` 负责,已在 Form.extra.spec.ts
// 中覆盖;此处聚焦 validateForm 独有的「命令式挂载 / 卸载 / 上下文注入 / 超时」等行为
// validateForm 走无渲染实现(不挂载任何组件、不需要 DOM
// 校验引擎本身的行为在 utils/validateValues.spec.ts 中覆盖;
// 此处聚焦 validateForm 这一层:静默语义、无 DOM 可用、未登记 type 不挂载、dialog 弹层
describe('validateForm', () => {
test('校验通过时 resolve 空字符串,并自动清理 DOM', async () => {
test('校验通过时 resolve 空字符串,且不产生任何 DOM', async () => {
const baseChildCount = document.body.children.length;
const error = await validateForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
appContext,
});
expect(error).toBe('');
expect(document.body.children.length).toBe(baseChildCount);
expect(document.body.querySelector('.m-form')).toBeNull();
});
test('resolve 结果始终为字符串校验入口不抛异常)', async () => {
test('校验失败时以错误文案 resolve不抛异常', async () => {
const error = await validateForm({
config: [{ type: 'text', name: 'name', text: '名称' }],
config: [{ type: 'text', name: 'name', text: '名称', rules: required() }],
initValues: { name: '' },
appContext,
});
expect(typeof error).toBe('string');
expect(document.body.querySelector('.m-form')).toBeNull();
expect(error).toBe('名称 -> 必填');
});
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' } },
});
expect(error).toBe('');
});
test('支持 extendState 扩展状态', async () => {
@ -69,16 +85,13 @@ describe('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。
// 无渲染实现下 extendState 一定先于遍历完成,不存在渲染式实现里的时序竞态
const error = await validateForm({
config: [
{
@ -97,214 +110,161 @@ describe('validateForm', () => {
},
],
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,
extendState: () => ({ services: { uiService: { get: () => false } } }),
});
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 () => {
test('lazy 标签页内的字段同样参与校验,且不污染调用方的原始配置', async () => {
const config: any = [
{
type: 'tab',
items: [
{ title: '属性', name: 'p', items: [{ type: 'text', name: 'name', text: '名称' }] },
{
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' }],
},
],
},
],
title: '样式',
name: 's',
lazy: true,
items: [{ type: 'text', name: 'style', text: '样式', rules: required() }],
},
],
},
];
const error = await validateForm({
config,
initValues: { inner1: 'a', inner2: 'b' },
appContext,
});
const error = await validateForm({ config, initValues: { p: { name: 'a' }, s: { style: '' } } });
expect(typeof error).toBe('string');
// 原始配置不被污染
expect(config[0].items[0].items[0].items[1].lazy).toBe(true);
expect(document.body.querySelector('.m-form')).toBeNull();
expect(error).toBe('样式 -> 必填');
expect(config[0].items[1].lazy).toBe(true);
});
test('多次并发调用互不干扰,且结束后不在 body 残留节点', async () => {
const baseChildCount = document.body.children.length;
test('多次并发调用互不干扰', async () => {
const config: any = [{ type: 'text', name: 'text', text: 'text', rules: required() }];
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,
}),
validateForm({ config, initValues: { text: 'first' } }),
validateForm({ config, initValues: { text: '' } }),
]);
expect(results).toEqual(['', '']);
expect(document.body.children.length).toBe(baseChildCount);
expect(document.body.querySelector('.m-form')).toBeNull();
expect(results).toEqual(['', 'text -> 必填']);
});
test('调用过程中临时容器会被附加到 body 上,结束后被移除', async () => {
const baseChildCount = document.body.children.length;
test('signal 已中断时立即以 reason 抛错', async () => {
const controller = new AbortController();
const reason = new Error('canceled by caller');
controller.abort(reason);
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({
await expect(
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();
initValues: { text: 'a' },
signal: controller.signal,
}),
).rejects.toBe(reason);
});
});
describe('validateForm —— debug 模式', () => {
const findButton = (text: string) =>
Array.from(document.body.querySelectorAll('button')).find(
(b) => (b.textContent || '').trim() === text,
) as HTMLButtonElement;
describe('validateForm —— 无 DOM 环境', () => {
test('全为内置类型时,无 document 也能完成校验', async () => {
const error = await withoutDocument(() =>
validateForm({
config: [{ type: 'text', name: 'name', text: '名称', rules: required() }],
initValues: { name: '' },
}),
);
// 通过 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;
};
expect(error).toBe('名称 -> 必填');
});
const mockExposed = (comp: any, method: string, fn: any) => {
Object.defineProperty(comp.exposed, method, { value: fn, configurable: true, writable: true });
};
test('无 document 时未登记 type 也能完成校验', async () => {
const error = await withoutDocument(() =>
validateForm({
config: [{ type: 'totally-unknown', name: 'x', text: 'X' }] as any,
initValues: { x: '' },
}),
);
test('debug 模式可见渲染弹层,点击「确定」校验通过后 resolve 空字符串并清理 DOM', async () => {
const pending = validateForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
debug: true,
expect(error).toBe('');
});
});
describe('validateForm —— 未登记字段 type', () => {
test('即使有 DOM 也不实例化字段组件,无 rules 时返回空字符串', async () => {
const setupSpy = vi.fn();
const probeContext = createFormAppContext((app) => {
app.component(
'm-fields-render-probe',
defineComponent({
name: 'MFieldsRenderProbe',
inheritAttrs: false,
props: { model: { type: Object, default: () => ({}) }, name: { type: String, default: '' } },
setup() {
setupSpy();
return () => h('div');
},
}),
);
});
const baseChildCount = document.body.children.length;
const error = await validateForm({
config: [{ type: 'render-probe', name: 'x', text: 'X' }] as any,
initValues: { x: '' },
appContext: probeContext,
});
expect(error).toBe('');
expect(setupSpy).not.toHaveBeenCalled();
expect(document.body.children.length).toBe(baseChildCount);
});
test('未登记但带 rules 的字段可直接校验,不必先登记为叶子', async () => {
const error = await validateForm({
config: [{ type: 'totally-unknown', name: 'x', text: 'X', rules: required() }] as any,
initValues: { x: '' },
});
expect(error).toBe('X -> 必填');
});
test('登记为叶子字段后即可校验', async () => {
registerFields({ 'totally-unknown': {} });
const error = await validateForm({
config: [{ type: 'totally-unknown', name: 'x', text: 'X', rules: required() }] as any,
initValues: { x: '' },
appContext,
});
expect(error).toBe('X -> 必填');
});
test('登记为叶子字段后,无 DOM 也能校验', async () => {
registerFields({ 'totally-unknown': {} });
const error = await withoutDocument(() =>
validateForm({
config: [{ type: 'totally-unknown', name: 'x', text: 'X', rules: required() }] as any,
initValues: { x: '' },
}),
);
expect(error).toBe('X -> 必填');
});
});
describe('validateForm —— dialog 弹层', () => {
test('可见渲染弹层,点击「确定」校验通过后 resolve 空字符串并清理 DOM', async () => {
const pending = validateForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
dialog: true,
appContext,
});
// 等待弹层与表单渲染
await nextTick();
await nextTick();
// debug 模式容器未隐藏,表单可见渲染
expect(document.body.querySelector('.m-form')).not.toBeNull();
findButton('确定').click();
@ -314,11 +274,29 @@ describe('validateForm —— debug 模式', () => {
expect(document.body.querySelector('.m-form')).toBeNull();
});
test('弹层标题可配置', async () => {
const pending = validateForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
dialog: true,
title: '校验配置',
appContext,
});
await nextTick();
await nextTick();
expect(document.body.textContent).toContain('校验配置');
expect(document.body.textContent).not.toContain('validateForm');
findButton('取消').click();
await captureError(() => pending);
});
test('点击「取消」以错误 reject 并清理 DOM', async () => {
const pending = validateForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
debug: true,
dialog: true,
appContext,
});
await nextTick();
@ -326,12 +304,7 @@ describe('validateForm —— debug 模式', () => {
findButton('取消').click();
let caught: any = null;
try {
await pending;
} catch (e) {
caught = e;
}
const caught = await captureError(() => pending);
expect(caught).toBeInstanceOf(Error);
expect(caught.message).toContain('canceled');
@ -342,20 +315,18 @@ describe('validateForm —— debug 模式', () => {
const pending = validateForm({
config: [{ type: 'text', name: 'name', text: '名称' }],
initValues: { name: '' },
debug: true,
dialog: 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) =>
@ -366,227 +337,31 @@ describe('validateForm —— debug 模式', () => {
{ 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;
}
const caught = await captureError(() => pending);
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');
test('dialog 弹层下字段照常完整渲染', async () => {
const options = vi.fn(() => [{ text: 'a', value: 'a' }]);
const pending = validateForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
debug: true,
timeout: 5000,
config: [{ type: 'select', name: 'kind', text: '类型', options }] as any,
initValues: { kind: 'a' },
dialog: true,
appContext,
});
await nextTick();
await nextTick();
const calledWithTimeout = setTimeoutSpy.mock.calls.some(([, delay]) => delay === 5000);
expect(calledWithTimeout).toBe(false);
expect(options).toHaveBeenCalled();
findButton('确定').click();
await pending;
setTimeoutSpy.mockRestore();
});
});
describe('validateForm —— 静默模式', () => {
/**
* DOM
* - `extra` Container formItemProps FormItem rules
* - `options` select setup watchEffect
*/
const createProbes = () => ({
extra: vi.fn(() => ''),
options: vi.fn(() => [{ text: 'a', value: 'a' }]),
tooltip: vi.fn(() => ({ text: 'tip' })),
});
const selectConfig = ({ extra, options, tooltip }: ReturnType<typeof createProbes>): any => [
{
type: 'select',
name: 'kind',
text: '类型',
extra,
options,
tooltip,
rules: [{ required: true, message: '类型不能为空' }],
},
];
test('开启后只挂载 FormItem不实例化内置叶子字段', async () => {
const probes = createProbes();
const error = await validateForm({
config: selectConfig(probes),
initValues: { kind: 'a' },
appContext,
});
expect(error).toBe('');
expect(probes.extra).toHaveBeenCalled();
expect(probes.options).not.toHaveBeenCalled();
expect(probes.tooltip).not.toHaveBeenCalled();
expect(document.body.querySelector('.m-form')).toBeNull();
});
test('带挂载初始化逻辑的字段不跳过渲染,保持校验值一致', async () => {
const error = await validateForm({
config: [
{
type: 'display',
name: 'status',
text: '状态',
initValue: 'ready',
rules: [{ required: true, message: '状态不能为空' }],
},
{
type: 'date',
name: 'date',
text: '日期',
rules: [{ typeMatch: true }],
},
] as any,
initValues: { date: new Date('2021-07-17T15:37:00') },
typeMatchValid: true,
appContext,
});
expect(error).toBe('');
});
test('非内置叶子字段仍照常渲染,避免漏掉其内部嵌套字段的校验', async () => {
const customSetup = vi.fn();
const customField = defineComponent({
name: 'CustomField',
inheritAttrs: false,
props: {
model: { type: Object, default: () => ({}) },
name: { type: String, default: '' },
},
setup() {
customSetup();
return () => h('div', 'custom');
},
});
await validateForm({
config: [{ type: 'component', name: 'custom', text: '自定义', component: customField }] as any,
initValues: { custom: 'x' },
appContext,
});
expect(customSetup).toHaveBeenCalled();
});
test('debug 模式下不生效:弹层需要完整渲染供人工操作', async () => {
const probes = createProbes();
const pending = validateForm({
config: selectConfig(probes),
initValues: { kind: 'a' },
appContext,
debug: true,
});
await nextTick();
await nextTick();
expect(probes.options).toHaveBeenCalled();
const confirm = Array.from(document.body.querySelectorAll('button')).find(
(b) => (b.textContent || '').trim() === '确定',
) as HTMLButtonElement;
confirm.click();
await pending;
});
});
describe('validateForm —— 可配置静默叶子字段集合', () => {
const createCustomField = (setupSpy: () => void) =>
defineComponent({
name: 'MFieldsCustomSafe',
inheritAttrs: false,
props: {
model: { type: Object, default: () => ({}) },
name: { type: String, default: '' },
},
setup() {
setupSpy();
return () => h('div', 'custom-safe');
},
});
test('registerSilentLeafFieldTypes 后,自定义 type 在静默模式跳过渲染', async () => {
const setupSpy = vi.fn();
const parentApp = createApp(defineComponent({ render: () => h('div') }));
parentApp.use(ElementPlus);
parentApp.use(MagicForm);
parentApp.component('m-fields-custom-safe', createCustomField(setupSpy));
registerSilentLeafFieldTypes(['custom-safe']);
await validateForm({
config: [{ type: 'custom-safe', name: 'safe', text: '安全自定义' }] as any,
initValues: { safe: 'x' },
appContext: parentApp._context,
});
expect(setupSpy).not.toHaveBeenCalled();
});
test('未注册时自定义 type 仍渲染', async () => {
const setupSpy = vi.fn();
const parentApp = createApp(defineComponent({ render: () => h('div') }));
parentApp.use(ElementPlus);
parentApp.use(MagicForm);
parentApp.component('m-fields-custom-safe', createCustomField(setupSpy));
await validateForm({
config: [{ type: 'custom-safe', name: 'safe', text: '安全自定义' }] as any,
initValues: { safe: 'x' },
appContext: parentApp._context,
});
expect(setupSpy).toHaveBeenCalled();
});
test('debug 模式下即使注册了自定义集合也完整渲染', async () => {
const setupSpy = vi.fn();
const parentApp = createApp(defineComponent({ render: () => h('div') }));
parentApp.use(ElementPlus);
parentApp.use(MagicForm);
parentApp.component('m-fields-custom-safe', createCustomField(setupSpy));
registerSilentLeafFieldTypes(['custom-safe']);
const pending = validateForm({
config: [{ type: 'custom-safe', name: 'safe', text: '安全自定义' }] as any,
initValues: { safe: 'x' },
appContext: parentApp._context,
debug: true,
});
await nextTick();
await nextTick();
expect(setupSpy).toHaveBeenCalled();
const confirm = Array.from(document.body.querySelectorAll('button')).find(
(b) => (b.textContent || '').trim() === '确定',
) as HTMLButtonElement;
confirm.click();
await pending;
});
});

View File

@ -80,9 +80,13 @@ export default defineConfig({
},
{ find: /^@tmagic\/core/, replacement: path.join(__dirname, '../packages/core/src/index.ts') },
{ find: /^@editor/, replacement: path.join(__dirname, '../packages/editor/src/') },
// `/headless` 必须在下方通用的 `^@tmagic/<pkg>` 规则之前命中,否则会被改写成
// `.../src/index.ts/headless` 导致 Vite 解析失败。
{ find: /^@tmagic\/editor\/headless$/, replacement: path.join(__dirname, '../packages/editor/src/headless.ts') },
{ find: /^@tmagic\/editor/, replacement: path.join(__dirname, '../packages/editor/src/index.ts') },
{ find: /^@tmagic\/form-schema/, replacement: path.join(__dirname, '../packages/form-schema/src/index.ts') },
{ find: /^@tmagic\/schema/, replacement: path.join(__dirname, '../packages/schema/src/index.ts') },
{ find: /^@tmagic\/form\/headless$/, replacement: path.join(__dirname, '../packages/form/src/headless.ts') },
{ find: /^@tmagic\/form/, replacement: path.join(__dirname, '../packages/form/src/index.ts') },
{
find: /^@tmagic\/tmagic-form-runtime/,
@ -91,6 +95,10 @@ export default defineConfig({
{ find: /^@tmagic\/table/, replacement: path.join(__dirname, '../packages/table/src/index.ts') },
{ find: /^@tmagic\/stage/, replacement: path.join(__dirname, '../packages/stage/src/index.ts') },
{ find: /^@tmagic\/utils/, replacement: path.join(__dirname, '../packages/utils/src/index.ts') },
{
find: /^@tmagic\/design\/headless$/,
replacement: path.join(__dirname, '../packages/design/src/headless.ts'),
},
{ find: /^@tmagic\/design/, replacement: path.join(__dirname, '../packages/design/src/index.ts') },
{
find: /^@tmagic\/data-source/,

3
pnpm-lock.yaml generated
View File

@ -415,6 +415,9 @@ importers:
'@tmagic/utils':
specifier: workspace:*
version: link:../utils
async-validator:
specifier: ^4.2.5
version: 4.2.5
dayjs:
specifier: ^1.11.21
version: 1.11.21

View File

@ -51,9 +51,9 @@ function aliasPlugin() {
};
}
function rolldownConfig(pkg, base) {
function rolldownConfig(pkg, base, entry = 'index') {
return {
input: `./temp/${base}/${pkg}/src/index.d.ts`,
input: `./temp/${base}/${pkg}/src/${entry}.d.ts`,
external: (id) =>
!id.startsWith('.') &&
!id.startsWith('/') &&
@ -62,15 +62,23 @@ function rolldownConfig(pkg, base) {
!id.startsWith('@data-source/'),
plugins: [aliasPlugin(), ...dts({ dtsInput: true, tsconfig: false })],
output: {
file: `${base}/${pkg}/types/index.d.ts`,
file: `${base}/${pkg}/types/${entry}.d.ts`,
format: 'es',
},
};
}
function packageDtsConfigs(pkg, base) {
const configs = [rolldownConfig(pkg, base, 'index')];
if (existsSync(`./temp/${base}/${pkg}/src/headless.d.ts`)) {
configs.push(rolldownConfig(pkg, base, 'headless'));
}
return configs;
}
export default [
...targetPackages.map((pkg) => rolldownConfig(pkg, 'packages')),
...runtimes.map((pkg) => rolldownConfig(pkg, 'runtime')),
...targetPackages.flatMap((pkg) => packageDtsConfigs(pkg, 'packages')),
...runtimes.flatMap((pkg) => packageDtsConfigs(pkg, 'runtime')),
];
function removeScss(path) {

View File

@ -17,38 +17,47 @@ const dirname = path.dirname(fileURLToPath(import.meta.url));
const packagesDir = path.resolve(dirname, '../packages');
const runtimeDir = path.resolve(dirname, '../runtime');
if (args.package) {
const pkgRoot = path.resolve(packagesDir, args.package);
if (fs.statSync(pkgRoot).isDirectory()) {
rimrafSync(path.resolve(packagesDir, `./${args.package}/dist`));
const pkg = createRequire(import.meta.url)(`../packages/${args.package}/package.json`);
/**
* 一个包的全部产物
*
* 同一个包内必须串行umd headless-umd 都写 `dist/`并发写会互相覆盖
* `emptyOutDir: false` 只是不清目录不解决同时写同名 chunk 的问题
* 包之间仍然并发
*/
async function buildPackage({ packageName, packagesDir, requirePath }) {
rimrafSync(path.resolve(packagesDir, `./${packageName}/dist`));
const pkg = createRequire(import.meta.url)(`${requirePath}/${packageName}/package.json`);
build({ packageName: args.package, format: 'es', pkg, packagesDir });
build({ packageName: args.package, format: 'umd', pkg, packagesDir });
buildThemes({ packageName: args.package, packagesDir });
}
} else {
const packages = getPackageNames(packagesDir);
const runtimeHelpers = getPackageNames(runtimeDir);
for (const packageName of packages) {
rimrafSync(path.resolve(packagesDir, `./${packageName}/dist`));
const pkg = createRequire(import.meta.url)(`../packages/${packageName}/package.json`);
build({ packageName, format: 'es', pkg, packagesDir });
build({ packageName, format: 'umd', pkg, packagesDir });
buildThemes({ packageName, packagesDir });
}
for (const packageName of runtimeHelpers) {
rimrafSync(path.resolve(runtimeDir, `./${packageName}/dist`));
const pkg = createRequire(import.meta.url)(`../runtime/${packageName}/package.json`);
build({ packageName, format: 'es', pkg, packagesDir: runtimeDir });
build({ packageName, format: 'umd', pkg, packagesDir: runtimeDir });
}
await build({ packageName, format: 'es', pkg, packagesDir });
await build({ packageName, format: 'umd', pkg, packagesDir });
await buildHeadlessUmd({ packageName, pkg, packagesDir });
buildThemes({ packageName, packagesDir });
}
async function main() {
if (args.package) {
const pkgRoot = path.resolve(packagesDir, args.package);
if (!fs.statSync(pkgRoot).isDirectory()) return;
await buildPackage({ packageName: args.package, packagesDir, requirePath: '../packages' });
return;
}
await Promise.all([
...getPackageNames(packagesDir).map((packageName) =>
buildPackage({ packageName, packagesDir, requirePath: '../packages' }),
),
...getPackageNames(runtimeDir).map((packageName) =>
buildPackage({ packageName, packagesDir: runtimeDir, requirePath: '../runtime' }),
),
]);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
// rolldown 在 UMD 输出顶部会注入
// Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
// 当内联的依赖(如 lodash-es 的 _Symbol.js声明 `var Symbol = root.Symbol;`
@ -73,9 +82,28 @@ function fixUmdSymbolShadow() {
};
}
async function build({ packageName, format, pkg, packagesDir }) {
function buildHeadlessUmd({ packageName, pkg, packagesDir }) {
const pkgRoot = path.resolve(packagesDir, `./${packageName}`);
if (!fs.existsSync(path.join(pkgRoot, 'src/headless.ts'))) return;
return build({
packageName,
format: 'umd',
pkg,
packagesDir,
entry: 'src/headless.ts',
name: `TMagic${toPascalCase(packageName)}Headless`,
fileName: `tmagic-${packageName}-headless`,
cssFileName: 'style-headless',
});
}
async function build({ packageName, format, pkg, packagesDir, entry, name, fileName, cssFileName = 'style' }) {
const pkgRoot = path.resolve(packagesDir, `./${packageName}`);
const hasHeadless = !entry && format === 'es' && fs.existsSync(path.join(pkgRoot, 'src/headless.ts'));
await buildVite({
root: path.resolve(packagesDir, `./${packageName}`),
root: pkgRoot,
clearScreen: false,
configFile: false,
plugins: [vue()],
@ -89,11 +117,11 @@ async function build({ packageName, format, pkg, packagesDir }) {
target: 'esnext',
lib: {
entry: 'src/index.ts',
name: `TMagic${toPascalCase(packageName)}`,
fileName: `tmagic-${packageName}`,
entry: entry ?? (hasHeadless ? { index: 'src/index.ts', headless: 'src/headless.ts' } : 'src/index.ts'),
name: name ?? `TMagic${toPascalCase(packageName)}`,
fileName: fileName ?? `tmagic-${packageName}`,
formats: [format],
cssFileName: 'style',
cssFileName,
},
rolldownOptions: {

View File

@ -0,0 +1,92 @@
/**
* 校验各包 headless 子路径的**发布产物**能在原生 Node 里跑起来
*
* 单测走 vitest别名指向 `src`依赖由 vite 解析看不到 Node 自己的 ESM 解析规则
* 例如 `dayjs/plugin/utc` 这种无 exports 映射又不带扩展名的深路径会直接 404
* 这里在真实的 node_modules `import` / `require` 一次 dist把这类只在发布后
* 才暴露的问题挡在构建阶段
*/
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execa } from 'execa';
const dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(dirname, '..');
/** 每个子路径入口应该导出的若干个符号,顺带验证产物不是空壳 */
const targets = [
{ specifier: '@tmagic/design/headless', expects: ['getDesignConfig', 'appendValidateSuggestion'] },
{ specifier: '@tmagic/form/headless', expects: ['validateForm', 'submitForm', 'registerField', 'builtInFields'] },
{ specifier: '@tmagic/editor/headless', expects: ['editorFields'] },
];
/**
* 找工作区里该包自己的目录当 cwd
*
* 在包根里 `import '@tmagic/xxx/headless'` 走的是 Node self-reference
* 命中的仍是包自己 `exports` 里声明的那份产物路径同时深层裸依赖按 realpath
* `<pkg>/node_modules` 解析比挑一个依赖了该包的目录更可靠依赖方的
* 链接可能指向 registry 上的同名旧版本那样检查的就是别人的产物了
*/
const findPackageDir = (specifier) => {
const pkgName = specifier.split('/').slice(0, 2).join('/');
const groups = ['packages', 'runtime', 'vue-components', 'react-components'].filter((group) =>
existsSync(path.join(root, group)),
);
for (const group of groups) {
for (const name of readdirSync(path.join(root, group))) {
const manifest = path.join(root, group, name, 'package.json');
if (!existsSync(manifest)) continue;
if (JSON.parse(readFileSync(manifest, 'utf-8')).name === pkgName) {
return path.join(root, group, name);
}
}
}
};
const run = async (cwd, code) => {
const { stdout } = await execa('node', ['--input-type=module', '-e', code], { cwd });
return stdout;
};
let failed = 0;
for (const { specifier, expects } of targets) {
const cwd = findPackageDir(specifier);
if (!cwd) {
console.error(`${specifier}: 工作区里找不到这个包`);
failed += 1;
continue;
}
const assertExports = `
const missing = ${JSON.stringify(expects)}.filter((name) => mod[name] === undefined);
if (missing.length) throw new Error('missing exports: ' + missing.join(', '));
`;
for (const [kind, code] of [
['import', `const mod = await import('${specifier}');${assertExports}`],
[
'require',
`const { createRequire } = await import('node:module');
const mod = createRequire(process.cwd() + '/index.js')('${specifier}');${assertExports}`,
],
]) {
try {
await run(cwd, code);
console.log(`${kind} '${specifier}'`);
} catch (error) {
failed += 1;
console.error(`${kind} '${specifier}'\n${error.stderr || error.message}`);
}
}
}
if (failed) {
console.error(`\n${failed} 个 headless 产物检查失败`);
process.exit(1);
}

View File

@ -20,6 +20,9 @@
"vue": ["./node_modules/vue"],
// src/index.ts, .
"@tmagic/*": ["./packages/*/src"],
"@tmagic/form/headless": ["./packages/form/src/headless.ts"],
"@tmagic/editor/headless": ["./packages/editor/src/headless.ts"],
"@tmagic/design/headless": ["./packages/design/src/headless.ts"],
"@tmagic/tmagic-form-runtime": ["./runtime/tmagic-form/src"],
"@tmagic/vue-runtime-help": ["./runtime/vue-runtime-help/src"],
"@tmagic/react-runtime-help": ["./runtime/react-runtime-help/src"],

View File

@ -8,6 +8,10 @@ const r = (p: string) => resolve(__dirname, p);
const alias = {
'@editor': r('./packages/editor/src'),
'@form': r('./packages/form/src'),
'@form/headless': r('./packages/form/src/headless.ts'),
'@tmagic/form/headless': r('./packages/form/src/headless.ts'),
'@tmagic/form': r('./packages/form/src'),
'@tmagic/editor/headless': r('./packages/editor/src/headless.ts'),
'@data-source': r('./packages/data-source/src'),
};
@ -35,7 +39,12 @@ export default defineConfig({
test: {
name: 'dom',
include: ['./packages/*/tests/**', './runtime/*/tests/**'],
exclude: ['./packages/cli/tests/**', './packages/editor/tests/unit/hooks/use-stage.spec.ts'],
exclude: [
'./packages/cli/tests/**',
'./packages/editor/tests/unit/hooks/use-stage.spec.ts',
// 多份用例共用的脚手架模块,本身不含用例
'./packages/*/tests/**/helpers/**',
],
environment: 'happy-dom',
pool: 'vmThreads',
vmMemoryLimit: '2GB',
@ -61,7 +70,7 @@ export default defineConfig({
resolve: { alias },
test: {
name: 'node',
include: ['./packages/cli/tests/**'],
include: ['./packages/cli/tests/**', './packages/form/tests/node/**'],
environment: 'node',
pool: 'forks',
isolate: false,