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; values: FormValue;
parent?: FormValue; parent?: FormValue;
formValue: FormValue; formValue: FormValue;
/** 与 `formValue` 相同,对齐 Select.vue / SelectOptionFunction 的 `formValues` 别名 */
formValues: FormValue;
prop: string; prop: string;
config: any; config: any;
index?: number; index?: number;

View File

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

View File

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

View File

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

View File

@ -247,14 +247,13 @@ describe('validateTypeMatch', () => {
expect(validateTypeMatch('a', mForm, propsOf(config))).toBe('a 类型应为数组\n\n请参考以下示例值["a"]'); expect(validateTypeMatch('a', mForm, propsOf(config))).toBe('a 类型应为数组\n\n请参考以下示例值["a"]');
}); });
test('select options 为函数 / group', () => { test('select options 为函数时求值后做枚举校验', () => {
const fnConfig = { const fnConfig = {
type: 'select', type: 'select',
options: () => [{ text: 'A', value: 1 }], options: () => [{ text: 'A', value: 1 }],
}; };
expect(validateTypeMatch(1, mForm, propsOf(fnConfig))).toBeUndefined(); expect(validateTypeMatch(1, mForm, propsOf(fnConfig))).toBeUndefined();
// options 为函数(动态)时,跳过「不在可选项中」枚举校验 expect(validateTypeMatch(2, mForm, propsOf(fnConfig))).toBe('2 不在可选项中\n\n请使用以下某一个值1');
expect(validateTypeMatch(2, mForm, propsOf(fnConfig))).toBeUndefined();
const groupConfig = { const groupConfig = {
type: 'select', type: 'select',
@ -271,6 +270,77 @@ describe('validateTypeMatch', () => {
expect(validateTypeMatch('b', mForm, propsOf(groupConfig))).toBe('b 不在可选项中\n\n请使用以下某一个值"a"'); 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 不做枚举', () => { test('select allowCreate / remote 不做枚举', () => {
expect(validateTypeMatch('custom', mForm, propsOf({ type: 'select', allowCreate: true }))).toBeUndefined(); expect(validateTypeMatch('custom', mForm, propsOf({ type: 'select', allowCreate: true }))).toBeUndefined();
// allowCreate 无 options 时跳过类型校验 // allowCreate 无 options 时跳过类型校验
@ -510,9 +580,31 @@ describe('validateTypeMatch', () => {
expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: 'select', allowCreate: true }))).toBeUndefined(); 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('ok', mForm, propsOf({ type: () => 'text', name: 'field' }))).toBeUndefined();
expect(validateTypeMatch(1, mForm, propsOf({ type: () => 'number', 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时跳过校验', () => { test('type 为异步函数(返回 Promise时跳过校验', () => {
@ -524,6 +616,53 @@ describe('validateTypeMatch', () => {
expect(validateTypeMatch({ a: 1 }, mForm, propsOf({ type: async () => 'text', name: 'field' }))).toBeUndefined(); 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时跳过校验', () => { test('cascader valueSeparator 为异步函数(返回 Promise时跳过校验', () => {
const options = [ const options = [
{ {