fix(form): resolve function options and type for typeMatch validation

Add formValues alias to filter callbacks.
Prevent unhandled rejections during sync validation with async configs.
This commit is contained in:
roymondchen 2026-09-20 16:50:55 +08:00
parent be33ef2bd0
commit 489a1143b7
5 changed files with 199 additions and 22 deletions

View File

@ -253,6 +253,8 @@ export type FilterFunction<T = boolean> = (
values: FormValue;
parent?: FormValue;
formValue: FormValue;
/** 与 `formValue` 相同,对齐 Select.vue / SelectOptionFunction 的 `formValues` 别名 */
formValues: FormValue;
prop: string;
config: any;
index?: number;

View File

@ -368,11 +368,14 @@ export const filterFunction = <T = any>(
props: any,
) => {
if (typeof config === 'function') {
const formValue = readonly(mForm?.values || props.model);
return (config as FilterFunction<T>)(mForm, {
values: readonly(mForm?.initValues || {}),
model: readonly(props.model),
parent: readonly(mForm?.parentValues || {}),
formValue: readonly(mForm?.values || props.model),
formValue,
// Select.vue / SelectOptionFunction 使用 formValues 别名
formValues: formValue,
prop: props.prop,
config: props.config,
index: props.index,

View File

@ -56,6 +56,18 @@ const builtInTypeMatchRules = new Map<string, TypeMatchValidator>();
const isPromise = (value: any): value is Promise<unknown> =>
typeof value === 'object' && value !== null && typeof value.then === 'function';
/**
* Promise handler rejection
*/
const ignorePromise = (value: any): value is Promise<unknown> => {
if (!isPromise(value)) return false;
value.then(
() => {},
() => {},
);
return true;
};
/** 注册或覆盖某个字段 type 的 typeMatch 校验规则。`builtIn` 登记不受 `clearTypeMatchRules` / `deleteTypeMatchRule` 影响。 */
export const registerTypeMatchRule = (type: string, validator: TypeMatchValidator, builtIn = false): void => {
(builtIn ? builtInTypeMatchRules : extraTypeMatchRules).set(toLine(type), validator);
@ -89,11 +101,14 @@ const resolveConfig = <T = any>(
props: any,
): T | undefined => {
if (typeof config === 'function') {
const formValue = readonly(mForm?.values || props.model);
return (config as Function)(mForm, {
values: readonly(mForm?.initValues || {}),
model: readonly(props.model),
parent: readonly(mForm?.parentValues || {}),
formValue: readonly(mForm?.values || props.model),
formValue,
// Select.vue / SelectOptionFunction 使用 formValues 别名
formValues: formValue,
prop: props.prop,
config: props.config,
index: props.index,
@ -200,7 +215,7 @@ const resolveFieldDefaultValue = (mForm: FormState | undefined, props: any): any
if (typeof defaultValue === 'undefined' || defaultValue === 'undefined') return undefined;
const resolvedDefaultValue = resolveConfig(mForm, defaultValue, props);
// resolveConfig 返回 Promise如 defaultValue 为异步函数)时无法同步获取默认值,回退到通用示例
if (isPromise(resolvedDefaultValue)) {
if (ignorePromise(resolvedDefaultValue)) {
return undefined;
}
return resolvedDefaultValue;
@ -331,9 +346,21 @@ const flattenSelectOptions = (options: any[]): any[] => {
return values;
};
const resolveOptions = (props: any): any[] => {
const { options } = props.config || {};
return Array.isArray(options) ? options : [];
/**
* options `display` / `type`
*
* Promise options
* `remote` / `allowCreate` `validateSelectValue`
*/
const resolveOptions = (mForm: FormState | undefined, props: any): any[] => {
let resolved: unknown;
try {
resolved = resolveConfig(mForm, props.config?.options, props);
} catch {
return [];
}
if (ignorePromise(resolved) || !Array.isArray(resolved)) return [];
return resolved;
};
const includesOptionValue = (optionValues: any[], value: any) => optionValues.some((item) => Object.is(item, value));
@ -440,9 +467,6 @@ const validateSelectValue = (
return undefined;
}
// 仅当 options 为静态数组时才校验值是否在可选项中,动态 options函数形式跳过
const isStaticOptions = Array.isArray(config.options);
if (config.multiple) {
if (!Array.isArray(value)) {
return defaultMessage(
@ -451,13 +475,13 @@ const validateSelectValue = (
optionExampleSuggestion(optionValues, '["选项1", "选项2"]', true),
);
}
if (isStaticOptions && value.some((item) => !includesOptionValue(optionValues, item))) {
if (value.some((item) => !includesOptionValue(optionValues, item))) {
return defaultMessage(message, `${value} 不在可选项中`, optionSuggestion(optionValues));
}
return undefined;
}
if (isStaticOptions && !includesOptionValue(optionValues, value)) {
if (!includesOptionValue(optionValues, value)) {
return defaultMessage(message, `${value} 不在可选项中`, optionSuggestion(optionValues));
}
return undefined;
@ -470,7 +494,7 @@ const validateCascaderValue = (
mForm: FormState | undefined,
message: string | undefined,
): string | undefined => {
const options = resolveOptions(props) as CascaderOption[];
const options = resolveOptions(mForm, props) as CascaderOption[];
if (!options.length) {
return;
@ -478,7 +502,7 @@ const validateCascaderValue = (
const valueSeparator = resolveConfig<string | undefined>(mForm, config.valueSeparator, props);
// resolveConfig 返回 Promise如 valueSeparator 为异步函数)时无法同步确定分隔符,跳过校验
if (isPromise(valueSeparator)) {
if (ignorePromise(valueSeparator)) {
return undefined;
}
const emitPath = config.emitPath !== false;
@ -631,12 +655,12 @@ const validateBuiltinTypeMatch = (
}
if (fieldType === 'select') {
const optionValues = flattenSelectOptions(resolveOptions(props));
const optionValues = flattenSelectOptions(resolveOptions(mForm, props));
return validateSelectValue(value, config, optionValues, message);
}
if (fieldType === 'radio-group') {
const optionValues = flattenSelectOptions(resolveOptions(props));
const optionValues = flattenSelectOptions(resolveOptions(mForm, props));
if (optionValues.length === 0) {
return undefined;
@ -649,7 +673,7 @@ const validateBuiltinTypeMatch = (
}
if (fieldType === 'checkbox-group') {
const optionValues = flattenSelectOptions(resolveOptions(props));
const optionValues = flattenSelectOptions(resolveOptions(mForm, props));
if (optionValues.length === 0) {
return undefined;
@ -727,12 +751,19 @@ export const validateTypeMatch = (
}
const rawFieldType = 'type' in (props.config || {}) ? props.config.type : '';
if (typeof rawFieldType !== 'string' || !rawFieldType) {
let resolvedType: unknown;
try {
resolvedType = resolveConfig<string>(mForm, rawFieldType, props);
} catch {
return undefined;
}
// 函数型 type 与 Container 的 resolveItemType 对齐;异步 type 无法同步判定,跳过
if (ignorePromise(resolvedType) || typeof resolvedType !== 'string' || !resolvedType) {
return undefined;
}
// 统一将驼峰形式(如 radioGroup归一化为连字符形式radio-group与内置规则的 key 保持一致
const fieldType = toLine(rawFieldType);
const fieldType = toLine(resolvedType);
const customValidator = getTypeMatchRule(fieldType);

View File

@ -108,6 +108,8 @@ describe('filterFunction', () => {
expect(receivedArgs.prop).toBe('testProp');
expect(receivedArgs.index).toBe(5);
expect(receivedArgs.config).toEqual({ type: 'text' });
expect(receivedArgs.formValue).toEqual({ form: 'formValue' });
expect(receivedArgs.formValues).toBe(receivedArgs.formValue);
});
test('config 函数通过 mForm 读穿到宿主 context', () => {

View File

@ -247,14 +247,13 @@ describe('validateTypeMatch', () => {
expect(validateTypeMatch('a', mForm, propsOf(config))).toBe('a 类型应为数组\n\n请参考以下示例值["a"]');
});
test('select options 为函数 / group', () => {
test('select options 为函数时求值后做枚举校验', () => {
const fnConfig = {
type: 'select',
options: () => [{ text: 'A', value: 1 }],
};
expect(validateTypeMatch(1, mForm, propsOf(fnConfig))).toBeUndefined();
// options 为函数(动态)时,跳过「不在可选项中」枚举校验
expect(validateTypeMatch(2, mForm, propsOf(fnConfig))).toBeUndefined();
expect(validateTypeMatch(2, mForm, propsOf(fnConfig))).toBe('2 不在可选项中\n\n请使用以下某一个值1');
const groupConfig = {
type: 'select',
@ -271,6 +270,77 @@ describe('validateTypeMatch', () => {
expect(validateTypeMatch('b', mForm, propsOf(groupConfig))).toBe('b 不在可选项中\n\n请使用以下某一个值"a"');
});
test('select options 函数返回空 / Promise / 非数组 / 抛错时跳过枚举', () => {
expect(validateTypeMatch(2, mForm, propsOf({ type: 'select', options: () => [] }))).toBeUndefined();
expect(
validateTypeMatch(2, mForm, propsOf({ type: 'select', options: () => Promise.resolve([{ value: 1 }]) })),
).toBeUndefined();
expect(validateTypeMatch(2, mForm, propsOf({ type: 'select', options: () => ({ value: 1 }) }))).toBeUndefined();
expect(validateTypeMatch(2, mForm, propsOf({ type: 'select', options: () => null }))).toBeUndefined();
expect(
validateTypeMatch(
2,
mForm,
propsOf({
type: 'select',
options: () => {
throw new Error('options boom');
},
}),
),
).toBeUndefined();
});
test('select options 函数可读 model / formValue / formValues', () => {
const form = { ...mForm, values: { mode: 'a' } };
const byFormValues = {
type: 'select',
options: (_mForm: any, { formValues }: any) =>
formValues.mode === 'a' ? [{ text: 'A', value: 1 }] : [{ text: 'B', value: 2 }],
};
expect(validateTypeMatch(1, form, propsOf(byFormValues))).toBeUndefined();
expect(validateTypeMatch(2, form, propsOf(byFormValues))).toBe('2 不在可选项中\n\n请使用以下某一个值1');
const byModel = {
type: 'select',
options: (_mForm: any, { model }: any) => [{ text: 'A', value: model.choice }],
};
expect(validateTypeMatch(1, mForm, propsOf(byModel, { choice: 1 }))).toBeUndefined();
expect(validateTypeMatch(2, mForm, propsOf(byModel, { choice: 1 }))).toBe(
'2 不在可选项中\n\n请使用以下某一个值1',
);
});
test('radio-group / checkbox-group / cascader 函数 options 求值后做枚举校验', () => {
const radioOptions = () => [{ text: 'A', value: 1 }];
expect(validateTypeMatch(1, mForm, propsOf({ type: 'radio-group', options: radioOptions }))).toBeUndefined();
expect(validateTypeMatch(2, mForm, propsOf({ type: 'radio-group', options: radioOptions }))).toBe(
'2 不在可选项中\n\n请使用以下某一个值1',
);
const checkboxOptions = () => [{ text: 'A', value: 'a' }];
expect(
validateTypeMatch(['a'], mForm, propsOf({ type: 'checkbox-group', options: checkboxOptions })),
).toBeUndefined();
expect(validateTypeMatch(['b'], mForm, propsOf({ type: 'checkbox-group', options: checkboxOptions }))).toBe(
'b 不在可选项中\n\n请使用以下某一个值"a"',
);
const cascaderOptions = () => [
{
value: 'zhejiang',
label: 'Zhejiang',
children: [{ value: 'hangzhou', label: 'Hangzhou' }],
},
];
expect(
validateTypeMatch(['zhejiang', 'hangzhou'], mForm, propsOf({ type: 'cascader', options: cascaderOptions })),
).toBeUndefined();
expect(
validateTypeMatch(['zhejiang', 'ningbo'], mForm, propsOf({ type: 'cascader', options: cascaderOptions })),
).toBe('zhejiang,ningbo 不在可选项中\n\n请使用以下某一个值"hangzhou"');
});
test('select allowCreate / remote 不做枚举', () => {
expect(validateTypeMatch('custom', mForm, propsOf({ type: 'select', allowCreate: true }))).toBeUndefined();
// allowCreate 无 options 时跳过类型校验
@ -510,9 +580,31 @@ describe('validateTypeMatch', () => {
expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'select', allowCreate: true }))).toBeUndefined();
});
test('动态 type 函数解析', () => {
test('动态 type 函数解析后按解析出的 type 校验', () => {
expect(validateTypeMatch('ok', mForm, propsOf({ type: () => 'text', name: 'field' }))).toBeUndefined();
expect(validateTypeMatch(1, mForm, propsOf({ type: () => 'number', name: 'field' }))).toBeUndefined();
expect(validateTypeMatch('ok', mForm, propsOf({ type: () => 'number', name: 'field' }))).toMatch(/类型应为数字/);
expect(
validateTypeMatch(2, mForm, propsOf({ type: () => 'select', name: 'field', options: [{ text: 'A', value: 1 }] })),
).toMatch(/不在可选项中/);
expect(
validateTypeMatch(
2,
mForm,
propsOf({ type: () => 'select', name: 'field', options: () => [{ text: 'A', value: 1 }] }),
),
).toMatch(/不在可选项中/);
expect(
validateTypeMatch(
'ok',
mForm,
propsOf({
type: () => {
throw new Error('type boom');
},
}),
),
).toBeUndefined();
});
test('type 为异步函数(返回 Promise时跳过校验', () => {
@ -524,6 +616,53 @@ describe('validateTypeMatch', () => {
expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: async () => 'text', name: 'field' }))).toBeUndefined();
});
test('异步 type / options / defaultValue / valueSeparator reject 时不产生未捕获 rejection', async () => {
const unhandled: any[] = [];
const onUnhandled = (reason: any) => unhandled.push(reason);
process.on('unhandledRejection', onUnhandled);
const options = [
{
value: 'zhejiang',
label: 'Zhejiang',
children: [{ value: 'hangzhou', label: 'Hangzhou' }],
},
];
expect(
validateTypeMatch('ok', mForm, propsOf({ type: () => Promise.reject(new Error('type reject')) })),
).toBeUndefined();
expect(
validateTypeMatch(
2,
mForm,
propsOf({ type: 'select', options: () => Promise.reject(new Error('options reject')) }),
),
).toBeUndefined();
expect(
validateTypeMatch(
'1',
mForm,
propsOf({ type: 'number', defaultValue: () => Promise.reject(new Error('defaultValue reject')) }),
),
).toBe('1 类型应为数字\n\n请参考以下示例值123');
expect(
validateTypeMatch(
123,
mForm,
propsOf({
type: 'cascader',
options,
valueSeparator: () => Promise.reject(new Error('valueSeparator reject')),
}),
),
).toBeUndefined();
await new Promise((resolve) => setTimeout(resolve, 0));
process.off('unhandledRejection', onUnhandled);
expect(unhandled).toEqual([]);
});
test('cascader valueSeparator 为异步函数(返回 Promise时跳过校验', () => {
const options = [
{