From 6cdf54a4a663dd6531c70a069220b6d9a63ca7c0 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Tue, 21 Jul 2026 16:20:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(form):=20typeMatch=20=E4=BD=BF=E7=94=A8=20t?= =?UTF-8?q?oLine=20=E7=BB=9F=E4=B8=80=E7=B1=BB=E5=9E=8B=E8=BD=AC=E6=8D=A2?= =?UTF-8?q?=E5=B9=B6=E5=A4=84=E7=90=86=E5=BC=82=E6=AD=A5=20type/defaultVal?= =?UTF-8?q?ue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 替换局部 normalizeType 为 @tmagic/utils 的 toLine,新增 isPromise 守卫。 在 type 或 defaultValue 为 Promise 时跳过校验并回退示例值,补充对应单元测试。 --- packages/form/src/utils/typeMatch.ts | 85 +++++++++++++------ .../form/tests/unit/utils/typeMatch.spec.ts | 81 +++++++++++++----- 2 files changed, 118 insertions(+), 48 deletions(-) diff --git a/packages/form/src/utils/typeMatch.ts b/packages/form/src/utils/typeMatch.ts index 7a659198..8a2d3715 100644 --- a/packages/form/src/utils/typeMatch.ts +++ b/packages/form/src/utils/typeMatch.ts @@ -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(); -const normalizeType = (type: string) => type.replace(/([A-Z])/g, '-$1').toLowerCase(); +const isPromise = (value: any): value is Promise => + 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 /** 获取某个字段 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(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(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) { diff --git a/packages/form/tests/unit/utils/typeMatch.spec.ts b/packages/form/tests/unit/utils/typeMatch.spec.ts index 496446e4..da3ed25a 100644 --- a/packages/form/tests/unit/utils/typeMatch.spec.ts +++ b/packages/form/tests/unit/utils/typeMatch.spec.ts @@ -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', () => {