mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-07-23 18:30:43 +08:00
fix(form): typeMatch 使用 toLine 统一类型转换并处理异步 type/defaultValue
替换局部 normalizeType 为 @tmagic/utils 的 toLine,新增 isPromise 守卫。 在 type 或 defaultValue 为 Promise 时跳过校验并回退示例值,补充对应单元测试。
This commit is contained in:
parent
08ac9038bf
commit
6cdf54a4a6
@ -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) {
|
||||
|
||||
@ -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请使用以下某一个值:1;2',
|
||||
);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user