fix(form): typeMatch 使用 toLine 统一类型转换并处理异步 type/defaultValue

替换局部 normalizeType 为 @tmagic/utils 的 toLine,新增 isPromise 守卫。

在 type 或 defaultValue 为 Promise 时跳过校验并回退示例值,补充对应单元测试。
This commit is contained in:
roymondchen 2026-07-21 16:20:23 +08:00
parent 08ac9038bf
commit 6cdf54a4a6
2 changed files with 118 additions and 48 deletions

View File

@ -21,7 +21,7 @@ import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import { appendValidateSuggestion } from '@tmagic/design';
import { getValueByKeyPath } from '@tmagic/utils';
import { getValueByKeyPath, toLine } from '@tmagic/utils';
import type { CascaderOption, FormState, Rule } from '../schema';
@ -44,11 +44,12 @@ export type TypeMatchValidator = (value: any, context: TypeMatchValidateContext)
const typeMatchRuleRegistry = new Map<string, TypeMatchValidator>();
const normalizeType = (type: string) => type.replace(/([A-Z])/g, '-$1').toLowerCase();
const isPromise = (value: any): value is Promise<unknown> =>
typeof value === 'object' && value !== null && typeof value.then === 'function';
/** 注册或覆盖某个字段 type 的 typeMatch 校验规则 */
export const registerTypeMatchRule = (type: string, validator: TypeMatchValidator): void => {
typeMatchRuleRegistry.set(normalizeType(type), validator);
typeMatchRuleRegistry.set(toLine(type), validator);
};
/** 批量注册 typeMatch 校验规则 */
@ -60,10 +61,10 @@ export const registerTypeMatchRules = (rules: Record<string, TypeMatchValidator>
/** 获取某个字段 type 的自定义 typeMatch 校验规则 */
export const getTypeMatchRule = (type: string): TypeMatchValidator | undefined =>
typeMatchRuleRegistry.get(normalizeType(type));
typeMatchRuleRegistry.get(toLine(type));
/** 删除某个字段 type 的自定义 typeMatch 校验规则 */
export const deleteTypeMatchRule = (type: string): boolean => typeMatchRuleRegistry.delete(normalizeType(type));
export const deleteTypeMatchRule = (type: string): boolean => typeMatchRuleRegistry.delete(toLine(type));
/** 清空所有自定义 typeMatch 校验规则 */
export const clearTypeMatchRules = (): void => {
@ -182,7 +183,12 @@ const toggleSuggestion = (activeValue: any, inactiveValue: any): string =>
const resolveFieldDefaultValue = (mForm: FormState | undefined, props: any): any => {
const { defaultValue } = props.config || {};
if (typeof defaultValue === 'undefined' || defaultValue === 'undefined') return undefined;
return resolveConfig(mForm, defaultValue, props);
const resolvedDefaultValue = resolveConfig(mForm, defaultValue, props);
// resolveConfig 返回 Promise如 defaultValue 为异步函数)时无法同步获取默认值,回退到通用示例
if (isPromise(resolvedDefaultValue)) {
return undefined;
}
return resolvedDefaultValue;
};
const isNumberValue = (value: any) => typeof value === 'number' && !Number.isNaN(value);
@ -271,13 +277,6 @@ const dateValueFormatErrorMessage = (message: string | undefined, valueFormat: s
dateSuggestion(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;
@ -317,13 +316,9 @@ const flattenSelectOptions = (options: any[]): any[] => {
return values;
};
const resolveOptions = (mForm: FormState | undefined, props: any): any[] => {
const resolveOptions = (props: any): any[] => {
const { options } = props.config || {};
if (Array.isArray(options)) return options;
if (typeof options === 'function') {
return resolveConfig(mForm, options, props) || [];
}
return [];
return Array.isArray(options) ? options : [];
};
const includesOptionValue = (optionValues: any[], value: any) => optionValues.some((item) => Object.is(item, value));
@ -404,6 +399,10 @@ const validateSelectValue = (
optionValues: any[],
message: string | undefined,
): string | undefined => {
if (optionValues.length === 0) {
return undefined;
}
if (config.allowCreate || config.remote) {
if (config.multiple) {
if (!Array.isArray(value)) {
@ -456,10 +455,19 @@ const validateCascaderValue = (
mForm: FormState | undefined,
message: string | undefined,
): string | undefined => {
const options = resolveOptions(props) as CascaderOption[];
if (!options.length) {
return;
}
const valueSeparator = resolveConfig<string | undefined>(mForm, config.valueSeparator, props);
// resolveConfig 返回 Promise如 valueSeparator 为异步函数)时无法同步确定分隔符,跳过校验
if (isPromise(valueSeparator)) {
return undefined;
}
const emitPath = config.emitPath !== false;
const multiple = Boolean(config.multiple);
const options = resolveOptions(mForm, props) as CascaderOption[];
const cascaderExample = (fallbackExample: string) =>
cascaderExampleSuggestion(options, config, valueSeparator, fallbackExample);
@ -603,12 +611,17 @@ const validateBuiltinTypeMatch = (
}
if (fieldType === 'select') {
const optionValues = flattenSelectOptions(resolveOptions(mForm, props));
const optionValues = flattenSelectOptions(resolveOptions(props));
return validateSelectValue(value, config, optionValues, message);
}
if (fieldType === 'radio-group') {
const optionValues = flattenSelectOptions(resolveOptions(mForm, props));
const optionValues = flattenSelectOptions(resolveOptions(props));
if (optionValues.length === 0) {
return undefined;
}
if (!includesOptionValue(optionValues, value)) {
return defaultMessage(message, `${value} 不在可选项中`, optionSuggestion(optionValues));
}
@ -616,7 +629,12 @@ const validateBuiltinTypeMatch = (
}
if (fieldType === 'checkbox-group') {
const optionValues = flattenSelectOptions(resolveOptions(mForm, props));
const optionValues = flattenSelectOptions(resolveOptions(props));
if (optionValues.length === 0) {
return undefined;
}
if (!Array.isArray(value)) {
return defaultMessage(
message,
@ -679,7 +697,14 @@ export const validateTypeMatch = (
return undefined;
}
const fieldType = resolveFieldType(mForm, props);
const rawFieldType = 'type' in (props.config || {}) ? props.config.type : '';
if (typeof rawFieldType !== 'string' || !rawFieldType) {
return;
}
// 统一将驼峰形式(如 radioGroup归一化为连字符形式radio-group与内置规则的 key 保持一致
const fieldType = toLine(rawFieldType);
const customValidator = getTypeMatchRule(fieldType);
// 自定义规则优先:可覆盖内置规则,也可为业务自定义字段 type 扩展校验
@ -695,11 +720,15 @@ export const createTypeMatchValidator = (mForm: FormState | undefined, props: an
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);
try {
const error = validateTypeMatch(actualValue, mForm, props, rule.message);
if (error) {
callback(new Error(error));
return;
if (error) {
callback(new Error(error));
return;
}
} catch (error) {
console.error(error);
}
if (originalValidator) {

View File

@ -236,15 +236,11 @@ describe('validateTypeMatch', () => {
test('select allowCreate / remote 不做枚举', () => {
expect(validateTypeMatch('custom', mForm, propsOf({ type: 'select', allowCreate: true }))).toBeUndefined();
// allowCreate 无 options 时类型不合法示例回退到通用示例
expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'select', allowCreate: true }))).toBe(
'[object Object] 类型不合法\n\n请参考以下示例值"文本内容" 或 123',
);
// allowCreate 无 options 时跳过类型校验
expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'select', allowCreate: true }))).toBeUndefined();
expect(validateTypeMatch(['x'], mForm, propsOf({ type: 'select', multiple: true, remote: true }))).toBeUndefined();
// remote multiple 无 options 时类型不匹配示例回退到通用数组示例
expect(validateTypeMatch('x', mForm, propsOf({ type: 'select', multiple: true, remote: true }))).toBe(
'x 类型应为数组\n\n请参考以下示例值["选项1", "选项2"]',
);
// remote multiple 无 options 时跳过类型校验
expect(validateTypeMatch('x', mForm, propsOf({ type: 'select', multiple: true, remote: true }))).toBeUndefined();
});
test('select allowCreate 有 options 时类型不匹配示例用真实 options', () => {
@ -268,7 +264,7 @@ describe('validateTypeMatch', () => {
test('radio-group / checkbox-group', () => {
const radio = {
type: 'radioGroup',
type: 'radio-group',
options: [
{ text: 'A', value: 1 },
{ text: 'B', value: 2 },
@ -288,6 +284,19 @@ describe('validateTypeMatch', () => {
expect(validateTypeMatch(['c'], mForm, propsOf(checkboxGroup))).toBe(
'c 不在可选项中\n\n请使用以下某一个值"a""b"',
);
// type 为驼峰形式 radioGroup应通过 toLine 归一化后按 radio-group 规则校验
const radioGroupCamelCase = {
type: 'radioGroup',
options: [
{ text: 'A', value: 1 },
{ text: 'B', value: 2 },
],
};
expect(validateTypeMatch(1, mForm, propsOf(radioGroupCamelCase))).toBeUndefined();
expect(validateTypeMatch(3, mForm, propsOf(radioGroupCamelCase))).toBe(
'3 不在可选项中\n\n请使用以下某一个值12',
);
});
test('cascader 静态路径与 valueSeparator', () => {
@ -311,10 +320,8 @@ describe('validateTypeMatch', () => {
expect(
validateTypeMatch('zhejiang/hangzhou', mForm, propsOf({ type: 'cascader', options, valueSeparator: '/' })),
).toBeUndefined();
// remote 无 options 时类型不匹配示例回退到通用数组示例
expect(validateTypeMatch('bad', mForm, propsOf({ type: 'cascader', remote: true }))).toBe(
'bad 类型应为数组\n\n请参考以下示例值["选项1", "选项2"]',
);
// remote 无 options 时跳过类型校验
expect(validateTypeMatch('bad', mForm, propsOf({ type: 'cascader', remote: true }))).toBeUndefined();
expect(validateTypeMatch(['a'], mForm, propsOf({ type: 'cascader', remote: true }))).toBeUndefined();
});
@ -338,7 +345,7 @@ describe('validateTypeMatch', () => {
expect(validateTypeMatch([{ id: 1 }], mForm, propsOf({ type: 'table' }))).toBeUndefined();
// table 无 options类型不匹配示例回退到通用对象数组示例
expect(validateTypeMatch({}, mForm, propsOf({ type: 'groupList' }))).toBe(
expect(validateTypeMatch({}, mForm, propsOf({ type: 'group-list' }))).toBe(
'[object Object] 类型应为对象数组\n\n请参考以下示例值[{}]',
);
// table / group-list 元素必须为对象,字符串数组不合法
@ -359,9 +366,9 @@ describe('validateTypeMatch', () => {
expect(validateTypeMatch(1, mForm, propsOf({ type: 'panel', items: [] }))).toBeUndefined();
});
test('无 type 默认按 text 校验', () => {
test('无 type 时跳过校验', () => {
expect(validateTypeMatch('ok', mForm, propsOf({}))).toBeUndefined();
expect(validateTypeMatch(1, mForm, propsOf({}))).toBe('1 类型应为字符串\n\n请参考以下示例值"文本内容"');
expect(validateTypeMatch(1, mForm, propsOf({}))).toBeUndefined();
});
test('textarea / color-picker / html 期望 string', () => {
@ -457,16 +464,50 @@ describe('validateTypeMatch', () => {
).toBe('bad 类型应为数组\n\n请参考以下示例值["hangzhou"]');
});
test('select allowCreate 对象值非法', () => {
expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'select', allowCreate: true }))).toBe(
'[object Object] 类型不合法\n\n请参考以下示例值"文本内容" 或 123',
);
test('select allowCreate 无 options 时跳过类型校验', () => {
expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'select', allowCreate: true }))).toBeUndefined();
});
test('动态 type 函数解析', () => {
expect(validateTypeMatch('ok', mForm, propsOf({ type: () => 'text', name: 'field' }))).toBeUndefined();
expect(validateTypeMatch(1, mForm, propsOf({ type: () => 'number', name: 'field' }))).toBeUndefined();
});
test('type 为异步函数(返回 Promise时跳过校验', () => {
expect(
validateTypeMatch('ok', mForm, propsOf({ type: () => Promise.resolve('text'), name: 'field' })),
).toBeUndefined();
expect(validateTypeMatch(123, mForm, propsOf({ type: async () => 'number', name: 'field' }))).toBeUndefined();
// 即便值类型明显不匹配,异步 type 也跳过校验
expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: async () => 'text', name: 'field' }))).toBeUndefined();
});
test('cascader valueSeparator 为异步函数(返回 Promise时跳过校验', () => {
const options = [
{
value: 'zhejiang',
label: 'Zhejiang',
children: [{ value: 'hangzhou', label: 'Hangzhou' }],
},
];
// valueSeparator 异步时无法同步确定分隔符,跳过校验(即便值类型不匹配也不报错)
expect(
validateTypeMatch(123, mForm, propsOf({ type: 'cascader', options, valueSeparator: () => Promise.resolve('/') })),
).toBeUndefined();
expect(
validateTypeMatch('bad', mForm, propsOf({ type: 'cascader', options, valueSeparator: async () => '/' })),
).toBeUndefined();
});
test('defaultValue 为异步函数(返回 Promise时示例回退到通用值', () => {
// defaultValue 异步时无法同步获取,错误信息中的示例值回退到通用示例,不报错也不 crash
expect(validateTypeMatch('1', mForm, propsOf({ type: 'number', defaultValue: () => Promise.resolve(123) }))).toBe(
'1 类型应为数字\n\n请参考以下示例值123',
);
expect(validateTypeMatch(1, mForm, propsOf({ type: 'text', defaultValue: async () => '示例' }))).toBe(
'1 类型应为字符串\n\n请参考以下示例值"文本内容"',
);
});
});
describe('getRules typeMatch', () => {