Feat/form (#342)

* feat(form): add form component

fix #125

* test(form): add test code

* docs(form): add form doc

* feat: add uitls merge

* fix(form): fix style

* test(form): add form test code

* refactor(form): review changes

* test(form): use idiomatic vue-test-util methods

* feat(core): bump vue version

* feat(form): rewrite label wrap

* feat(form): fix tons of bugs

* fix(form): reuse ts extension

* refactor(form): move out label width computation

* fix(form): fix tons of bugs

* fix(form): test

Co-authored-by: 286506460 <286506460@qq.com>
This commit is contained in:
Herrington Darkholme 2020-10-03 16:02:53 +08:00 committed by GitHub
parent 128436214e
commit 62f1135768
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 1815 additions and 1 deletions

View File

@ -37,6 +37,7 @@ import ElDialog from '@element-plus/dialog'
import ElCalendar from '@element-plus/calendar'
import ElInfiniteScroll from '@element-plus/infinite-scroll'
import ElDrawer from '@element-plus/drawer'
import ElForm from '@element-plus/form'
import ElUpload from '@element-plus/upload'
import ElTree from '@element-plus/tree'
@ -78,6 +79,7 @@ export {
ElCalendar,
ElInfiniteScroll,
ElDrawer,
ElForm,
ElUpload,
ElTree,
}

View File

@ -38,6 +38,7 @@
"@element-plus/collapse": "^0.0.0",
"@element-plus/time-picker": "^0.0.0",
"@element-plus/tabs": "^0.0.0",
"@element-plus/form": "^0.0.0",
"@element-plus/tree": "^0.0.0"
}
}

File diff suppressed because it is too large Load Diff

10
packages/form/index.ts Normal file
View File

@ -0,0 +1,10 @@
import { App } from 'vue'
import Form from './src/form.vue'
import FormItem from './src/form-item.vue'
import LabelWrap from './src/label-wrap.vue'
export default (app: App): void => {
app.component(Form.name, Form)
app.component(FormItem.name, FormItem)
app.component(LabelWrap.name, LabelWrap)
}

View File

@ -0,0 +1,15 @@
{
"name": "@element-plus/form",
"version": "0.0.0",
"main": "dist/index.js",
"license": "MIT",
"peerDependencies": {
"vue": "^3.0.0-rc.5"
},
"devDependencies": {
"@vue/test-utils": "^2.0.0-beta.0"
},
"dependencies": {
"async-validator": "^3.4.0"
}
}

View File

