mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-09-21 03:16:24 +00:00
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:
parent
be33ef2bd0
commit
489a1143b7
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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 = [
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user