mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-09-16 01:35:42 +08:00
feat(form): 新增无渲染校验入口,支持 Node/CI 环境执行表单校验
将 submitForm/validateForm 改为无 DOM 的 headless 实现,并抽取字段登记与编辑器复合字段配置,便于脚本与 CI 批量校验。
This commit is contained in:
parent
4247cfdaf5
commit
93dc0d2187
@ -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') },
|
||||
]
|
||||
|
||||
@ -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 子路径同理。
|
||||
:::
|
||||
|
||||
## 类型定义
|
||||
|
||||
@ -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}
|
||||
:::
|
||||
|
||||
@ -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 应为字符串';
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": [
|
||||
|
||||
35
packages/design/src/headless.ts
Normal file
35
packages/design/src/headless.ts
Normal 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';
|
||||
@ -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"
|
||||
|
||||
@ -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];
|
||||
}>();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<{
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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']);
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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])) {
|
||||
|
||||
@ -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]));
|
||||
|
||||
/**
|
||||
* 对比模式判定:
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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 || '');
|
||||
|
||||
|
||||
690
packages/editor/src/fields/StyleSetter/configs.ts
Normal file
690
packages/editor/src/fields/StyleSetter/configs.ts
Normal 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 的 model:left/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 里的 Box(margin/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,
|
||||
})),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
122
packages/editor/src/fields/configs/codeSelect.ts
Normal file
122
packages/editor/src/fields/configs/codeSelect.ts
Normal 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: [],
|
||||
};
|
||||
}
|
||||
};
|
||||
178
packages/editor/src/fields/configs/displayConds.ts
Normal file
178
packages/editor/src/fields/configs/displayConds.ts
Normal 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;
|
||||
};
|
||||
296
packages/editor/src/fields/configs/eventSelect.ts
Normal file
296
packages/editor/src/fields/configs/eventSelect.ts
Normal 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');
|
||||
};
|
||||
163
packages/editor/src/fields/headless-validation.ts
Normal file
163
packages/editor/src/fields/headless-validation.ts
Normal 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 复用了字段自身的 name,prop 基准要退回父级,否则 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'] },
|
||||
};
|
||||
33
packages/editor/src/headless.ts
Normal file
33
packages/editor/src/headless.ts
Normal 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.vue、各面板、monaco 等),可在无 DOM 的 Node 里 import。
|
||||
* 配合 `@tmagic/form/headless`:`registerBuiltInFields(builtInFields); registerFields(editorFields);`
|
||||
*
|
||||
* 注意这里不是「零 Vue 依赖」:`style-setter` 的字段配置与编辑器 UI 共用同一份工厂
|
||||
* (`fields/StyleSetter/configs.ts`),配置里的 `icon` / `component` 会带进来若干图标
|
||||
* SFC;typeMatch 的提示文案要读画布与数据源,也会带进 editor services。这些都只在
|
||||
* import 时定义、不访问 DOM,但产物体积明显大于 `@tmagic/form/headless`。
|
||||
*
|
||||
* @module @tmagic/editor/headless
|
||||
*/
|
||||
|
||||
export { editorFields } from './fields/headless-validation';
|
||||
@ -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';
|
||||
|
||||
@ -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)));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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({
|
||||
|
||||
307
packages/editor/tests/unit/fields/headless-validation.spec.ts
Normal file
307
packages/editor/tests/unit/fields/headless-validation.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 时使用默认配置', () => {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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]) {
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 || '';
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -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]]);
|
||||
|
||||
73
packages/form/src/headless.ts
Normal file
73
packages/form/src/headless.ts
Normal 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';
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@ -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` 以隐藏方式挂载临时表单时 provide(debug 弹层模式不提供)。
|
||||
*
|
||||
* 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;
|
||||
|
||||
@ -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.provide:appContext 合并后 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` 决定「保留弹层展示错误(debug)」还是「reject 并清理(普通)」,
|
||||
* 从而让 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
|
||||
|
||||
70
packages/form/src/utils/builtInFields.ts
Normal file
70
packages/form/src/utils/builtInFields.ts
Normal 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 },
|
||||
};
|
||||
408
packages/form/src/utils/collectFields.ts
Normal file
408
packages/form/src/utils/collectFields.ts
Normal 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.vue:col 用 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` 全部字段。
|
||||
*
|
||||
* 与渲染的差异:不做分页截断(table)、不跳过折叠行(groupList)。二者都是列表的视图状态,
|
||||
* 与「这些行的值是否合法」无关,无渲染校验覆盖全部行。
|
||||
*/
|
||||
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;
|
||||
};
|
||||
@ -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 };
|
||||
|
||||
127
packages/form/src/utils/fieldNestedConfig.ts
Normal file
127
packages/form/src/utils/fieldNestedConfig.ts
Normal 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();
|
||||
287
packages/form/src/utils/fieldValueEffects.ts
Normal file
287
packages/form/src/utils/fieldValueEffects.ts
Normal 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();
|
||||
};
|
||||
@ -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-validator(Element 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 },
|
||||
|
||||
309
packages/form/src/utils/registerField.ts
Normal file
309
packages/form/src/utils/registerField.ts
Normal 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();
|
||||
};
|
||||
@ -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();
|
||||
};
|
||||
205
packages/form/src/utils/submitHeadless.ts
Normal file
205
packages/form/src/utils/submitHeadless.ts
Normal file
@ -0,0 +1,205 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
};
|
||||
119
packages/form/src/utils/tableGroupList.ts
Normal file
119
packages/form/src/utils/tableGroupList.ts
Normal 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;
|
||||
};
|
||||
@ -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 循环依赖 */
|
||||
|
||||
92
packages/form/src/utils/validateError.ts
Normal file
92
packages/form/src/utils/validateError.ts
Normal 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>');
|
||||
};
|
||||
189
packages/form/src/utils/validateValues.ts
Normal file
189
packages/form/src/utils/validateValues.ts
Normal 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 }),
|
||||
};
|
||||
};
|
||||
53
packages/form/tests/node/headless.spec.ts
Normal file
53
packages/form/tests/node/headless.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
89
packages/form/tests/unit/helpers/formValidation.ts
Normal file
89
packages/form/tests/unit/helpers/formValidation.ts
Normal 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;
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
243
packages/form/tests/unit/utils/registerField.spec.ts
Normal file
243
packages/form/tests/unit/utils/registerField.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
764
packages/form/tests/unit/utils/validateValues.spec.ts
Normal file
764
packages/form/tests/unit/utils/validateValues.spec.ts
Normal 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 拼接 prop,lazy 标签页也参与校验', () => {
|
||||
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('tab:display 为假的标签页不参与校验', () => {
|
||||
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('step:prop 基准被重置为 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();
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
3
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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: {
|
||||
|
||||
92
scripts/check-headless-dist.mjs
Normal file
92
scripts/check-headless-dist.mjs
Normal 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);
|
||||
}
|
||||
@ -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"],
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user