mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-07-13 22:52:31 +08:00
feat(form): 支持 rules.typeMatch 按字段 type 校验值类型与选项匹配
新增可扩展的 typeMatch 校验能力,兼容 async-validator 规则体系,并补充文档与单元测试。
This commit is contained in:
parent
f7d737a0c0
commit
734e6a8ee6
@ -488,6 +488,15 @@ export default defineConfig({
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '表单校验',
|
||||
items: [
|
||||
{
|
||||
text: '表单校验',
|
||||
link: '/form-config/rules.md',
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '表单对比',
|
||||
items: [
|
||||
|
||||
@ -68,7 +68,10 @@ try {
|
||||
type: 'text',
|
||||
name: 'username',
|
||||
text: '用户名',
|
||||
rules: [{ required: true, message: '请输入用户名' }],
|
||||
rules: [
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ typeMatch: true, message: '用户名类型不合法' },
|
||||
],
|
||||
},
|
||||
],
|
||||
initValues: { username: '' },
|
||||
|
||||
@ -24,6 +24,26 @@ type为'select'
|
||||
</template>
|
||||
</demo-block>
|
||||
|
||||
## 选项值校验
|
||||
|
||||
可在 `rules` 中开启 `typeMatch`,校验当前值是否落在 `options` 中(`multiple` 时校验数组元素)。详见[表单校验](/form-config/rules.md)。
|
||||
|
||||
```ts
|
||||
{
|
||||
type: 'select',
|
||||
name: 'status',
|
||||
text: '状态',
|
||||
options: [
|
||||
{ text: '启用', value: 1 },
|
||||
{ text: '禁用', value: 0 },
|
||||
],
|
||||
rules: [
|
||||
{ required: true, message: '请选择状态' },
|
||||
{ typeMatch: true, message: '状态值不合法' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## 有禁用选项
|
||||
|
||||
<demo-block type="form" :config="[{
|
||||
|
||||
@ -82,6 +82,21 @@ Input输入框的type为'text', 是type的默认值,所以可以不配置
|
||||
</template>
|
||||
</demo-block>
|
||||
|
||||
## 表单校验
|
||||
|
||||
可通过 `rules` 配置校验规则。其中 `typeMatch: true` 会按字段 type(以及 `filter` / `valueFormat` 等)校验值是否合法,详见[表单校验](/form-config/rules.md)。
|
||||
|
||||
```ts
|
||||
{
|
||||
name: 'text',
|
||||
text: '输入框',
|
||||
rules: [
|
||||
{ required: true, message: '请输入' },
|
||||
{ typeMatch: true, message: '值类型不合法' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## 去掉首尾空格
|
||||
|
||||
<demo-block type="form" :config="[{
|
||||
@ -124,6 +139,7 @@ Input输入框的type为'text', 是type的默认值,所以可以不配置
|
||||
| tooltip | 输入时显示内容 | string / `ToolTipConfigType` | — | — |
|
||||
| trim | 是否去掉首尾空格 | boolean | — | false |
|
||||
| filter | 过滤值 | string / Function | number | - |
|
||||
| rules | 表单验证规则 | `Rule[]` | — | — |
|
||||
| prepend | 前置内容 | string | — | - |
|
||||
| append | 后置内容 | string / Object | — | - |
|
||||
| onChange | 值变化时触发的函数 | `OnChangeHandler` | — | - |
|
||||
@ -140,6 +156,8 @@ Input输入框的type为'text', 是type的默认值,所以可以不配置
|
||||
<<< @/../packages/form-schema/src/base.ts#FormValue{ts}
|
||||
|
||||
<<< @/../packages/form-schema/src/base.ts#ToolTipConfigType{ts}
|
||||
|
||||
<<< @/../packages/form-schema/src/base.ts#Rule{ts}
|
||||
:::
|
||||
|
||||
|
||||
|
||||
183
docs/form-config/rules.md
Normal file
183
docs/form-config/rules.md
Normal file
@ -0,0 +1,183 @@
|
||||
# 表单校验
|
||||
|
||||
`@tmagic/form` 字段可通过 `rules` 配置校验规则,兼容 [async-validator](https://github.com/yiminghe/async-validator)(Element Plus 内部使用)的常见能力,并额外支持按字段 `type` 做值类型 / 选项匹配校验(`typeMatch`)。
|
||||
|
||||
## 基础用法
|
||||
|
||||
```ts
|
||||
{
|
||||
name: 'title',
|
||||
text: '名称',
|
||||
rules: [
|
||||
{ required: true, message: '请输入名称' },
|
||||
{ typeMatch: true, message: '名称类型不合法' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
`rules` 类型见下方 [`Rule`](#类型定义)。未配置 `typeMatch` 时,现有表单行为不变。
|
||||
|
||||
## typeMatch
|
||||
|
||||
开启 `typeMatch: true` 后,表单会按字段 `config.type`(及关联配置)校验当前值是否合法。空值(`undefined` / `null` / `''`,多选类空数组 `[]`)直接通过,必填请继续使用 `required`。
|
||||
|
||||
### 内置映射
|
||||
|
||||
| 字段 type | 期望值 |
|
||||
| --- | --- |
|
||||
| `text` / `textarea` / `color-picker` / `html` / 默认无 type | `string`;若 `filter: 'number'` 则为 `number`;`filter` 为自定义函数时跳过内置类型校验 |
|
||||
| `display` / `hidden` | 不校验 |
|
||||
| `number` | `number`(非 NaN) |
|
||||
| `number-range` | 长度为 2 的数字数组 |
|
||||
| `date` / `datetime` / `time` | 按 `valueFormat` 校验;`x` / `timestamp` 期望 `number`,其余按 [Day.js format](https://day.js.org/docs/en/display/format) 严格解析字符串 |
|
||||
| `daterange` / `timerange` | 无 `names` 时为长度为 2 的数组,元素按 `valueFormat` 校验;有 `names` 时跳过 |
|
||||
| `switch` / `checkbox` | 值必须是解析后的 `activeValue` / `inactiveValue` 之一(显式配置优先;未配置且 `filter === 'number'` 时为 `1`/`0`;否则为 `true`/`false`) |
|
||||
| `select` | 单选:值 ∈ options;`multiple`:数组且每项 ∈ options;`allowCreate` / `remote`:只校验基础形态,不做 options 枚举 |
|
||||
| `radio-group` / `radioGroup` | 值 ∈ options |
|
||||
| `checkbox-group` / `checkboxGroup` | 数组且每项 ∈ options |
|
||||
| `cascader` | 有 `valueSeparator` 时可为 `string` 或 `array`;默认 `emitPath` 为路径数组;`emitPath: false` 为叶子值;`multiple` 为数组;静态 options 校验路径/叶子;`remote` 只做形态校验 |
|
||||
| `table` / `group-list` / `groupList` | `array` |
|
||||
| 容器类(`row` / `tab` / `fieldset` / `panel` / `step` / `flex-layout` / `link` / `component` / `dynamic-field` 等) | 不校验 |
|
||||
|
||||
日期类默认 `valueFormat` 与字段组件一致:
|
||||
|
||||
- `date`:`YYYY/MM/DD`
|
||||
- `datetime` / `daterange`:`YYYY/MM/DD HH:mm:ss`
|
||||
- `time` / `timerange`:`HH:mm:ss`
|
||||
|
||||
### 与自定义 validator 共存
|
||||
|
||||
同一条 rule 同时配置了 `typeMatch` 与 `validator` 时,会先做类型匹配校验,通过后再执行自定义 `validator`。
|
||||
|
||||
```ts
|
||||
{
|
||||
name: 'age',
|
||||
type: 'number',
|
||||
rules: [
|
||||
{
|
||||
typeMatch: true,
|
||||
message: '年龄必须是数字',
|
||||
validator: ({ value, callback }) => {
|
||||
if (value < 0) {
|
||||
callback(new Error('年龄不能小于 0'));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## 扩展自定义 type 规则
|
||||
|
||||
业务可覆盖内置规则,或为自定义字段 type 注册校验。自定义规则优先于内置规则。
|
||||
|
||||
### 运行时注册
|
||||
|
||||
```ts
|
||||
import {
|
||||
registerTypeMatchRule,
|
||||
registerTypeMatchRules,
|
||||
deleteTypeMatchRule,
|
||||
clearTypeMatchRules,
|
||||
} from '@tmagic/form';
|
||||
|
||||
// 覆盖内置 text
|
||||
registerTypeMatchRule('text', (value, { message }) => {
|
||||
if (typeof value !== 'string') {
|
||||
return message || '值类型应为字符串';
|
||||
}
|
||||
});
|
||||
|
||||
// 扩展业务字段
|
||||
registerTypeMatchRule('vs-code', (value, { message }) => {
|
||||
if (typeof value !== 'string') {
|
||||
return message || '代码字段应为字符串';
|
||||
}
|
||||
});
|
||||
|
||||
// 批量注册
|
||||
registerTypeMatchRules({
|
||||
foo: (value) => (Array.isArray(value) ? undefined : '应为数组'),
|
||||
});
|
||||
|
||||
// 删除 / 清空
|
||||
deleteTypeMatchRule('foo');
|
||||
clearTypeMatchRules();
|
||||
```
|
||||
|
||||
自定义校验器签名:`(value, context) => string | undefined`。返回错误文案表示失败,返回 `undefined` 表示通过。`context` 包含 `fieldType`、`mForm`、`props`、`message`。
|
||||
|
||||
### 安装时注册
|
||||
|
||||
```ts
|
||||
import MagicForm from '@tmagic/form';
|
||||
|
||||
app.use(MagicForm, {
|
||||
typeMatchRules: {
|
||||
'my-field': (value, { message }) => {
|
||||
if (typeof value !== 'string') {
|
||||
return message || 'my-field 应为字符串';
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## 示例
|
||||
|
||||
### select 选项匹配
|
||||
|
||||
```ts
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ text: '启用', value: 1 },
|
||||
{ text: '禁用', value: 0 },
|
||||
],
|
||||
rules: [
|
||||
{ required: true, message: '请选择状态' },
|
||||
{ typeMatch: true, message: '状态值不合法' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### date 按 valueFormat 校验
|
||||
|
||||
```ts
|
||||
{
|
||||
name: 'birthday',
|
||||
type: 'date',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
rules: [{ typeMatch: true, message: '日期格式不正确' }],
|
||||
}
|
||||
```
|
||||
|
||||
### text + filter: number
|
||||
|
||||
```ts
|
||||
{
|
||||
name: 'width',
|
||||
type: 'text',
|
||||
filter: 'number',
|
||||
rules: [{ typeMatch: true, message: '宽度应为数字' }],
|
||||
}
|
||||
```
|
||||
|
||||
## 类型定义
|
||||
|
||||
::: details 查看 Rule 类型定义
|
||||
<<< @/../packages/form-schema/src/base.ts#Rule{ts}
|
||||
:::
|
||||
|
||||
::: details 查看 TypeMatchValidator / TypeMatchValidateContext 类型定义
|
||||
<<< @/../packages/form/src/utils/typeMatch.ts#TypeMatchValidator{ts}
|
||||
|
||||
<<< @/../packages/form/src/utils/typeMatch.ts#TypeMatchValidateContext{ts}
|
||||
:::
|
||||
|
||||
::: details 查看 FormInstallOptions 类型定义
|
||||
<<< @/../packages/form/src/plugin.ts#FormInstallOptions{ts}
|
||||
:::
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
tmagic-editor的表单配置,核心就是使用了 @tmagic/form 来作为渲染器。@tmagic/form 是一个 npm 包,可以安装它,在你想使用的地方单独使用。
|
||||
|
||||
@tmagic/form 接受一个表单配置,详细配置可参考[表单 api](../../api/form/form-props.md)。
|
||||
@tmagic/form 接受一个表单配置,详细配置可参考[表单 api](../../api/form/form-props.md)。字段校验(含 `typeMatch` 类型匹配)见[表单校验](../../form-config/rules.md)。
|
||||
|
||||
## 安装
|
||||
|
||||
@ -45,6 +45,20 @@ app.use(MagicForm);
|
||||
app.mount("#app");
|
||||
```
|
||||
|
||||
也可在安装时传入自定义 `typeMatch` 规则,详见[表单校验 - 扩展自定义 type 规则](../../form-config/rules.md#扩展自定义-type-规则):
|
||||
|
||||
```javascript
|
||||
app.use(MagicForm, {
|
||||
typeMatchRules: {
|
||||
'my-field': (value, { message }) => {
|
||||
if (typeof value !== 'string') {
|
||||
return message || 'my-field 应为字符串';
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
以上代码便完成了 @tmagic/form 的引入。需要注意的是,Element Plus 的样式文件需要单独引入。
|
||||
|
||||
|
||||
@ -163,10 +163,13 @@ export interface ContainerCommonConfig<T = never> extends FormItem {
|
||||
}
|
||||
// #endregion ContainerCommonConfig
|
||||
|
||||
// #region Rule
|
||||
export interface Rule {
|
||||
message?: string;
|
||||
/** 系统提供的验证器类型。有:string,number,boolean,method,regexp,integer,float,array,object,enum,date,url,hex,email,any */
|
||||
type?: string;
|
||||
/** 是否按字段 config.type 校验值类型/选项匹配 */
|
||||
typeMatch?: boolean;
|
||||
/** 是否必填 */
|
||||
required?: boolean;
|
||||
trigger?: string;
|
||||
@ -195,6 +198,7 @@ export interface Rule {
|
||||
mForm: FormState | undefined,
|
||||
) => void;
|
||||
}
|
||||
// #endregion Rule
|
||||
|
||||
// #region Input
|
||||
export interface Input {
|
||||
|
||||
@ -63,6 +63,17 @@ export {
|
||||
registerField as registerFormField,
|
||||
} from './utils/config';
|
||||
|
||||
export {
|
||||
clearTypeMatchRules,
|
||||
deleteTypeMatchRule,
|
||||
getTypeMatchRule,
|
||||
registerTypeMatchRule,
|
||||
registerTypeMatchRules,
|
||||
validateTypeMatch,
|
||||
} from './utils/typeMatch';
|
||||
|
||||
export type { TypeMatchValidateContext, TypeMatchValidator } from './utils/typeMatch';
|
||||
|
||||
export type { FormInstallOptions } from './plugin';
|
||||
|
||||
export const createForm = <T extends [] = []>(config: FormConfig | T) => config;
|
||||
|
||||
@ -47,14 +47,19 @@ import Textarea from './fields/Textarea.vue';
|
||||
import Time from './fields/Time.vue';
|
||||
import Timerange from './fields/Timerange.vue';
|
||||
import { setConfig } from './utils/config';
|
||||
import { registerTypeMatchRules, type TypeMatchValidator } from './utils/typeMatch';
|
||||
import Form from './Form.vue';
|
||||
import FormDialog from './FormDialog.vue';
|
||||
|
||||
import './theme/index.scss';
|
||||
|
||||
// #region FormInstallOptions
|
||||
export interface FormInstallOptions {
|
||||
/** 自定义字段 type 的 typeMatch 校验规则,可覆盖内置规则或扩展业务字段 */
|
||||
typeMatchRules?: Record<string, TypeMatchValidator>;
|
||||
[key: string]: any;
|
||||
}
|
||||
// #endregion FormInstallOptions
|
||||
|
||||
const defaultInstallOpt: FormInstallOptions = {};
|
||||
|
||||
@ -65,6 +70,10 @@ export default {
|
||||
app.config.globalProperties.$MAGIC_FORM = option;
|
||||
setConfig(option);
|
||||
|
||||
if (option.typeMatchRules) {
|
||||
registerTypeMatchRules(option.typeMatchRules);
|
||||
}
|
||||
|
||||
app.component('m-form', Form);
|
||||
app.component('m-form-dialog', FormDialog);
|
||||
app.component('m-form-container', Container);
|
||||
|
||||
@ -39,6 +39,8 @@ import type {
|
||||
TypeFunction,
|
||||
} from '../schema';
|
||||
|
||||
import { createTypeMatchValidator } from './typeMatch';
|
||||
|
||||
interface DefaultItem {
|
||||
defaultValue: any;
|
||||
type: string;
|
||||
@ -261,6 +263,11 @@ export const getRules = function (mForm: FormState | undefined, rules: Rule[] |
|
||||
}
|
||||
|
||||
return rules.map((item) => {
|
||||
if (item.typeMatch) {
|
||||
(item as any).validator = createTypeMatchValidator(mForm, props, item);
|
||||
return item;
|
||||
}
|
||||
|
||||
if (typeof item.validator === 'function') {
|
||||
const fnc = item.validator;
|
||||
|
||||
|
||||
514
packages/form/src/utils/typeMatch.ts
Normal file
514
packages/form/src/utils/typeMatch.ts
Normal file
@ -0,0 +1,514 @@
|
||||
/*
|
||||
* 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 { readonly } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
||||
|
||||
import { getValueByKeyPath } from '@tmagic/utils';
|
||||
|
||||
import type { CascaderOption, FormState, Rule } from '../schema';
|
||||
|
||||
// dayjs.extend 内部有防重复机制,可安全多次调用
|
||||
dayjs.extend(customParseFormat);
|
||||
|
||||
// #region TypeMatchValidateContext
|
||||
export interface TypeMatchValidateContext {
|
||||
fieldType: string;
|
||||
mForm: FormState | undefined;
|
||||
props: any;
|
||||
message?: string;
|
||||
}
|
||||
// #endregion TypeMatchValidateContext
|
||||
|
||||
// #region TypeMatchValidator
|
||||
/** 自定义 type 校验器:返回错误文案;通过则返回 undefined */
|
||||
export type TypeMatchValidator = (value: any, context: TypeMatchValidateContext) => string | undefined;
|
||||
// #endregion TypeMatchValidator
|
||||
|
||||
const typeMatchRuleRegistry = new Map<string, TypeMatchValidator>();
|
||||
|
||||
const normalizeType = (type: string) => type.replace(/([A-Z])/g, '-$1').toLowerCase();
|
||||
|
||||
/** 注册或覆盖某个字段 type 的 typeMatch 校验规则 */
|
||||
export const registerTypeMatchRule = (type: string, validator: TypeMatchValidator): void => {
|
||||
typeMatchRuleRegistry.set(normalizeType(type), validator);
|
||||
};
|
||||
|
||||
/** 批量注册 typeMatch 校验规则 */
|
||||
export const registerTypeMatchRules = (rules: Record<string, TypeMatchValidator>): void => {
|
||||
Object.entries(rules).forEach(([type, validator]) => {
|
||||
registerTypeMatchRule(type, validator);
|
||||
});
|
||||
};
|
||||
|
||||
/** 获取某个字段 type 的自定义 typeMatch 校验规则 */
|
||||
export const getTypeMatchRule = (type: string): TypeMatchValidator | undefined =>
|
||||
typeMatchRuleRegistry.get(normalizeType(type));
|
||||
|
||||
/** 删除某个字段 type 的自定义 typeMatch 校验规则 */
|
||||
export const deleteTypeMatchRule = (type: string): boolean => typeMatchRuleRegistry.delete(normalizeType(type));
|
||||
|
||||
/** 清空所有自定义 typeMatch 校验规则 */
|
||||
export const clearTypeMatchRules = (): void => {
|
||||
typeMatchRuleRegistry.clear();
|
||||
};
|
||||
|
||||
/** 本地解析配置函数,避免与 form.ts 循环依赖 */
|
||||
const resolveConfig = <T = any>(
|
||||
mForm: FormState | undefined,
|
||||
config: T | ((...args: any[]) => T) | undefined,
|
||||
props: any,
|
||||
): T | undefined => {
|
||||
if (typeof config === 'function') {
|
||||
return (config as Function)(mForm, {
|
||||
values: readonly(mForm?.initValues || {}),
|
||||
model: readonly(props.model),
|
||||
parent: readonly(mForm?.parentValues || {}),
|
||||
formValue: readonly(mForm?.values || props.model),
|
||||
prop: props.prop,
|
||||
config: props.config,
|
||||
index: props.index,
|
||||
getFormValue: (prop: string) => getValueByKeyPath(prop, mForm?.values || props.model),
|
||||
});
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
const SKIP_TYPES = new Set([
|
||||
'display',
|
||||
'hidden',
|
||||
'row',
|
||||
'tab',
|
||||
'dynamic-tab',
|
||||
'fieldset',
|
||||
'panel',
|
||||
'step',
|
||||
'flex-layout',
|
||||
'link',
|
||||
'component',
|
||||
'dynamic-field',
|
||||
'dynamicfield',
|
||||
'form',
|
||||
'container',
|
||||
]);
|
||||
|
||||
const STRING_TYPES = new Set(['text', 'textarea', 'color-picker', 'html', '']);
|
||||
|
||||
const DATE_LIKE_TYPES = new Set(['date', 'datetime', 'time']);
|
||||
|
||||
const DATE_LIKE_DEFAULT_VALUE_FORMAT: Record<string, string> = {
|
||||
date: 'YYYY/MM/DD',
|
||||
datetime: 'YYYY/MM/DD HH:mm:ss',
|
||||
time: 'HH:mm:ss',
|
||||
daterange: 'YYYY/MM/DD HH:mm:ss',
|
||||
timerange: 'HH:mm:ss',
|
||||
};
|
||||
|
||||
const TIMESTAMP_VALUE_FORMATS = new Set(['x', 'timestamp']);
|
||||
|
||||
const defaultMessage = (message: string | undefined, fallback: string) => message || fallback;
|
||||
|
||||
const isEmptyValue = (value: any) => value === undefined || value === null || value === '';
|
||||
|
||||
const isEmptyArray = (value: any) => Array.isArray(value) && value.length === 0;
|
||||
|
||||
const resolveDateValueFormat = (fieldType: string, config: any): string =>
|
||||
config.valueFormat || DATE_LIKE_DEFAULT_VALUE_FORMAT[fieldType] || 'YYYY/MM/DD HH:mm:ss';
|
||||
|
||||
const isTimestampValueFormat = (valueFormat: string) => TIMESTAMP_VALUE_FORMATS.has(valueFormat);
|
||||
|
||||
const isValidDateValueByFormat = (value: any, valueFormat: string): boolean => {
|
||||
if (isTimestampValueFormat(valueFormat)) {
|
||||
return typeof value === 'number' && !Number.isNaN(value);
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
// 按 Day.js format tokens 严格解析,参见 https://day.js.org/docs/en/display/format
|
||||
return dayjs(value, valueFormat, true).isValid();
|
||||
};
|
||||
|
||||
const dateValueFormatErrorMessage = (message: string | undefined, valueFormat: string) =>
|
||||
defaultMessage(message, isTimestampValueFormat(valueFormat) ? '值类型应为时间戳数字' : `值格式应为 ${valueFormat}`);
|
||||
|
||||
const resolveFieldType = (mForm: FormState | undefined, props: any): string => {
|
||||
let type = 'type' in (props.config || {}) ? props.config.type : '';
|
||||
type = type ? resolveConfig<string>(mForm, type, props) || '' : '';
|
||||
if (type === 'form' || type === 'container') return '';
|
||||
return normalizeType(type || '') || (props.config?.items ? '' : 'text');
|
||||
};
|
||||
|
||||
const resolveToggleValues = (config: any) => {
|
||||
const filterIsNumber = config.filter === 'number';
|
||||
const { activeValue: configActiveValue, inactiveValue: configInactiveValue } = config;
|
||||
|
||||
let activeValue = configActiveValue;
|
||||
let inactiveValue = configInactiveValue;
|
||||
|
||||
if (typeof activeValue === 'undefined') {
|
||||
activeValue = filterIsNumber ? 1 : true;
|
||||
}
|
||||
|
||||
if (typeof inactiveValue === 'undefined') {
|
||||
inactiveValue = filterIsNumber ? 0 : false;
|
||||
}
|
||||
|
||||
return { activeValue, inactiveValue };
|
||||
};
|
||||
|
||||
const flattenSelectOptions = (options: any[]): any[] => {
|
||||
const values: any[] = [];
|
||||
|
||||
options.forEach((option) => {
|
||||
if (Array.isArray(option?.options)) {
|
||||
option.options.forEach((child: any) => {
|
||||
if (typeof child?.value !== 'undefined') {
|
||||
values.push(child.value);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof option?.value !== 'undefined') {
|
||||
values.push(option.value);
|
||||
}
|
||||
});
|
||||
|
||||
return values;
|
||||
};
|
||||
|
||||
const resolveOptions = (mForm: FormState | undefined, props: any): any[] => {
|
||||
const { options } = props.config || {};
|
||||
if (Array.isArray(options)) return options;
|
||||
if (typeof options === 'function') {
|
||||
return resolveConfig(mForm, options, props) || [];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const includesOptionValue = (optionValues: any[], value: any) => optionValues.some((item) => Object.is(item, value));
|
||||
|
||||
const collectCascaderLeafValues = (options: CascaderOption[], result: any[] = []) => {
|
||||
options.forEach((option) => {
|
||||
if (option.children?.length) {
|
||||
collectCascaderLeafValues(option.children, result);
|
||||
} else if (typeof option.value !== 'undefined') {
|
||||
result.push(option.value);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const isValidCascaderPath = (options: CascaderOption[], path: any[]): boolean => {
|
||||
if (!path.length) return false;
|
||||
|
||||
let currentOptions = options;
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const node = currentOptions.find((option) => Object.is(option.value, path[i]));
|
||||
if (!node) return false;
|
||||
if (i === path.length - 1) return true;
|
||||
currentOptions = node.children || [];
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const validateSelectValue = (
|
||||
value: any,
|
||||
config: any,
|
||||
optionValues: any[],
|
||||
message: string | undefined,
|
||||
): string | undefined => {
|
||||
if (config.allowCreate || config.remote) {
|
||||
if (config.multiple) {
|
||||
if (!Array.isArray(value)) {
|
||||
return defaultMessage(message, '值类型应为数组');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return defaultMessage(message, '值类型不合法');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (config.multiple) {
|
||||
if (!Array.isArray(value)) {
|
||||
return defaultMessage(message, '值类型应为数组');
|
||||
}
|
||||
if (value.some((item) => !includesOptionValue(optionValues, item))) {
|
||||
return defaultMessage(message, '值不在可选项中');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!includesOptionValue(optionValues, value)) {
|
||||
return defaultMessage(message, '值不在可选项中');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const validateCascaderValue = (
|
||||
value: any,
|
||||
config: any,
|
||||
props: any,
|
||||
mForm: FormState | undefined,
|
||||
message: string | undefined,
|
||||
): string | undefined => {
|
||||
const valueSeparator = resolveConfig<string | undefined>(mForm, config.valueSeparator, props);
|
||||
const emitPath = config.emitPath !== false;
|
||||
const multiple = Boolean(config.multiple);
|
||||
|
||||
if (valueSeparator) {
|
||||
if (typeof value !== 'string' && !Array.isArray(value)) {
|
||||
return defaultMessage(message, '值类型应为字符串或数组');
|
||||
}
|
||||
} else if (multiple) {
|
||||
if (!Array.isArray(value)) {
|
||||
return defaultMessage(message, '值类型应为数组');
|
||||
}
|
||||
} else if (emitPath && !Array.isArray(value)) {
|
||||
return defaultMessage(message, '值类型应为数组');
|
||||
}
|
||||
|
||||
if (config.remote) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const options = resolveOptions(mForm, props) as CascaderOption[];
|
||||
if (!options.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedValue = typeof value === 'string' && valueSeparator ? value.split(valueSeparator) : value;
|
||||
|
||||
if (multiple) {
|
||||
if (!Array.isArray(normalizedValue)) {
|
||||
return defaultMessage(message, '值类型应为数组');
|
||||
}
|
||||
|
||||
const invalid = normalizedValue.some((item) => {
|
||||
if (emitPath) {
|
||||
return !Array.isArray(item) || !isValidCascaderPath(options, item);
|
||||
}
|
||||
return !includesOptionValue(collectCascaderLeafValues(options), item);
|
||||
});
|
||||
|
||||
if (invalid) {
|
||||
return defaultMessage(message, '值不在可选项中');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (emitPath) {
|
||||
if (!Array.isArray(normalizedValue) || !isValidCascaderPath(options, normalizedValue)) {
|
||||
return defaultMessage(message, '值不在可选项中');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!includesOptionValue(collectCascaderLeafValues(options), normalizedValue)) {
|
||||
return defaultMessage(message, '值不在可选项中');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const validateBuiltinTypeMatch = (
|
||||
value: any,
|
||||
fieldType: string,
|
||||
mForm: FormState | undefined,
|
||||
props: any,
|
||||
message?: string,
|
||||
): string | undefined => {
|
||||
if (SKIP_TYPES.has(fieldType)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const config = props.config || {};
|
||||
|
||||
if (STRING_TYPES.has(fieldType)) {
|
||||
if (config.filter === 'number') {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
return defaultMessage(message, '值类型应为数字');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 自定义 filter 函数会改写值类型,内置规则无法推断,跳过
|
||||
if (typeof config.filter === 'function') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return defaultMessage(message, '值类型应为字符串');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (DATE_LIKE_TYPES.has(fieldType)) {
|
||||
const valueFormat = resolveDateValueFormat(fieldType, config);
|
||||
if (!isValidDateValueByFormat(value, valueFormat)) {
|
||||
return dateValueFormatErrorMessage(message, valueFormat);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (fieldType === 'number') {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
return defaultMessage(message, '值类型应为数字');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (fieldType === 'number-range') {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length !== 2 ||
|
||||
value.some((item) => typeof item !== 'number' || Number.isNaN(item))
|
||||
) {
|
||||
return defaultMessage(message, '值类型应为长度为 2 的数字数组');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (fieldType === 'switch' || fieldType === 'checkbox') {
|
||||
const { activeValue, inactiveValue } = resolveToggleValues(config);
|
||||
if (!Object.is(value, activeValue) && !Object.is(value, inactiveValue)) {
|
||||
return defaultMessage(message, '值不在合法开关值中');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (fieldType === 'select') {
|
||||
const optionValues = flattenSelectOptions(resolveOptions(mForm, props));
|
||||
return validateSelectValue(value, config, optionValues, message);
|
||||
}
|
||||
|
||||
if (fieldType === 'radio-group') {
|
||||
const optionValues = flattenSelectOptions(resolveOptions(mForm, props));
|
||||
if (!includesOptionValue(optionValues, value)) {
|
||||
return defaultMessage(message, '值不在可选项中');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (fieldType === 'checkbox-group') {
|
||||
if (!Array.isArray(value)) {
|
||||
return defaultMessage(message, '值类型应为数组');
|
||||
}
|
||||
const optionValues = flattenSelectOptions(resolveOptions(mForm, props));
|
||||
if (value.some((item) => !includesOptionValue(optionValues, item))) {
|
||||
return defaultMessage(message, '值不在可选项中');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (fieldType === 'cascader') {
|
||||
return validateCascaderValue(value, config, props, mForm, message);
|
||||
}
|
||||
|
||||
if (fieldType === 'daterange' || fieldType === 'timerange') {
|
||||
if (config.names) {
|
||||
return undefined;
|
||||
}
|
||||
const valueFormat = resolveDateValueFormat(fieldType, config);
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length !== 2 ||
|
||||
value.some((item) => !isValidDateValueByFormat(item, valueFormat))
|
||||
) {
|
||||
return defaultMessage(
|
||||
message,
|
||||
isTimestampValueFormat(valueFormat)
|
||||
? '值类型应为长度为 2 的时间戳数字数组'
|
||||
: `值格式应为长度为 2 的 ${valueFormat} 数组`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (fieldType === 'table' || fieldType === 'group-list' || fieldType === 'grouplist') {
|
||||
if (!Array.isArray(value)) {
|
||||
return defaultMessage(message, '值类型应为数组');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const validateTypeMatch = (
|
||||
value: any,
|
||||
mForm: FormState | undefined,
|
||||
props: any,
|
||||
message?: string,
|
||||
): string | undefined => {
|
||||
if (isEmptyValue(value) || isEmptyArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const fieldType = resolveFieldType(mForm, props);
|
||||
const customValidator = getTypeMatchRule(fieldType);
|
||||
|
||||
// 自定义规则优先:可覆盖内置规则,也可为业务自定义字段 type 扩展校验
|
||||
if (customValidator) {
|
||||
return customValidator(value, { fieldType, mForm, props, message });
|
||||
}
|
||||
|
||||
return validateBuiltinTypeMatch(value, fieldType, mForm, props, message);
|
||||
};
|
||||
|
||||
export const createTypeMatchValidator = (mForm: FormState | undefined, props: any, rule: Rule) => {
|
||||
const originalValidator = typeof rule.validator === 'function' ? rule.validator : undefined;
|
||||
|
||||
return (asyncValidatorRule: any, value: any, callback: Function, source: any, options: any) => {
|
||||
const actualValue = props.config?.names ? props.model : value;
|
||||
const error = validateTypeMatch(actualValue, mForm, props, rule.message);
|
||||
|
||||
if (error) {
|
||||
callback(new Error(error));
|
||||
return;
|
||||
}
|
||||
|
||||
if (originalValidator) {
|
||||
return originalValidator(
|
||||
{
|
||||
rule: asyncValidatorRule,
|
||||
value: actualValue,
|
||||
callback,
|
||||
source,
|
||||
options,
|
||||
},
|
||||
{
|
||||
values: mForm?.initValues || {},
|
||||
model: props.model,
|
||||
parent: mForm?.parentValues || {},
|
||||
formValue: mForm?.values || props.model,
|
||||
prop: props.prop,
|
||||
config: props.config,
|
||||
},
|
||||
mForm,
|
||||
);
|
||||
}
|
||||
|
||||
callback();
|
||||
};
|
||||
};
|
||||
491
packages/form/tests/unit/utils/typeMatch.spec.ts
Normal file
491
packages/form/tests/unit/utils/typeMatch.spec.ts
Normal file
@ -0,0 +1,491 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import type { FormState } from '@form/index';
|
||||
import { getRules } from '@form/utils/form';
|
||||
import {
|
||||
clearTypeMatchRules,
|
||||
deleteTypeMatchRule,
|
||||
getTypeMatchRule,
|
||||
registerTypeMatchRule,
|
||||
registerTypeMatchRules,
|
||||
validateTypeMatch,
|
||||
} from '@form/utils/typeMatch';
|
||||
|
||||
const mForm: FormState = {
|
||||
config: [],
|
||||
initValues: {},
|
||||
parentValues: {},
|
||||
values: {},
|
||||
lastValues: {},
|
||||
isCompare: false,
|
||||
$emit: (event: string) => event,
|
||||
setField: (prop: string, field: any) => field,
|
||||
getField: (prop: string) => prop,
|
||||
deleteField: (prop: string) => prop,
|
||||
$messageBox: {
|
||||
alert: () => Promise.resolve(),
|
||||
confirm: () => Promise.resolve(),
|
||||
prompt: () => Promise.resolve(),
|
||||
close: () => undefined,
|
||||
},
|
||||
$message: (() => Promise.resolve()) as any,
|
||||
};
|
||||
|
||||
const propsOf = (config: Record<string, any>, model: Record<string, any> = {}) => ({
|
||||
config,
|
||||
model,
|
||||
prop: config.name || 'field',
|
||||
});
|
||||
|
||||
describe('validateTypeMatch', () => {
|
||||
test('空值直接通过', () => {
|
||||
expect(validateTypeMatch(undefined, mForm, propsOf({ type: 'text' }))).toBeUndefined();
|
||||
expect(validateTypeMatch(null, mForm, propsOf({ type: 'text' }))).toBeUndefined();
|
||||
expect(validateTypeMatch('', mForm, propsOf({ type: 'text' }))).toBeUndefined();
|
||||
expect(validateTypeMatch([], mForm, propsOf({ type: 'select', multiple: true }))).toBeUndefined();
|
||||
});
|
||||
|
||||
test('0 / false 不视为空值', () => {
|
||||
expect(validateTypeMatch(0, mForm, propsOf({ type: 'number' }))).toBeUndefined();
|
||||
expect(validateTypeMatch(false, mForm, propsOf({ type: 'switch' }))).toBeUndefined();
|
||||
});
|
||||
|
||||
test('display / hidden 不校验', () => {
|
||||
expect(validateTypeMatch(123, mForm, propsOf({ type: 'display' }))).toBeUndefined();
|
||||
expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'hidden' }))).toBeUndefined();
|
||||
});
|
||||
|
||||
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('自定义错误');
|
||||
});
|
||||
|
||||
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('值类型应为数字');
|
||||
});
|
||||
|
||||
test('text 自定义 filter 函数时跳过内置类型校验', () => {
|
||||
expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'text', filter: () => ({ a: 1 }) }))).toBeUndefined();
|
||||
});
|
||||
|
||||
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('值类型应为数字');
|
||||
});
|
||||
|
||||
test('date / time / datetime 按 valueFormat(Day.js format)校验', () => {
|
||||
// 默认 date: YYYY/MM/DD
|
||||
expect(validateTypeMatch('2020/01/01', mForm, propsOf({ type: 'date' }))).toBeUndefined();
|
||||
expect(validateTypeMatch('2020-01-01', mForm, propsOf({ type: 'date' }))).toBe('值格式应为 YYYY/MM/DD');
|
||||
expect(validateTypeMatch(new Date(), mForm, propsOf({ type: 'datetime' }))).toBe('值格式应为 YYYY/MM/DD HH:mm:ss');
|
||||
expect(validateTypeMatch(1710000000000, mForm, propsOf({ type: 'time' }))).toBe('值格式应为 HH:mm:ss');
|
||||
expect(validateTypeMatch('12:30:00', mForm, propsOf({ type: 'time' }))).toBeUndefined();
|
||||
|
||||
// 自定义格式
|
||||
expect(
|
||||
validateTypeMatch('2020-01-25', mForm, propsOf({ type: 'date', valueFormat: 'YYYY-MM-DD' })),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validateTypeMatch('25/01/2019', mForm, propsOf({ type: 'date', valueFormat: 'DD/MM/YYYY' })),
|
||||
).toBeUndefined();
|
||||
|
||||
// timestamp / x → number
|
||||
expect(
|
||||
validateTypeMatch(1710000000000, mForm, propsOf({ type: 'date', valueFormat: 'timestamp' })),
|
||||
).toBeUndefined();
|
||||
expect(validateTypeMatch(1710000000000, mForm, propsOf({ type: 'datetime', valueFormat: 'x' }))).toBeUndefined();
|
||||
expect(validateTypeMatch('2020-01-01', mForm, propsOf({ type: 'date', valueFormat: 'x' }))).toBe(
|
||||
'值类型应为时间戳数字',
|
||||
);
|
||||
});
|
||||
|
||||
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('值不在合法开关值中');
|
||||
});
|
||||
|
||||
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('值不在合法开关值中');
|
||||
});
|
||||
|
||||
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('值不在合法开关值中');
|
||||
});
|
||||
|
||||
test('select 单选值必须在 options 中', () => {
|
||||
const config = {
|
||||
type: 'select',
|
||||
options: [
|
||||
{ text: 'A', value: 1 },
|
||||
{ text: 'B', value: 2 },
|
||||
],
|
||||
};
|
||||
expect(validateTypeMatch(1, mForm, propsOf(config))).toBeUndefined();
|
||||
expect(validateTypeMatch(3, mForm, propsOf(config))).toBe('值不在可选项中');
|
||||
});
|
||||
|
||||
test('select multiple 校验数组元素', () => {
|
||||
const config = {
|
||||
type: 'select',
|
||||
multiple: true,
|
||||
options: [
|
||||
{ text: 'A', value: 'a' },
|
||||
{ text: 'B', value: 'b' },
|
||||
],
|
||||
};
|
||||
expect(validateTypeMatch(['a'], mForm, propsOf(config))).toBeUndefined();
|
||||
expect(validateTypeMatch(['a', 'c'], mForm, propsOf(config))).toBe('值不在可选项中');
|
||||
expect(validateTypeMatch('a', mForm, propsOf(config))).toBe('值类型应为数组');
|
||||
});
|
||||
|
||||
test('select options 为函数 / group', () => {
|
||||
const fnConfig = {
|
||||
type: 'select',
|
||||
options: () => [{ text: 'A', value: 1 }],
|
||||
};
|
||||
expect(validateTypeMatch(1, mForm, propsOf(fnConfig))).toBeUndefined();
|
||||
expect(validateTypeMatch(2, mForm, propsOf(fnConfig))).toBe('值不在可选项中');
|
||||
|
||||
const groupConfig = {
|
||||
type: 'select',
|
||||
group: true,
|
||||
options: [
|
||||
{
|
||||
label: 'g',
|
||||
disabled: false,
|
||||
options: [{ text: 'A', value: 'a' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(validateTypeMatch('a', mForm, propsOf(groupConfig))).toBeUndefined();
|
||||
expect(validateTypeMatch('b', mForm, propsOf(groupConfig))).toBe('值不在可选项中');
|
||||
});
|
||||
|
||||
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(['x'], mForm, propsOf({ type: 'select', multiple: true, remote: true }))).toBeUndefined();
|
||||
expect(validateTypeMatch('x', mForm, propsOf({ type: 'select', multiple: true, remote: true }))).toBe(
|
||||
'值类型应为数组',
|
||||
);
|
||||
});
|
||||
|
||||
test('radio-group / checkbox-group', () => {
|
||||
const radio = {
|
||||
type: 'radioGroup',
|
||||
options: [
|
||||
{ text: 'A', value: 1 },
|
||||
{ text: 'B', value: 2 },
|
||||
],
|
||||
};
|
||||
expect(validateTypeMatch(1, mForm, propsOf(radio))).toBeUndefined();
|
||||
expect(validateTypeMatch(3, mForm, propsOf(radio))).toBe('值不在可选项中');
|
||||
|
||||
const checkboxGroup = {
|
||||
type: 'checkbox-group',
|
||||
options: [
|
||||
{ text: 'A', value: 'a' },
|
||||
{ text: 'B', value: 'b' },
|
||||
],
|
||||
};
|
||||
expect(validateTypeMatch(['a', 'b'], mForm, propsOf(checkboxGroup))).toBeUndefined();
|
||||
expect(validateTypeMatch(['c'], mForm, propsOf(checkboxGroup))).toBe('值不在可选项中');
|
||||
});
|
||||
|
||||
test('cascader 静态路径与 valueSeparator', () => {
|
||||
const options = [
|
||||
{
|
||||
value: 'zhejiang',
|
||||
label: 'Zhejiang',
|
||||
children: [
|
||||
{
|
||||
value: 'hangzhou',
|
||||
label: 'Hangzhou',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(validateTypeMatch(['zhejiang', 'hangzhou'], mForm, propsOf({ type: 'cascader', options }))).toBeUndefined();
|
||||
expect(validateTypeMatch(['zhejiang', 'ningbo'], mForm, propsOf({ type: 'cascader', options }))).toBe(
|
||||
'值不在可选项中',
|
||||
);
|
||||
expect(
|
||||
validateTypeMatch('zhejiang/hangzhou', mForm, propsOf({ type: 'cascader', options, valueSeparator: '/' })),
|
||||
).toBeUndefined();
|
||||
expect(validateTypeMatch('bad', mForm, propsOf({ type: 'cascader', remote: true }))).toBe('值类型应为数组');
|
||||
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(['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 数组',
|
||||
);
|
||||
expect(validateTypeMatch(['a'], mForm, propsOf({ type: 'daterange' }))).toBe(
|
||||
'值格式应为长度为 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('值类型应为数组');
|
||||
});
|
||||
|
||||
test('容器类字段 no-op', () => {
|
||||
expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'fieldset', items: [] }))).toBeUndefined();
|
||||
expect(validateTypeMatch(1, mForm, propsOf({ type: 'panel', items: [] }))).toBeUndefined();
|
||||
});
|
||||
|
||||
test('无 type 默认按 text 校验', () => {
|
||||
expect(validateTypeMatch('ok', mForm, propsOf({}))).toBeUndefined();
|
||||
expect(validateTypeMatch(1, mForm, propsOf({}))).toBe('值类型应为字符串');
|
||||
});
|
||||
|
||||
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('值类型应为字符串');
|
||||
});
|
||||
|
||||
test('checkbox-group 非数组', () => {
|
||||
expect(
|
||||
validateTypeMatch('a', mForm, propsOf({ type: 'checkbox-group', options: [{ text: 'A', value: 'a' }] })),
|
||||
).toBe('值类型应为数组');
|
||||
});
|
||||
|
||||
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 数组',
|
||||
);
|
||||
});
|
||||
|
||||
test('cascader emitPath=false 校验叶子值', () => {
|
||||
const options = [
|
||||
{
|
||||
value: 'zhejiang',
|
||||
label: 'Zhejiang',
|
||||
children: [{ value: 'hangzhou', label: 'Hangzhou' }],
|
||||
},
|
||||
];
|
||||
expect(
|
||||
validateTypeMatch('hangzhou', mForm, propsOf({ type: 'cascader', options, emitPath: false })),
|
||||
).toBeUndefined();
|
||||
expect(validateTypeMatch('ningbo', mForm, propsOf({ type: 'cascader', options, emitPath: false }))).toBe(
|
||||
'值不在可选项中',
|
||||
);
|
||||
});
|
||||
|
||||
test('cascader multiple 且 emitPath=false', () => {
|
||||
const options = [
|
||||
{
|
||||
value: 'zhejiang',
|
||||
label: 'Zhejiang',
|
||||
children: [{ value: 'hangzhou', label: 'Hangzhou' }],
|
||||
},
|
||||
];
|
||||
expect(
|
||||
validateTypeMatch(['hangzhou'], mForm, propsOf({ type: 'cascader', options, multiple: true, emitPath: false })),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validateTypeMatch(['ningbo'], mForm, propsOf({ type: 'cascader', options, multiple: true, emitPath: false })),
|
||||
).toBe('值不在可选项中');
|
||||
});
|
||||
|
||||
test('cascader valueSeparator 时数组值', () => {
|
||||
const options = [
|
||||
{
|
||||
value: 'zhejiang',
|
||||
label: 'Zhejiang',
|
||||
children: [{ value: 'hangzhou', label: 'Hangzhou' }],
|
||||
},
|
||||
];
|
||||
expect(
|
||||
validateTypeMatch(['zhejiang', 'hangzhou'], mForm, propsOf({ type: 'cascader', options, valueSeparator: '/' })),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('select allowCreate 对象值非法', () => {
|
||||
expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'select', allowCreate: true }))).toBe('值类型不合法');
|
||||
});
|
||||
|
||||
test('动态 type 函数解析', () => {
|
||||
expect(validateTypeMatch('ok', mForm, propsOf({ type: () => 'text', name: 'field' }))).toBeUndefined();
|
||||
expect(validateTypeMatch(1, mForm, propsOf({ type: () => 'number', name: 'field' }))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRules typeMatch', () => {
|
||||
test('未配置 typeMatch 时行为不变', () => {
|
||||
const rules: any = [{ required: true, message: '必填' }];
|
||||
const newRules = getRules(mForm, rules, propsOf({ type: 'text' }));
|
||||
expect(newRules).toHaveLength(1);
|
||||
expect(newRules[0].required).toBe(true);
|
||||
expect((newRules[0] as any).validator).toBeUndefined();
|
||||
});
|
||||
|
||||
test('typeMatch 注入 validator 并校验失败', () => {
|
||||
const rules: any = [{ typeMatch: true, message: '类型错误' }];
|
||||
const newRules: any = getRules(mForm, rules, propsOf({ type: 'text' }));
|
||||
const callback = vi.fn();
|
||||
newRules[0].validator({}, 123, callback);
|
||||
expect(callback).toHaveBeenCalledWith(expect.any(Error));
|
||||
expect(callback.mock.calls[0][0].message).toBe('类型错误');
|
||||
});
|
||||
|
||||
test('typeMatch 校验通过时 callback 无参调用', () => {
|
||||
const rules: any = [{ typeMatch: true }];
|
||||
const newRules: any = getRules(mForm, rules, propsOf({ type: 'text' }));
|
||||
const callback = vi.fn();
|
||||
newRules[0].validator({}, 'ok', callback);
|
||||
expect(callback).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
test('typeMatch 与自定义 validator 共存,先做类型校验', () => {
|
||||
const custom = vi.fn((_params: any, _ctx: any, _form: any) => {
|
||||
_params.callback();
|
||||
});
|
||||
const rules: any = [{ typeMatch: true, validator: custom }];
|
||||
const newRules: any = getRules(mForm, rules, propsOf({ type: 'number' }));
|
||||
|
||||
const failCallback = vi.fn();
|
||||
newRules[0].validator({}, 'bad', failCallback);
|
||||
expect(failCallback).toHaveBeenCalledWith(expect.any(Error));
|
||||
expect(custom).not.toHaveBeenCalled();
|
||||
|
||||
const okCallback = vi.fn();
|
||||
newRules[0].validator({}, 1, okCallback);
|
||||
expect(custom).toHaveBeenCalled();
|
||||
expect(okCallback).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
|
||||
describe('typeMatch 扩展注册', () => {
|
||||
beforeEach(() => {
|
||||
clearTypeMatchRules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearTypeMatchRules();
|
||||
});
|
||||
|
||||
test('registerTypeMatchRule 可覆盖内置 text 规则', () => {
|
||||
registerTypeMatchRule('text', (value, { message }) => {
|
||||
if (typeof value !== 'string' || !value.startsWith('magic')) {
|
||||
return message || '必须以 magic 开头';
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// 覆盖后:普通 string 不再直接通过
|
||||
expect(validateTypeMatch('hello', mForm, propsOf({ type: 'text' }))).toBe('必须以 magic 开头');
|
||||
expect(validateTypeMatch('magic-ok', mForm, propsOf({ type: 'text' }))).toBeUndefined();
|
||||
});
|
||||
|
||||
test('可为业务自定义字段 type 扩展校验', () => {
|
||||
registerTypeMatchRule('vs-code', (value, { message }) => {
|
||||
if (typeof value !== 'string') {
|
||||
return message || '代码字段应为字符串';
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
expect(validateTypeMatch(123, mForm, propsOf({ type: 'vs-code' }))).toBe('代码字段应为字符串');
|
||||
expect(validateTypeMatch('const a = 1', mForm, propsOf({ type: 'vsCode' }))).toBeUndefined();
|
||||
});
|
||||
|
||||
test('registerTypeMatchRules 批量注册 + delete/get', () => {
|
||||
registerTypeMatchRules({
|
||||
foo: () => 'foo error',
|
||||
bar: () => undefined,
|
||||
});
|
||||
|
||||
expect(getTypeMatchRule('foo')).toBeTypeOf('function');
|
||||
expect(validateTypeMatch(1, mForm, propsOf({ type: 'foo' }))).toBe('foo error');
|
||||
expect(validateTypeMatch(1, mForm, propsOf({ type: 'bar' }))).toBeUndefined();
|
||||
|
||||
expect(deleteTypeMatchRule('foo')).toBe(true);
|
||||
expect(getTypeMatchRule('foo')).toBeUndefined();
|
||||
// 删除后回退到内置:未知 type 默认通过
|
||||
expect(validateTypeMatch(1, mForm, propsOf({ type: 'foo' }))).toBeUndefined();
|
||||
});
|
||||
|
||||
test('自定义规则可覆盖 display(内置跳过)', () => {
|
||||
expect(validateTypeMatch(123, mForm, propsOf({ type: 'display' }))).toBeUndefined();
|
||||
|
||||
registerTypeMatchRule('display', (value, { message }) => {
|
||||
if (typeof value !== 'string') {
|
||||
return message || 'display 自定义为 string';
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
expect(validateTypeMatch(123, mForm, propsOf({ type: 'display' }))).toBe('display 自定义为 string');
|
||||
});
|
||||
|
||||
test('registerTypeMatchRules 批量注册', () => {
|
||||
registerTypeMatchRules({
|
||||
batch: () => 'batch error',
|
||||
});
|
||||
expect(validateTypeMatch(1, mForm, propsOf({ type: 'batch' }))).toBe('batch error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('plugin typeMatchRules', () => {
|
||||
beforeEach(() => {
|
||||
clearTypeMatchRules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearTypeMatchRules();
|
||||
});
|
||||
|
||||
test('install 时注册 typeMatchRules', async () => {
|
||||
const { createApp } = await import('vue');
|
||||
const plugin = (await import('@form/plugin')).default;
|
||||
|
||||
const app = createApp({});
|
||||
plugin.install(app, {
|
||||
typeMatchRules: {
|
||||
'install-type': (value) => (value === 'ok' ? undefined : 'install error'),
|
||||
},
|
||||
});
|
||||
|
||||
expect(validateTypeMatch('ok', mForm, propsOf({ type: 'install-type' }))).toBeUndefined();
|
||||
expect(validateTypeMatch('bad', mForm, propsOf({ type: 'install-type' }))).toBe('install error');
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user