diff --git a/antdv-demo b/antdv-demo index 26019de23..f836704c6 160000 --- a/antdv-demo +++ b/antdv-demo @@ -1 +1 @@ -Subproject commit 26019de237561b0d68e22a4e46537ee8e2e6f0fe +Subproject commit f836704c60bf7e647e9e59f4a136024a960698b3 diff --git a/components/form-model/Form.jsx b/components/form-model/Form.jsx index cd6e84848..79006249f 100755 --- a/components/form-model/Form.jsx +++ b/components/form-model/Form.jsx @@ -8,7 +8,10 @@ import warning from '../_util/warning'; import FormItem from './FormItem'; import { initDefaultProps, getSlot } from '../_util/props-util'; import { ConfigConsumerProps } from '../config-provider'; -import { getParams } from './utils'; +import { getNamePath, containsNamePath } from './utils/valueUtil'; +import { defaultValidateMessages } from './utils/messages'; +import { allPromiseFinish } from './utils/asyncUtil'; +import { toArray } from './utils/typeUtil'; export const FormProps = { layout: PropTypes.oneOf(['horizontal', 'inline', 'vertical']), @@ -66,6 +69,7 @@ const Form = { created() { this.fields = []; this.form = undefined; + this.lastValidatePromise = null; provide('FormContext', this); }, setup() { @@ -100,12 +104,16 @@ const Form = { e.preventDefault(); e.stopPropagation(); this.$emit('submit', e); - const res = this.validate(); + const res = this.validateFields(); res .then(values => { + // eslint-disable-next-line no-console + console.log('values', values); this.$emit('finish', values); }) .catch(errors => { + // eslint-disable-next-line no-console + console.log('errors', errors); this.handleFinishFailed(errors); }); }, @@ -179,74 +187,98 @@ const Form = { // } }, scrollToField() {}, - getFieldsValue(allFields) { + // TODO + // eslint-disable-next-line no-unused-vars + getFieldsValue(nameList) { const values = {}; - allFields.forEach(({ prop, fieldValue }) => { + this.fields.forEach(({ prop, fieldValue }) => { values[prop] = fieldValue; }); return values; }, - validateFields() { - return this.validateField(...arguments); - }, - validateField(ns, opt, cb) { - const pending = new Promise((resolve, reject) => { - const params = getParams(ns, opt, cb); - const { names, options } = params; - let { callback } = params; - if (!callback || typeof callback === 'function') { - const oldCb = callback; - callback = (errorFields, values) => { - if (oldCb) { - oldCb(errorFields, values); - } else if (errorFields) { - reject({ errorFields, values }); - } else { - resolve(values); - } - }; + validateFields(nameList, options) { + if (!this.model) { + warning(false, 'Form', 'model is required for validateFields to work.'); + return; + } + const provideNameList = !!nameList; + const namePathList = provideNameList ? toArray(nameList).map(getNamePath) : []; + + // Collect result in promise list + const promiseList = []; + + this.fields.forEach(field => { + // Add field if not provide `nameList` + if (!provideNameList) { + namePathList.push(field.getNamePath()); } - const allFields = names - ? this.fields.filter(field => names.indexOf(field.prop) !== -1) - : this.fields; - const fields = allFields.filter(field => { - const rules = field.getFilteredRule(''); - return rules && rules.length; - }); - if (!fields.length) { - callback(null, this.getFieldsValue(allFields)); + + // Skip if without rule + if (!field.getRules().length) { return; } - if (!('firstFields' in options)) { - options.firstFields = allFields.filter(field => { - return !!field.validateFirst; - }); - } - let fieldsErrors = {}; - let valid = true; - let count = 0; - const promiseList = []; - fields.forEach(field => { - const promise = field.validate('', errors => { - if (errors) { - valid = false; - fieldsErrors[field.prop] = errors; - } - if (++count === fields.length) { - callback(valid ? null : fieldsErrors, this.getFieldsValue(fields)); - } + const fieldNamePath = field.getNamePath(); + + // Add field validate rule in to promise list + if (!provideNameList || containsNamePath(namePathList, fieldNamePath)) { + const promise = field.validateRules({ + validateMessages: { + ...defaultValidateMessages, + ...this.validateMessages, + }, + ...options, }); - promiseList.push(promise.then(() => {})); - }); - }); - pending.catch(e => { - if (console.error && process.env.NODE_ENV !== 'production') { - console.error(e); + + // Wrap promise with field + promiseList.push( + promise + .then(() => ({ name: fieldNamePath, errors: [] })) + .catch(errors => + Promise.reject({ + name: fieldNamePath, + errors, + }), + ), + ); } - return e; }); - return pending; + + const summaryPromise = allPromiseFinish(promiseList); + this.lastValidatePromise = summaryPromise; + + // // Notify fields with rule that validate has finished and need update + // summaryPromise + // .catch(results => results) + // .then(results => { + // const resultNamePathList = results.map(({ name }) => name); + // // eslint-disable-next-line no-console + // console.log(resultNamePathList); + // }); + + const returnPromise = summaryPromise + .then(() => { + if (this.lastValidatePromise === summaryPromise) { + return Promise.resolve(this.getFieldsValue(namePathList)); + } + return Promise.reject([]); + }) + .catch(results => { + const errorList = results.filter(result => result && result.errors.length); + return Promise.reject({ + values: this.getFieldsValue(namePathList), + errorFields: errorList, + outOfDate: this.lastValidatePromise !== summaryPromise, + }); + }); + + // Do not throw in console + returnPromise.catch(e => e); + + return returnPromise; + }, + validateField() { + return this.validateFields(...arguments); }, }, diff --git a/components/form-model/FormItem.jsx b/components/form-model/FormItem.jsx index 1b19b3bf5..8b485cab1 100644 --- a/components/form-model/FormItem.jsx +++ b/components/form-model/FormItem.jsx @@ -1,5 +1,4 @@ import { inject, provide, Transition } from 'vue'; -import AsyncValidator from 'async-validator'; import cloneDeep from 'lodash/cloneDeep'; import PropTypes from '../_util/vue-types'; import classNames from 'classnames'; @@ -22,8 +21,9 @@ import CheckCircleFilled from '@ant-design/icons-vue/CheckCircleFilled'; import ExclamationCircleFilled from '@ant-design/icons-vue/ExclamationCircleFilled'; import CloseCircleFilled from '@ant-design/icons-vue/CloseCircleFilled'; import LoadingOutlined from '@ant-design/icons-vue/LoadingOutlined'; -import { finishOnAllFailed, finishOnFirstFailed, getNamePath } from './utils'; -import { warning } from '../vc-util/warning'; +import { validateRules } from './utils/validateUtil'; +import { getNamePath } from './utils/valueUtil'; +import { toArray } from './utils/typeUtil'; const iconMap = { success: CheckCircleFilled, @@ -100,6 +100,7 @@ export default { validateDisabled: false, validator: {}, helpShow: false, + errors: [], }; }, @@ -155,188 +156,51 @@ export default { removeField && removeField(this); }, methods: { - async validateRule(name, value, rule) { - const cloneRule = { ...rule }; - // We should special handle array validate - let subRuleField = null; - if (cloneRule && cloneRule.type === 'array' && cloneRule.defaultField) { - subRuleField = cloneRule.defaultField; - delete cloneRule.defaultField; - } - let result = []; - const validator = new AsyncValidator({ - [name]: [cloneRule], - }); - if (this.FormContext && this.FormContext.validateMessages) { - validator.messages(this.FormContext.validateMessages); - } - try { - await validator.validate( - { [this.prop]: this.fieldValue }, - { firstFields: !!this.validateFirst }, - ); - } catch (errObj) { - if (errObj.errors) { - result = errObj.errors.map(({ message }) => message); - } else { - console.error(errObj); - } - } - if (!result.length && subRuleField) { - const subResults = await Promise.all( - value.map((subValue, i) => this.validateRule(`${name}.${i}`, subValue, subRuleField)), - ); + getNamePath() { + const { prop } = this.$props; + const { prefixName = [] } = this.FormContext; - return subResults.reduce((prev, errors) => [...prev, ...errors], []); - } - return result; + return prop !== undefined ? [...prefixName, ...getNamePath(prop)] : []; }, - validateRules(namePath, value, rules, validateFirst) { - const name = namePath.join('.'); + validateRules(options) { + const { validateFirst = false, messageVariables } = this.$props; + const { triggerName } = options || {}; + const namePath = this.getNamePath(); - // Fill rule with context - const filledRules = rules.map(currentRule => { - const originValidatorFunc = currentRule.validator; - - if (!originValidatorFunc) { - return currentRule; - } - return { - ...currentRule, - validator(rule, val, callback) { - let hasPromise = false; - - // Wrap callback only accept when promise not provided - const wrappedCallback = (...args) => { - // 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 - .then(() => { - callback(); - }) - .catch(err => { - callback(err); - }); - } - }, - }; - }); - - let summaryPromise; - - if (validateFirst === true) { - // >>>>> Validate by serialization - summaryPromise = new Promise(async resolve => { - /* eslint-disable no-await-in-loop */ - for (let i = 0; i < filledRules.length; i += 1) { - const errors = await this.validateRule(name, value, filledRules[i]); - if (errors.length) { - resolve(errors); - return; - } + let filteredRules = this.getRules(); + if (triggerName) { + filteredRules = filteredRules.filter(rule => { + const { validateTrigger } = rule; + if (!validateTrigger) { + return true; } - /* eslint-enable */ - - resolve([]); - }); - } else { - // >>>>> Validate by parallel - const rulePromises = filledRules.map(rule => this.validateRule(name, value, rule)); - - summaryPromise = (validateFirst - ? finishOnFirstFailed(rulePromises) - : finishOnAllFailed(rulePromises) - ).then(errors => { - if (!errors.length) { - return []; - } - - return Promise.reject(errors); + const triggerList = toArray(validateTrigger); + return triggerList.includes(triggerName); }); } - // Internal catch error to avoid console error log. - summaryPromise.catch(e => e); - - return summaryPromise; - }, - validate(trigger) { - this.validateDisabled = false; - const rules = this.getFilteredRule(trigger); - if (!rules || rules.length === 0) { - return; - } + const promise = validateRules( + namePath, + this.fieldValue, + filteredRules, + options, + validateFirst, + messageVariables, + ); this.validateState = 'validating'; - if (rules && rules.length > 0) { - rules.forEach(rule => { - delete rule.trigger; - }); - } - // descriptor[this.prop] = rules; - // const validator = new AsyncValidator(descriptor); - // if (this.FormContext && this.FormContext.validateMessages) { - // validator.messages(this.FormContext.validateMessages); - // } - const fieldNamePath = getNamePath(this.prop); - // const promiseList = []; - const promise = this.validateRules(fieldNamePath, this.fieldValue, rules, this.validateFirst); + this.errors = []; + promise - .then(res => { - // eslint-disable-next-line no-console - console.log(res); - this.validateState = 'success'; - this.validateMessage = ''; - return { name: fieldNamePath, errors: [] }; - }) - .catch(errors => { - this.validateState = 'error'; - this.validateMessage = errors; - Promise.reject({ - name: fieldNamePath, - errors, - }); + .catch(e => e) + .then((errors = []) => { + if (this.validateState === 'validating') { + this.validateState = errors.length ? 'error' : 'success'; + this.validateMessage = errors[0]; + this.errors = errors; + } }); + return promise; - // // Wrap promise with field - // promiseList.push( - // promise - // .then(() => ({ name: fieldNamePath, errors: [] })) - // .catch(errors => - // Promise.reject({ - // name: fieldNamePath, - // errors, - // }), - // ), - // ); - // this.validateState = result.length ? 'error' : 'success'; - // this.validateMessage = result.length ? result[0] : ''; - // this.FormContext && - // this.FormContext.$emit && - // this.FormContext.$emit('validate', this.prop, !result.length, this.validateMessage || null); - // return result; }, getRules() { let formRules = this.FormContext.rules; @@ -361,14 +225,14 @@ export default { .map(rule => ({ ...rule })); }, onFieldBlur() { - this.validate('blur'); + this.validateRules({ triggerName: 'blur' }); }, onFieldChange() { if (this.validateDisabled) { this.validateDisabled = false; return; } - this.validate('change'); + this.validateRules({ triggerName: 'change' }); }, clearValidate() { this.validateState = ''; diff --git a/components/form-model/utils.js b/components/form-model/utils.js index 93fe350eb..9754f17b0 100644 --- a/components/form-model/utils.js +++ b/components/form-model/utils.js @@ -103,41 +103,41 @@ export function getScrollableContainer(n) { return nodeName === 'body' ? node.ownerDocument : node; } -export async function finishOnAllFailed(rulePromises) { - return Promise.all(rulePromises).then(errorsList => { - const errors = [].concat(...errorsList); +// export async function finishOnAllFailed(rulePromises) { +// return Promise.all(rulePromises).then(errorsList => { +// const errors = [].concat(...errorsList); - return errors; - }); -} +// return errors; +// }); +// } -export async function finishOnFirstFailed(rulePromises) { - let count = 0; +// export async function finishOnFirstFailed(rulePromises) { +// let count = 0; - return new Promise(resolve => { - rulePromises.forEach(promise => { - promise.then(errors => { - if (errors.length) { - resolve(errors); - } +// return new Promise(resolve => { +// rulePromises.forEach(promise => { +// promise.then(errors => { +// if (errors.length) { +// resolve(errors); +// } - count += 1; - if (count === rulePromises.length) { - resolve([]); - } - }); - }); - }); -} +// count += 1; +// if (count === rulePromises.length) { +// resolve([]); +// } +// }); +// }); +// }); +// } -export function toArray(value) { - if (value === undefined || value === null) { - return []; - } +// export function toArray(value) { +// if (value === undefined || value === null) { +// return []; +// } - return Array.isArray(value) ? value : [value]; -} +// return Array.isArray(value) ? value : [value]; +// } -export function getNamePath(path) { - return toArray(path); -} +// export function getNamePath(path) { +// return toArray(path); +// } diff --git a/components/form-model/utils/asyncUtil.js b/components/form-model/utils/asyncUtil.js new file mode 100644 index 000000000..3c5b0df6c --- /dev/null +++ b/components/form-model/utils/asyncUtil.js @@ -0,0 +1,32 @@ +export function allPromiseFinish(promiseList) { + let hasError = false; + let count = promiseList.length; + const results = []; + + if (!promiseList.length) { + return Promise.resolve([]); + } + + return new Promise((resolve, reject) => { + promiseList.forEach((promise, index) => { + promise + .catch(e => { + hasError = true; + return e; + }) + .then(result => { + count -= 1; + results[index] = result; + + if (count > 0) { + return; + } + + if (hasError) { + reject(results); + } + resolve(results); + }); + }); + }); +} diff --git a/components/form-model/messages.js b/components/form-model/utils/messages.js similarity index 100% rename from components/form-model/messages.js rename to components/form-model/utils/messages.js diff --git a/components/form-model/utils/typeUtil.js b/components/form-model/utils/typeUtil.js new file mode 100644 index 000000000..e62954c28 --- /dev/null +++ b/components/form-model/utils/typeUtil.js @@ -0,0 +1,7 @@ +export function toArray(value) { + if (value === undefined || value === null) { + return []; + } + + return Array.isArray(value) ? value : [value]; +} diff --git a/components/form-model/utils/validateUtil.js b/components/form-model/utils/validateUtil.js new file mode 100644 index 000000000..b75907935 --- /dev/null +++ b/components/form-model/utils/validateUtil.js @@ -0,0 +1,227 @@ +import RawAsyncValidator from 'async-validator'; +import { cloneVNode } from 'vue'; +import { warning } from '../../vc-util/warning'; + +import { setValues } from './valueUtil'; +import { defaultValidateMessages } from './messages'; +import { isValidElement } from '../../_util/props-util'; + +// Remove incorrect original ts define +const AsyncValidator = RawAsyncValidator; + +/** + * Replace with template. + * `I'm ${name}` + { name: 'bamboo' } = I'm bamboo + */ +function replaceMessage(template, kv) { + return template.replace(/\$\{\w+\}/g, str => { + 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, name, rule, messageVariables) { + const kv = { + ...rule, + name, + enum: (rule.enum || []).join(', '), + }; + + const replaceFunc = (template, additionalKV) => () => + replaceMessage(template, { ...kv, ...additionalKV }); + + /* eslint-disable no-param-reassign */ + function fillTemplate(source, target = {}) { + 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)); +} + +async function validateRule(name, value, rule, options, messageVariables) { + const cloneRule = { ...rule }; + // We should special handle array validate + let subRuleField = null; + if (cloneRule && cloneRule.type === 'array' && cloneRule.defaultField) { + subRuleField = cloneRule.defaultField; + delete cloneRule.defaultField; + } + + const validator = new AsyncValidator({ + [name]: [cloneRule], + }); + + const messages = convertMessages(options.validateMessages, name, cloneRule, messageVariables); + validator.messages(messages); + + let result = []; + + try { + await Promise.resolve(validator.validate({ [name]: value }, { ...options })); + } catch (errObj) { + if (errObj.errors) { + result = errObj.errors.map(({ message }, index) => + // Wrap VueNode with `key` + isValidElement(message) ? cloneVNode(message, { key: `error_${index}` }) : message, + ); + } else { + console.error(errObj); + result = [messages.default()]; + } + } + + if (!result.length && subRuleField) { + const subResults = await Promise.all( + value.map((subValue, i) => + validateRule(`${name}.${i}`, subValue, subRuleField, options, messageVariables), + ), + ); + + return subResults.reduce((prev, errors) => [...prev, ...errors], []); + } + + return result; +} + +/** + * We use `async-validator` to validate the value. + * But only check one value in a time to avoid namePath validate issue. + */ +export function validateRules(namePath, value, rules, options, validateFirst, messageVariables) { + const name = namePath.join('.'); + + // Fill rule with context + const filledRules = rules.map(currentRule => { + const originValidatorFunc = currentRule.validator; + + if (!originValidatorFunc) { + return currentRule; + } + return { + ...currentRule, + validator(rule, val, callback) { + let hasPromise = false; + + // Wrap callback only accept when promise not provided + const wrappedCallback = (...args) => { + // 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 + .then(() => { + callback(); + }) + .catch(err => { + callback(err); + }); + } + }, + }; + }); + + let summaryPromise; + + if (validateFirst === true) { + // >>>>> Validate by serialization + summaryPromise = new Promise(async resolve => { + /* 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); + if (errors.length) { + resolve(errors); + return; + } + } + /* eslint-enable */ + + resolve([]); + }); + } else { + // >>>>> Validate by parallel + const rulePromises = filledRules.map(rule => + validateRule(name, value, rule, options, messageVariables), + ); + + summaryPromise = (validateFirst + ? finishOnFirstFailed(rulePromises) + : finishOnAllFailed(rulePromises) + ).then(errors => { + if (!errors.length) { + return []; + } + + return Promise.reject(errors); + }); + } + + // Internal catch error to avoid console error log. + summaryPromise.catch(e => e); + + return summaryPromise; +} + +async function finishOnAllFailed(rulePromises) { + return Promise.all(rulePromises).then(errorsList => { + const errors = [].concat(...errorsList); + + return errors; + }); +} + +async function finishOnFirstFailed(rulePromises) { + let count = 0; + + return new Promise(resolve => { + rulePromises.forEach(promise => { + promise.then(errors => { + if (errors.length) { + resolve(errors); + } + + count += 1; + if (count === rulePromises.length) { + resolve([]); + } + }); + }); + }); +} diff --git a/components/form-model/utils/valueUtil.js b/components/form-model/utils/valueUtil.js new file mode 100644 index 000000000..1f1733146 --- /dev/null +++ b/components/form-model/utils/valueUtil.js @@ -0,0 +1,74 @@ +import { toArray } from './typeUtil'; + +/** + * Convert name to internal supported format. + * This function should keep since we still thinking if need support like `a.b.c` format. + * 'a' => ['a'] + * 123 => [123] + * ['a', 123] => ['a', 123] + */ +export function getNamePath(path) { + return toArray(path); +} + +// export function getValue(store: Store, namePath: InternalNamePath) { +// const value = get(store, namePath); +// return value; +// } + +// export function setValue(store: Store, namePath: InternalNamePath, value: StoreValue): Store { +// const newStore = set(store, namePath, value); +// return newStore; +// } + +// export function cloneByNamePathList(store: Store, namePathList: InternalNamePath[]): Store { +// let newStore = {}; +// namePathList.forEach(namePath => { +// const value = getValue(store, namePath); +// newStore = setValue(newStore, namePath, value); +// }); + +// return newStore; +// } + +export function containsNamePath(namePathList, namePath) { + return namePathList && namePathList.some(path => matchNamePath(path, namePath)); +} + +function isObject(obj) { + return typeof obj === 'object' && obj !== null && Object.getPrototypeOf(obj) === Object.prototype; +} + +/** + * Copy values into store and return a new values object + * ({ a: 1, b: { c: 2 } }, { a: 4, b: { d: 5 } }) => { a: 4, b: { c: 2, d: 5 } } + */ +function internalSetValues(store, values) { + const newStore = Array.isArray(store) ? [...store] : { ...store }; + + if (!values) { + return newStore; + } + + Object.keys(values).forEach(key => { + const prevValue = newStore[key]; + const value = values[key]; + + // If both are object (but target is not array), we use recursion to set deep value + const recursive = isObject(prevValue) && isObject(value); + newStore[key] = recursive ? internalSetValues(prevValue, value || {}) : value; + }); + + return newStore; +} + +export function setValues(store, ...restValues) { + return restValues.reduce((current, newStore) => internalSetValues(current, newStore), store); +} + +export function matchNamePath(namePath, changedNamePath) { + if (!namePath || !changedNamePath || namePath.length !== changedNamePath.length) { + return false; + } + return namePath.every((nameUnit, i) => changedNamePath[i] === nameUnit); +}