@ -0,0 +1,374 @@
<template>
<div
class="el-form-item"
:class="formItemClass"
>
<LabelWrap
:is-auto-width="labelStyle.width === 'auto'"
:update-all="elForm.labelWidth === 'auto'"
>
<label
v-if="label || $slots.label"
:for="labelFor"
class="el-form-item__label"
:style="labelStyle"
>
<slot name="label">{{ label + elForm.labelSuffix }}</slot>
</label>
</LabelWrap>
<div class="el-form-item__content" :style="contentStyle">
<slot></slot>
<transition name="el-zoom-in-top">
<slot
v-if="shouldShowError"
name="error"
:error="validateMessage"
>
<div
class="el-form-item__error"
:class="{
'el-form-item__error--inline':
typeof inlineMessage === 'boolean'
? inlineMessage
: elForm.inlineMessage || false
}"
>
{{ validateMessage }}
</div>
</slot>
</transition>
</div>
</div>
</template>
<script lang="ts">
import {
defineComponent,
provide,
inject,
ref,
watch,
computed,
nextTick,
onMounted,
onBeforeUnmount,
getCurrentInstance,
toRefs,
reactive,
PropType,
} from 'vue'
import AsyncValidator from 'async-validator'
import { RuleItem } from 'async-validator'
import LabelWrap from './label-wrap'
import { getPropByPath, useGlobalConfig } from '@element-plus/utils/util'
import mitt from 'mitt'
import { elFormKey, elFormItemKey, ValidateFieldCallback } from './token'
export default defineComponent({
name: 'ElFormItem',
componentName: 'ElFormItem',
components: {
LabelWrap,
},
props: {
label: String,
labelWidth: String,
prop: String,
required: {
type: Boolean,
default: undefined,
},
rules: [Object, Array] as PropType<RuleItem | RuleItem[]>,
error: String,
validateStatus: String,
for: String,
inlineMessage: {
type: [String, Boolean],
default: '',
},
showMessage: {
type: Boolean,
default: true,
},
size: String,
},
setup(props) {
const formItemMitt = mitt()
const $ELEMENT = useGlobalConfig()
const elForm = inject(elFormKey)
const validateState = ref('')
const validateMessage = ref('')
const validateDisabled = ref(false)
const computedLabelWidth = ref('')
const vm = getCurrentInstance()
const isNested = computed(() => {
let parent = vm.parent
while (parent && parent.type.name !== 'ElForm') {
if (parent.type.name === 'ElFormItem') {
return true
}
parent = parent.parent
}
return false
})
let initialValue = undefined
watch(
() => props.error,
val => {
validateMessage.value = val
validateState.value = val ? 'error' : ''
}, {
immediate: true,
},
)
watch(
() => props.validateStatus,
val => {
validateState.value = val
},
)
const labelFor = computed(() => props.for || props.prop)
const labelStyle = computed(() => {
if (elForm.labelPosition === 'top') return {}
const labelWidth = props.labelWidth || elForm.labelWidth
if (labelWidth) {
return {
width: labelWidth,
}
}
return {}
})
const contentStyle = computed(() => {
if (elForm.labelPosition === 'top' || elForm.inline) {
return {}
}
if (!props.label && !props.labelWidth && isNested.value) {
return {}
}
const labelWidth = props.labelWidth || elForm.labelWidth
const ret: Partial<CSSStyleDeclaration> = {}
if (labelWidth === 'auto') {
if (props.labelWidth === 'auto') {
ret.marginLeft = computedLabelWidth.value
} else if (elForm.labelWidth === 'auto') {
ret.marginLeft = elForm.autoLabelWidth
}
} else {
ret.marginLeft = labelWidth
}
return ret
})
const fieldValue = computed(() => {
const model = elForm.model
if (!model || !props.prop) {
return
}
let path = props.prop
if (path.indexOf(':') !== -1) {
path = path.replace(/:/, '.')
}
return getPropByPath(model, path, true).v
})
const isRequired = computed(() => {
let rules = getRules()
let required = false
if (rules && rules.length) {
rules.every(rule => {
if (rule.required) {
required = true
return false
}
return true
})
}
return required
})
const elFormItemSize = computed(() => props.size || elForm.size)
const sizeClass = computed(() => {
return elFormItemSize.value || $ELEMENT.size
})
const validate = (trigger: string, callback?: ValidateFieldCallback) => {
validateDisabled.value = false
const rules = getFilteredRule(trigger)
if ((!rules || rules.length === 0) && props.required === undefined) {
callback()
return
}
validateState.value = 'validating'
const descriptor = {}
if (rules && rules.length > 0) {
rules.forEach(rule => {
delete rule.trigger
})
}
descriptor[props.prop] = rules
const validator = new AsyncValidator(descriptor)
const model = {}
model[props.prop] = fieldValue.value
validator.validate(
model,
{ firstFields: true },
(errors, invalidFields) => {
validateState.value = !errors ? 'success' : 'error'
validateMessage.value = errors ? errors[0].message : ''
callback(validateMessage.value, invalidFields)
elForm?.emit(
'validate',
props.prop,
!errors,
validateMessage.value || null,
)
},
)
}
const clearValidate = () => {
validateState.value = ''
validateMessage.value = ''
validateDisabled.value = false
}
const resetField = () => {
validateState.value = ''
validateMessage.value = ''
let model = elForm.model
let value = fieldValue.value
let path = props.prop
if (path.indexOf(':') !== -1) {
path = path.replace(/:/, '.')
}
let prop = getPropByPath(model, path, true)
validateDisabled.value = true
if (Array.isArray(value)) {
prop.o[prop.k] = [].concat(initialValue)
} else {
prop.o[prop.k] = initialValue
}
// reset validateDisabled after onFieldChange triggered
nextTick(() => {
validateDisabled.value = false
})
}
const getRules = () => {
const formRules = elForm.rules
const selfRules = props.rules
const requiredRule =
props.required !== undefined ? { required: !!props.required } : []
const prop = getPropByPath(formRules, props.prop || '', true)
const normalizedRule = formRules
? (prop.o[props.prop || ''] || prop.v)
: []
return [].concat(selfRules || normalizedRule || []).concat(requiredRule)
}
const getFilteredRule = trigger => {
const rules = 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 }))
}
const onFieldBlur = () => {
validate('blur')
}
const onFieldChange = () => {
if (validateDisabled.value) {
validateDisabled.value = false
return
}
validate('change')
}
const updateComputedLabelWidth = width => {
computedLabelWidth.value = width ? `${width}px` : ''
}
const addValidateEvents = () => {
const rules = getRules()
if (rules.length || props.required !== undefined) {
formItemMitt.on('el.form.blur', onFieldBlur)
formItemMitt.on('el.form.change', onFieldChange)
}
}
const removeValidateEvents = () => {
formItemMitt.off('el.form.blur', onFieldBlur)
formItemMitt.off('el.form.change', onFieldChange)
}
const elFormItem = reactive({
...toRefs(props),
removeValidateEvents,
addValidateEvents,
resetField,
clearValidate,
validate,
formItemMitt,
updateComputedLabelWidth,
})
onMounted(() => {
if (props.prop) {
elForm.formMitt.emit('el.form.addField', elFormItem)
let value = fieldValue.value
initialValue = Array.isArray(value)
? [...value] : value
addValidateEvents()
}
})
onBeforeUnmount(() => {
elForm.formMitt.emit('el.form.removeField', elFormItem)
})
provide(elFormItemKey, elFormItem)
const formItemClass = computed(() => [
{
'el-form-item--feedback': elForm && elForm.statusIcon,
'is-error': validateState.value === 'error',
'is-validating': validateState.value === 'validating',
'is-success': validateState.value === 'success',
'is-required': isRequired.value || props.required,
'is-no-asterisk': elForm && elForm.hideRequiredAsterisk,
},
sizeClass.value ? 'el-form-item--' + sizeClass.value : '',
])
const shouldShowError = computed(() => {
return validateState.value === 'error' && props.showMessage && elForm.showMessage
})
return {
formItemClass,
shouldShowError,
elForm,
labelStyle,
contentStyle,
validateMessage,
labelFor,
}
},
})
</script>

