From 1c67b5e77b726ded63b12d761faff37e97563ff2 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Tue, 14 Jul 2026 17:15:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E6=B3=A8=E5=86=8C=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E5=AD=97=E6=AE=B5=E5=86=85=E7=BD=AE=20typeMa?= =?UTF-8?q?tch=20=E6=A0=A1=E9=AA=8C=E8=A7=84=E5=88=99=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 typeMatchRules.ts,为编辑器自定义字段注册内置 typeMatch 校验规则 - 抽取 event.ts 事件工具函数 - form typeMatch 适配 TDesign 校验器签名,错误消息补充实际值 - rules.md 新增「Editor 字段内置规则」章节,16 个字段文档补充校验说明 --- .../editor-fields/code-select-col.md | 4 + docs/form-config/editor-fields/code-select.md | 4 + docs/form-config/editor-fields/code.md | 4 + .../editor-fields/cond-op-select.md | 4 + .../editor-fields/data-source-field-select.md | 4 + .../editor-fields/data-source-fields.md | 4 + .../editor-fields/data-source-input.md | 4 + .../data-source-method-select.md | 4 + .../editor-fields/data-source-methods.md | 4 + .../editor-fields/data-source-mocks.md | 4 + .../editor-fields/data-source-select.md | 4 + .../editor-fields/display-conds.md | 4 + .../form-config/editor-fields/event-select.md | 4 + docs/form-config/editor-fields/key-value.md | 4 + .../editor-fields/page-fragment-select.md | 4 + docs/form-config/editor-fields/ui-select.md | 4 + docs/form-config/rules.md | 23 + package.json | 2 +- packages/editor/src/fields/CodeSelect.vue | 5 +- packages/editor/src/fields/CondOpSelect.vue | 26 +- .../editor/src/fields/DataSourceInput.vue | 32 +- packages/editor/src/fields/DisplayConds.vue | 12 + packages/editor/src/fields/EventSelect.vue | 212 +++---- packages/editor/src/plugin.ts | 4 +- packages/editor/src/theme/event.scss | 8 + .../editor/src/utils/data-source/index.ts | 42 +- packages/editor/src/utils/event.ts | 224 +++++++ packages/editor/src/utils/index.ts | 2 + packages/editor/src/utils/props.ts | 31 + packages/editor/src/utils/typeMatchRules.ts | 545 ++++++++++++++++++ .../tests/unit/fields/CodeSelect.spec.ts | 13 + .../tests/unit/fields/CondOpSelect.spec.ts | 25 +- .../tests/unit/fields/DataSourceInput.spec.ts | 16 +- .../tests/unit/fields/DisplayConds.spec.ts | 29 + .../tests/unit/fields/EventSelect.spec.ts | 164 +++++- packages/editor/tests/unit/plugin.spec.ts | 16 +- .../tests/unit/utils/data-source.spec.ts | 43 ++ .../editor/tests/unit/utils/event.spec.ts | 107 ++++ .../tests/unit/utils/props-config.spec.ts | 25 +- .../tests/unit/utils/typeMatchRules.spec.ts | 340 +++++++++++ packages/form/src/theme/form-dialog.scss | 2 +- packages/form/src/theme/table.scss | 12 +- packages/form/src/utils/form.ts | 93 ++- packages/form/src/utils/typeMatch.ts | 46 +- .../form/tests/unit/utils/typeMatch.spec.ts | 110 +++- playground/package.json | 2 +- pnpm-lock.yaml | 112 +++- 47 files changed, 2056 insertions(+), 331 deletions(-) create mode 100644 packages/editor/src/utils/event.ts create mode 100644 packages/editor/src/utils/typeMatchRules.ts create mode 100644 packages/editor/tests/unit/utils/event.spec.ts create mode 100644 packages/editor/tests/unit/utils/typeMatchRules.spec.ts diff --git a/docs/form-config/editor-fields/code-select-col.md b/docs/form-config/editor-fields/code-select-col.md index 8ee2aae9..3bd3ba07 100644 --- a/docs/form-config/editor-fields/code-select-col.md +++ b/docs/form-config/editor-fields/code-select-col.md @@ -27,6 +27,10 @@ } ``` +## 值校验 + +安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:要求 `string`,有代码块 DSL 时须为已有 codeId。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。 + ## Attributes | 参数 | 说明 | 类型 | 可选值 | 默认值 | diff --git a/docs/form-config/editor-fields/code-select.md b/docs/form-config/editor-fields/code-select.md index d69530f7..b5ee3936 100644 --- a/docs/form-config/editor-fields/code-select.md +++ b/docs/form-config/editor-fields/code-select.md @@ -24,6 +24,10 @@ CodeSelect 组件支持: - 选择数据源方法 - 配置代码块参数 +## 值校验 + +安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:做 `{ hookType: 'code', hookData }` 的浅层结构校验,`codeId` / 数据源方法存在性由内部单元格各自校验。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。 + ## Attributes | 参数 | 说明 | 类型 | 可选值 | 默认值 | diff --git a/docs/form-config/editor-fields/code.md b/docs/form-config/editor-fields/code.md index 569ae9a6..c682685f 100644 --- a/docs/form-config/editor-fields/code.md +++ b/docs/form-config/editor-fields/code.md @@ -41,6 +41,10 @@ } ``` +## 值校验 + +安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:默认要求 `string`,`parse: true` 时跳过校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。 + ## Attributes | 参数 | 说明 | 类型 | 可选值 | 默认值 | diff --git a/docs/form-config/editor-fields/cond-op-select.md b/docs/form-config/editor-fields/cond-op-select.md index c1c101f1..e6db17c0 100644 --- a/docs/form-config/editor-fields/cond-op-select.md +++ b/docs/form-config/editor-fields/cond-op-select.md @@ -27,6 +27,10 @@ } ``` +## 值校验 + +安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为已知算子,能解析字段类型时按类型收窄。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。 + ## Attributes | 参数 | 说明 | 类型 | 可选值 | 默认值 | diff --git a/docs/form-config/editor-fields/data-source-field-select.md b/docs/form-config/editor-fields/data-source-field-select.md index 9c3b60cc..79792290 100644 --- a/docs/form-config/editor-fields/data-source-field-select.md +++ b/docs/form-config/editor-fields/data-source-field-select.md @@ -49,6 +49,10 @@ } ``` +## 值校验 + +安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为数据源路径 `string[]`,有 `fieldConfig` 且非路径值时跳过。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。 + ## Attributes | 参数 | 说明 | 类型 | 可选值 | 默认值 | diff --git a/docs/form-config/editor-fields/data-source-fields.md b/docs/form-config/editor-fields/data-source-fields.md index fc47c89e..e35beb4d 100644 --- a/docs/form-config/editor-fields/data-source-fields.md +++ b/docs/form-config/editor-fields/data-source-fields.md @@ -16,6 +16,10 @@ } ``` +## 值校验 + +安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为数组并做 `name`/`type` 等浅层结构校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。 + ## Attributes | 参数 | 说明 | 类型 | 可选值 | 默认值 | diff --git a/docs/form-config/editor-fields/data-source-input.md b/docs/form-config/editor-fields/data-source-input.md index af60989e..85b3ab99 100644 --- a/docs/form-config/editor-fields/data-source-input.md +++ b/docs/form-config/editor-fields/data-source-input.md @@ -16,6 +16,10 @@ } ``` +## 值校验 + +安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:要求 `string`,`${...}` 绑定须指向已有数据源 / 字段。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。 + ## Attributes | 参数 | 说明 | 类型 | 可选值 | 默认值 | diff --git a/docs/form-config/editor-fields/data-source-method-select.md b/docs/form-config/editor-fields/data-source-method-select.md index 824d3645..1dda2d3a 100644 --- a/docs/form-config/editor-fields/data-source-method-select.md +++ b/docs/form-config/editor-fields/data-source-method-select.md @@ -27,6 +27,10 @@ } ``` +## 值校验 + +安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为 `[dsId, methodName]`,方法须在该数据源可选方法集中。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。 + ## Attributes | 参数 | 说明 | 类型 | 可选值 | 默认值 | diff --git a/docs/form-config/editor-fields/data-source-methods.md b/docs/form-config/editor-fields/data-source-methods.md index 7f829a93..90351659 100644 --- a/docs/form-config/editor-fields/data-source-methods.md +++ b/docs/form-config/editor-fields/data-source-methods.md @@ -16,6 +16,10 @@ } ``` +## 值校验 + +安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为数组并做 `content`/`params` 等浅层结构校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。 + ## Attributes | 参数 | 说明 | 类型 | 可选值 | 默认值 | diff --git a/docs/form-config/editor-fields/data-source-mocks.md b/docs/form-config/editor-fields/data-source-mocks.md index 40009145..9b127d21 100644 --- a/docs/form-config/editor-fields/data-source-mocks.md +++ b/docs/form-config/editor-fields/data-source-mocks.md @@ -16,6 +16,10 @@ } ``` +## 值校验 + +安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为数组并做 `title`/`enable`/`data` 等浅层结构校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。 + ## Attributes | 参数 | 说明 | 类型 | 可选值 | 默认值 | diff --git a/docs/form-config/editor-fields/data-source-select.md b/docs/form-config/editor-fields/data-source-select.md index bae19321..d7614555 100644 --- a/docs/form-config/editor-fields/data-source-select.md +++ b/docs/form-config/editor-fields/data-source-select.md @@ -38,6 +38,10 @@ } ``` +## 值校验 + +安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:`value: 'id'` 时须为已有 ds id,否则为含 `isBindDataSource` + `dataSourceId` 的对象。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。 + ## Attributes | 参数 | 说明 | 类型 | 可选值 | 默认值 | diff --git a/docs/form-config/editor-fields/display-conds.md b/docs/form-config/editor-fields/display-conds.md index d47db65c..dc72fdd5 100644 --- a/docs/form-config/editor-fields/display-conds.md +++ b/docs/form-config/editor-fields/display-conds.md @@ -27,6 +27,10 @@ } ``` +## 值校验 + +安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为数组并做 `cond[].field`/`op` 浅层结构校验,算子合法性 / 字段路径存在性由内部单元格各自校验。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。 + ## Attributes | 参数 | 说明 | 类型 | 可选值 | 默认值 | diff --git a/docs/form-config/editor-fields/event-select.md b/docs/form-config/editor-fields/event-select.md index 6ccfde87..246466d0 100644 --- a/docs/form-config/editor-fields/event-select.md +++ b/docs/form-config/editor-fields/event-select.md @@ -28,6 +28,10 @@ } ``` +## 值校验 + +安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为数组并做兼容新旧格式的浅层结构校验,事件名 / 联动动作是否属于可选项由内部单元格各自校验。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。 + ## Attributes | 参数 | 说明 | 类型 | 可选值 | 默认值 | diff --git a/docs/form-config/editor-fields/key-value.md b/docs/form-config/editor-fields/key-value.md index e06047f2..975c1fd0 100644 --- a/docs/form-config/editor-fields/key-value.md +++ b/docs/form-config/editor-fields/key-value.md @@ -29,6 +29,10 @@ } ``` +## 值校验 + +安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为普通对象,`advanced` 且值为 `function` 时放行。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。 + ## Attributes | 参数 | 说明 | 类型 | 可选值 | 默认值 | diff --git a/docs/form-config/editor-fields/page-fragment-select.md b/docs/form-config/editor-fields/page-fragment-select.md index d6b7e960..deecbf09 100644 --- a/docs/form-config/editor-fields/page-fragment-select.md +++ b/docs/form-config/editor-fields/page-fragment-select.md @@ -16,6 +16,10 @@ } ``` +## 值校验 + +安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为 `string` 或 `number`,有节点树时须为已有页面片 id。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。 + ## Attributes | 参数 | 说明 | 类型 | 可选值 | 默认值 | diff --git a/docs/form-config/editor-fields/ui-select.md b/docs/form-config/editor-fields/ui-select.md index eabee770..d8a48300 100644 --- a/docs/form-config/editor-fields/ui-select.md +++ b/docs/form-config/editor-fields/ui-select.md @@ -16,6 +16,10 @@ } ``` +## 值校验 + +安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为 `string` 或 `number`,有节点树时须为已有组件 id。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。 + ## Attributes | 参数 | 说明 | 类型 | 可选值 | 默认值 | diff --git a/docs/form-config/rules.md b/docs/form-config/rules.md index 4e4397e7..83f81d16 100644 --- a/docs/form-config/rules.md +++ b/docs/form-config/rules.md @@ -125,6 +125,29 @@ app.use(MagicForm, { }); ``` +### Editor 字段内置规则 + +安装 `@tmagic/editor` 时会自动 `registerTypeMatchRules` 注册编辑器自定义字段规则。服务数据(数据源 / 代码块 / 节点树)未就绪时,只做基础形态校验,不做枚举或存在性失败。 + +| 字段 type | 期望值 | +| --- | --- | +| `key-value` / `style-setter` | 普通对象;`key-value` + `advanced` 且值为 `function` 时放行 | +| `cond-op-select` | 已知算子;能解析字段类型时按类型收窄 | +| `code-select-col` | `string`;有代码块 DSL 时须为已有 codeId | +| `page-fragment-select` / `ui-select` | `string \| number`;有节点树时须为已有页面片 / 组件 id | +| `data-source-input` | `string`;`${...}` 绑定须指向已有数据源/字段 | +| `data-source-method-select` | `[dsId, methodName]`,方法须在该数据源可选方法集中 | +| `data-source-field-select` | 数据源路径 `string[]`;有 `fieldConfig` 且非路径值时跳过 | +| `data-source-select` | `value: 'id'` 为已有 ds id;否则为含 `isBindDataSource` + `dataSourceId` 的对象 | +| `code-select` | `{ hookType: 'code', hookData }` 的浅层结构校验(`codeId` 存在性 / 数据源方法存在性由内部 `code-select-col`、`data-source-method-select` 单元格各自校验,只标红出错单元格) | +| `data-source-fields` / `data-source-mocks` / `data-source-methods` | 数组 + 浅层结构(`name`/`type`、`title`/`enable`/`data`、`content`/`params` 等) | +| `event-select` | 数组;兼容新旧格式的浅层结构校验(`name` / 联动组件 `method` 是否 ∈ 可选项由字段内 `eventNameConfig.rules`、`compActionConfig.rules` 单独校验,只标红对应 select) | +| `display-conds` | 数组 + `cond[].field`/`op` 浅层结构校验(`op` 是否为已知算子、字段路径是否存在由内部 `cond-op-select`、`field` 单元格各自校验,只标红出错单元格) | + +> 容器类字段(`event-select` / `code-select` / `display-conds`)遵循同一约定:容器级 typeMatch 只做结构校验,「枚举 / 存在性」下沉到内部单元格各自的 typeMatch/rules,避免单个子项非法导致整块表单标红。 + +业务仍可用 `registerTypeMatchRule` 覆盖上述任一 type。 + ## 示例 ### select 选项匹配 diff --git a/package.json b/package.json index 0c63f5c1..f94f4289 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "conventional-changelog-cli": "^5.0.0", "cosmiconfig": "^ 9.0.0 ", "cz-conventional-changelog": "^3.3.0", - "element-plus": "^2.11.8", + "element-plus": "^2.14.3", "enquirer": "^2.4.1", "eslint": "^10.3.0", "execa": "^9.6.0", diff --git a/packages/editor/src/fields/CodeSelect.vue b/packages/editor/src/fields/CodeSelect.vue index ee038e73..0d9f508b 100644 --- a/packages/editor/src/fields/CodeSelect.vue +++ b/packages/editor/src/fields/CodeSelect.vue @@ -93,7 +93,8 @@ const codeConfig = computed(() => ({ { value: HookCodeType.CODE, text: '代码块' }, { value: HookCodeType.DATA_SOURCE_METHOD, text: '数据源方法' }, ], - defaultValue: 'code', + rules: [{ typeMatch: true, trigger: 'blur' }], + defaultValue: HookCodeType.CODE, onChange: (_mForm, v: HookCodeType, { setModel }) => { if (v === HookCodeType.DATA_SOURCE_METHOD) { setModel('codeId', []); @@ -111,6 +112,7 @@ const codeConfig = computed(() => ({ labelWidth: 0, display: (_mForm, { model }) => model.codeType !== HookCodeType.DATA_SOURCE_METHOD, notEditable: () => !codeBlockService.getEditStatus(), + rules: [{ typeMatch: true, trigger: 'blur' }], }, { type: 'data-source-method-select', @@ -119,6 +121,7 @@ const codeConfig = computed(() => ({ labelWidth: 0, display: (_mForm, { model }) => model.codeType === HookCodeType.DATA_SOURCE_METHOD, notEditable: () => !dataSourceService.get('editable'), + rules: [{ typeMatch: true }], }, ], }, diff --git a/packages/editor/src/fields/CondOpSelect.vue b/packages/editor/src/fields/CondOpSelect.vue index 8bb059ce..5e59d322 100644 --- a/packages/editor/src/fields/CondOpSelect.vue +++ b/packages/editor/src/fields/CondOpSelect.vue @@ -33,7 +33,7 @@ import { getDesignConfig, TMagicSelect } from '@tmagic/design'; import type { CondOpSelectConfig, FieldProps } from '@tmagic/form'; import { useServices } from '@editor/hooks/use-services'; -import { arrayOptions, eqOptions, getFieldType, numberOptions } from '@editor/utils'; +import { getCondOpOptionsByFieldType, getFieldType } from '@editor/utils'; defineOptions({ name: 'MFieldsCondOpSelect', @@ -51,31 +51,9 @@ const optionComponent = getDesignConfig('components')?.option; const options = computed(() => { const [id, ...fieldNames] = [...(props.config.parentFields || []), ...props.model.field]; - const ds = dataSourceService.getDataSourceById(id); - const type = getFieldType(ds, fieldNames); - - if (type === 'array') { - return arrayOptions; - } - - if (type === 'boolean' || type === 'null') { - return [ - { text: '是', value: 'is' }, - { text: '不是', value: 'not' }, - ]; - } - - if (type === 'number') { - return [...eqOptions, ...numberOptions]; - } - - if (type === 'string') { - return [...arrayOptions, ...eqOptions]; - } - - return [...arrayOptions, ...eqOptions, ...numberOptions]; + return getCondOpOptionsByFieldType(type); }); const fieldChangeHandler = (v: string) => { diff --git a/packages/editor/src/fields/DataSourceInput.vue b/packages/editor/src/fields/DataSourceInput.vue index 3608852c..0ef26904 100644 --- a/packages/editor/src/fields/DataSourceInput.vue +++ b/packages/editor/src/fields/DataSourceInput.vue @@ -79,11 +79,11 @@ import { Coin } from '@element-plus/icons-vue'; import type { DataSchema, DataSourceSchema } from '@tmagic/core'; import { getDesignConfig, TMagicAutocomplete, TMagicInput, TMagicTag } from '@tmagic/design'; import type { DataSourceInputConfig, FieldProps } from '@tmagic/form'; -import { getKeysArray, isNumber } from '@tmagic/utils'; +import { getKeysArray } from '@tmagic/utils'; import Icon from '@editor/components/Icon.vue'; import { useServices } from '@editor/hooks/use-services'; -import { getDisplayField } from '@editor/utils/data-source'; +import { getDisplayField, resolveFieldByPath } from '@editor/utils/data-source'; defineOptions({ name: 'MFieldsDataSourceInput', @@ -251,34 +251,16 @@ const fieldQuerySearch = ( return; } - let fields = ds.fields || []; - - // 后面这些是字段 - let key = keys.shift(); - while (key) { - if (isNumber(key)) { - key = keys.shift(); - continue; - } - - for (const field of fields) { - if (field.name === key) { - fields = field.fields || []; - key = keys.shift(); - break; - } - } - } + const { fields } = resolveFieldByPath(ds.fields, keys, { skipNumberIndices: true }); if (curCharIsDot(dotIndex)) { // 当前输入的是. - result = fields || []; + result = fields; } else if (dotIndex > -1) { const queryName = queryString.substring(dotIndex + 1).toLowerCase(); - result = - fields.filter( - (field) => field.name?.toLowerCase().includes(queryName) || field.title?.toLowerCase().includes(queryName), - ) || []; + result = fields.filter( + (field) => field.name?.toLowerCase().includes(queryName) || field.title?.toLowerCase().includes(queryName), + ); } cb( diff --git a/packages/editor/src/fields/DisplayConds.vue b/packages/editor/src/fields/DisplayConds.vue index d8b6e30b..0564ab41 100644 --- a/packages/editor/src/fields/DisplayConds.vue +++ b/packages/editor/src/fields/DisplayConds.vue @@ -98,6 +98,10 @@ const config = computed(() => ({ label: '字段', checkStrictly: false, onChange: fieldOnChange, + rules: [ + { required: true, trigger: 'blur', message: '请选择字段' }, + { typeMatch: true, trigger: 'blur' }, + ], } : { type: 'data-source-field-select', @@ -107,6 +111,10 @@ const config = computed(() => ({ checkStrictly: false, dataSourceFieldType: ['string', 'number', 'boolean', 'any'], onChange: fieldOnChange, + rules: [ + { required: true, trigger: 'blur', message: '请选择字段' }, + { typeMatch: true, trigger: 'blur' }, + ], }, { type: 'cond-op-select', @@ -114,6 +122,10 @@ const config = computed(() => ({ label: '条件', width: 140, name: 'op', + rules: [ + { required: true, trigger: 'blur', message: '请选择条件' }, + { typeMatch: true, trigger: 'blur' }, + ], }, { label: '值', diff --git a/packages/editor/src/fields/EventSelect.vue b/packages/editor/src/fields/EventSelect.vue index 2b7c864c..57454217 100644 --- a/packages/editor/src/fields/EventSelect.vue +++ b/packages/editor/src/fields/EventSelect.vue @@ -67,11 +67,9 @@ import { computed } from 'vue'; import { Delete } from '@element-plus/icons-vue'; import { has } from 'lodash-es'; -import type { EventOption, MComponent, MContainer } from '@tmagic/core'; import { ActionType } from '@tmagic/core'; import { TMagicButton } from '@tmagic/design'; import type { - CascaderOption, CodeSelectColConfig, ContainerChangeEventData, DataSourceMethodSelectConfig, @@ -84,10 +82,15 @@ import type { UISelectConfig, } from '@tmagic/form'; import { defineFormItem, MContainer as MFormContainer, MPanel, MTable } from '@tmagic/form'; -import { DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX, traverseNode } from '@tmagic/utils'; import { useServices } from '@editor/hooks/use-services'; -import { getCascaderOptionsFromFields } from '@editor/utils'; +import { + getCompActionAllowedValues, + getCompActionOptions, + getEventNameAllowedValues, + getEventNameOptions, + normalizeCompActionValue, +} from '@editor/utils'; defineOptions({ name: 'MFieldsEventSelect', @@ -118,68 +121,56 @@ const eventNameConfig = computed(() => { labelWidth: '40px', checkStrictly: () => props.config.src !== 'component', valueSeparator: '.', - options: (mForm: FormState, { formValue }: any) => { - let events: EventOption[] | CascaderOption[] = []; - - if (props.config.src === 'component') { - events = eventsService.getEvent(formValue.type); - - if (formValue.type === 'page-fragment-container' && formValue.pageFragmentId) { - const pageFragment = editorService.get('root')?.items?.find((page) => page.id === formValue.pageFragmentId); - if (pageFragment) { - events = [ - { - label: pageFragment.name || '页面片容器', - value: pageFragment.id, - children: events, - }, - ]; - pageFragment.items.forEach((node) => { - traverseNode(node, (node) => { - const nodeEvents = (node.type && eventsService.getEvent(node.type)) || []; - - events.push({ - label: `${node.name}_${node.id}`, - value: `${node.id}`, - children: nodeEvents, - }); - }); - }); - - return events; + 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})不存在`); } - } - - return events.map((option) => ({ - text: option.label, - value: option.value, - })); - } - - if (props.config.src === 'datasource') { - // 从数据源类型中获取到相关事件 - events = dataSourceService.getFormEvent(formValue.type); - // 从数据源类型和实例中分别获取数据以追加数据变化的事件 - const dataSource = dataSourceService.getDataSourceById(formValue.id); - const fields = dataSource?.fields || []; - if (fields.length > 0) { - return [ - ...events, - { - label: '数据变化', - value: DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX, - children: getCascaderOptionsFromFields(fields), - }, - ]; - } - - return events; - } - }, + callback(); + }, + }, + ], }; 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 = { @@ -187,39 +178,17 @@ const actionTypeConfig = computed(() => { text: '联动类型', type: 'select', defaultValue: ActionType.COMP, - options: () => { - 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; - }, + options: actionTypeOptions.value, + rules: [ + { + required: true, + message: '联动类型不能为空', + }, + { + typeMatch: true, + trigger: 'blur', + }, + ], }; return { ...defaultActionTypeConfig, ...props.config.actionTypeConfig }; }); @@ -234,6 +203,12 @@ const targetCompConfig = computed(() => { onChange: (_MForm, _v, { setModel }) => { setModel('method', ''); }, + rules: [ + { + typeMatch: true, + trigger: 'blur', + }, + ], }; return { ...defaultTargetCompConfig, ...props.config.targetCompConfig }; }); @@ -254,41 +229,20 @@ const compActionConfig = computed(() => { }, checkStrictly: () => props.config.src !== 'component', display: (mForm: FormState | undefined, { model }: any) => model.actionType === ActionType.COMP, - options: (mForm: FormState, { model }: any) => { - const node = editorService.getNodeById(model.to); - if (!node?.type) return []; - - let methods: EventOption[] | CascaderOption[] = []; - - methods = eventsService.getMethod(node.type, model.to); - - if (node.type === 'page-fragment-container' && node.pageFragmentId) { - const pageFragment = editorService.get('root')?.items?.find((page) => page.id === node.pageFragmentId); - if (pageFragment) { - methods = []; - pageFragment.items.forEach((node: MComponent | MContainer) => { - traverseNode(node, (node) => { - const nodeMethods = (node.type && eventsService.getMethod(node.type, node.id)) || []; - - if (nodeMethods.length) { - methods.push({ - label: `${node.name}_${node.id}`, - value: `${node.id}`, - children: nodeMethods, - }); - } - }); - }); - - return methods; - } - } - - return methods.map((method) => ({ - text: method.label, - value: method.value, - })); - }, + 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 }; }); diff --git a/packages/editor/src/plugin.ts b/packages/editor/src/plugin.ts index 497c7d34..482b02e8 100644 --- a/packages/editor/src/plugin.ts +++ b/packages/editor/src/plugin.ts @@ -21,7 +21,7 @@ import type { App } from 'vue'; import type { DesignPluginOptions } from '@tmagic/design'; import designPlugin from '@tmagic/design'; import type { FormInstallOptions } from '@tmagic/form'; -import formPlugin from '@tmagic/form'; +import formPlugin, { registerTypeMatchRules } from '@tmagic/form'; import tablePlugin from '@tmagic/table'; import Code from './fields/Code.vue'; @@ -44,6 +44,7 @@ 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/typeMatchRules'; import Editor from './Editor.vue'; import type { EditorInstallOptions } from './type'; @@ -64,6 +65,7 @@ export default { app.use(designPlugin, opt || {}); app.use(formPlugin, opt || {}); app.use(tablePlugin); + registerTypeMatchRules(editorTypeMatchRules); app.config.globalProperties.$TMAGIC_EDITOR = option; setEditorConfig(option); diff --git a/packages/editor/src/theme/event.scss b/packages/editor/src/theme/event.scss index bb573fc3..3b411848 100644 --- a/packages/editor/src/theme/event.scss +++ b/packages/editor/src/theme/event.scss @@ -35,3 +35,11 @@ margin-right: 5px; } } + +.el-form-item { + .el-form-item { + &.is-error { + margin-bottom: 18px; + } + } +} diff --git a/packages/editor/src/utils/data-source/index.ts b/packages/editor/src/utils/data-source/index.ts index 74d789c8..34e193df 100644 --- a/packages/editor/src/utils/data-source/index.ts +++ b/packages/editor/src/utils/data-source/index.ts @@ -235,19 +235,37 @@ export const getCascaderOptionsFromFields = ( return result; }; -export const getFieldType = (ds: DataSourceSchema | undefined, fieldNames: string[]) => { - let fields = ds?.fields; - let type = ''; +/** + * 按字段名路径下钻 DataSchema。 + * @param skipNumberIndices 为 true 时跳过数字段(模板路径中的数组下标,如 arr[0].x) + */ +export const resolveFieldByPath = ( + fields: DataSchema[] | undefined, + fieldNames: string[], + options: { skipNumberIndices?: boolean } = {}, +): { ok: boolean; field?: DataSchema; fields: DataSchema[] } => { + let currentFields = fields || []; + let field: DataSchema | undefined; - for (const fieldName of fieldNames) { - if (!fields?.length) return ''; - - const field = fields.find((f) => f.name === fieldName); - if (!field) return ''; - - type = field.type || ''; - fields = field.fields; + for (const name of fieldNames) { + if (options.skipNumberIndices && isNumber(name)) { + continue; + } + if (!currentFields.length) { + return { ok: false, fields: currentFields }; + } + field = currentFields.find((item) => item.name === name); + if (!field) { + return { ok: false, fields: currentFields }; + } + currentFields = field.fields || []; } - return type; + return { field, ok: true, fields: currentFields }; +}; + +export const getFieldType = (ds: DataSourceSchema | undefined, fieldNames: string[]) => { + const { ok, field } = resolveFieldByPath(ds?.fields, fieldNames); + if (!ok) return ''; + return field?.type || ''; }; diff --git a/packages/editor/src/utils/event.ts b/packages/editor/src/utils/event.ts new file mode 100644 index 00000000..6ee80f1b --- /dev/null +++ b/packages/editor/src/utils/event.ts @@ -0,0 +1,224 @@ +/* + * 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 { EventOption, Id, MComponent, MContainer } from '@tmagic/core'; +import type { CascaderOption } from '@tmagic/form'; +import { DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX, traverseNode } from '@tmagic/utils'; + +import dataSourceService from '@editor/services/dataSource'; +import editorService from '@editor/services/editor'; +import eventsService from '@editor/services/events'; +import { getCascaderOptionsFromFields } from '@editor/utils/data-source'; + +export const EVENT_NAME_VALUE_SEPARATOR = '.'; + +export type EventNameSelectOption = { text: string; value: string }; +export type EventNameOption = EventOption | CascaderOption | EventNameSelectOption; + +/** 与 EventSelect 中 checkStrictly 一致:component 为 false,其余为 true */ +export const isEventNameCheckStrictly = (src?: string) => src !== 'component'; + +/** 将动作 method 值规范为与 collectEventNameOptionValues 一致的字符串(cascader 无 valueSeparator 时存数组) */ +export const normalizeCompActionValue = (value: unknown): string => { + if (Array.isArray(value)) { + return value.map((item) => `${item}`).join(EVENT_NAME_VALUE_SEPARATOR); + } + if (value === null || value === undefined || value === '') { + return ''; + } + return `${value}`; +}; + +/** + * 组装事件名称下拉/级联 options,与 EventSelect 默认 eventNameConfig.options 逻辑一致。 + */ +export const getEventNameOptions = ( + src: string | undefined, + formValue: Record = {}, +): EventNameOption[] => { + if (!formValue.type) { + return []; + } + + if (src === 'component') { + let events: EventOption[] | CascaderOption[] = eventsService.getEvent(formValue.type) || []; + + if (formValue.type === 'page-fragment-container' && formValue.pageFragmentId) { + const pageFragment = editorService.get('root')?.items?.find((page) => page.id === formValue.pageFragmentId); + if (!pageFragment) { + return []; + } + + events = [ + { + label: pageFragment.name || '页面片容器', + value: pageFragment.id, + children: events, + }, + ]; + + (pageFragment.items || []).forEach((node) => { + traverseNode(node, (current) => { + const nodeEvents = (current.type && eventsService.getEvent(current.type)) || []; + (events as CascaderOption[]).push({ + label: `${current.name}_${current.id}`, + value: `${current.id}`, + children: nodeEvents, + }); + }); + }); + + return events; + } + + return (events as EventOption[]).map((option) => ({ + text: option.label, + value: option.value, + })); + } + + if (src === 'datasource') { + const events: EventNameOption[] = dataSourceService.getFormEvent(formValue.type) || []; + const dataSource = dataSourceService.getDataSourceById(formValue.id); + const fields = dataSource?.fields || []; + + if (fields.length > 0) { + return [ + ...events, + { + label: '数据变化', + value: DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX, + children: getCascaderOptionsFromFields(fields), + }, + ]; + } + + return events; + } + + return []; +}; + +/** 将 select / cascader options 展平为最终写入 name 的字符串集合(cascader 用 `.` 拼接) */ +export const collectEventNameOptionValues = ( + options: EventNameOption[], + checkStrictly: boolean, + prefix: any[] = [], + result: Set = new Set(), +): Set => { + options.forEach((option) => { + if (typeof option?.value === 'undefined') { + return; + } + + const path = [...prefix, option.value]; + const joined = path.map((item) => `${item}`).join(EVENT_NAME_VALUE_SEPARATOR); + const children = 'children' in option ? option.children : undefined; + + if (Array.isArray(children) && children.length) { + if (checkStrictly) { + result.add(joined); + } + collectEventNameOptionValues(children as EventNameOption[], checkStrictly, path, result); + return; + } + + result.add(joined); + }); + + return result; +}; + +/** + * 解析 event-select 允许的 name 集合。 + * 自定义 eventNameConfig.options 时返回 null(跳过枚举校验)。 + */ +export const getEventNameAllowedValues = ( + config: { src?: string; eventNameConfig?: { options?: unknown } } = {}, + formValue: Record = {}, +): Set | null => { + if (typeof config.eventNameConfig?.options !== 'undefined') { + return null; + } + + if (config.src !== 'component' && config.src !== 'datasource') { + return null; + } + + return collectEventNameOptionValues(getEventNameOptions(config.src, formValue), isEventNameCheckStrictly(config.src)); +}; + +/** + * 组装联动组件动作下拉/级联 options,与 EventSelect 默认 compActionConfig.options 逻辑一致。 + */ +export const getCompActionOptions = (toId?: Id): EventNameOption[] => { + if (typeof toId === 'undefined' || toId === null || toId === '') { + return []; + } + + const node = editorService.getNodeById(toId); + if (!node?.type) { + return []; + } + + let methods: EventOption[] | CascaderOption[] = eventsService.getMethod(node.type, toId) || []; + + if (node.type === 'page-fragment-container' && node.pageFragmentId) { + const pageFragment = editorService.get('root')?.items?.find((page) => page.id === node.pageFragmentId); + if (!pageFragment) { + return []; + } + + methods = []; + (pageFragment.items || []).forEach((item: MComponent | MContainer) => { + traverseNode(item, (current) => { + const nodeMethods = (current.type && eventsService.getMethod(current.type, current.id)) || []; + + if (nodeMethods.length) { + (methods as CascaderOption[]).push({ + label: `${current.name}_${current.id}`, + value: `${current.id}`, + children: nodeMethods, + }); + } + }); + }); + + return methods; + } + + return (methods as EventOption[]).map((method) => ({ + text: method.label, + value: method.value, + })); +}; + +/** + * 解析 event-select 联动组件动作允许的 method 集合。 + * 自定义 compActionConfig.options 时返回 null(跳过枚举校验)。 + */ +export const getCompActionAllowedValues = ( + config: { src?: string; compActionConfig?: { options?: unknown } } = {}, + model: { to?: Id } = {}, +): Set | null => { + if (typeof config.compActionConfig?.options !== 'undefined') { + return null; + } + + return collectEventNameOptionValues(getCompActionOptions(model.to), isEventNameCheckStrictly(config.src)); +}; diff --git a/packages/editor/src/utils/index.ts b/packages/editor/src/utils/index.ts index 90a8ef2c..f57c8754 100644 --- a/packages/editor/src/utils/index.ts +++ b/packages/editor/src/utils/index.ts @@ -30,4 +30,6 @@ export * from './undo-redo'; export * from './indexed-db'; export * from './history'; export * from './const'; +export * from './typeMatchRules'; +export * from './event'; export { default as loadMonaco } from './monaco-editor'; diff --git a/packages/editor/src/utils/props.ts b/packages/editor/src/utils/props.ts index 5ca121f3..197d8c62 100644 --- a/packages/editor/src/utils/props.ts +++ b/packages/editor/src/utils/props.ts @@ -45,6 +45,32 @@ export const numberOptions = [ { text: '不在范围内', value: 'not_between' }, ]; +export const booleanOptions = [ + { text: '是', value: 'is' }, + { text: '不是', value: 'not' }, +]; + +/** 按字段类型返回条件运算符选项(UI 与 typeMatch 校验共用) */ +export const getCondOpOptionsByFieldType = (type: string) => { + if (type === 'array') { + return arrayOptions; + } + + if (type === 'boolean' || type === 'null') { + return booleanOptions; + } + + if (type === 'number') { + return [...eqOptions, ...numberOptions]; + } + + if (type === 'string') { + return [...arrayOptions, ...eqOptions]; + } + + return [...arrayOptions, ...eqOptions, ...numberOptions]; +}; + export const styleTabConfig: TabPaneConfig = { title: '样式', lazy: true, @@ -127,6 +153,7 @@ export const eventTabConfig: TabPaneConfig = { src: 'component', labelWidth: '100px', type: 'event-select', + rules: [{ typeMatch: true }], }, ], }; @@ -155,6 +182,7 @@ export const advancedTabConfig: TabPaneConfig = { labelPosition: 'top', type: 'code-select', extra: '组件初始化时执行', + rules: [{ typeMatch: true }], }, { name: 'mounted', @@ -162,6 +190,7 @@ export const advancedTabConfig: TabPaneConfig = { labelPosition: 'top', type: 'code-select', extra: '组件挂载到dom时执行', + rules: [{ typeMatch: true }], }, { name: 'display', @@ -169,6 +198,7 @@ export const advancedTabConfig: TabPaneConfig = { extra: '控制组件是否渲染,关系的代码块返回值为false时不渲染', labelPosition: 'top', type: 'code-select', + rules: [{ typeMatch: true }], }, ], }; @@ -194,6 +224,7 @@ export const displayTabConfig: TabPaneConfig = { name: NODE_CONDS_KEY, titlePrefix: '条件组', defaultValue: [], + rules: [{ typeMatch: true }], }, ], }; diff --git a/packages/editor/src/utils/typeMatchRules.ts b/packages/editor/src/utils/typeMatchRules.ts new file mode 100644 index 00000000..47e0716b --- /dev/null +++ b/packages/editor/src/utils/typeMatchRules.ts @@ -0,0 +1,545 @@ +/* + * 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 { DataSourceFieldType, DataSourceSchema, Id } from '@tmagic/core'; +import { HookCodeType, HookType, NodeType } from '@tmagic/core'; +import type { TypeMatchValidateContext, TypeMatchValidator } from '@tmagic/form'; +import { + DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX, + DATA_SOURCE_SET_DATA_METHOD_NAME, + dataSourceTemplateRegExp, + getKeysArray, + removeDataSourceFieldPrefix, +} from '@tmagic/utils'; + +import codeBlockService from '@editor/services/codeBlock'; +import dataSourceService from '@editor/services/dataSource'; +import editorService from '@editor/services/editor'; +import { getFieldType, resolveFieldByPath } from '@editor/utils/data-source'; +import { + arrayOptions, + booleanOptions, + eqOptions, + getCondOpOptionsByFieldType, + numberOptions, +} from '@editor/utils/props'; + +const defaultMessage = (message: string | undefined, fallback: string) => message || fallback; + +const isPlainObject = (value: any): value is Record => + Object.prototype.toString.call(value) === '[object Object]'; + +const isId = (value: any): value is Id => typeof value === 'string' || typeof value === 'number'; + +const DATA_SOURCE_FIELD_TYPES = new Set([ + 'null', + 'boolean', + 'object', + 'array', + 'number', + 'string', + 'any', +]); + +export const ALL_COND_OPS = new Set([ + ...arrayOptions.map((item) => item.value), + ...eqOptions.map((item) => item.value), + ...numberOptions.map((item) => item.value), + ...booleanOptions.map((item) => item.value), +]); + +const getCondOpsByFieldType = (type: string): Set => + new Set(getCondOpOptionsByFieldType(type).map((item) => item.value)); + +const getDataSources = (): DataSourceSchema[] => dataSourceService.get('dataSources') || []; + +const findDataSource = (id: string): DataSourceSchema | undefined => + getDataSources().find((ds) => `${ds.id}` === `${id}`); + +const getMethodNamesForDataSource = (ds: DataSourceSchema): Set => { + const names = new Set([DATA_SOURCE_SET_DATA_METHOD_NAME]); + (dataSourceService.getFormMethod(ds.type) || []).forEach((item) => { + if (item?.value) names.add(item.value); + }); + (ds.methods || []).forEach((method) => { + if (method?.name) names.add(method.name); + }); + return names; +}; + +const validateDataSourceFieldPath = ( + path: string[], + options: { + dataSourceId?: string; + valueMode?: 'key' | 'value'; + dataSourceFieldType?: DataSourceFieldType[]; + message?: string; + } = {}, +): string | undefined => { + const dataSources = getDataSources(); + if (!dataSources.length) { + return undefined; + } + + let dsId = options.dataSourceId; + let fieldNames = path; + + if (!dsId) { + if (!path.length) { + return defaultMessage(options.message, '值不在可选项中'); + } + const rawDsId = path[0]; + dsId = + options.valueMode === 'key' || !`${rawDsId}`.startsWith(DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX) + ? `${rawDsId}` + : removeDataSourceFieldPrefix(`${rawDsId}`); + fieldNames = path.slice(1); + } + + const ds = findDataSource(`${dsId}`); + if (!ds) { + return defaultMessage(options.message, '值不在可选项中'); + } + + if (!fieldNames.length) { + return undefined; + } + + const { field, ok } = resolveFieldByPath(ds.fields, fieldNames); + if (!ok) { + return defaultMessage(options.message, '值不在可选项中'); + } + + const allowedTypes = options.dataSourceFieldType || ['any']; + if (!allowedTypes.length || allowedTypes.includes('any')) { + return undefined; + } + + const leafType = field?.type || 'any'; + if (leafType === 'any' || allowedTypes.includes(leafType)) { + return undefined; + } + + return defaultMessage(options.message, '值不在可选项中'); +}; + +const validateDataSourceMethodTuple = (value: any, message?: string): string | undefined => { + if (!Array.isArray(value) || value.length !== 2 || value.some((item) => typeof item !== 'string')) { + return defaultMessage(message, `${value}类型应为长度为 2 的字符串数组`); + } + + const dataSources = getDataSources(); + if (!dataSources.length) { + return undefined; + } + + const [dsId, methodName] = value; + const ds = findDataSource(dsId); + if (!ds) { + return defaultMessage(message, `数据源(${dsId})不存在`); + } + + if (!getMethodNamesForDataSource(ds).has(methodName)) { + return defaultMessage(message, `数据源方法(${methodName})不存在`); + } + + return undefined; +}; + +const validateDataSourceTemplateBindings = (value: string, message?: string): string | undefined => { + const dataSources = getDataSources(); + if (!dataSources.length) { + return undefined; + } + + const matches = value.matchAll(dataSourceTemplateRegExp); + for (const match of matches) { + const keys = getKeysArray(match[1]); + if (!keys.length) { + return defaultMessage(message, `数据源绑定(${value})不合法`); + } + + const [dsId, ...fieldNames] = keys; + const ds = findDataSource(`${dsId}`); + if (!ds) { + return defaultMessage(message, `数据源绑定(${value})不合法`); + } + + const { ok } = resolveFieldByPath(ds.fields, fieldNames, { skipNumberIndices: true }); + if (!ok) { + return defaultMessage(message, `数据源绑定(${value})不合法`); + } + } + + return undefined; +}; + +const validateDataSourceFieldsItem = (item: any, message?: string): string | undefined => { + if (!isPlainObject(item) || typeof item.name !== 'string') { + return defaultMessage(message, '字段项结构不合法'); + } + + if (typeof item.type !== 'undefined' && !DATA_SOURCE_FIELD_TYPES.has(item.type)) { + return defaultMessage(message, '字段类型不合法'); + } + + if (typeof item.fields !== 'undefined') { + if (!Array.isArray(item.fields)) { + return defaultMessage(message, '字段项结构不合法'); + } + for (const child of item.fields) { + const error = validateDataSourceFieldsItem(child, message); + if (error) return error; + } + } + + return undefined; +}; + +const validateKeyValue: TypeMatchValidator = (value, { message, props }) => { + if (props.config?.advanced && typeof value === 'function') { + return undefined; + } + if (!isPlainObject(value)) { + return defaultMessage(message, '值类型应为对象'); + } + return undefined; +}; + +const validateStyleSetter: TypeMatchValidator = (value, { message }) => { + if (!isPlainObject(value)) { + return defaultMessage(message, '值类型应为对象'); + } + return undefined; +}; + +const validateCondOpSelect: TypeMatchValidator = (value, { message, props }) => { + if (typeof value !== 'string') { + return defaultMessage(message, '值类型应为字符串'); + } + + const parentFields = props.config?.parentFields || []; + const fieldPath = Array.isArray(props.model?.field) ? props.model.field : []; + const [id, ...fieldNames] = [...parentFields, ...fieldPath]; + const ds = id ? findDataSource(`${id}`) : undefined; + const fieldType = getFieldType(ds, fieldNames); + const allowed = getCondOpsByFieldType(fieldType); + + if (!allowed.has(value)) { + return defaultMessage(message, `${value} 不在可选项中`); + } + return undefined; +}; + +const validateCodeSelectCol: TypeMatchValidator = (value, { message }) => { + if (typeof value !== 'string') { + return defaultMessage(message, `${value}类型应为字符串`); + } + + const codeDsl = codeBlockService.getCodeDsl(); + if (!codeDsl || !Object.keys(codeDsl).length) { + return undefined; + } + + if (!(value in codeDsl)) { + return defaultMessage(message, `代码块(${value})不存在`); + } + return undefined; +}; + +const validatePageFragmentSelect: TypeMatchValidator = (value, { message }) => { + if (!isId(value)) { + return defaultMessage(message, `${value}类型应为字符串或数字`); + } + + const items = editorService.get('root')?.items; + if (!items?.length) { + return undefined; + } + + const exists = items.some((item) => item.type === NodeType.PAGE_FRAGMENT && `${item.id}` === `${value}`); + if (!exists) { + return defaultMessage(message, `页面片段(${value})不存在`); + } + return undefined; +}; + +const validateUiSelect: TypeMatchValidator = (value, { message }) => { + if (!isId(value)) { + return defaultMessage(message, `${value}类型应为字符串或数字`); + } + + const root = editorService.get('root'); + if (!root) { + return undefined; + } + + if (!editorService.getNodeById(value)) { + return defaultMessage(message, `组件(${value})不存在`); + } + return undefined; +}; + +const validateDataSourceInput: TypeMatchValidator = (value, { message }) => { + if (typeof value !== 'string') { + return defaultMessage(message, `${value}类型应为字符串`); + } + return validateDataSourceTemplateBindings(value, message); +}; + +const validateDataSourceMethodSelect: TypeMatchValidator = (value, { message }) => + validateDataSourceMethodTuple(value, message); + +const isDataSourceFieldPathValue = (value: any, config: any): value is string[] => { + if (!Array.isArray(value) || !value.length || value.some((item) => typeof item !== 'string')) { + return false; + } + if (config.dataSourceId) { + return true; + } + if (config.value === 'key') { + return true; + } + return `${value[0]}`.startsWith(DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX); +}; + +const validateDataSourceFieldSelect: TypeMatchValidator = (value, { message, props }) => { + const config = props.config || {}; + + if (config.fieldConfig && !isDataSourceFieldPathValue(value, config)) { + return undefined; + } + + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { + return defaultMessage(message, `${value}类型应为字符串数组`); + } + + return validateDataSourceFieldPath(value, { + dataSourceId: config.dataSourceId, + valueMode: config.value, + dataSourceFieldType: config.dataSourceFieldType, + message, + }); +}; + +const validateDataSourceSelect: TypeMatchValidator = (value, { message, props }) => { + const config = props.config || {}; + const dataSources = getDataSources(); + const filtered = config.dataSourceType ? dataSources.filter((ds) => ds.type === config.dataSourceType) : dataSources; + + if (config.value === 'id') { + if (typeof value !== 'string') { + return defaultMessage(message, `${value}类型应为字符串`); + } + if (!filtered.length) { + return undefined; + } + if (!filtered.some((ds) => `${ds.id}` === `${value}`)) { + return defaultMessage(message, `${value}不在可选项中`); + } + return undefined; + } + + if (!isPlainObject(value) || value.isBindDataSource !== true || typeof value.dataSourceId === 'undefined') { + return defaultMessage(message, `${value}类型不合法`); + } + + if (!filtered.length) { + return undefined; + } + + const ds = filtered.find((item) => `${item.id}` === `${value.dataSourceId}`); + if (!ds) { + return defaultMessage(message, `${value}不在可选项中`); + } + + if (typeof value.dataSourceType !== 'undefined' && value.dataSourceType !== ds.type) { + return defaultMessage(message, `${value}不在可选项中`); + } + + return undefined; +}; + +const validateCodeSelect: TypeMatchValidator = (value, { message }) => { + if (!isPlainObject(value) || value.hookType !== HookType.CODE || !Array.isArray(value.hookData)) { + return defaultMessage(message, `${value}类型不合法`); + } + + // 「codeId 是否存在 / 数据源方法是否存在」由 CodeSelect 内部 code-select-col、 + // data-source-method-select 单元格各自的 typeMatch 校验,只标红出错单元格。此处仅做结构校验。 + for (const item of value.hookData) { + if (!isPlainObject(item)) { + return defaultMessage(message, '钩子项结构不合法'); + } + + if (item.codeType !== HookCodeType.CODE && item.codeType !== HookCodeType.DATA_SOURCE_METHOD) { + return defaultMessage(message, '钩子项结构不合法'); + } + + if (typeof item.params !== 'undefined' && !isPlainObject(item.params)) { + return defaultMessage(message, '钩子项结构不合法'); + } + + if (item.codeType === HookCodeType.CODE) { + if (typeof item.codeId !== 'string') { + return defaultMessage(message, '钩子项结构不合法'); + } + continue; + } + + // DATA_SOURCE_METHOD:仅校验元组形态,存在性交给单元格 + if ( + !Array.isArray(item.codeId) || + item.codeId.length !== 2 || + item.codeId.some((part: any) => typeof part !== 'string') + ) { + return defaultMessage(message, '钩子项结构不合法'); + } + } + + return undefined; +}; + +const validateDataSourceFields: TypeMatchValidator = (value, { message }) => { + if (!Array.isArray(value)) { + return defaultMessage(message, `${value}类型应为数组`); + } + for (const item of value) { + const error = validateDataSourceFieldsItem(item, message); + if (error) return error; + } + return undefined; +}; + +const validateDataSourceMocks: TypeMatchValidator = (value, { message }) => { + if (!Array.isArray(value)) { + return defaultMessage(message, `${value}类型应为数组`); + } + + for (const item of value) { + if ( + !isPlainObject(item) || + typeof item.title !== 'string' || + typeof item.enable !== 'boolean' || + typeof item.useInEditor !== 'boolean' || + !isPlainObject(item.data) + ) { + return defaultMessage(message, 'mock 项结构不合法'); + } + } + + return undefined; +}; + +const validateDataSourceMethods: TypeMatchValidator = (value, { message }) => { + if (!Array.isArray(value)) { + return defaultMessage(message, `${value}类型应为数组`); + } + + for (const item of value) { + if (!isPlainObject(item) || typeof item.name !== 'string') { + return defaultMessage(message, '方法项结构不合法'); + } + if (typeof item.content !== 'undefined' && typeof item.content !== 'string' && typeof item.content !== 'function') { + return defaultMessage(message, '方法项结构不合法'); + } + if (typeof item.params !== 'undefined' && !Array.isArray(item.params)) { + return defaultMessage(message, '方法项结构不合法'); + } + } + + return undefined; +}; + +const validateEventSelect: TypeMatchValidator = (value, { message }) => { + if (!Array.isArray(value)) { + return defaultMessage(message, `${value}类型应为数组`); + } + + // 「事件名 / 动作名是否在可选项中」由 EventSelect 的 eventNameConfig.rules、compActionConfig.rules 单独校验, + // 以便只标红对应 select,而不是整张联动卡片。此处仅做结构校验。 + for (const item of value) { + if (!isPlainObject(item) || typeof item.name !== 'string') { + return defaultMessage(message, '事件项结构不合法'); + } + + if (Array.isArray(item.actions)) { + for (const action of item.actions) { + if (!isPlainObject(action) || typeof action.actionType === 'undefined') { + return defaultMessage(message, '事件项结构不合法'); + } + } + continue; + } + + if (typeof item.to === 'undefined' || typeof item.method !== 'string') { + return defaultMessage(message, '事件项结构不合法'); + } + } + + return undefined; +}; + +const validateDisplayConds: TypeMatchValidator = (value, { message }) => { + if (!Array.isArray(value)) { + return defaultMessage(message, `${value}类型应为数组`); + } + + // 「op 是否为合法算子 / 字段路径是否存在」由 DisplayConds 内部 cond-op-select、 + // field 单元格(data-source-field-select / cascader)各自的 typeMatch 校验,只标红出错单元格。 + // 此处仅做结构校验。 + for (const item of value) { + if (!isPlainObject(item) || !Array.isArray(item.cond)) { + return defaultMessage(message, '显示条件结构不合法'); + } + + for (const cond of item.cond) { + if ( + !isPlainObject(cond) || + !Array.isArray(cond.field) || + cond.field.some((part: any) => typeof part !== 'string') || + typeof cond.op !== 'string' + ) { + return defaultMessage(message, '显示条件结构不合法'); + } + } + } + + return undefined; +}; + +export const editorTypeMatchRules: Record = { + 'key-value': validateKeyValue, + 'style-setter': validateStyleSetter, + 'cond-op-select': validateCondOpSelect, + 'code-select-col': validateCodeSelectCol, + 'page-fragment-select': validatePageFragmentSelect, + 'ui-select': validateUiSelect, + 'data-source-input': validateDataSourceInput, + 'data-source-method-select': validateDataSourceMethodSelect, + 'data-source-field-select': validateDataSourceFieldSelect, + 'data-source-select': validateDataSourceSelect, + 'code-select': validateCodeSelect, + 'data-source-fields': validateDataSourceFields, + 'data-source-mocks': validateDataSourceMocks, + 'data-source-methods': validateDataSourceMethods, + 'event-select': validateEventSelect, + 'display-conds': validateDisplayConds, +}; + +export type { TypeMatchValidateContext }; diff --git a/packages/editor/tests/unit/fields/CodeSelect.spec.ts b/packages/editor/tests/unit/fields/CodeSelect.spec.ts index 96841461..2c6f0a55 100644 --- a/packages/editor/tests/unit/fields/CodeSelect.spec.ts +++ b/packages/editor/tests/unit/fields/CodeSelect.spec.ts @@ -137,6 +137,19 @@ describe('CodeSelect', () => { expect(dsCol.display(undefined, { model: { codeType: 'code' } })).toBe(false); }); + test('codeId 单元格开启 typeMatch(存在性校验下沉到单元格)', () => { + const wrapper = mount(CodeSelect, { props: baseProps() as any }); + const container = wrapper.findComponent({ name: 'MContainer' }); + const config = container.props('config') as any; + const row = config.items[0]; + const codeIdCol = row.items[1]; + const dsCol = row.items[2]; + expect(codeIdCol.type).toBe('code-select-col'); + expect(codeIdCol.rules).toEqual([{ typeMatch: true, trigger: 'blur' }]); + expect(dsCol.type).toBe('data-source-method-select'); + expect(dsCol.rules).toEqual([{ typeMatch: true }]); + }); + test('notEditable 调用各服务', () => { codeBlockService.getEditStatus.mockReturnValue(false); dataSourceService.get.mockReturnValue(false); diff --git a/packages/editor/tests/unit/fields/CondOpSelect.spec.ts b/packages/editor/tests/unit/fields/CondOpSelect.spec.ts index fd0d144a..5714f0eb 100644 --- a/packages/editor/tests/unit/fields/CondOpSelect.spec.ts +++ b/packages/editor/tests/unit/fields/CondOpSelect.spec.ts @@ -23,9 +23,6 @@ vi.mock('@editor/utils', async () => { return { ...actual, getFieldType: vi.fn(), - arrayOptions: [{ text: 'in', value: 'in' }], - eqOptions: [{ text: 'eq', value: 'eq' }], - numberOptions: [{ text: 'gt', value: 'gt' }], }; }); @@ -61,7 +58,9 @@ describe('CondOpSelect', () => { test('array 类型展示 arrayOptions', () => { (getFieldType as any).mockReturnValue('array'); const wrapper = mount(CondOpSelect, { props: baseProps() as any }); - expect(wrapper.findAll('option, .tmagic-design-option, [label]').length).toBeGreaterThan(0); + const html = wrapper.html(); + expect(html).toContain('label="包含"'); + expect(html).toContain('label="不包含"'); }); test('boolean/null 类型展示 是/不是', () => { @@ -74,31 +73,31 @@ describe('CondOpSelect', () => { (getFieldType as any).mockReturnValue('number'); const wrapper = mount(CondOpSelect, { props: baseProps() as any }); const html = wrapper.html(); - expect(html).toContain('label="eq"'); - expect(html).toContain('label="gt"'); + expect(html).toContain('label="等于"'); + expect(html).toContain('label="大于"'); }); test('string 类型 options 包含 array+eq', () => { (getFieldType as any).mockReturnValue('string'); const wrapper = mount(CondOpSelect, { props: baseProps() as any }); const html = wrapper.html(); - expect(html).toContain('label="in"'); - expect(html).toContain('label="eq"'); + expect(html).toContain('label="包含"'); + expect(html).toContain('label="等于"'); }); test('其他类型展示 array+eq+number', () => { (getFieldType as any).mockReturnValue('any'); const wrapper = mount(CondOpSelect, { props: baseProps() as any }); const html = wrapper.html(); - expect(html).toContain('label="in"'); - expect(html).toContain('label="eq"'); - expect(html).toContain('label="gt"'); + expect(html).toContain('label="包含"'); + expect(html).toContain('label="等于"'); + expect(html).toContain('label="大于"'); }); test('change 事件 emit', async () => { (getFieldType as any).mockReturnValue('string'); const wrapper = mount(CondOpSelect, { props: baseProps() as any }); - await wrapper.findComponent({ name: 'TMagicSelect' }).vm.$emit('change', 'eq'); - expect(wrapper.emitted('change')?.[0]?.[0]).toBe('eq'); + await wrapper.findComponent({ name: 'TMagicSelect' }).vm.$emit('change', '='); + expect(wrapper.emitted('change')?.[0]?.[0]).toBe('='); }); }); diff --git a/packages/editor/tests/unit/fields/DataSourceInput.spec.ts b/packages/editor/tests/unit/fields/DataSourceInput.spec.ts index 923d26a3..926e973b 100644 --- a/packages/editor/tests/unit/fields/DataSourceInput.spec.ts +++ b/packages/editor/tests/unit/fields/DataSourceInput.spec.ts @@ -29,12 +29,16 @@ vi.mock('@editor/hooks/use-services', () => ({ useServices: () => ({ dataSourceService, propsService }), })); -vi.mock('@editor/utils/data-source', () => ({ - getDisplayField: vi.fn((_dss: any, value: string) => { - if (!value) return []; - return [{ value, type: 'text' }]; - }), -})); +vi.mock('@editor/utils/data-source', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDisplayField: vi.fn((_dss: any, value: string) => { + if (!value) return []; + return [{ value, type: 'text' }]; + }), + }; +}); vi.mock('@editor/components/Icon.vue', () => ({ default: defineComponent({ name: 'IconStub', setup: () => () => h('i') }), diff --git a/packages/editor/tests/unit/fields/DisplayConds.spec.ts b/packages/editor/tests/unit/fields/DisplayConds.spec.ts index 18536e5a..2108b16b 100644 --- a/packages/editor/tests/unit/fields/DisplayConds.spec.ts +++ b/packages/editor/tests/unit/fields/DisplayConds.spec.ts @@ -100,6 +100,35 @@ describe('DisplayConds', () => { expect(item.type).toBe('data-source-field-select'); }); + test('field / op 单元格开启 typeMatch(枚举校验下沉到单元格)', () => { + mount(DisplayConds, { + props: { config: { titlePrefix: 't', parentFields: ['ds1'] }, model: {}, name: 'conds' } as any, + }); + const cascaderField = capturedConfig.items[0].items[0]; + const opItem = capturedConfig.items[0].items[1]; + expect(cascaderField.rules).toEqual([ + { required: true, trigger: 'blur', message: '请选择字段' }, + { typeMatch: true, trigger: 'blur' }, + ]); + expect(opItem.type).toBe('cond-op-select'); + expect(opItem.rules).toEqual([ + { required: true, trigger: 'blur', message: '请选择条件' }, + { typeMatch: true, trigger: 'blur' }, + ]); + + // parentFields 为空时 field 走 data-source-field-select,同样开启 typeMatch + capturedConfig = null; + mount(DisplayConds, { + props: { config: { titlePrefix: 't', parentFields: [] }, model: {}, name: 'conds' } as any, + }); + const dsField = capturedConfig.items[0].items[0]; + expect(dsField.type).toBe('data-source-field-select'); + expect(dsField.rules).toEqual([ + { required: true, trigger: 'blur', message: '请选择字段' }, + { typeMatch: true, trigger: 'blur' }, + ]); + }); + test('value 字段类型 - number', () => { mount(DisplayConds, { props: { config: { titlePrefix: 't', parentFields: ['ds1'] }, model: {}, name: 'conds' } as any, diff --git a/packages/editor/tests/unit/fields/EventSelect.spec.ts b/packages/editor/tests/unit/fields/EventSelect.spec.ts index 933ced8d..a2af2631 100644 --- a/packages/editor/tests/unit/fields/EventSelect.spec.ts +++ b/packages/editor/tests/unit/fields/EventSelect.spec.ts @@ -9,34 +9,42 @@ import { mount } from '@vue/test-utils'; import EventSelect from '@editor/fields/EventSelect.vue'; -const editorService = { - get: vi.fn(), - getNodeById: vi.fn(), -}; -const dataSourceService = { - get: vi.fn(), - getDataSourceById: vi.fn(), - getFormEvent: vi.fn(() => []), -}; -const eventsService = { - getEvent: vi.fn(() => [{ label: 'click', value: 'click' }]), - getMethod: vi.fn(() => [{ label: 'open', value: 'open' }]), -}; -const codeBlockService = { - getCodeDsl: vi.fn(() => ({ c1: {} })), - getEditStatus: vi.fn(() => true), -}; -const propsService = { - getDisabledCodeBlock: vi.fn(() => false), - getDisabledDataSource: vi.fn(() => false), -}; +const { editorService, dataSourceService, eventsService, codeBlockService, propsService } = vi.hoisted(() => ({ + editorService: { + get: vi.fn(), + getNodeById: vi.fn(), + }, + dataSourceService: { + get: vi.fn(), + getDataSourceById: vi.fn(), + getFormEvent: vi.fn(() => []), + }, + eventsService: { + getEvent: vi.fn(() => [{ label: 'click', value: 'click' }]), + getMethod: vi.fn(() => [{ label: 'open', value: 'open' }]), + }, + codeBlockService: { + getCodeDsl: vi.fn(() => ({ c1: {} })), + getEditStatus: vi.fn(() => true), + }, + propsService: { + getDisabledCodeBlock: vi.fn(() => false), + getDisabledDataSource: vi.fn(() => false), + }, +})); vi.mock('@editor/hooks/use-services', () => ({ useServices: () => ({ editorService, dataSourceService, eventsService, codeBlockService, propsService }), })); -vi.mock('@editor/utils', async () => { - const actual = await vi.importActual('@editor/utils'); +vi.mock('@editor/services/editor', () => ({ default: editorService })); +vi.mock('@editor/services/dataSource', () => ({ default: dataSourceService })); +vi.mock('@editor/services/events', () => ({ default: eventsService })); +vi.mock('@editor/services/codeBlock', () => ({ default: codeBlockService })); +vi.mock('@editor/services/props', () => ({ default: propsService })); + +vi.mock('@editor/utils/data-source', async () => { + const actual = await vi.importActual('@editor/utils/data-source'); return { ...actual, getCascaderOptionsFromFields: vi.fn(() => []) }; }); @@ -192,6 +200,44 @@ describe('EventSelect', () => { expect(opts[0]).toMatchObject({ text: 'click', value: 'click' }); }); + test('eventNameConfig.rules 仅校验事件名是否在可选项中', () => { + const wrapper = mount(EventSelect, { + props: baseProps({ + model: { events: [{ name: 'a', actions: [] }] }, + }) as any, + }); + const cfg = wrapper.findComponent({ name: 'MFormContainer' }).props('config') as any; + const [rule] = cfg.rules; + + const okCb = vi.fn(); + rule.validator({ value: 'click', callback: okCb }, { formValue: { type: 'btn' } }); + expect(okCb).toHaveBeenCalledWith(); + + const errCb = vi.fn(); + rule.validator({ value: 'unknown', callback: errCb }, { formValue: { type: 'btn' } }); + expect(errCb).toHaveBeenCalledWith('事件名(unknown)不存在'); + + // 空值不做枚举校验 + const emptyCb = vi.fn(); + rule.validator({ value: '', callback: emptyCb }, { formValue: { type: 'btn' } }); + expect(emptyCb).toHaveBeenCalledWith(); + }); + + test('eventNameConfig.rules 自定义 options 时跳过枚举', () => { + const wrapper = mount(EventSelect, { + props: baseProps({ + config: { type: 'event-select', src: 'component', eventNameConfig: { options: () => [{ value: 'x' }] } }, + model: { events: [{ name: 'a', actions: [] }] }, + }) as any, + }); + const cfg = wrapper.findComponent({ name: 'MFormContainer' }).props('config') as any; + const [rule] = cfg.rules; + + const cb = vi.fn(); + rule.validator({ value: 'whatever', callback: cb }, { formValue: { type: 'btn' } }); + expect(cb).toHaveBeenCalledWith(); + }); + test('eventNameConfig type 当 page-fragment 且有 pageFragmentId 返回 cascader', () => { editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [] }] }); const wrapper = mount(EventSelect, { @@ -244,7 +290,7 @@ describe('EventSelect', () => { const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any; const groupItems = panelCfg.items[0].items; const actionType = groupItems[0]; - const opts = actionType.options(); + const opts = typeof actionType.options === 'function' ? actionType.options() : actionType.options; expect(opts.map((o: any) => o.value).sort()).toEqual(['code', 'comp', 'data-source'].sort()); }); @@ -258,7 +304,7 @@ describe('EventSelect', () => { }); const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any; const actionType = panelCfg.items[0].items[0]; - const opts = actionType.options(); + const opts = typeof actionType.options === 'function' ? actionType.options() : actionType.options; expect(opts.map((o: any) => o.value)).toEqual(['comp']); propsService.getDisabledCodeBlock.mockReturnValue(false); propsService.getDisabledDataSource.mockReturnValue(false); @@ -318,6 +364,74 @@ describe('EventSelect', () => { expect(compAction.options(undefined, { model: { to: 'unknown' } })).toEqual([]); }); + test('compActionConfig.rules 仅校验动作名是否在可选项中', () => { + editorService.getNodeById.mockReturnValue({ type: 'btn', id: '1' }); + eventsService.getMethod.mockReturnValue([{ label: 'open', value: 'open' }]); + const wrapper = mount(EventSelect, { + props: baseProps({ + model: { events: [{ name: 'a', actions: [] }] }, + }) as any, + }); + const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any; + const compAction = panelCfg.items[0].items[2]; + const [rule] = compAction.rules; + + const okCb = vi.fn(); + rule.validator({ value: 'open', callback: okCb }, { model: { to: '1' } }); + expect(okCb).toHaveBeenCalledWith(); + + const errCb = vi.fn(); + rule.validator({ value: 'unknown', callback: errCb }, { model: { to: '1' } }); + expect(errCb).toHaveBeenCalledWith('动作名(unknown)不存在'); + + const emptyCb = vi.fn(); + rule.validator({ value: '', callback: emptyCb }, { model: { to: '1' } }); + expect(emptyCb).toHaveBeenCalledWith(); + }); + + test('compActionConfig.rules 自定义 options 时跳过枚举', () => { + editorService.getNodeById.mockReturnValue({ type: 'btn', id: '1' }); + const wrapper = mount(EventSelect, { + props: baseProps({ + config: { + type: 'event-select', + src: 'component', + compActionConfig: { options: () => [{ text: 'x', value: 'x' }] }, + }, + model: { events: [{ name: 'a', actions: [] }] }, + }) as any, + }); + const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any; + const compAction = panelCfg.items[0].items[2]; + const [rule] = compAction.rules; + + const cb = vi.fn(); + rule.validator({ value: 'whatever', callback: cb }, { model: { to: '1' } }); + expect(cb).toHaveBeenCalledWith(); + }); + + test('compActionConfig.rules 页面片 cascader 支持数组 method', () => { + editorService.getNodeById.mockReturnValue({ type: 'page-fragment-container', id: '1', pageFragmentId: 'pf1' }); + editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [{ id: 'c1', type: 'btn', name: 'b' }] }] }); + eventsService.getMethod.mockReturnValue([{ label: 'open', value: 'open' }]); + const wrapper = mount(EventSelect, { + props: baseProps({ + model: { events: [{ name: 'a', actions: [] }] }, + }) as any, + }); + const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any; + const compAction = panelCfg.items[0].items[2]; + const [rule] = compAction.rules; + + const okCb = vi.fn(); + rule.validator({ value: ['c1', 'open'], callback: okCb }, { model: { to: '1' } }); + expect(okCb).toHaveBeenCalledWith(); + + const errCb = vi.fn(); + rule.validator({ value: ['c1', 'missing'], callback: errCb }, { model: { to: '1' } }); + expect(errCb).toHaveBeenCalledWith('动作名(c1.missing)不存在'); + }); + test('codeActionConfig display/notEditable', () => { codeBlockService.getEditStatus.mockReturnValue(false); const wrapper = mount(EventSelect, { diff --git a/packages/editor/tests/unit/plugin.spec.ts b/packages/editor/tests/unit/plugin.spec.ts index 3c9150b8..dd190c8e 100644 --- a/packages/editor/tests/unit/plugin.spec.ts +++ b/packages/editor/tests/unit/plugin.spec.ts @@ -10,9 +10,14 @@ import editorPlugin from '@editor/plugin'; vi.mock('@tmagic/design', () => ({ default: { install: vi.fn() }, })); -vi.mock('@tmagic/form', () => ({ - default: { install: vi.fn() }, -})); +vi.mock('@tmagic/form', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { install: vi.fn() }, + registerTypeMatchRules: vi.fn(), + }; +}); vi.mock('@tmagic/table', () => ({ default: { install: vi.fn() }, })); @@ -70,10 +75,13 @@ describe('plugin install', () => { }; }; - test('install 调用 design/form/table 插件并注册全局组件', () => { + test('install 调用 design/form/table 插件并注册全局组件', async () => { + const { registerTypeMatchRules } = await import('@tmagic/form'); + const { editorTypeMatchRules } = await import('@editor/utils/typeMatchRules'); const { app, components } = buildApp(); editorPlugin.install(app, { someOption: true } as any); expect(app.use).toHaveBeenCalledTimes(3); + expect(registerTypeMatchRules).toHaveBeenCalledWith(editorTypeMatchRules); expect(Object.keys(components).length).toBeGreaterThan(10); expect(components.MEditor).toBeDefined(); }); diff --git a/packages/editor/tests/unit/utils/data-source.spec.ts b/packages/editor/tests/unit/utils/data-source.spec.ts index 2de35b6e..f77eef57 100644 --- a/packages/editor/tests/unit/utils/data-source.spec.ts +++ b/packages/editor/tests/unit/utils/data-source.spec.ts @@ -11,6 +11,7 @@ import { getFieldType, getFormConfig, getFormValue, + resolveFieldByPath, } from '@editor/utils/data-source'; describe('data-source utils', () => { @@ -84,6 +85,48 @@ describe('data-source utils', () => { expect(opts.map((o) => o.value)).toEqual(['a']); }); + test('resolveFieldByPath 沿 path 下钻', () => { + const fields: any = [ + { + name: 'obj', + type: 'object', + fields: [{ name: 'name', type: 'string' }], + }, + { + name: 'arr', + type: 'array', + fields: [{ name: 'item', type: 'number' }], + }, + ]; + + expect(resolveFieldByPath(fields, ['obj', 'name'])).toMatchObject({ + ok: true, + field: { name: 'name', type: 'string' }, + fields: [], + }); + expect(resolveFieldByPath(fields, ['obj']).field?.name).toBe('obj'); + expect(resolveFieldByPath(fields, ['unknown'])).toEqual({ ok: false, fields }); + expect(resolveFieldByPath(undefined, ['x'])).toEqual({ ok: false, fields: [] }); + expect(resolveFieldByPath(fields, []).ok).toBe(true); + }); + + test('resolveFieldByPath 可跳过数字下标', () => { + const fields: any = [ + { + name: 'arr', + type: 'array', + fields: [{ name: 'item', type: 'number', fields: [{ name: 'n', type: 'string' }] }], + }, + ]; + + const result = resolveFieldByPath(fields, ['arr', '0', 'item'], { skipNumberIndices: true }); + expect(result.ok).toBe(true); + expect(result.field?.name).toBe('item'); + expect(result.fields).toEqual([{ name: 'n', type: 'string' }]); + + expect(resolveFieldByPath(fields, ['arr', '0', 'missing'], { skipNumberIndices: true }).ok).toBe(false); + }); + test('getFieldType 沿 path 取最终类型', () => { const ds: any = { fields: [{ name: 'obj', type: 'object', fields: [{ name: 'name', type: 'string' }] }], diff --git a/packages/editor/tests/unit/utils/event.spec.ts b/packages/editor/tests/unit/utils/event.spec.ts new file mode 100644 index 00000000..c23f2b3a --- /dev/null +++ b/packages/editor/tests/unit/utils/event.spec.ts @@ -0,0 +1,107 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { + collectEventNameOptionValues, + getCompActionAllowedValues, + getCompActionOptions, + getEventNameAllowedValues, + getEventNameOptions, + normalizeCompActionValue, +} from '@editor/utils/event'; + +const { editorService, dataSourceService, eventsService } = vi.hoisted(() => ({ + editorService: { + get: vi.fn(), + getNodeById: vi.fn(), + }, + dataSourceService: { + getDataSourceById: vi.fn(), + getFormEvent: vi.fn(() => []), + }, + eventsService: { + getEvent: vi.fn(() => [{ label: 'click', value: 'click' }]), + getMethod: vi.fn(() => [{ label: 'open', value: 'open' }]), + }, +})); + +vi.mock('@editor/services/editor', () => ({ default: editorService })); +vi.mock('@editor/services/dataSource', () => ({ default: dataSourceService })); +vi.mock('@editor/services/events', () => ({ default: eventsService })); +vi.mock('@editor/utils/data-source', async () => { + const actual = await vi.importActual('@editor/utils/data-source'); + return { ...actual, getCascaderOptionsFromFields: vi.fn(() => [{ label: 'f1', value: 'f1' }]) }; +}); + +describe('event utils', () => { + beforeEach(() => { + vi.clearAllMocks(); + eventsService.getEvent.mockReturnValue([{ label: 'click', value: 'click' }]); + eventsService.getMethod.mockReturnValue([{ label: 'open', value: 'open' }]); + }); + + test('normalizeCompActionValue 支持字符串与数组', () => { + expect(normalizeCompActionValue('open')).toBe('open'); + expect(normalizeCompActionValue(['c1', 'open'])).toBe('c1.open'); + expect(normalizeCompActionValue('')).toBe(''); + expect(normalizeCompActionValue(undefined)).toBe(''); + }); + + test('getCompActionOptions 普通组件返回 select options', () => { + editorService.getNodeById.mockReturnValue({ type: 'btn', id: '1' }); + expect(getCompActionOptions('1')).toEqual([{ text: 'open', value: 'open' }]); + }); + + test('getCompActionOptions 无节点返回空', () => { + editorService.getNodeById.mockReturnValue(null); + expect(getCompActionOptions('missing')).toEqual([]); + expect(getCompActionOptions(undefined)).toEqual([]); + }); + + test('getCompActionOptions 页面片容器返回 cascader options', () => { + editorService.getNodeById.mockReturnValue({ type: 'page-fragment-container', id: '1', pageFragmentId: 'pf1' }); + editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [{ id: 'c1', type: 'btn', name: 'b' }] }] }); + expect(getCompActionOptions('1')).toEqual([ + { + label: 'b_c1', + value: 'c1', + children: [{ label: 'open', value: 'open' }], + }, + ]); + }); + + test('getCompActionAllowedValues 枚举合法动作', () => { + editorService.getNodeById.mockReturnValue({ type: 'btn', id: '1' }); + expect(getCompActionAllowedValues({ src: 'component' }, { to: '1' })).toEqual(new Set(['open'])); + }); + + test('getCompActionAllowedValues 自定义 options 返回 null', () => { + expect( + getCompActionAllowedValues({ src: 'component', compActionConfig: { options: () => [] } }, { to: '1' }), + ).toBeNull(); + }); + + test('getCompActionAllowedValues 页面片路径用 . 拼接', () => { + editorService.getNodeById.mockReturnValue({ type: 'page-fragment-container', id: '1', pageFragmentId: 'pf1' }); + editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [{ id: 'c1', type: 'btn', name: 'b' }] }] }); + expect(getCompActionAllowedValues({ src: 'component' }, { to: '1' })).toEqual(new Set(['c1.open'])); + }); + + test('getEventNameAllowedValues / collectEventNameOptionValues', () => { + expect(getEventNameAllowedValues({ src: 'component' }, { type: 'btn' })).toEqual(new Set(['click'])); + expect( + getEventNameAllowedValues({ src: 'component', eventNameConfig: { options: [] } }, { type: 'btn' }), + ).toBeNull(); + expect(collectEventNameOptionValues([{ value: 'a', children: [{ value: 'b' }] }], false)).toEqual(new Set(['a.b'])); + }); + + test('getEventNameOptions datasource 追加数据变化', () => { + dataSourceService.getDataSourceById.mockReturnValue({ fields: [{ name: 'f1' }] }); + const opts = getEventNameOptions('datasource', { type: 'ds', id: 'd1' }); + expect(opts.some((item: any) => item.value === 'ds-field-changed' || item.label === '数据变化')).toBe(true); + }); +}); diff --git a/packages/editor/tests/unit/utils/props-config.spec.ts b/packages/editor/tests/unit/utils/props-config.spec.ts index 39c638a5..4cdd5ab3 100644 --- a/packages/editor/tests/unit/utils/props-config.spec.ts +++ b/packages/editor/tests/unit/utils/props-config.spec.ts @@ -10,10 +10,12 @@ import { NODE_CONDS_RESULT_KEY } from '@tmagic/core'; import { advancedTabConfig, arrayOptions, + booleanOptions, displayTabConfig, eqOptions, eventTabConfig, fillConfig, + getCondOpOptionsByFieldType, numberOptions, styleTabConfig, } from '@editor/utils/props'; @@ -26,10 +28,31 @@ vi.mock('@tmagic/design', () => ({ })); describe('props 选项常量', () => { - test('eqOptions / arrayOptions / numberOptions 内容稳定', () => { + test('eqOptions / arrayOptions / numberOptions / booleanOptions 内容稳定', () => { expect(eqOptions.map((o) => o.value)).toEqual(['=', '!=']); expect(arrayOptions.map((o) => o.value)).toEqual(['include', 'not_include']); expect(numberOptions.map((o) => o.value)).toEqual(['>', '>=', '<', '<=', 'between', 'not_between']); + expect(booleanOptions.map((o) => o.value)).toEqual(['is', 'not']); + }); + + test('getCondOpOptionsByFieldType 按类型返回选项', () => { + expect(getCondOpOptionsByFieldType('array')).toEqual(arrayOptions); + expect(getCondOpOptionsByFieldType('boolean')).toEqual(booleanOptions); + expect(getCondOpOptionsByFieldType('null')).toEqual(booleanOptions); + expect(getCondOpOptionsByFieldType('number').map((o) => o.value)).toEqual([ + ...eqOptions.map((o) => o.value), + ...numberOptions.map((o) => o.value), + ]); + expect(getCondOpOptionsByFieldType('string').map((o) => o.value)).toEqual([ + ...arrayOptions.map((o) => o.value), + ...eqOptions.map((o) => o.value), + ]); + expect(getCondOpOptionsByFieldType('any').map((o) => o.value)).toEqual([ + ...arrayOptions.map((o) => o.value), + ...eqOptions.map((o) => o.value), + ...numberOptions.map((o) => o.value), + ]); + expect(getCondOpOptionsByFieldType('').map((o) => o.value)).not.toContain('is'); }); test('styleTabConfig / eventTabConfig / displayTabConfig 基础结构', () => { diff --git a/packages/editor/tests/unit/utils/typeMatchRules.spec.ts b/packages/editor/tests/unit/utils/typeMatchRules.spec.ts new file mode 100644 index 00000000..411df463 --- /dev/null +++ b/packages/editor/tests/unit/utils/typeMatchRules.spec.ts @@ -0,0 +1,340 @@ +/* + * 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 { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { HookCodeType, HookType, NodeType } from '@tmagic/core'; +import { DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX, DATA_SOURCE_SET_DATA_METHOD_NAME } from '@tmagic/utils'; + +import { ALL_COND_OPS, editorTypeMatchRules } from '@editor/utils/typeMatchRules'; + +const codeDslState = vi.hoisted(() => ({ value: null as Record | null })); +const dataSourcesState = vi.hoisted(() => ({ value: [] as any[] })); +const formMethodsState = vi.hoisted(() => ({ value: [] as { label: string; value: string }[] })); +const formEventsState = vi.hoisted(() => ({ value: [] as { label: string; value: string }[] })); +const componentEventsState = vi.hoisted((): { value: Record } => ({ + value: {}, +})); +const rootState = vi.hoisted(() => ({ value: null as any })); +const nodesState = vi.hoisted((): { value: Record } => ({ value: {} })); + +vi.mock('@editor/services/codeBlock', () => ({ + default: { + getCodeDsl: () => codeDslState.value, + }, +})); + +vi.mock('@editor/services/dataSource', () => ({ + default: { + get: (key: string) => { + if (key === 'dataSources') return dataSourcesState.value; + if (key === 'events') return {}; + return undefined; + }, + getFormMethod: () => formMethodsState.value, + getFormEvent: () => formEventsState.value, + getDataSourceById: (id: string) => dataSourcesState.value.find((ds) => `${ds.id}` === `${id}`), + }, +})); + +vi.mock('@editor/services/editor', () => ({ + default: { + get: (key: string) => (key === 'root' ? rootState.value : undefined), + getNodeById: (id: string | number) => nodesState.value[`${id}`] || null, + }, +})); + +vi.mock('@editor/services/events', () => ({ + default: { + getEvent: (type: string) => componentEventsState.value[type] || [], + }, +})); + +const ctx = (config: Record = {}, model: Record = {}, message?: string) => ({ + fieldType: config.type || '', + mForm: undefined, + props: { config, model, prop: config.name || 'field' }, + message, +}); + +const run = ( + type: string, + value: any, + config: Record = {}, + model: Record = {}, + message?: string, +) => editorTypeMatchRules[type](value, ctx({ ...config, type }, model, message)); + +describe('editorTypeMatchRules', () => { + beforeEach(() => { + codeDslState.value = null; + dataSourcesState.value = []; + formMethodsState.value = []; + formEventsState.value = []; + componentEventsState.value = {}; + rootState.value = null; + nodesState.value = {}; + }); + + test('key-value / style-setter 形态校验', () => { + expect(run('key-value', { a: '1' })).toBeUndefined(); + expect(run('key-value', [])).toBe('值类型应为对象'); + expect(run('key-value', () => 1, { advanced: true })).toBeUndefined(); + expect(run('style-setter', { width: '1px' })).toBeUndefined(); + expect(run('style-setter', [])).toBe('值类型应为对象'); + }); + + test('cond-op-select 按字段类型收窄,未知类型用默认全集', () => { + expect(run('cond-op-select', 1)).toBe('值类型应为字符串'); + expect(run('cond-op-select', '=')).toBeUndefined(); + expect(ALL_COND_OPS.has('between')).toBe(true); + expect(ALL_COND_OPS.has('is')).toBe(true); + + dataSourcesState.value = [ + { + id: 'ds1', + type: 'base', + fields: [ + { name: 'age', type: 'number' }, + { name: 'flag', type: 'boolean' }, + ], + methods: [], + }, + ]; + expect(run('cond-op-select', '>', {}, { field: ['ds1', 'age'] })).toBeUndefined(); + expect(run('cond-op-select', 'include', {}, { field: ['ds1', 'age'] })).toBe('include 不在可选项中'); + expect(run('cond-op-select', 'is', {}, { field: ['ds1', 'flag'] })).toBeUndefined(); + // 未知类型与 UI 默认选项对齐:不含 boolean ops + expect(run('cond-op-select', 'is', {}, { field: ['ds1', 'missing'] })).toBe('is 不在可选项中'); + expect(run('cond-op-select', 'include', {}, { field: ['ds1', 'missing'] })).toBeUndefined(); + }); + + test('code-select-col 有 DSL 时校验 key', () => { + expect(run('code-select-col', 1)).toBe('1类型应为字符串'); + expect(run('code-select-col', 'code_1')).toBeUndefined(); + + codeDslState.value = { code_1: { name: 'A' } }; + expect(run('code-select-col', 'code_1')).toBeUndefined(); + expect(run('code-select-col', 'missing')).toBe('代码块(missing)不存在'); + }); + + test('page-fragment-select / ui-select 校验节点存在性', () => { + expect(run('page-fragment-select', true)).toBe('true类型应为字符串或数字'); + expect(run('page-fragment-select', 'pf_1')).toBeUndefined(); + + rootState.value = { + items: [ + { id: 'pf_1', type: NodeType.PAGE_FRAGMENT }, + { id: 'page_1', type: NodeType.PAGE }, + ], + }; + expect(run('page-fragment-select', 'pf_1')).toBeUndefined(); + expect(run('page-fragment-select', 'page_1')).toBe('页面片段(page_1)不存在'); + + rootState.value = null; + expect(run('ui-select', 'node_1')).toBeUndefined(); + + rootState.value = { items: [] }; + nodesState.value = { node_1: { id: 'node_1' } }; + expect(run('ui-select', 'node_1')).toBeUndefined(); + expect(run('ui-select', 'missing')).toBe('组件(missing)不存在'); + }); + + test('data-source-input 校验模板绑定', () => { + expect(run('data-source-input', 1)).toBe('1类型应为字符串'); + expect(run('data-source-input', 'plain')).toBeUndefined(); + expect(run('data-source-input', '${ds1.title}')).toBeUndefined(); + + dataSourcesState.value = [ + { + id: 'ds1', + type: 'base', + fields: [ + { name: 'title', type: 'string' }, + { name: 'arr', type: 'array', fields: [{ name: 'item', type: 'string' }] }, + ], + methods: [], + }, + ]; + expect(run('data-source-input', 'hello ${ds1.title}')).toBeUndefined(); + expect(run('data-source-input', '${ds1.arr[0].item}')).toBeUndefined(); + expect(run('data-source-input', '${missing.title}')).toBe('数据源绑定(${missing.title})不合法'); + expect(run('data-source-input', '${ds1.missing}')).toBe('数据源绑定(${ds1.missing})不合法'); + }); + + test('data-source-method-select 校验方法元组', () => { + expect(run('data-source-method-select', ['ds1'])).toBe('ds1类型应为长度为 2 的字符串数组'); + expect(run('data-source-method-select', ['ds1', 'foo'])).toBeUndefined(); + + dataSourcesState.value = [ + { + id: 'ds1', + type: 'base', + fields: [], + methods: [{ name: 'custom' }], + }, + ]; + formMethodsState.value = [{ label: '内置', value: 'builtin' }]; + + expect(run('data-source-method-select', ['ds1', DATA_SOURCE_SET_DATA_METHOD_NAME])).toBeUndefined(); + expect(run('data-source-method-select', ['ds1', 'builtin'])).toBeUndefined(); + expect(run('data-source-method-select', ['ds1', 'custom'])).toBeUndefined(); + expect(run('data-source-method-select', ['ds1', 'missing'])).toBe('数据源方法(missing)不存在'); + expect(run('data-source-method-select', ['missing', 'custom'])).toBe('数据源(missing)不存在'); + }); + + test('data-source-field-select 校验路径与 fieldConfig 跳过', () => { + expect(run('data-source-field-select', 'x')).toBe('x类型应为字符串数组'); + expect(run('data-source-field-select', 'text-value', { fieldConfig: { type: 'text' } })).toBeUndefined(); + + dataSourcesState.value = [ + { + id: 'ds1', + type: 'base', + fields: [ + { name: 'title', type: 'string' }, + { name: 'obj', type: 'object', fields: [{ name: 'a', type: 'number' }] }, + ], + methods: [], + }, + ]; + + expect(run('data-source-field-select', ['ds1', 'title'], { value: 'key' })).toBeUndefined(); + expect(run('data-source-field-select', [`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}ds1`, 'title'])).toBeUndefined(); + expect(run('data-source-field-select', ['ds1', 'missing'], { value: 'key' })).toBe('值不在可选项中'); + expect( + run('data-source-field-select', ['title'], { + dataSourceId: 'ds1', + dataSourceFieldType: ['number'], + }), + ).toBe('值不在可选项中'); + expect( + run('data-source-field-select', ['obj', 'a'], { + dataSourceId: 'ds1', + dataSourceFieldType: ['number'], + }), + ).toBeUndefined(); + }); + + test('data-source-select id / 对象形态', () => { + expect(run('data-source-select', { isBindDataSource: true, dataSourceId: 'ds1' })).toBeUndefined(); + expect(run('data-source-select', 'ds1', { value: 'id' })).toBeUndefined(); + + dataSourcesState.value = [ + { id: 'ds1', type: 'base', fields: [], methods: [] }, + { id: 'ds2', type: 'http', fields: [], methods: [] }, + ]; + + expect(run('data-source-select', 'ds1', { value: 'id' })).toBeUndefined(); + expect(run('data-source-select', 'missing', { value: 'id' })).toBe('missing不在可选项中'); + expect(run('data-source-select', 'ds2', { value: 'id', dataSourceType: 'base' })).toBe('ds2不在可选项中'); + expect( + run('data-source-select', { isBindDataSource: true, dataSourceId: 'ds1', dataSourceType: 'base' }), + ).toBeUndefined(); + expect(run('data-source-select', { isBindDataSource: true, dataSourceId: 'ds1', dataSourceType: 'http' })).toBe( + '[object Object]不在可选项中', + ); + expect(run('data-source-select', { isBindDataSource: false, dataSourceId: 'ds1' })).toBe( + '[object Object]类型不合法', + ); + }); + + test('code-select 校验 hookData', () => { + expect(run('code-select', { hookType: HookType.CODE, hookData: [] })).toBeUndefined(); + expect(run('code-select', { hookType: 'x', hookData: [] })).toBe('[object Object]类型不合法'); + + codeDslState.value = { code_1: { name: 'A' } }; + dataSourcesState.value = [{ id: 'ds1', type: 'base', fields: [], methods: [{ name: 'custom' }] }]; + + expect( + run('code-select', { + hookType: HookType.CODE, + hookData: [{ codeType: HookCodeType.CODE, codeId: 'code_1', params: {} }], + }), + ).toBeUndefined(); + // 存在性(codeId 是否在 DSL 中)已下沉到 code-select-col 单元格校验,容器级仅校验结构 + expect( + run('code-select', { + hookType: HookType.CODE, + hookData: [{ codeType: HookCodeType.CODE, codeId: 'missing' }], + }), + ).toBeUndefined(); + expect( + run('code-select', { + hookType: HookType.CODE, + hookData: [{ codeType: HookCodeType.DATA_SOURCE_METHOD, codeId: ['ds1', 'custom'] }], + }), + ).toBeUndefined(); + // 数据源方法存在性已下沉到 data-source-method-select 单元格校验 + expect( + run('code-select', { + hookType: HookType.CODE, + hookData: [{ codeType: HookCodeType.DATA_SOURCE_METHOD, codeId: ['ds1', 'missing'] }], + }), + ).toBeUndefined(); + expect( + run('code-select', { + hookType: HookType.CODE, + hookData: [{ codeType: 'bad', codeId: 'code_1' }], + }), + ).toBe('钩子项结构不合法'); + // DATA_SOURCE_METHOD 的 codeId 元组形态仍在容器级校验 + expect( + run('code-select', { + hookType: HookType.CODE, + hookData: [{ codeType: HookCodeType.DATA_SOURCE_METHOD, codeId: 'not-tuple' }], + }), + ).toBe('钩子项结构不合法'); + }); + + test('容器类浅层结构校验', () => { + expect(run('data-source-fields', [{ name: 'a', type: 'string' }])).toBeUndefined(); + expect(run('data-source-fields', [{ name: 'a', type: 'oops' }])).toBe('字段类型不合法'); + expect( + run('data-source-fields', [{ name: 'obj', type: 'object', fields: [{ name: 'child', type: 'number' }] }]), + ).toBeUndefined(); + expect(run('data-source-fields', [{ name: 'obj', type: 'object', fields: 'bad' }])).toBe('字段项结构不合法'); + + expect( + run('data-source-mocks', [{ title: 'm', enable: true, useInEditor: false, data: { a: 1 } }]), + ).toBeUndefined(); + expect(run('data-source-mocks', [{ title: 'm', enable: true, useInEditor: false, data: [] }])).toBe( + 'mock 项结构不合法', + ); + + expect(run('data-source-methods', [{ name: 'fn', content: '() => {}', params: [] }])).toBeUndefined(); + expect(run('data-source-methods', [{ name: 'fn', content: 1 }])).toBe('方法项结构不合法'); + + expect(run('event-select', [{ name: 'click', actions: [{ actionType: 'comp' }] }])).toBeUndefined(); + expect(run('event-select', [{ name: 'click', to: 'node_1', method: 'show' }])).toBeUndefined(); + expect(run('event-select', [{ name: 'click' }])).toBe('事件项结构不合法'); + expect(run('event-select', [{ name: 'click', actions: [{}] }])).toBe('事件项结构不合法'); + + expect(run('display-conds', [{ cond: [{ field: ['ds1', 'title'], op: '=' }] }])).toBeUndefined(); + // op 是否合法、字段路径是否存在已下沉到 cond-op-select / field 单元格校验,容器级仅校验结构 + expect(run('display-conds', [{ cond: [{ field: ['ds1'], op: 'bad' }] }])).toBeUndefined(); + expect(run('display-conds', [{ cond: [{ field: ['ds1', 'missing'], op: '=' }] }])).toBeUndefined(); + expect(run('display-conds', [{ cond: 'bad' }])).toBe('显示条件结构不合法'); + expect(run('display-conds', [{ cond: [{ field: 'bad', op: '=' }] }])).toBe('显示条件结构不合法'); + expect(run('display-conds', [{ cond: [{ field: ['ds1'], op: 1 }] }])).toBe('显示条件结构不合法'); + }); + + test('自定义 message 优先', () => { + expect(run('key-value', [], {}, {}, '自定义错误')).toBe('自定义错误'); + }); +}); diff --git a/packages/form/src/theme/form-dialog.scss b/packages/form/src/theme/form-dialog.scss index 68af67eb..42db037f 100644 --- a/packages/form/src/theme/form-dialog.scss +++ b/packages/form/src/theme/form-dialog.scss @@ -7,7 +7,7 @@ padding: 0 20px; } - .el-table .m-form-item .el-form-item { + .el-table .m-form-item .el-form-item:not(.is-error) { margin-bottom: 0; } } diff --git a/packages/form/src/theme/table.scss b/packages/form/src/theme/table.scss index e649da94..25f7dab5 100644 --- a/packages/form/src/theme/table.scss +++ b/packages/form/src/theme/table.scss @@ -26,6 +26,16 @@ } } +.el-form-item { + .m-fields-table { + .el-form-item { + &.is-error { + margin-bottom: 18px; + } + } + } +} + .m-fields-table { width: 100%; @@ -62,7 +72,7 @@ } } - .el-form-item { + .el-form-item:not(.is-error) { margin-bottom: 0; } diff --git a/packages/form/src/utils/form.ts b/packages/form/src/utils/form.ts index d2e96856..f852dbef 100644 --- a/packages/form/src/utils/form.ts +++ b/packages/form/src/utils/form.ts @@ -21,6 +21,7 @@ import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; import { cloneDeep } from 'lodash-es'; +import { getDesignConfig } from '@tmagic/design'; import { getValueByKeyPath } from '@tmagic/utils'; import type { @@ -41,6 +42,56 @@ import type { import { createTypeMatchValidator } from './typeMatch'; +type AsyncValidatorFn = (rule: any, value: any, callback: Function, source?: any, options?: any) => any; + +const isTDesignAdapter = () => getDesignConfig('adapterType') === 'tdesign-vue-next'; + +/** + * 将 async-validator(Element Plus)风格的 validator 适配到当前 UI 库。 + * TDesign 调用签名为 `(val) => boolean | CustomValidateObj | Promise`,无 callback。 + */ +export const adaptFormValidator = (validator: AsyncValidatorFn): AsyncValidatorFn => { + return (arg1: any, arg2?: any, arg3?: any, arg4?: any, arg5?: any) => { + if (!isTDesignAdapter()) { + return validator(arg1, arg2, arg3, arg4, arg5); + } + + // TDesign: validator(val) + const value = arg1; + return new Promise((resolve) => { + let settled = false; + const callback = (error?: Error | string) => { + if (settled) return; + settled = true; + if (error) { + resolve({ + result: false, + message: typeof error === 'string' ? error : error.message, + }); + } else { + resolve(true); + } + }; + + try { + const result = validator(undefined, value, callback); + if (result !== null && typeof (result as PromiseLike).then === 'function') { + Promise.resolve(result).then( + () => { + if (!settled) callback(); + }, + (err) => { + callback(err instanceof Error ? err : new Error(String(err))); + }, + ); + } + } catch (e) { + callback(e instanceof Error ? e : new Error(String(e))); + } + }); + }; +}; + interface DefaultItem { defaultValue: any; type: string; @@ -264,32 +315,34 @@ export const getRules = function (mForm: FormState | undefined, rules: Rule[] | return rules.map((item) => { if (item.typeMatch) { - (item as any).validator = createTypeMatchValidator(mForm, props, item); + (item as any).validator = adaptFormValidator(createTypeMatchValidator(mForm, props, item)); return item; } if (typeof item.validator === 'function') { const fnc = item.validator; - (item as any).validator = (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 = 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, + ), + ); } return item; }); diff --git a/packages/form/src/utils/typeMatch.ts b/packages/form/src/utils/typeMatch.ts index f8ab2b56..6ce3faf7 100644 --- a/packages/form/src/utils/typeMatch.ts +++ b/packages/form/src/utils/typeMatch.ts @@ -239,29 +239,29 @@ const validateSelectValue = ( if (config.allowCreate || config.remote) { if (config.multiple) { if (!Array.isArray(value)) { - return defaultMessage(message, '值类型应为数组'); + return defaultMessage(message, `${value} 类型应为数组`); } return undefined; } if (typeof value === 'object') { - return defaultMessage(message, '值类型不合法'); + return defaultMessage(message, `${value} 类型不合法`); } return undefined; } if (config.multiple) { if (!Array.isArray(value)) { - return defaultMessage(message, '值类型应为数组'); + return defaultMessage(message, `${value} 类型应为数组`); } if (value.some((item) => !includesOptionValue(optionValues, item))) { - return defaultMessage(message, '值不在可选项中'); + return defaultMessage(message, `${value} 不在可选项中`); } return undefined; } if (!includesOptionValue(optionValues, value)) { - return defaultMessage(message, '值不在可选项中'); + return defaultMessage(message, `${value} 不在可选项中`); } return undefined; }; @@ -279,14 +279,14 @@ const validateCascaderValue = ( if (valueSeparator) { if (typeof value !== 'string' && !Array.isArray(value)) { - return defaultMessage(message, '值类型应为字符串或数组'); + return defaultMessage(message, `${value} 类型应为字符串或数组`); } } else if (multiple) { if (!Array.isArray(value)) { - return defaultMessage(message, '值类型应为数组'); + return defaultMessage(message, `${value} 类型应为数组`); } } else if (emitPath && !Array.isArray(value)) { - return defaultMessage(message, '值类型应为数组'); + return defaultMessage(message, `${value} 类型应为数组`); } if (config.remote) { @@ -302,7 +302,7 @@ const validateCascaderValue = ( if (multiple) { if (!Array.isArray(normalizedValue)) { - return defaultMessage(message, '值类型应为数组'); + return defaultMessage(message, `${value} 类型应为数组`); } const invalid = normalizedValue.some((item) => { @@ -313,20 +313,20 @@ const validateCascaderValue = ( }); if (invalid) { - return defaultMessage(message, '值不在可选项中'); + return defaultMessage(message, `${value} 不在可选项中`); } return undefined; } if (emitPath) { if (!Array.isArray(normalizedValue) || !isValidCascaderPath(options, normalizedValue)) { - return defaultMessage(message, '值不在可选项中'); + return defaultMessage(message, `${value} 不在可选项中`); } return undefined; } if (!includesOptionValue(collectCascaderLeafValues(options), normalizedValue)) { - return defaultMessage(message, '值不在可选项中'); + return defaultMessage(message, `${value} 不在可选项中`); } return undefined; }; @@ -347,7 +347,7 @@ const validateBuiltinTypeMatch = ( if (STRING_TYPES.has(fieldType)) { if (config.filter === 'number') { if (typeof value !== 'number' || Number.isNaN(value)) { - return defaultMessage(message, '值类型应为数字'); + return defaultMessage(message, `${value} 类型应为数字`); } return undefined; } @@ -358,7 +358,7 @@ const validateBuiltinTypeMatch = ( } if (typeof value !== 'string') { - return defaultMessage(message, '值类型应为字符串'); + return defaultMessage(message, `${value} 类型应为字符串`); } return undefined; } @@ -373,7 +373,7 @@ const validateBuiltinTypeMatch = ( if (fieldType === 'number') { if (typeof value !== 'number' || Number.isNaN(value)) { - return defaultMessage(message, '值类型应为数字'); + return defaultMessage(message, `${value} 类型应为数字`); } return undefined; } @@ -384,7 +384,7 @@ const validateBuiltinTypeMatch = ( value.length !== 2 || value.some((item) => typeof item !== 'number' || Number.isNaN(item)) ) { - return defaultMessage(message, '值类型应为长度为 2 的数字数组'); + return defaultMessage(message, `${value} 类型应为长度为 2 的数字数组`); } return undefined; } @@ -392,7 +392,7 @@ const validateBuiltinTypeMatch = ( if (fieldType === 'switch' || fieldType === 'checkbox') { const { activeValue, inactiveValue } = resolveToggleValues(config); if (!Object.is(value, activeValue) && !Object.is(value, inactiveValue)) { - return defaultMessage(message, '值不在合法开关值中'); + return defaultMessage(message, `${value} 不在合法开关值中`); } return undefined; } @@ -405,18 +405,18 @@ const validateBuiltinTypeMatch = ( if (fieldType === 'radio-group') { const optionValues = flattenSelectOptions(resolveOptions(mForm, props)); if (!includesOptionValue(optionValues, value)) { - return defaultMessage(message, '值不在可选项中'); + return defaultMessage(message, `${value} 不在可选项中`); } return undefined; } if (fieldType === 'checkbox-group') { if (!Array.isArray(value)) { - return defaultMessage(message, '值类型应为数组'); + return defaultMessage(message, `${value} 类型应为数组`); } const optionValues = flattenSelectOptions(resolveOptions(mForm, props)); if (value.some((item) => !includesOptionValue(optionValues, item))) { - return defaultMessage(message, '值不在可选项中'); + return defaultMessage(message, `${value} 不在可选项中`); } return undefined; } @@ -438,8 +438,8 @@ const validateBuiltinTypeMatch = ( return defaultMessage( message, isTimestampValueFormat(valueFormat) - ? '值类型应为长度为 2 的时间戳数字数组' - : `值格式应为长度为 2 的 ${valueFormat} 数组`, + ? `${value} 类型应为长度为 2 的时间戳数字数组` + : `${value} 格式应为长度为 2 的 ${valueFormat} 数组`, ); } return undefined; @@ -447,7 +447,7 @@ const validateBuiltinTypeMatch = ( if (fieldType === 'table' || fieldType === 'group-list' || fieldType === 'grouplist') { if (!Array.isArray(value)) { - return defaultMessage(message, '值类型应为数组'); + return defaultMessage(message, `${value} 类型应为数组`); } return undefined; } diff --git a/packages/form/tests/unit/utils/typeMatch.spec.ts b/packages/form/tests/unit/utils/typeMatch.spec.ts index 5c90a0ce..0d2982b9 100644 --- a/packages/form/tests/unit/utils/typeMatch.spec.ts +++ b/packages/form/tests/unit/utils/typeMatch.spec.ts @@ -28,6 +28,8 @@ import { validateTypeMatch, } from '@form/utils/typeMatch'; +import { setDesignConfig } from '@tmagic/design'; + const mForm: FormState = { config: [], initValues: {}, @@ -74,15 +76,15 @@ describe('validateTypeMatch', () => { test('text 期望 string', () => { expect(validateTypeMatch('ok', mForm, propsOf({ type: 'text' }))).toBeUndefined(); - expect(validateTypeMatch(1, mForm, propsOf({ type: 'text' }))).toBe('值类型应为字符串'); + expect(validateTypeMatch(1, mForm, propsOf({ type: 'text' }))).toBe('1 类型应为字符串'); expect(validateTypeMatch(1, mForm, propsOf({ type: 'text' }), '自定义错误')).toBe('自定义错误'); }); test('text filter=number 时期望 number', () => { expect(validateTypeMatch(1, mForm, propsOf({ type: 'text', filter: 'number' }))).toBeUndefined(); expect(validateTypeMatch(0, mForm, propsOf({ type: 'text', filter: 'number' }))).toBeUndefined(); - expect(validateTypeMatch('1', mForm, propsOf({ type: 'text', filter: 'number' }))).toBe('值类型应为数字'); - expect(validateTypeMatch(NaN, mForm, propsOf({ type: 'textarea', filter: 'number' }))).toBe('值类型应为数字'); + expect(validateTypeMatch('1', mForm, propsOf({ type: 'text', filter: 'number' }))).toBe('1 类型应为数字'); + expect(validateTypeMatch(NaN, mForm, propsOf({ type: 'textarea', filter: 'number' }))).toBe('NaN 类型应为数字'); }); test('text 自定义 filter 函数时跳过内置类型校验', () => { @@ -91,8 +93,8 @@ describe('validateTypeMatch', () => { test('number 期望 number', () => { expect(validateTypeMatch(1, mForm, propsOf({ type: 'number' }))).toBeUndefined(); - expect(validateTypeMatch('1', mForm, propsOf({ type: 'number' }))).toBe('值类型应为数字'); - expect(validateTypeMatch(NaN, mForm, propsOf({ type: 'number' }))).toBe('值类型应为数字'); + expect(validateTypeMatch('1', mForm, propsOf({ type: 'number' }))).toBe('1 类型应为数字'); + expect(validateTypeMatch(NaN, mForm, propsOf({ type: 'number' }))).toBe('NaN 类型应为数字'); }); test('date / time / datetime 按 valueFormat(Day.js format)校验', () => { @@ -124,20 +126,20 @@ describe('validateTypeMatch', () => { test('switch / checkbox 默认 true/false', () => { expect(validateTypeMatch(true, mForm, propsOf({ type: 'switch' }))).toBeUndefined(); expect(validateTypeMatch(false, mForm, propsOf({ type: 'checkbox' }))).toBeUndefined(); - expect(validateTypeMatch(1, mForm, propsOf({ type: 'switch' }))).toBe('值不在合法开关值中'); + expect(validateTypeMatch(1, mForm, propsOf({ type: 'switch' }))).toBe('1 不在合法开关值中'); }); test('switch / checkbox filter=number 时为 1/0', () => { expect(validateTypeMatch(1, mForm, propsOf({ type: 'switch', filter: 'number' }))).toBeUndefined(); expect(validateTypeMatch(0, mForm, propsOf({ type: 'checkbox', filter: 'number' }))).toBeUndefined(); - expect(validateTypeMatch(true, mForm, propsOf({ type: 'switch', filter: 'number' }))).toBe('值不在合法开关值中'); + expect(validateTypeMatch(true, mForm, propsOf({ type: 'switch', filter: 'number' }))).toBe('true 不在合法开关值中'); }); test('switch / checkbox 自定义 activeValue/inactiveValue', () => { const config = { type: 'switch', activeValue: 'on', inactiveValue: 'off' }; expect(validateTypeMatch('on', mForm, propsOf(config))).toBeUndefined(); expect(validateTypeMatch('off', mForm, propsOf(config))).toBeUndefined(); - expect(validateTypeMatch('maybe', mForm, propsOf(config))).toBe('值不在合法开关值中'); + expect(validateTypeMatch('maybe', mForm, propsOf(config))).toBe('maybe 不在合法开关值中'); }); test('select 单选值必须在 options 中', () => { @@ -149,7 +151,7 @@ describe('validateTypeMatch', () => { ], }; expect(validateTypeMatch(1, mForm, propsOf(config))).toBeUndefined(); - expect(validateTypeMatch(3, mForm, propsOf(config))).toBe('值不在可选项中'); + expect(validateTypeMatch(3, mForm, propsOf(config))).toBe('3 不在可选项中'); }); test('select multiple 校验数组元素', () => { @@ -162,8 +164,8 @@ describe('validateTypeMatch', () => { ], }; expect(validateTypeMatch(['a'], mForm, propsOf(config))).toBeUndefined(); - expect(validateTypeMatch(['a', 'c'], mForm, propsOf(config))).toBe('值不在可选项中'); - expect(validateTypeMatch('a', mForm, propsOf(config))).toBe('值类型应为数组'); + expect(validateTypeMatch(['a', 'c'], mForm, propsOf(config))).toBe('a,c 不在可选项中'); + expect(validateTypeMatch('a', mForm, propsOf(config))).toBe('a 类型应为数组'); }); test('select options 为函数 / group', () => { @@ -172,7 +174,7 @@ describe('validateTypeMatch', () => { options: () => [{ text: 'A', value: 1 }], }; expect(validateTypeMatch(1, mForm, propsOf(fnConfig))).toBeUndefined(); - expect(validateTypeMatch(2, mForm, propsOf(fnConfig))).toBe('值不在可选项中'); + expect(validateTypeMatch(2, mForm, propsOf(fnConfig))).toBe('2 不在可选项中'); const groupConfig = { type: 'select', @@ -186,15 +188,17 @@ describe('validateTypeMatch', () => { ], }; expect(validateTypeMatch('a', mForm, propsOf(groupConfig))).toBeUndefined(); - expect(validateTypeMatch('b', mForm, propsOf(groupConfig))).toBe('值不在可选项中'); + expect(validateTypeMatch('b', mForm, propsOf(groupConfig))).toBe('b 不在可选项中'); }); test('select allowCreate / remote 不做枚举', () => { expect(validateTypeMatch('custom', mForm, propsOf({ type: 'select', allowCreate: true }))).toBeUndefined(); - expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'select', allowCreate: true }))).toBe('值类型不合法'); + expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'select', allowCreate: true }))).toBe( + '[object Object] 类型不合法', + ); expect(validateTypeMatch(['x'], mForm, propsOf({ type: 'select', multiple: true, remote: true }))).toBeUndefined(); expect(validateTypeMatch('x', mForm, propsOf({ type: 'select', multiple: true, remote: true }))).toBe( - '值类型应为数组', + 'x 类型应为数组', ); }); @@ -207,7 +211,7 @@ describe('validateTypeMatch', () => { ], }; expect(validateTypeMatch(1, mForm, propsOf(radio))).toBeUndefined(); - expect(validateTypeMatch(3, mForm, propsOf(radio))).toBe('值不在可选项中'); + expect(validateTypeMatch(3, mForm, propsOf(radio))).toBe('3 不在可选项中'); const checkboxGroup = { type: 'checkbox-group', @@ -217,7 +221,7 @@ describe('validateTypeMatch', () => { ], }; expect(validateTypeMatch(['a', 'b'], mForm, propsOf(checkboxGroup))).toBeUndefined(); - expect(validateTypeMatch(['c'], mForm, propsOf(checkboxGroup))).toBe('值不在可选项中'); + expect(validateTypeMatch(['c'], mForm, propsOf(checkboxGroup))).toBe('c 不在可选项中'); }); test('cascader 静态路径与 valueSeparator', () => { @@ -236,33 +240,33 @@ describe('validateTypeMatch', () => { expect(validateTypeMatch(['zhejiang', 'hangzhou'], mForm, propsOf({ type: 'cascader', options }))).toBeUndefined(); expect(validateTypeMatch(['zhejiang', 'ningbo'], mForm, propsOf({ type: 'cascader', options }))).toBe( - '值不在可选项中', + 'zhejiang,ningbo 不在可选项中', ); expect( validateTypeMatch('zhejiang/hangzhou', mForm, propsOf({ type: 'cascader', options, valueSeparator: '/' })), ).toBeUndefined(); - expect(validateTypeMatch('bad', mForm, propsOf({ type: 'cascader', remote: true }))).toBe('值类型应为数组'); + expect(validateTypeMatch('bad', mForm, propsOf({ type: 'cascader', remote: true }))).toBe('bad 类型应为数组'); expect(validateTypeMatch(['a'], mForm, propsOf({ type: 'cascader', remote: true }))).toBeUndefined(); }); test('number-range / daterange / table', () => { expect(validateTypeMatch([1, 2], mForm, propsOf({ type: 'number-range' }))).toBeUndefined(); - expect(validateTypeMatch([1], mForm, propsOf({ type: 'number-range' }))).toBe('值类型应为长度为 2 的数字数组'); + expect(validateTypeMatch([1], mForm, propsOf({ type: 'number-range' }))).toBe('1 类型应为长度为 2 的数字数组'); expect( validateTypeMatch(['2020/01/01 00:00:00', '2020/01/02 00:00:00'], mForm, propsOf({ type: 'daterange' })), ).toBeUndefined(); expect(validateTypeMatch(['2020-01-01', '2020-01-02'], mForm, propsOf({ type: 'daterange' }))).toBe( - '值格式应为长度为 2 的 YYYY/MM/DD HH:mm:ss 数组', + '2020-01-01,2020-01-02 格式应为长度为 2 的 YYYY/MM/DD HH:mm:ss 数组', ); expect(validateTypeMatch(['a'], mForm, propsOf({ type: 'daterange' }))).toBe( - '值格式应为长度为 2 的 YYYY/MM/DD HH:mm:ss 数组', + 'a 格式应为长度为 2 的 YYYY/MM/DD HH:mm:ss 数组', ); expect(validateTypeMatch([1, 2], mForm, propsOf({ type: 'daterange', valueFormat: 'timestamp' }))).toBeUndefined(); expect(validateTypeMatch('x', mForm, propsOf({ type: 'daterange', names: ['a', 'b'] }))).toBeUndefined(); expect(validateTypeMatch([{ id: 1 }], mForm, propsOf({ type: 'table' }))).toBeUndefined(); - expect(validateTypeMatch({}, mForm, propsOf({ type: 'groupList' }))).toBe('值类型应为数组'); + expect(validateTypeMatch({}, mForm, propsOf({ type: 'groupList' }))).toBe('[object Object] 类型应为数组'); }); test('容器类字段 no-op', () => { @@ -272,25 +276,25 @@ describe('validateTypeMatch', () => { test('无 type 默认按 text 校验', () => { expect(validateTypeMatch('ok', mForm, propsOf({}))).toBeUndefined(); - expect(validateTypeMatch(1, mForm, propsOf({}))).toBe('值类型应为字符串'); + expect(validateTypeMatch(1, mForm, propsOf({}))).toBe('1 类型应为字符串'); }); test('textarea / color-picker / html 期望 string', () => { expect(validateTypeMatch('ok', mForm, propsOf({ type: 'textarea' }))).toBeUndefined(); expect(validateTypeMatch('ok', mForm, propsOf({ type: 'color-picker' }))).toBeUndefined(); - expect(validateTypeMatch(1, mForm, propsOf({ type: 'html' }))).toBe('值类型应为字符串'); + expect(validateTypeMatch(1, mForm, propsOf({ type: 'html' }))).toBe('1 类型应为字符串'); }); test('checkbox-group 非数组', () => { expect( validateTypeMatch('a', mForm, propsOf({ type: 'checkbox-group', options: [{ text: 'A', value: 'a' }] })), - ).toBe('值类型应为数组'); + ).toBe('a 类型应为数组'); }); test('timerange 按 valueFormat 校验', () => { expect(validateTypeMatch(['12:00:00', '13:00:00'], mForm, propsOf({ type: 'timerange' }))).toBeUndefined(); expect(validateTypeMatch(['bad'], mForm, propsOf({ type: 'timerange' }))).toBe( - '值格式应为长度为 2 的 HH:mm:ss 数组', + 'bad 格式应为长度为 2 的 HH:mm:ss 数组', ); }); @@ -306,7 +310,7 @@ describe('validateTypeMatch', () => { validateTypeMatch('hangzhou', mForm, propsOf({ type: 'cascader', options, emitPath: false })), ).toBeUndefined(); expect(validateTypeMatch('ningbo', mForm, propsOf({ type: 'cascader', options, emitPath: false }))).toBe( - '值不在可选项中', + 'ningbo 不在可选项中', ); }); @@ -323,7 +327,7 @@ describe('validateTypeMatch', () => { ).toBeUndefined(); expect( validateTypeMatch(['ningbo'], mForm, propsOf({ type: 'cascader', options, multiple: true, emitPath: false })), - ).toBe('值不在可选项中'); + ).toBe('ningbo 不在可选项中'); }); test('cascader valueSeparator 时数组值', () => { @@ -340,7 +344,9 @@ describe('validateTypeMatch', () => { }); test('select allowCreate 对象值非法', () => { - expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'select', allowCreate: true }))).toBe('值类型不合法'); + expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'select', allowCreate: true }))).toBe( + '[object Object] 类型不合法', + ); }); test('动态 type 函数解析', () => { @@ -394,6 +400,50 @@ describe('getRules typeMatch', () => { }); }); +describe('getRules tdesign validator', () => { + beforeEach(() => { + setDesignConfig({ adapterType: 'tdesign-vue-next' }); + }); + + afterEach(() => { + setDesignConfig({}); + }); + + test('typeMatch 校验失败时返回 CustomValidateObj', async () => { + const rules: any = [{ typeMatch: true, message: '类型错误' }]; + const newRules: any = getRules(mForm, rules, propsOf({ type: 'text' })); + // TDesign 调用签名:validator(val) + await expect(newRules[0].validator(123)).resolves.toEqual({ + result: false, + message: '类型错误', + }); + }); + + test('typeMatch 校验通过时返回 true', async () => { + const rules: any = [{ typeMatch: true }]; + const newRules: any = getRules(mForm, rules, propsOf({ type: 'text' })); + await expect(newRules[0].validator('ok')).resolves.toBe(true); + }); + + test('自定义 validator 通过 callback 报告错误', async () => { + const rules: any = { + validator: ({ value, callback }: any) => { + if (value < 0) { + callback(new Error('不能为负')); + return; + } + callback(); + }, + }; + const newRules: any = getRules(mForm, rules, { config: {} }); + await expect(newRules[0].validator(-1)).resolves.toEqual({ + result: false, + message: '不能为负', + }); + await expect(newRules[0].validator(1)).resolves.toBe(true); + }); +}); + describe('typeMatch 扩展注册', () => { beforeEach(() => { clearTypeMatchRules(); diff --git a/playground/package.json b/playground/package.json index df53494c..f2239c59 100644 --- a/playground/package.json +++ b/playground/package.json @@ -18,7 +18,7 @@ "@tmagic/element-plus-adapter": "1.8.0-beta.11", "@tmagic/tdesign-vue-next-adapter": "1.8.0-beta.11", "@tmagic/tmagic-form-runtime": "1.1.3", - "element-plus": "^2.11.8", + "element-plus": "^2.14.3", "lodash-es": "^4.17.21", "monaco-editor": "^0.55.1", "serialize-javascript": "^7.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f3c764b..16f4a764 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,8 +60,8 @@ importers: specifier: ^3.3.0 version: 3.3.0(@types/node@24.0.10)(typescript@6.0.3) element-plus: - specifier: ^2.11.8 - version: 2.11.8(@vue/composition-api@1.7.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3)) + specifier: ^2.14.3 + version: 2.14.3(vue@3.5.38(typescript@6.0.3)) enquirer: specifier: ^2.4.1 version: 2.4.1 @@ -573,16 +573,16 @@ importers: version: 1.8.0-beta.11(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.11(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.11(typescript@6.0.3))(monaco-editor@0.55.1)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)) '@tmagic/element-plus-adapter': specifier: 1.8.0-beta.11 - version: 1.8.0-beta.11(@tmagic/design@1.8.0-beta.11(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)))(element-plus@2.11.8(@vue/composition-api@1.7.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)) + version: 1.8.0-beta.11(@tmagic/design@1.8.0-beta.11(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)))(element-plus@2.14.3(vue@3.5.38(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)) '@tmagic/tdesign-vue-next-adapter': specifier: 1.8.0-beta.11 version: 1.8.0-beta.11(@tmagic/design@1.8.0-beta.11(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)))(tdesign-vue-next@1.17.3(vue@3.5.38(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)) '@tmagic/tmagic-form-runtime': specifier: 1.1.3 - version: 1.1.3(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/editor@1.8.0-beta.11(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.11(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.11(typescript@6.0.3))(monaco-editor@0.55.1)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)))(element-plus@2.11.8(@vue/composition-api@1.7.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)) + version: 1.1.3(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/editor@1.8.0-beta.11(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.11(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.11(typescript@6.0.3))(monaco-editor@0.55.1)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)))(element-plus@2.14.3(vue@3.5.38(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)) element-plus: - specifier: ^2.11.8 - version: 2.11.8(@vue/composition-api@1.7.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3)) + specifier: ^2.14.3 + version: 2.14.3(vue@3.5.38(typescript@6.0.3)) lodash-es: specifier: ^4.17.21 version: 4.17.21 @@ -923,7 +923,7 @@ importers: version: 1.8.0-beta.11(typescript@6.0.3) '@tmagic/react-runtime-help': specifier: 0.2.2 - version: 0.2.2(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/stage@1.8.0-beta.11(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(typescript@6.0.3))(lodash-es@4.17.21)(react@18.3.1)(typescript@6.0.3) + version: 0.2.2(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/stage@1.8.0-beta.11(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(typescript@6.0.3))(lodash-es@4.18.1)(react@18.3.1)(typescript@6.0.3) '@tmagic/stage': specifier: 1.8.0-beta.11 version: 1.8.0-beta.11(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(typescript@6.0.3) @@ -2048,6 +2048,10 @@ packages: resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==} engines: {node: '>=10'} + '@ctrl/tinycolor@4.2.0': + resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==} + engines: {node: '>=14'} + '@daybrush/utils@1.13.0': resolution: {integrity: sha512-ALK12C6SQNNHw1enXK+UO8bdyQ+jaWNQ1Af7Z3FNxeAwjYhQT7do+TRE4RASAJ3ObaS2+TJ7TXR3oz2Gzbw0PQ==} @@ -2457,12 +2461,21 @@ packages: '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + '@floating-ui/dom@1.7.4': resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -2922,6 +2935,9 @@ packages: '@sxzz/popperjs-es@2.11.7': resolution: {integrity: sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==} + '@sxzz/popperjs-es@2.11.8': + resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==} + '@tmagic/cli@1.8.0-beta.11': resolution: {integrity: sha512-j0yKe2+wVlSeSo/VuieaYh+WDQVEx2U5XA3FfGwusllgvInLB0DUNRcPyhdEtwckl4bDI7p+vvcmlMyVDeYXcg==} engines: {node: '>=18'} @@ -3348,6 +3364,9 @@ packages: '@types/lodash@4.17.20': resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} + '@types/lodash@4.17.24': + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + '@types/markdown-it@14.1.2': resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} @@ -4326,6 +4345,9 @@ packages: dayjs@1.11.19: resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -4447,6 +4469,11 @@ packages: peerDependencies: vue: ^3.2.0 + element-plus@2.14.3: + resolution: {integrity: sha512-pJcvxcpZjYruNzuJhAeVwnbYjfNgzBKnWHwSVEhwzM2/kcLI3brzmtIBxtPqd4hQWJfD1PRnjoc1WipLw2eBGg==} + peerDependencies: + vue: ^3.3.7 + emmet-monaco-es@5.7.0: resolution: {integrity: sha512-q8jtgsEWWaXHnAJ6HAcwMB7WHraRtf032TWpeOpN6SGLtwxr7hd6RgF3Kbj2rPxb6Q19fYVVafAFODYRYUS8NQ==} peerDependencies: @@ -5455,6 +5482,9 @@ packages: lodash-es@4.17.21: resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash-unified@1.0.3: resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==} peerDependencies: @@ -5498,6 +5528,9 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} @@ -6902,6 +6935,9 @@ packages: vue-component-type-helpers@2.2.12: resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==} + vue-component-type-helpers@3.3.7: + resolution: {integrity: sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg==} + vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} engines: {node: '>=12'} @@ -8113,6 +8149,8 @@ snapshots: '@ctrl/tinycolor@3.6.1': {} + '@ctrl/tinycolor@4.2.0': {} + '@daybrush/utils@1.13.0': {} '@docsearch/css@4.6.3': {} @@ -8379,13 +8417,24 @@ snapshots: dependencies: '@floating-ui/utils': 0.2.10 + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + '@floating-ui/dom@1.7.4': dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/utils': 0.2.10 + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + '@floating-ui/utils@0.2.10': {} + '@floating-ui/utils@0.2.12': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -8743,6 +8792,8 @@ snapshots: '@sxzz/popperjs-es@2.11.7': {} + '@sxzz/popperjs-es@2.11.8': {} + '@tmagic/cli@1.8.0-beta.11(typescript@6.0.3)': dependencies: cac: 6.7.14 @@ -8906,10 +8957,10 @@ snapshots: - '@tmagic/form-schema' - '@tmagic/schema' - '@tmagic/element-plus-adapter@1.8.0-beta.11(@tmagic/design@1.8.0-beta.11(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)))(element-plus@2.11.8(@vue/composition-api@1.7.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))': + '@tmagic/element-plus-adapter@1.8.0-beta.11(@tmagic/design@1.8.0-beta.11(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)))(element-plus@2.14.3(vue@3.5.38(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))': dependencies: '@tmagic/design': 1.8.0-beta.11(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)) - element-plus: 2.11.8(@vue/composition-api@1.7.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3)) + element-plus: 2.14.3(vue@3.5.38(typescript@6.0.3)) vue: 3.5.38(typescript@6.0.3) optionalDependencies: typescript: 6.0.3 @@ -8954,9 +9005,9 @@ snapshots: optionalDependencies: typescript: 6.0.3 - '@tmagic/react-runtime-help@0.2.2(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/stage@1.8.0-beta.11(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(typescript@6.0.3))(lodash-es@4.17.21)(react@18.3.1)(typescript@6.0.3)': + '@tmagic/react-runtime-help@0.2.2(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/stage@1.8.0-beta.11(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(typescript@6.0.3))(lodash-es@4.18.1)(react@18.3.1)(typescript@6.0.3)': dependencies: - lodash-es: 4.17.21 + lodash-es: 4.18.1 react: 18.3.1 optionalDependencies: '@tmagic/core': 1.8.0-beta.11(typescript@6.0.3) @@ -9042,10 +9093,10 @@ snapshots: optionalDependencies: typescript: 6.0.3 - '@tmagic/tmagic-form-runtime@1.1.3(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/editor@1.8.0-beta.11(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.11(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.11(typescript@6.0.3))(monaco-editor@0.55.1)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)))(element-plus@2.11.8(@vue/composition-api@1.7.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))': + '@tmagic/tmagic-form-runtime@1.1.3(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/editor@1.8.0-beta.11(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.11(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.11(typescript@6.0.3))(monaco-editor@0.55.1)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)))(element-plus@2.14.3(vue@3.5.38(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3))': dependencies: '@tmagic/editor': 1.8.0-beta.11(@tmagic/core@1.8.0-beta.11(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.11(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.11(typescript@6.0.3))(monaco-editor@0.55.1)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.38(typescript@6.0.3)) - element-plus: 2.11.8(@vue/composition-api@1.7.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3)) + element-plus: 2.14.3(vue@3.5.38(typescript@6.0.3)) vue: 3.5.38(typescript@6.0.3) optionalDependencies: '@tmagic/core': 1.8.0-beta.11(typescript@6.0.3) @@ -9131,6 +9182,8 @@ snapshots: '@types/lodash@4.17.20': {} + '@types/lodash@4.17.24': {} + '@types/markdown-it@14.1.2': dependencies: '@types/linkify-it': 5.0.0 @@ -10369,6 +10422,8 @@ snapshots: dayjs@1.11.19: {} + dayjs@1.11.21: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -10477,6 +10532,25 @@ snapshots: transitivePeerDependencies: - '@vue/composition-api' + element-plus@2.14.3(vue@3.5.38(typescript@6.0.3)): + dependencies: + '@ctrl/tinycolor': 4.2.0 + '@element-plus/icons-vue': 2.3.2(vue@3.5.38(typescript@6.0.3)) + '@floating-ui/dom': 1.8.0 + '@popperjs/core': '@sxzz/popperjs-es@2.11.8' + '@types/lodash': 4.17.24 + '@types/lodash-es': 4.17.12 + '@vueuse/core': 14.3.0(vue@3.5.38(typescript@6.0.3)) + async-validator: 4.2.5 + dayjs: 1.11.21 + lodash: 4.18.1 + lodash-es: 4.18.1 + lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1) + memoize-one: 6.0.0 + normalize-wheel-es: 1.2.0 + vue: 3.5.38(typescript@6.0.3) + vue-component-type-helpers: 3.3.7 + emmet-monaco-es@5.7.0(monaco-editor@0.55.1): dependencies: emmet: 2.4.11 @@ -11658,12 +11732,20 @@ snapshots: lodash-es@4.17.21: {} + lodash-es@4.18.1: {} + lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.21)(lodash@4.17.21): dependencies: '@types/lodash-es': 4.17.12 lodash: 4.17.21 lodash-es: 4.17.21 + lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1): + dependencies: + '@types/lodash-es': 4.17.12 + lodash: 4.18.1 + lodash-es: 4.18.1 + lodash.camelcase@4.3.0: {} lodash.debounce@4.0.8: {} @@ -11688,6 +11770,8 @@ snapshots: lodash@4.17.21: {} + lodash@4.18.1: {} + log-symbols@4.1.0: dependencies: chalk: 4.1.2 @@ -13140,6 +13224,8 @@ snapshots: vue-component-type-helpers@2.2.12: {} + vue-component-type-helpers@3.3.7: {} + vue-demi@0.14.10(@vue/composition-api@1.7.2(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3)): dependencies: vue: 3.5.38(typescript@6.0.3)