From 4c3feca9c5f2838c3f3fe76018046940d241dc43 Mon Sep 17 00:00:00 2001 From: tanjinzhou <415800467@qq.com> Date: Fri, 13 Mar 2020 18:40:26 +0800 Subject: [PATCH] feat: add new form support v-model --- antdv-demo | 2 +- components/form/Form.jsx | 2 +- components/form/FormItem.jsx | 22 +- components/index.js | 3 + components/input/Input.jsx | 1 + components/n-form/Form.jsx | 189 +++++++ components/n-form/FormItem.jsx | 215 ++++++++ components/n-form/__tests__/demo.test.js | 3 + components/n-form/index.jsx | 20 + components/n-form/style/index.js | 5 + components/n-form/style/index.less | 653 +++++++++++++++++++++++ components/n-form/style/mixin.less | 126 +++++ components/style.js | 1 + 13 files changed, 1228 insertions(+), 14 deletions(-) create mode 100755 components/n-form/Form.jsx create mode 100644 components/n-form/FormItem.jsx create mode 100644 components/n-form/__tests__/demo.test.js create mode 100644 components/n-form/index.jsx create mode 100644 components/n-form/style/index.js create mode 100644 components/n-form/style/index.less create mode 100644 components/n-form/style/mixin.less diff --git a/antdv-demo b/antdv-demo index ce10f4f24..9a67d0070 160000 --- a/antdv-demo +++ b/antdv-demo @@ -1 +1 @@ -Subproject commit ce10f4f242cdf252dd25df905bada3b5f7f722e2 +Subproject commit 9a67d0070ac57b29c70624e8a630e39146b44725 diff --git a/components/form/Form.jsx b/components/form/Form.jsx index 8441dc03e..d956d549d 100755 --- a/components/form/Form.jsx +++ b/components/form/Form.jsx @@ -151,7 +151,7 @@ const Form = { }, provide() { return { - FormContextProps: this, + FormContext: this, // https://github.com/vueComponent/ant-design-vue/issues/446 collectFormItemContext: this.form && this.form.templateContext diff --git a/components/form/FormItem.jsx b/components/form/FormItem.jsx index 8731de351..18e96ff48 100644 --- a/components/form/FormItem.jsx +++ b/components/form/FormItem.jsx @@ -75,7 +75,7 @@ export default { }, inject: { isFormItemChildren: { default: false }, - FormContextProps: { default: () => ({}) }, + FormContext: { default: () => ({}) }, decoratorFormProps: { default: () => ({}) }, collectFormItemContext: { default: () => noop }, configProvider: { default: () => ConfigConsumerProps }, @@ -85,7 +85,7 @@ export default { }, computed: { itemSelfUpdate() { - return !!(this.selfUpdate === undefined ? this.FormContextProps.selfUpdate : this.selfUpdate); + return !!(this.selfUpdate === undefined ? this.FormContext.selfUpdate : this.selfUpdate); }, }, created() { @@ -117,8 +117,8 @@ export default { }, methods: { collectContext() { - if (this.FormContextProps.form && this.FormContextProps.form.templateContext) { - const { templateContext } = this.FormContextProps.form; + if (this.FormContext.form && this.FormContext.form.templateContext) { + const { templateContext } = this.FormContext.form; const vnodes = Object.values(templateContext.$slots || {}).reduce((a, b) => { return [...a, ...b]; }, []); @@ -356,9 +356,7 @@ export default { }, renderWrapper(prefixCls, children) { - const { wrapperCol: contextWrapperCol } = this.isFormItemChildren - ? {} - : this.FormContextProps; + const { wrapperCol: contextWrapperCol } = this.isFormItemChildren ? {} : this.FormContext; const { wrapperCol } = this; const mergedWrapperCol = wrapperCol || contextWrapperCol || {}; const { style, id, on, ...restProps } = mergedWrapperCol; @@ -380,7 +378,7 @@ export default { labelAlign: contextLabelAlign, labelCol: contextLabelCol, colon: contextColon, - } = this.FormContextProps; + } = this.FormContext; const { labelAlign, labelCol, colon, id, htmlFor } = this; const label = getComponentFromProp(this, 'label'); const required = this.isRequired(); @@ -481,8 +479,8 @@ export default { } }, decoratorChildren(vnodes) { - const { FormContextProps } = this; - const getFieldDecorator = FormContextProps.form.getFieldDecorator; + const { FormContext } = this; + const getFieldDecorator = FormContext.form.getFieldDecorator; for (let i = 0, len = vnodes.length; i < len; i++) { const vnode = vnodes[i]; if (getSlotOptions(vnode).__ANT_FORM_ITEM) { @@ -510,7 +508,7 @@ export default { decoratorFormProps, fieldDecoratorId, fieldDecoratorOptions = {}, - FormContextProps, + FormContext, } = this; let child = filterEmpty($slots.default || []); if (decoratorFormProps.form && fieldDecoratorId && child.length) { @@ -522,7 +520,7 @@ export default { '`autoFormCreate` just `decorator` then first children. but you can use JSX to support multiple children', ); this.slotDefault = child; - } else if (FormContextProps.form) { + } else if (FormContext.form) { child = cloneVNodes(child); this.slotDefault = this.decoratorChildren(child); } else { diff --git a/components/index.js b/components/index.js index dc581a4e6..5a1f03384 100644 --- a/components/index.js +++ b/components/index.js @@ -56,6 +56,7 @@ import { default as Divider } from './divider'; import { default as Dropdown } from './dropdown'; import { default as Form } from './form'; +import { default as NewForm } from './n-form'; import { default as Icon } from './icon'; @@ -166,6 +167,7 @@ const components = [ Divider, Dropdown, Form, + NewForm, Icon, Input, InputNumber, @@ -254,6 +256,7 @@ export { Divider, Dropdown, Form, + NewForm, Icon, Input, InputNumber, diff --git a/components/input/Input.jsx b/components/input/Input.jsx index 4b82589a1..f852224f5 100644 --- a/components/input/Input.jsx +++ b/components/input/Input.jsx @@ -161,6 +161,7 @@ export default { this.removePasswordTimeout = setTimeout(() => { if ( this.$refs.input && + this.$refs.input.getAttribute && this.$refs.input.getAttribute('type') === 'password' && this.$refs.input.hasAttribute('value') ) { diff --git a/components/n-form/Form.jsx b/components/n-form/Form.jsx new file mode 100755 index 000000000..e46597e55 --- /dev/null +++ b/components/n-form/Form.jsx @@ -0,0 +1,189 @@ +import PropTypes from '../_util/vue-types'; +import classNames from 'classnames'; +import { ColProps } from '../grid/Col'; +import Vue from 'vue'; +import isRegExp from 'lodash/isRegExp'; +import warning from '../_util/warning'; +import createDOMForm from '../vc-form/src/createDOMForm'; +import createFormField from '../vc-form/src/createFormField'; +import FormItem from './FormItem'; +import { FIELD_META_PROP, FIELD_DATA_PROP } from './constants'; +import { initDefaultProps, getListeners } from '../_util/props-util'; +import { ConfigConsumerProps } from '../config-provider'; +import Base from '../base'; + +export const FormProps = { + layout: PropTypes.oneOf(['horizontal', 'inline', 'vertical']), + labelCol: PropTypes.shape(ColProps).loose, + wrapperCol: PropTypes.shape(ColProps).loose, + colon: PropTypes.bool, + labelAlign: PropTypes.oneOf(['left', 'right']), + prefixCls: PropTypes.string, + hideRequiredMark: PropTypes.bool, + model: PropTypes.object, + rules: PropTypes.object, + validateOnRuleChange: PropTypes.bool, +}; + +export const ValidationRule = { + /** validation error message */ + message: PropTypes.string, + /** built-in validation type, available options: https://github.com/yiminghe/async-validator#type */ + type: PropTypes.string, + /** indicates whether field is required */ + required: PropTypes.boolean, + /** treat required fields that only contain whitespace as errors */ + whitespace: PropTypes.boolean, + /** validate the exact length of a field */ + len: PropTypes.number, + /** validate the min length of a field */ + min: PropTypes.number, + /** validate the max length of a field */ + max: PropTypes.number, + /** validate the value from a list of possible values */ + enum: PropTypes.oneOfType([String, PropTypes.arrayOf(String)]), + /** validate from a regular expression */ + pattern: PropTypes.custom(isRegExp), + /** transform a value before validation */ + transform: PropTypes.func, + /** custom validate function (Note: callback must be called) */ + validator: PropTypes.func, +}; + +const Form = { + name: 'ANForm', + props: initDefaultProps(FormProps, { + layout: 'horizontal', + hideRequiredMark: false, + colon: true, + }), + Item: FormItem, + created() { + this.fields = []; + }, + provide() { + return { + FormContext: this, + }; + }, + inject: { + configProvider: { default: () => ConfigConsumerProps }, + }, + watch: { + rules() { + if (this.validateOnRuleChange) { + this.validate(() => {}); + } + }, + }, + computed: { + vertical() { + return this.layout === 'vertical'; + }, + }, + methods: { + addField(field) { + if (field) { + this.fields.push(field); + } + }, + removeField(field) { + if (field.prop) { + this.fields.splice(this.fields.indexOf(field), 1); + } + }, + onSubmit(e) { + if (!getListeners(this).submit) { + e.preventDefault(); + } else { + this.$emit('submit', e); + } + }, + resetFields() { + if (!this.model) { + warning(false, 'NewForm', 'model is required for resetFields to work.'); + return; + } + this.fields.forEach(field => { + field.resetField(); + }); + }, + clearValidate(props = []) { + const fields = props.length + ? typeof props === 'string' + ? this.fields.filter(field => props === field.prop) + : this.fields.filter(field => props.indexOf(field.prop) > -1) + : this.fields; + fields.forEach(field => { + field.clearValidate(); + }); + }, + validate(callback) { + if (!this.model) { + warning(false, 'NewForm', 'model is required for resetFields to work.'); + return; + } + let promise; + // if no callback, return promise + if (typeof callback !== 'function' && window.Promise) { + promise = new window.Promise((resolve, reject) => { + callback = function(valid) { + valid ? resolve(valid) : reject(valid); + }; + }); + } + let valid = true; + let count = 0; + // 如果需要验证的fields为空,调用验证时立刻返回callback + if (this.fields.length === 0 && callback) { + callback(true); + } + let invalidFields = {}; + this.fields.forEach(field => { + field.validate('', (message, field) => { + if (message) { + valid = false; + } + invalidFields = objectAssign({}, invalidFields, field); + if (typeof callback === 'function' && ++count === this.fields.length) { + callback(valid, invalidFields); + } + }); + }); + if (promise) { + return promise; + } + }, + validateField(props, cb) { + props = [].concat(props); + const fields = this.fields.filter(field => props.indexOf(field.prop) !== -1); + if (!fields.length) { + warning(false, 'NewForm', 'please pass correct props!'); + return; + } + fields.forEach(field => { + field.validate('', cb); + }); + }, + }, + + render() { + const { prefixCls: customizePrefixCls, hideRequiredMark, layout, onSubmit, $slots } = this; + const getPrefixCls = this.configProvider.getPrefixCls; + const prefixCls = getPrefixCls('form', customizePrefixCls); + + const formClassName = classNames(prefixCls, { + [`${prefixCls}-horizontal`]: layout === 'horizontal', + [`${prefixCls}-vertical`]: layout === 'vertical', + [`${prefixCls}-inline`]: layout === 'inline', + [`${prefixCls}-hide-required-mark`]: hideRequiredMark, + }); + return ( +
+ {$slots.default} +
+ ); + }, +}; + +export default Form; diff --git a/components/n-form/FormItem.jsx b/components/n-form/FormItem.jsx new file mode 100644 index 000000000..cec3f6f8a --- /dev/null +++ b/components/n-form/FormItem.jsx @@ -0,0 +1,215 @@ +import AsyncValidator from 'async-validator'; +import PropTypes from '../_util/vue-types'; +import { ColProps } from '../grid/Col'; +import { initDefaultProps, getComponentFromProp, getOptionProps } from '../_util/props-util'; +import BaseMixin from '../_util/BaseMixin'; +import { ConfigConsumerProps } from '../config-provider'; +import FormItem from '../form/FormItem'; + +function noop() {} + +function getPropByPath(obj, path, strict) { + let tempObj = obj; + path = path.replace(/\[(\w+)\]/g, '.$1'); + path = path.replace(/^\./, ''); + + let keyArr = path.split('.'); + let i = 0; + for (let len = keyArr.length; i < len - 1; ++i) { + if (!tempObj && !strict) break; + let key = keyArr[i]; + if (key in tempObj) { + tempObj = tempObj[key]; + } else { + if (strict) { + throw new Error('please transfer a valid prop path to form item!'); + } + break; + } + } + return { + o: tempObj, + k: keyArr[i], + v: tempObj ? tempObj[keyArr[i]] : null, + }; +} +export const FormItemProps = { + id: PropTypes.string, + htmlFor: PropTypes.string, + prefixCls: PropTypes.string, + label: PropTypes.any, + help: PropTypes.any, + extra: PropTypes.any, + labelCol: PropTypes.shape(ColProps).loose, + wrapperCol: PropTypes.shape(ColProps).loose, + hasFeedback: PropTypes.bool, + colon: PropTypes.bool, + labelAlign: PropTypes.oneOf(['left', 'right']), + name: PropTypes.string, + rules: PropTypes.array, + required: PropTypes.bool, + validateStatus: PropTypes.oneOf(['', 'success', 'warning', 'error', 'validating']), +}; + +export default { + name: 'ANFormItem', + __ANT_NEW_FORM_ITEM: true, + mixins: [BaseMixin], + props: initDefaultProps(FormItemProps, { + hasFeedback: false, + }), + provide() { + return { + NewFormItemContext: this, + }; + }, + inject: { + configProvider: { default: () => ConfigConsumerProps }, + FormContext: { default: () => ({}) }, + }, + data() { + return { + validateState: this.validateStatus, + validateMessage: '', + validateDisabled: false, + validator: {}, + }; + }, + + computed: { + fieldValue() { + const model = this.FormContext.model; + if (!model || !this.prop) { + return; + } + let path = this.prop; + if (path.indexOf(':') !== -1) { + path = path.replace(/:/g, '.'); + } + return getPropByPath(model, path, true).v; + }, + }, + watch: { + validateStatus(val) { + this.validateState = val; + }, + }, + mounted() { + if (this.prop) { + const { addField } = this.FormContext; + addField && addField(this); + let initialValue = this.fieldValue; + if (Array.isArray(initialValue)) { + initialValue = [...initialValue]; + } + this.initialValue = initialValue; + } + }, + beforeDestroy() { + const { removeField } = this.FormContext; + removeField && removeField(this); + }, + methods: { + validate(trigger, callback = noop) { + this.validateDisabled = false; + const rules = this.getFilteredRule(trigger); + if ((!rules || rules.length === 0) && this.required === undefined) { + callback(); + return true; + } + 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); + const model = {}; + model[this.prop] = this.fieldValue; + validator.validate(model, { firstFields: true }, (errors, invalidFields) => { + this.validateState = errors ? 'error' : 'success'; + this.validateMessage = errors ? errors[0].message : ''; + callback(this.validateMessage, invalidFields); + this.FormContext && + this.FormContext.$emit && + this.FormContext.$emit('validate', this.prop, !errors, this.validateMessage || null); + }); + }, + getRules() { + let formRules = this.FormContext.rules || {}; + const selfRules = this.rules; + const requiredRule = this.required !== undefined ? { required: !!this.required } : []; + const prop = getPropByPath(formRules, this.prop || ''); + formRules = formRules ? prop.o[this.prop || ''] || prop.v : []; + return [...selfRules, ...formRules, ...requiredRule]; + }, + getFilteredRule(trigger) { + const rules = this.getRules(); + return rules + .filter(rule => { + if (!rule.trigger || trigger === '') return true; + if (Array.isArray(rule.trigger)) { + return rule.trigger.indexOf(trigger) > -1; + } else { + return rule.trigger === trigger; + } + }) + .map(rule => ({ ...rule })); + }, + onFieldBlur() { + this.validate('blur'); + }, + onFieldChange() { + if (this.validateDisabled) { + this.validateDisabled = false; + return; + } + this.validate('change'); + }, + clearValidate() { + this.validateState = ''; + this.validateMessage = ''; + this.validateDisabled = false; + }, + resetField() { + this.validateState = ''; + this.validateMessage = ''; + let model = this.FormContext.model || {}; + let value = this.fieldValue; + let path = this.prop; + if (path.indexOf(':') !== -1) { + path = path.replace(/:/, '.'); + } + let prop = getPropByPath(model, path, true); + this.validateDisabled = true; + if (Array.isArray(value)) { + prop.o[prop.k] = [].concat(this.initialValue); + } else { + prop.o[prop.k] = this.initialValue; + } + // reset validateDisabled after onFieldChange triggered + this.$nextTick(() => { + this.validateDisabled = false; + }); + }, + }, + render() { + const { $slots } = this; + const props = getOptionProps(this); + const label = getComponentFromProp(this, 'label'); + const extra = getComponentFromProp(this, 'extra'); + const help = getComponentFromProp(this, 'help'); + const formProps = { + props: { + ...props, + label, + extra, + validateStatus: this.validateState, + help: this.validateMessage || help, + }, + }; + return {$slots.default}; + }, +}; diff --git a/components/n-form/__tests__/demo.test.js b/components/n-form/__tests__/demo.test.js new file mode 100644 index 000000000..8f8e33928 --- /dev/null +++ b/components/n-form/__tests__/demo.test.js @@ -0,0 +1,3 @@ +import demoTest from '../../../tests/shared/demoTest'; + +demoTest('n-form'); diff --git a/components/n-form/index.jsx b/components/n-form/index.jsx new file mode 100644 index 000000000..afc701b07 --- /dev/null +++ b/components/n-form/index.jsx @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import Form from './Form'; +import ref from 'vue-ref'; +import FormDecoratorDirective from '../_util/FormDecoratorDirective'; +import Base from '../base'; + +Vue.use(ref, { name: 'ant-ref' }); +Vue.use(FormDecoratorDirective); + +export { FormProps, ValidationRule } from './Form'; +export { FormItemProps } from './FormItem'; + +/* istanbul ignore next */ +Form.install = function(Vue) { + Vue.use(Base); + Vue.component(Form.name, Form); + Vue.component(Form.Item.name, Form.Item); +}; + +export default Form; diff --git a/components/n-form/style/index.js b/components/n-form/style/index.js new file mode 100644 index 000000000..30c84ff81 --- /dev/null +++ b/components/n-form/style/index.js @@ -0,0 +1,5 @@ +import '../../style/index.less'; +import './index.less'; + +// style dependencies +import '../../grid/style'; diff --git a/components/n-form/style/index.less b/components/n-form/style/index.less new file mode 100644 index 000000000..1967010e3 --- /dev/null +++ b/components/n-form/style/index.less @@ -0,0 +1,653 @@ +@import '../../style/themes/index'; +@import '../../style/mixins/index'; +@import '../../input/style/mixin'; +@import '../../button/style/mixin'; +@import '../../grid/style/mixin'; +@import './mixin'; + +@form-prefix-cls: ~'@{ant-prefix}-form'; +@form-component-height: @input-height-base; +@form-component-max-height: @input-height-lg; +@form-feedback-icon-size: @font-size-base; +@form-help-margin-top: (@form-component-height - @form-component-max-height) / 2 + 2px; +@form-explain-font-size: @font-size-base; +// Extends additional 1px to fix precision issue. +// https://github.com/ant-design/ant-design/issues/12803 +// https://github.com/ant-design/ant-design/issues/8220 +@form-explain-precision: 1px; +@form-explain-height: floor(@form-explain-font-size * @line-height-base); + +.@{form-prefix-cls} { + .reset-component; + .reset-form; +} + +.@{form-prefix-cls}-item-required::before { + display: inline-block; + margin-right: 4px; + color: @label-required-color; + font-size: @font-size-base; + font-family: SimSun, sans-serif; + line-height: 1; + content: '*'; + .@{form-prefix-cls}-hide-required-mark & { + display: none; + } +} + +.@{form-prefix-cls}-item-label > label { + color: @label-color; + + &::after { + & when (@form-item-trailing-colon=true) { + content: ':'; + } + & when not (@form-item-trailing-colon=true) { + content: ' '; + } + + position: relative; + top: -0.5px; + margin: 0 @form-item-label-colon-margin-right 0 @form-item-label-colon-margin-left; + } + + &.@{form-prefix-cls}-item-no-colon::after { + content: ' '; + } +} + +// Form items +// You should wrap labels and controls in .@{form-prefix-cls}-item for optimum spacing +.@{form-prefix-cls}-item { + label { + position: relative; + + > .@{iconfont-css-prefix} { + font-size: @font-size-base; + vertical-align: top; + } + } + + .reset-component; + + margin-bottom: @form-item-margin-bottom; + vertical-align: top; + + &-control { + position: relative; + line-height: @form-component-max-height; + .clearfix; + } + + &-children { + position: relative; + } + + &-with-help { + margin-bottom: max(0, @form-item-margin-bottom - @form-explain-height - @form-help-margin-top); + } + + &-label { + display: inline-block; + overflow: hidden; + line-height: @form-component-max-height - 0.0001px; + white-space: nowrap; + text-align: right; + vertical-align: middle; + + &-left { + text-align: left; + } + } + + .@{ant-prefix}-switch { + margin: 2px 0 4px; + } +} + +.@{form-prefix-cls}-explain, +.@{form-prefix-cls}-extra { + clear: both; + min-height: @form-explain-height + @form-explain-precision; + margin-top: @form-help-margin-top; + color: @text-color-secondary; + font-size: @form-explain-font-size; + line-height: @line-height-base; + transition: color 0.3s @ease-out; // sync input color transition +} + +.@{form-prefix-cls}-explain { + margin-bottom: -@form-explain-precision; +} + +.@{form-prefix-cls}-extra { + padding-top: 4px; +} + +.@{form-prefix-cls}-text { + display: inline-block; + padding-right: 8px; +} + +.@{form-prefix-cls}-split { + display: block; + text-align: center; +} + +form { + .has-feedback { + .@{ant-prefix}-input { + padding-right: @input-padding-horizontal-base + @input-affix-width; + } + + // https://github.com/ant-design/ant-design/issues/19884 + .@{ant-prefix}-input-affix-wrapper { + .@{ant-prefix}-input-suffix { + padding-right: 18px; + } + .@{ant-prefix}-input { + padding-right: @input-padding-horizontal-base + @input-affix-width * 2; + } + &.@{ant-prefix}-input-affix-wrapper-input-with-clear-btn { + .@{ant-prefix}-input { + padding-right: @input-padding-horizontal-base + @input-affix-width * 3; + } + } + } + + // Fix overlapping between feedback icon and