224
packages/form/src/form.vue Normal file
View File

@ -0,0 +1,224 @@
<template>
<form
class="el-form"
:class="[
labelPosition ? 'el-form--label-' + labelPosition : '',
{ 'el-form--inline': inline }
]"
>
<slot></slot>
</form>
</template>
<script lang="ts">
import {
defineComponent, provide, watch, ref,
computed, reactive, toRefs,
} from 'vue'
import mitt from 'mitt'
import {
elFormKey, ElFormItemContext as FormItemCtx,
elFormEvents, ValidateFieldCallback,
} from './token'
import { FieldErrorList } from 'async-validator'
function useFormLabelWidth() {
const potentialLabelWidthArr = ref([])
const autoLabelWidth = computed(() => {
if (!potentialLabelWidthArr.value.length) return '0'
const max = Math.max(...potentialLabelWidthArr.value)
return max ? `${max}px` : ''
})
function getLabelWidthIndex(width: number) {
const index = potentialLabelWidthArr.value.indexOf(width)
// it's impossible
if (index === -1) {
throw new Error('[ElementForm]unpected width ' + width)
}
return index
}
function registerLabelWidth(val: number, oldVal: number) {
if (val && oldVal) {
const index = getLabelWidthIndex(oldVal)
potentialLabelWidthArr.value.splice(index, 1, val)
} else if (val) {
potentialLabelWidthArr.value.push(val)
}
}
function deregisterLabelWidth(val: number) {
const index = getLabelWidthIndex(val)
potentialLabelWidthArr.value.splice(index, 1)
}
return {
autoLabelWidth,
registerLabelWidth,
deregisterLabelWidth,
}
}
interface Callback {
(isValid?: boolean, invalidFields?: FieldErrorList): void
}
export default defineComponent({
name: 'ElForm',
props: {
model: Object,
rules: Object,
labelPosition: String,
labelWidth: String,
labelSuffix: {
type: String,
default: '',
},
inline: Boolean,
inlineMessage: Boolean,
statusIcon: Boolean,
showMessage: {
type: Boolean,
default: true,
},
size: String,
disabled: Boolean,
validateOnRuleChange: {
type: Boolean,
default: true,
},
hideRequiredAsterisk: {
type: Boolean,
default: false,
},
},
setup(props, { emit }) {
const formMitt = mitt()
const fields: FormItemCtx[] = []
watch(
() => props.rules,
() => {
fields.forEach(field => {
field.removeValidateEvents()
field.addValidateEvents()
})
if (props.validateOnRuleChange) {
validate(() => ({}))
}
},
)
formMitt.on<FormItemCtx>(elFormEvents.addField, field => {
if (field) {
fields.push(field)
}
})
formMitt.on<FormItemCtx>(elFormEvents.removeField, field => {
if (field.prop) {
fields.splice(fields.indexOf(field), 1)
}
})
const resetFields = () => {
if (!props.model) {
console.warn(
'[Element Warn][Form]model is required for resetFields to work.',
)
return
}
fields.forEach(field => {
field.resetField()
})
}
const clearValidate = (props: string | string[] = []) => {
const fds = props.length
? typeof props === 'string'
? fields.filter(field => props === field.prop)
: fields.filter(field => props.indexOf(field.prop) > -1)
: fields
fds.forEach(field => {
field.clearValidate()
})
}
const validate = (callback?: Callback) => {
if (!props.model) {
console.warn(
'[Element Warn][Form]model is required for validate to work!',
)
return
}
let promise: Promise<boolean> | undefined
// if no callback, return promise
if (typeof callback !== 'function') {
promise = new Promise((resolve, reject) => {
callback = function(valid, invalidFields) {
if (valid) {
resolve(true)
} else {
reject(invalidFields)
}
}
})
}
if (fields.length === 0) {
callback(true)
}
let valid = true
let count = 0
let invalidFields = {}
for (const field of fields) {
field.validate('', (message, field) => {
if (message) {
valid = false
}
invalidFields = { ...invalidFields, ...field }
if (++count === fields.length) {
callback(valid, invalidFields)
}
})
}
return promise
}
const validateField = (props: string|string[], cb: ValidateFieldCallback) => {
props = [].concat(props)
const fds = fields.filter(field => props.indexOf(field.prop) !== -1)
if (!fields.length) {
console.warn('[Element Warn]please pass correct props!')
return
}
fds.forEach(field => {
field.validate('', cb)
})
}
const elForm = reactive({
formMitt,
...toRefs(props),
resetFields,
clearValidate,
validateField,
emit,
...useFormLabelWidth(),
})
provide(elFormKey, elForm)
return {
validate, // export
resetFields,
clearValidate,
validateField,
}
},
})
</script>

