From 346ab47eb65ff11acf5d06b4220cc52cf87917d9 Mon Sep 17 00:00:00 2001 From: tanjinzhou <415800467@qq.com> Date: Mon, 13 Jul 2020 18:56:31 +0800 Subject: [PATCH] refactor: form --- components/form-model/Form.jsx | 60 ++-- components/form-model/FormItem.jsx | 438 ++++++++++++++++++++++++++--- components/form-model/messages.js | 49 ++++ components/form-model/utils.js | 39 +++ 4 files changed, 518 insertions(+), 68 deletions(-) create mode 100644 components/form-model/messages.js diff --git a/components/form-model/Form.jsx b/components/form-model/Form.jsx index c2e1da528..cd6e84848 100755 --- a/components/form-model/Form.jsx +++ b/components/form-model/Form.jsx @@ -6,7 +6,7 @@ import { ColProps } from '../grid/Col'; import isRegExp from 'lodash/isRegExp'; import warning from '../_util/warning'; import FormItem from './FormItem'; -import { initDefaultProps, getListeners, getSlot } from '../_util/props-util'; +import { initDefaultProps, getSlot } from '../_util/props-util'; import { ConfigConsumerProps } from '../config-provider'; import { getParams } from './utils'; @@ -22,6 +22,11 @@ export const FormProps = { rules: PropTypes.object, validateMessages: PropTypes.any, validateOnRuleChange: PropTypes.bool, + // 提交失败自动滚动到第一个错误字段 + scrollToFirstError: PropTypes.bool, + onFinish: PropTypes.func, + onFinishFailed: PropTypes.func, + name: PropTypes.name, }; export const ValidationRule = { @@ -47,8 +52,6 @@ export const ValidationRule = { transform: PropTypes.func, /** custom validate function (Note: callback must be called) */ validator: PropTypes.func, - // 提交失败自动滚动到第一个错误字段 - scrollToFirstError: PropTypes.bool, }; const Form = { @@ -93,12 +96,18 @@ const Form = { this.fields.splice(this.fields.indexOf(field), 1); } }, - onSubmit(e) { - if (!getListeners(this).submit) { - e.preventDefault(); - } else { - this.$emit('submit', e); - } + handleSubmit(e) { + e.preventDefault(); + e.stopPropagation(); + this.$emit('submit', e); + const res = this.validate(); + res + .then(values => { + this.$emit('finish', values); + }) + .catch(errors => { + this.handleFinishFailed(errors); + }); }, resetFields(props = []) { if (!this.model) { @@ -124,8 +133,16 @@ const Form = { field.clearValidate(); }); }, + handleFinishFailed(errorInfo) { + const { scrollToFirstError } = this; + this.$emit('finishFailed', errorInfo); + if (scrollToFirstError && errorInfo.errorFields.length) { + this.scrollToField(errorInfo.errorFields[0].name); + } + }, validate() { return this.validateField(...arguments); + // if (!this.model) { // warning(false, 'FormModel', 'model is required for resetFields to work.'); // return; @@ -179,11 +196,11 @@ const Form = { let { callback } = params; if (!callback || typeof callback === 'function') { const oldCb = callback; - callback = (errors, values) => { + callback = (errorFields, values) => { if (oldCb) { - oldCb(errors, values); - } else if (errors) { - reject({ errors, values }); + oldCb(errorFields, values); + } else if (errorFields) { + reject({ errorFields, values }); } else { resolve(values); } @@ -208,8 +225,9 @@ const Form = { let fieldsErrors = {}; let valid = true; let count = 0; + const promiseList = []; fields.forEach(field => { - field.validate('', errors => { + const promise = field.validate('', errors => { if (errors) { valid = false; fieldsErrors[field.prop] = errors; @@ -219,6 +237,7 @@ const Form = { callback(valid ? null : fieldsErrors, this.getFieldsValue(fields)); } }); + promiseList.push(promise.then(() => {})); }); }); pending.catch(e => { @@ -228,20 +247,11 @@ const Form = { return e; }); return pending; - // names = [].concat(names); - // const fields = this.fields.filter(field => names.indexOf(field.prop) !== -1); - // if (!fields.length) { - // warning(false, 'FormModel', 'please pass correct props!'); - // return; - // } - // fields.forEach(field => { - // field.validate('', cb); - // }); }, }, render() { - const { prefixCls: customizePrefixCls, hideRequiredMark, layout, onSubmit } = this; + const { prefixCls: customizePrefixCls, hideRequiredMark, layout, handleSubmit } = this; const getPrefixCls = this.configProvider.getPrefixCls; const prefixCls = getPrefixCls('form', customizePrefixCls); const { class: className, onSubmit: originSubmit, ...restProps } = this.$attrs; @@ -253,7 +263,7 @@ const Form = { [`${prefixCls}-hide-required-mark`]: hideRequiredMark, }); return ( -
+ {getSlot(this)}
); diff --git a/components/form-model/FormItem.jsx b/components/form-model/FormItem.jsx index cd6fa7a4d..1b19b3bf5 100644 --- a/components/form-model/FormItem.jsx +++ b/components/form-model/FormItem.jsx @@ -1,10 +1,14 @@ -import { inject } from 'vue'; +import { inject, provide, Transition } from 'vue'; import AsyncValidator from 'async-validator'; import cloneDeep from 'lodash/cloneDeep'; import PropTypes from '../_util/vue-types'; -import { ColProps } from '../grid/Col'; +import classNames from 'classnames'; +import getTransitionProps from '../_util/getTransitionProps'; +import Row from '../grid/Row'; +import Col, { ColProps } from '../grid/Col'; import { initDefaultProps, + findDOMNode, getComponent, getOptionProps, getEvents, @@ -13,10 +17,20 @@ import { } from '../_util/props-util'; import BaseMixin from '../_util/BaseMixin'; import { ConfigConsumerProps } from '../config-provider'; -import FormItem from '../form/FormItem'; import { cloneElement } from '../_util/vnode'; +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'; -function noop() {} +const iconMap = { + success: CheckCircleFilled, + warning: ExclamationCircleFilled, + error: CloseCircleFilled, + validating: LoadingOutlined, +}; function getPropByPath(obj, path, strict) { let tempObj = obj; @@ -74,6 +88,7 @@ export default { }), setup() { return { + isFormItemChildren: inject('isFormItemChildren', false), configProvider: inject('configProvider', ConfigConsumerProps), FormContext: inject('FormContext', {}), }; @@ -84,10 +99,16 @@ export default { validateMessage: '', validateDisabled: false, validator: {}, + helpShow: false, }; }, computed: { + fieldId() { + return this.id || (this.FormContext.name && this.prop) + ? `${this.FormContext.name}_${this.prop}` + : undefined; + }, fieldValue() { const model = this.FormContext.model; if (!model || !this.prop) { @@ -111,7 +132,7 @@ export default { return true; }); } - return isRequired; + return isRequired || this.required; }, }, watch: { @@ -119,6 +140,9 @@ export default { this.validateState = val; }, }, + created() { + provide('isFormItemChildren', true); + }, mounted() { if (this.prop) { const { addField } = this.FormContext; @@ -131,35 +155,188 @@ export default { removeField && removeField(this); }, methods: { - validate(trigger, callback = noop) { + 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)), + ); + + return subResults.reduce((prev, errors) => [...prev, ...errors], []); + } + return result; + }, + validateRules(namePath, value, rules, validateFirst) { + 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 this.validateRule(name, value, filledRules[i]); + if (errors.length) { + resolve(errors); + return; + } + } + /* 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); + }); + } + + // 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) { - callback(); - return true; + return; } this.validateState = 'validating'; - const descriptor = {}; 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 model = {}; - model[this.prop] = this.fieldValue; - validator.validate(model, {}, errors => { - this.validateState = errors ? 'error' : 'success'; - this.validateMessage = errors ? errors[0].message : ''; - callback(errors); - this.FormContext && - this.FormContext.$emit && - this.FormContext.$emit('validate', this.prop, !errors, this.validateMessage || null); - }); + // 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); + 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, + }); + }); + 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; @@ -219,21 +396,200 @@ export default { this.validateDisabled = false; }); }, + getHelpMessage() { + const help = getComponent(this, 'help'); + + return this.validateMessage || help; + }, + + onLabelClick() { + const id = this.fieldId; + if (!id) { + return; + } + const formItemNode = findDOMNode(this); + const control = formItemNode.querySelector(`[id="${id}"]`); + if (control && control.focus) { + control.focus(); + } + }, + + onHelpAnimEnd(_key, helpShow) { + this.helpShow = helpShow; + if (!helpShow) { + this.$forceUpdate(); + } + }, + + renderHelp(prefixCls) { + const help = this.getHelpMessage(); + const children = help ? ( +
+ {help} +
+ ) : null; + if (children) { + this.helpShow = !!children; + } + const transitionProps = getTransitionProps('show-help', { + onAfterEnter: () => this.onHelpAnimEnd('help', true), + onAfterLeave: () => this.onHelpAnimEnd('help', false), + }); + return ( + + {children} + + ); + }, + + renderExtra(prefixCls) { + const extra = getComponent(this, 'extra'); + return extra ?
{extra}
: null; + }, + + renderValidateWrapper(prefixCls, c1, c2, c3) { + const validateStatus = this.validateState; + + let classes = `${prefixCls}-item-control`; + if (validateStatus) { + classes = classNames(`${prefixCls}-item-control`, { + 'has-feedback': this.hasFeedback || validateStatus === 'validating', + 'has-success': validateStatus === 'success', + 'has-warning': validateStatus === 'warning', + 'has-error': validateStatus === 'error', + 'is-validating': validateStatus === 'validating', + }); + } + const IconNode = validateStatus && iconMap[validateStatus]; + + const icon = + this.hasFeedback && IconNode ? ( + + + + ) : null; + return ( +
+ + {c1} + {icon} + + {c2} + {c3} +
+ ); + }, + + renderWrapper(prefixCls, children) { + const { wrapperCol: contextWrapperCol } = this.isFormItemChildren ? {} : this.FormContext; + const { wrapperCol } = this; + const mergedWrapperCol = wrapperCol || contextWrapperCol || {}; + const { style, id, ...restProps } = mergedWrapperCol; + const className = classNames(`${prefixCls}-item-control-wrapper`, mergedWrapperCol.class); + const colProps = { + ...restProps, + class: className, + key: 'wrapper', + style, + id, + }; + return {children}; + }, + + renderLabel(prefixCls) { + const { + vertical, + labelAlign: contextLabelAlign, + labelCol: contextLabelCol, + colon: contextColon, + } = this.FormContext; + const { labelAlign, labelCol, colon, fieldId, htmlFor } = this; + const label = getComponent(this, 'label'); + const required = this.isRequired; + const mergedLabelCol = labelCol || contextLabelCol || {}; + + const mergedLabelAlign = labelAlign || contextLabelAlign; + const labelClsBasic = `${prefixCls}-item-label`; + const labelColClassName = classNames( + labelClsBasic, + mergedLabelAlign === 'left' && `${labelClsBasic}-left`, + mergedLabelCol.class, + ); + const { + class: labelColClass, + style: labelColStyle, + id: labelColId, + ...restProps + } = mergedLabelCol; + let labelChildren = label; + // Keep label is original where there should have no colon + const computedColon = colon === true || (contextColon !== false && colon !== false); + const haveColon = computedColon && !vertical; + // Remove duplicated user input colon + if (haveColon && typeof label === 'string' && label.trim() !== '') { + labelChildren = label.replace(/[::]\s*$/, ''); + } + + const labelClassName = classNames({ + [`${prefixCls}-item-required`]: required, + [`${prefixCls}-item-no-colon`]: !computedColon, + }); + const colProps = { + ...restProps, + class: labelColClassName, + key: 'label', + style: labelColStyle, + id: labelColId, + }; + + return label ? ( + + + + ) : null; + }, + renderChildren(prefixCls, child) { + return [ + this.renderLabel(prefixCls), + this.renderWrapper( + prefixCls, + this.renderValidateWrapper( + prefixCls, + child, + this.renderHelp(prefixCls), + this.renderExtra(prefixCls), + ), + ), + ]; + }, + renderFormItem(child) { + const { prefixCls: customizePrefixCls } = this.$props; + const { class: className, ...restProps } = this.$attrs; + const getPrefixCls = this.configProvider.getPrefixCls; + const prefixCls = getPrefixCls('form', customizePrefixCls); + const children = this.renderChildren(prefixCls, child); + const itemClassName = { + [className]: className, + [`${prefixCls}-item`]: true, + [`${prefixCls}-item-with-help`]: this.helpShow, + }; + + return ( + + {children} + + ); + }, }, render() { - const { autoLink, ...props } = getOptionProps(this); - const label = getComponent(this, 'label'); - const extra = getComponent(this, 'extra'); - const help = getComponent(this, 'help'); - const formProps = { - ...this.$attrs, - ...props, - label, - extra, - validateStatus: this.validateState, - help: this.validateMessage || help, - required: this.isRequired || props.required, - }; + const { autoLink } = getOptionProps(this); const children = getSlot(this); let firstChildren = children[0]; if (this.prop && autoLink && isValidElement(firstChildren)) { @@ -241,6 +597,7 @@ export default { const originalBlur = originalEvents.onBlur; const originalChange = originalEvents.onChange; firstChildren = cloneElement(firstChildren, { + ...(this.fieldId ? { id: this.fieldId } : undefined), onBlur: (...args) => { originalBlur && originalBlur(...args); this.onFieldBlur(); @@ -257,11 +614,6 @@ export default { }, }); } - return ( - - {firstChildren} - {children.slice(1)} - - ); + return this.renderFormItem([firstChildren, children.slice(1)]); }, }; diff --git a/components/form-model/messages.js b/components/form-model/messages.js new file mode 100644 index 000000000..392b3ebec --- /dev/null +++ b/components/form-model/messages.js @@ -0,0 +1,49 @@ +const typeTemplate = "'${name}' is not a valid ${type}"; + +export const defaultValidateMessages = { + default: "Validation error on field '${name}'", + required: "'${name}' is required", + enum: "'${name}' must be one of [${enum}]", + whitespace: "'${name}' cannot be empty", + date: { + format: "'${name}' is invalid for format date", + parse: "'${name}' could not be parsed as date", + invalid: "'${name}' is invalid date", + }, + types: { + string: typeTemplate, + method: typeTemplate, + array: typeTemplate, + object: typeTemplate, + number: typeTemplate, + date: typeTemplate, + boolean: typeTemplate, + integer: typeTemplate, + float: typeTemplate, + regexp: typeTemplate, + email: typeTemplate, + url: typeTemplate, + hex: typeTemplate, + }, + string: { + len: "'${name}' must be exactly ${len} characters", + min: "'${name}' must be at least ${min} characters", + max: "'${name}' cannot be longer than ${max} characters", + range: "'${name}' must be between ${min} and ${max} characters", + }, + number: { + len: "'${name}' must equal ${len}", + min: "'${name}' cannot be less than ${min}", + max: "'${name}' cannot be greater than ${max}", + range: "'${name}' must be between ${min} and ${max}", + }, + array: { + len: "'${name}' must be exactly ${len} in length", + min: "'${name}' cannot be less than ${min} in length", + max: "'${name}' cannot be greater than ${max} in length", + range: "'${name}' must be between ${min} and ${max} in length", + }, + pattern: { + mismatch: "'${name}' does not match pattern ${pattern}", + }, +}; diff --git a/components/form-model/utils.js b/components/form-model/utils.js index 9c95f47a6..93fe350eb 100644 --- a/components/form-model/utils.js +++ b/components/form-model/utils.js @@ -102,3 +102,42 @@ 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); + + return errors; + }); +} + +export 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([]); + } + }); + }); + }); +} + +export function toArray(value) { + if (value === undefined || value === null) { + return []; + } + + return Array.isArray(value) ? value : [value]; +} + +export function getNamePath(path) { + return toArray(path); +}