diff --git a/components/form/Form.tsx b/components/form/Form.tsx index 0761aeebd..350fdc654 100755 --- a/components/form/Form.tsx +++ b/components/form/Form.tsx @@ -16,7 +16,13 @@ import initDefaultProps from '../_util/props-util/initDefaultProps'; import type { VueNode } from '../_util/type'; import { tuple } from '../_util/type'; import type { ColProps } from '../grid/Col'; -import type { InternalNamePath, NamePath, ValidateErrorEntity, ValidateOptions } from './interface'; +import type { + InternalNamePath, + NamePath, + RuleError, + ValidateErrorEntity, + ValidateOptions, +} from './interface'; import { useInjectSize } from '../_util/hooks/useSize'; import useConfigInject from '../_util/hooks/useConfigInject'; import { useProvideForm } from './context'; @@ -247,13 +253,33 @@ const Form = defineComponent({ // Wrap promise with field promiseList.push( promise - .then(() => ({ name: fieldNamePath, errors: [] })) - .catch((errors: any) => - Promise.reject({ + .then(() => ({ name: fieldNamePath, errors: [], warnings: [] })) + .catch((ruleErrors: RuleError[]) => { + const mergedErrors: string[] = []; + const mergedWarnings: string[] = []; + + ruleErrors.forEach(({ rule: { warningOnly }, errors }) => { + if (warningOnly) { + mergedWarnings.push(...errors); + } else { + mergedErrors.push(...errors); + } + }); + + if (mergedErrors.length) { + return Promise.reject({ + name: fieldNamePath, + errors: mergedErrors, + warnings: mergedWarnings, + }); + } + + return { name: fieldNamePath, - errors, - }), - ), + errors: mergedErrors, + warnings: mergedWarnings, + }; + }), ); } }); diff --git a/components/form/FormItem.tsx b/components/form/FormItem.tsx index 1a79cca31..986ba8699 100644 --- a/components/form/FormItem.tsx +++ b/components/form/FormItem.tsx @@ -13,7 +13,7 @@ import { toArray } from './utils/typeUtil'; import { warning } from '../vc-util/warning'; import find from 'lodash-es/find'; import { tuple } from '../_util/type'; -import type { InternalNamePath, RuleObject, ValidateOptions } from './interface'; +import type { InternalNamePath, RuleError, RuleObject, ValidateOptions } from './interface'; import useConfigInject from '../_util/hooks/useConfigInject'; import { useInjectForm } from './context'; import FormItemLabel from './FormItemLabel'; @@ -31,7 +31,7 @@ export interface FieldExpose { clearValidate: () => void; namePath: ComputedRef; rules?: ComputedRef; - validateRules: (options: ValidateOptions) => Promise | Promise; + validateRules: (options: ValidateOptions) => Promise | Promise; } function getPropByPath(obj: any, namePathList: any, strict?: boolean) { @@ -209,10 +209,12 @@ export default defineComponent({ promise .catch(e => e) - .then((ers = []) => { + .then((results: RuleError[] = []) => { if (validateState.value === 'validating') { - validateState.value = ers.length ? 'error' : 'success'; - errors.value = ers; + const res = results.filter(result => result && result.errors.length); + validateState.value = res.length ? 'error' : 'success'; + + errors.value = res.map(r => r.errors); } }); diff --git a/components/form/interface.ts b/components/form/interface.ts index 8c6f41ed8..0c2652839 100644 --- a/components/form/interface.ts +++ b/components/form/interface.ts @@ -50,11 +50,13 @@ type Validator = ( ) => Promise | void; export interface ValidatorRule { + warningOnly?: boolean; message?: string | VueNode; validator: Validator; } interface BaseRule { + warningOnly?: boolean; enum?: StoreValue[]; len?: number; max?: number; @@ -92,6 +94,11 @@ export interface FieldError { errors: string[]; } +export interface RuleError { + errors: string[]; + rule: RuleObject; +} + export interface ValidateOptions { triggerName?: string; validateMessages?: ValidateMessages; diff --git a/components/form/utils/asyncUtil.ts b/components/form/utils/asyncUtil.ts index b766a8834..51593b5f3 100644 --- a/components/form/utils/asyncUtil.ts +++ b/components/form/utils/asyncUtil.ts @@ -1,8 +1,9 @@ import type { FieldError } from '../interface'; + export function allPromiseFinish(promiseList: Promise[]): Promise { let hasError = false; let count = promiseList.length; - const results = []; + const results: FieldError[] = []; if (!promiseList.length) { return Promise.resolve([]); diff --git a/components/form/utils/validateUtil.ts b/components/form/utils/validateUtil.ts index c2a969261..167b995e6 100644 --- a/components/form/utils/validateUtil.ts +++ b/components/form/utils/validateUtil.ts @@ -5,7 +5,7 @@ import { warning } from '../../vc-util/warning'; import { setValues } from './valueUtil'; import { defaultValidateMessages } from './messages'; import { isValidElement } from '../../_util/props-util'; -import type { InternalNamePath, RuleObject, ValidateMessages, ValidateOptions } from '../interface'; +import type { InternalNamePath, RuleError, RuleObject, ValidateOptions } from '../interface'; // Remove incorrect original ts define const AsyncValidator: any = RawAsyncValidator; @@ -15,52 +15,12 @@ const AsyncValidator: any = RawAsyncValidator; * `I'm ${name}` + { name: 'bamboo' } = I'm bamboo */ function replaceMessage(template: string, kv: Record): string { - return template.replace(/\$\{\w+\}/g, str => { + return template.replace(/\$\{\w+\}/g, (str: string) => { const key = str.slice(2, -1); return kv[key]; }); } -/** - * We use `async-validator` to validate rules. So have to hot replace the message with validator. - * { required: '${name} is required' } => { required: () => 'field is required' } - */ -function convertMessages( - messages: ValidateMessages, - name: string, - rule: RuleObject, - messageVariables?: Record, -): ValidateMessages { - const kv = { - ...(rule as Record), - name, - enum: (rule.enum || []).join(', '), - }; - - const replaceFunc = (template: string, additionalKV?: Record) => () => - replaceMessage(template, { ...kv, ...additionalKV }); - - /* eslint-disable no-param-reassign */ - function fillTemplate(source: ValidateMessages, target: ValidateMessages = {}) { - Object.keys(source).forEach(ruleName => { - const value = source[ruleName]; - if (typeof value === 'string') { - target[ruleName] = replaceFunc(value, messageVariables); - } else if (value && typeof value === 'object') { - target[ruleName] = {}; - fillTemplate(value, target[ruleName]); - } else { - target[ruleName] = value; - } - }); - - return target; - } - /* eslint-enable */ - - return fillTemplate(setValues({}, defaultValidateMessages, messages)) as ValidateMessages; -} - async function validateRule( name: string, value: any, @@ -69,29 +29,22 @@ async function validateRule( messageVariables?: Record, ): Promise { const cloneRule = { ...rule }; + + // Bug of `async-validator` + delete (cloneRule as any).ruleIndex; + // We should special handle array validate let subRuleField: RuleObject = null; if (cloneRule && cloneRule.type === 'array' && cloneRule.defaultField) { subRuleField = cloneRule.defaultField; delete cloneRule.defaultField; } - if ( - !rule.type && - typeof rule.validator !== 'function' && - typeof value !== 'string' && - typeof value !== 'undefined' - ) { - warning( - false, - `Form rules must provide type property when validating the form item named [${name}] which is not string type`, - ); - } const validator = new AsyncValidator({ [name]: [cloneRule], }); - const messages = convertMessages(options.validateMessages, name, cloneRule, messageVariables); + const messages = setValues({}, defaultValidateMessages, options.validateMessages); validator.messages(messages); let result = []; @@ -120,7 +73,22 @@ async function validateRule( return subResults.reduce((prev, errors) => [...prev, ...errors], []); } - return result; + // Replace message with variables + const kv = { + ...(rule as Record), + name, + enum: (rule.enum || []).join(', '), + ...messageVariables, + }; + + const fillVariableResult = result.map(error => { + if (typeof error === 'string') { + return replaceMessage(error, kv); + } + return error; + }); + + return fillVariableResult; } /** @@ -138,66 +106,84 @@ export function validateRules( const name = namePath.join('.'); // Fill rule with context - const filledRules: RuleObject[] = rules.map(currentRule => { - const originValidatorFunc = currentRule.validator; - - if (!originValidatorFunc) { - return currentRule; - } - return { - ...currentRule, - validator(rule: RuleObject, val: any, callback: (error?: string) => void) { - let hasPromise = false; - - // Wrap callback only accept when promise not provided - const wrappedCallback = (...args: string[]) => { - // Wait a tick to make sure return type is a promise - Promise.resolve().then(() => { - warning( - !hasPromise, - 'Your validator function has already return a promise. `callback` will be ignored.', - ); - - if (!hasPromise) { - callback(...args); - } - }); + const filledRules: RuleObject[] = rules + .map((currentRule, ruleIndex) => { + const originValidatorFunc = currentRule.validator; + const cloneRule = { + ...currentRule, + ruleIndex, + }; + + // Replace validator if needed + if (originValidatorFunc) { + cloneRule.validator = (rule: RuleObject, val: any, callback: (error?: string) => void) => { + let hasPromise = false; + + // Wrap callback only accept when promise not provided + const wrappedCallback = (...args: string[]) => { + // Wait a tick to make sure return type is a promise + Promise.resolve().then(() => { + warning( + !hasPromise, + 'Your validator function has already return a promise. `callback` will be ignored.', + ); + + if (!hasPromise) { + callback(...args); + } + }); + }; + + // Get promise + const promise = originValidatorFunc(rule, val, wrappedCallback); + hasPromise = + promise && typeof promise.then === 'function' && typeof promise.catch === 'function'; + + /** + * 1. Use promise as the first priority. + * 2. If promise not exist, use callback with warning instead + */ + warning(hasPromise, '`callback` is deprecated. Please return a promise instead.'); + + if (hasPromise) { + (promise as Promise) + .then(() => { + callback(); + }) + .catch(err => { + callback(err || ' '); + }); + } }; + } - // Get promise - const promise = originValidatorFunc(rule, val, wrappedCallback); - hasPromise = - promise && typeof promise.then === 'function' && typeof promise.catch === 'function'; - - /** - * 1. Use promise as the first priority. - * 2. If promise not exist, use callback with warning instead - */ - warning(hasPromise, '`callback` is deprecated. Please return a promise instead.'); - - if (hasPromise) { - (promise as Promise) - .then(() => { - callback(); - }) - .catch(err => { - callback(err); - }); - } - }, - }; - }); + return cloneRule; + }) + .sort(({ warningOnly: w1, ruleIndex: i1 }, { warningOnly: w2, ruleIndex: i2 }) => { + if (!!w1 === !!w2) { + // Let keep origin order + return i1 - i2; + } - let summaryPromise: Promise; + if (w1) { + return 1; + } + + return -1; + }); + + // Do validate rules + let summaryPromise: Promise; if (validateFirst === true) { // >>>>> Validate by serialization - summaryPromise = new Promise(async resolve => { + summaryPromise = new Promise(async (resolve, reject) => { /* eslint-disable no-await-in-loop */ for (let i = 0; i < filledRules.length; i += 1) { - const errors = await validateRule(name, value, filledRules[i], options, messageVariables); + const rule = filledRules[i]; + const errors = await validateRule(name, value, rule, options, messageVariables); if (errors.length) { - resolve(errors); + reject([{ errors, rule }]); return; } } @@ -207,18 +193,15 @@ export function validateRules( }); } else { // >>>>> Validate by parallel - const rulePromises = filledRules.map(rule => - validateRule(name, value, rule, options, messageVariables), + const rulePromises: Promise[] = filledRules.map(rule => + validateRule(name, value, rule, options, messageVariables).then(errors => ({ errors, rule })), ); summaryPromise = ( validateFirst ? finishOnFirstFailed(rulePromises) : finishOnAllFailed(rulePromises) - ).then((errors: string[]): string[] | Promise => { - if (!errors.length) { - return []; - } - - return Promise.reject(errors); + ).then((errors: RuleError[]): RuleError[] | Promise => { + // Always change to rejection for Field to catch + return Promise.reject(errors); }); } @@ -228,22 +211,24 @@ export function validateRules( return summaryPromise; } -async function finishOnAllFailed(rulePromises: Promise[]): Promise { - return Promise.all(rulePromises).then(errorsList => { - const errors = [].concat(...errorsList); +async function finishOnAllFailed(rulePromises: Promise[]): Promise { + return Promise.all(rulePromises).then( + (errorsList: RuleError[]): RuleError[] | Promise => { + const errors: RuleError[] = [].concat(...errorsList); - return errors; - }); + return errors; + }, + ); } -async function finishOnFirstFailed(rulePromises: Promise[]): Promise { +async function finishOnFirstFailed(rulePromises: Promise[]): Promise { let count = 0; return new Promise(resolve => { rulePromises.forEach(promise => { - promise.then(errors => { - if (errors.length) { - resolve(errors); + promise.then(ruleError => { + if (ruleError.errors.length) { + resolve([ruleError]); } count += 1;