View File

@ -0,0 +1,88 @@
import {
defineComponent,
h,
inject,
ref,
watch,
onMounted,
onUpdated,
onBeforeUnmount,
nextTick,
} from 'vue'
import {
elFormKey, elFormItemKey,
} from './token'
export default defineComponent({
props: {
isAutoWidth: Boolean,
updateAll: Boolean,
},
setup(props, { slots }) {
const el = ref<Nullable<HTMLElement>>(null)
const elForm = inject(elFormKey)
const elFormItem = inject(elFormItemKey)
const computedWidth = ref(0)
watch(computedWidth, (val, oldVal) => {
if (props.updateAll) {
elForm.registerLabelWidth(val, oldVal)
elFormItem.updateComputedLabelWidth(val)
}
})
const getLabelWidth = () => {
if (el.value?.firstElementChild) {
const width = window.getComputedStyle(el.value.firstElementChild)
.width
return Math.ceil(parseFloat(width))
} else {
return 0
}
}
const updateLabelWidth = (action = 'update') => {
nextTick(() => {
if (slots.default && props.isAutoWidth) {
if (action === 'update') {
computedWidth.value = getLabelWidth()
} else if (action === 'remove') {
elForm.deregisterLabelWidth(computedWidth.value)
}
}
})
}
onMounted(() => updateLabelWidth('update'))
onUpdated(() => updateLabelWidth('update'))
onBeforeUnmount(() => updateLabelWidth('remove'))
function render() {
if (!slots) return null
if (props.isAutoWidth) {
const autoLabelWidth = elForm.autoLabelWidth
const style = {} as CSSStyleDeclaration
if (autoLabelWidth && autoLabelWidth !== 'auto') {
const marginLeft = parseInt(autoLabelWidth, 10) - computedWidth.value
if (marginLeft) {
style.marginLeft = marginLeft + 'px'
}
}
return h(
'div',
{
ref: el,
class: ['el-form-item__label-wrap'],
style,
},
slots.default?.(),
)
} else {
return h('div', { ref: el }, slots.default?.())
}
}
return render
},
})

View File

@ -0,0 +1,48 @@
import type { InjectionKey } from 'vue'
import type { Emitter } from 'mitt'
import type {
FieldErrorList,
} from 'async-validator'
export interface ElFormContext {
registerLabelWidth(width: number, oldWidth: number): void
deregisterLabelWidth(width: number): void
autoLabelWidth: string | undefined
formMitt: Emitter
emit: (evt: string, ...args: any[]) => void
labelSuffix: string
inline?: boolean
model?: Record<string, unknown>
size?: string
showMessage?: boolean
labelPosition?: string
labelWidth?: string
rules?: Record<string, unknown>
statusIcon?: boolean
hideRequiredAsterisk?: boolean
}
export interface ValidateFieldCallback {
(message?: string, invalidFields?: FieldErrorList): void
}
export interface ElFormItemContext {
prop?: string
validate(trigger?: string, callback?: ValidateFieldCallback): void
updateComputedLabelWidth(width: number): void
addValidateEvents(): void
removeValidateEvents(): void
resetField(): void
clearValidate(): void
}
// TODO: change it to symbol
export const elFormKey: InjectionKey<ElFormContext> = 'elForm' as any
export const elFormItemKey: InjectionKey<ElFormItemContext> = 'elFormItem' as any
export const elFormEvents = {
addField: 'el.form.addField',
removeField: 'el.form.removeField',
} as const

View File

@ -0,0 +1,13 @@
import { config } from '@vue/test-utils'
const stylePlugin = (wrapper: any) => {
return {
style: wrapper.element.style,
}
}
export default function install() {
config.plugins.DOMWrapper.install(stylePlugin)
config.plugins.VueWrapper.install(stylePlugin)
}

View File

@ -16,6 +16,7 @@ import {
import isServer from './isServer'
import type { AnyFunction } from './types'
import type { Ref } from 'vue'
import { getCurrentInstance } from 'vue'
export type PartialCSSStyleDeclaration = Partial<
Pick<CSSStyleDeclaration, 'transform' | 'transition' | 'animation'>
@ -171,3 +172,11 @@ export function isUndefined(val: any) {
}
export { isVNode } from 'vue'
export function useGlobalConfig() {
const vm: any = getCurrentInstance()
if ('$ELEMENT' in vm.proxy) {
return vm.proxy.$ELEMENT
}
return {}
}

11
typings/vue-test-utils.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
import { ComponentPublicInstance } from '@vue/test-utils'
declare module '@vue/test-utils' {
interface DOMWrapper<ElementType> {
style: CSSStyleDeclaration
}
interface VueWrapper<T extends ComponentPublicInstance> {
style: CSSStyleDeclaration
}
}

View File

@ -9,6 +9,6 @@
export default {
data() {
return {}
}
},
}
</script>

View File

@ -3250,6 +3250,11 @@ async-settle@^1.0.0:
dependencies:
async-done "^1.2.2"
async-validator@^3.4.0:
version "3.4.0"
resolved "https://registry.yarnpkg.com/async-validator/-/async-validator-3.4.0.tgz#871b3e594124bf4c4eb7bcd1a9e78b44f3b09cae"
integrity sha512-VrFk4eYiJAWKskEz115iiuCf9O0ftnMMPXrOFMqyzGH2KxO7YwncKyn/FgOOP+0MDHMfXL7gLExagCutaZGigA==
async@^2.6.2:
version "2.6.3"
resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"