From 32b5c3d5097653a1a4cf3b8e9becb6a75d42eff4 Mon Sep 17 00:00:00 2001 From: wangyupei <2311595895@qq.com> Date: Wed, 20 Apr 2022 09:45:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(Form):=20=E5=AE=8C=E6=88=90=E5=9F=BA?= =?UTF-8?q?=E6=9C=AC=E7=9A=84=E6=A0=A1=E9=AA=8C=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/form-control/form-control.scss | 23 +++- .../components/form-control/form-control.tsx | 102 ++------------ .../form-control/use-form-control.ts | 14 +- .../components/form-item/form-item-types.ts | 33 ++++- .../src/components/form-item/form-item.tsx | 99 +++----------- .../src/components/form-item/use-form-item.ts | 125 +++++++++++++++++- .../components/form-label/form-label-types.ts | 4 - .../src/components/form-label/form-label.tsx | 2 +- .../components/form-label/use-form-label.ts | 15 ++- .../form-operation/form-operation.tsx | 6 +- .../src/composables/use-field-collection.ts | 16 +++ .../src/composables/use-form-validation.ts | 64 +++++++++ .../devui-vue/devui/form/src/form-types.ts | 36 +++-- packages/devui-vue/devui/form/src/form.tsx | 11 ++ packages/devui-vue/devui/input/src/input.scss | 6 + packages/devui-vue/devui/input/src/input.tsx | 36 +++-- .../devui-vue/docs/components/form/index.md | 84 ++++++++++-- 17 files changed, 437 insertions(+), 239 deletions(-) create mode 100644 packages/devui-vue/devui/form/src/composables/use-field-collection.ts create mode 100644 packages/devui-vue/devui/form/src/composables/use-form-validation.ts diff --git a/packages/devui-vue/devui/form/src/components/form-control/form-control.scss b/packages/devui-vue/devui/form/src/components/form-control/form-control.scss index c71b603c86..c9e7448f5e 100644 --- a/packages/devui-vue/devui/form/src/components/form-control/form-control.scss +++ b/packages/devui-vue/devui/form/src/components/form-control/form-control.scss @@ -1,3 +1,5 @@ +@import '../../../../styles-var/devui-var.scss'; + .devui-form__control { flex: 1 1 auto; position: relative; @@ -73,11 +75,20 @@ } } - .devui-form__control-extra { - font-size: 12px; - color: #8a8e99; - min-height: 20px; - line-height: 1.5; - text-align: justify; + .devui-form__control-info { + .error-message { + display: inline-block; + min-height: 20px; + font-size: $devui-font-size; + color: $devui-danger; + } + + .devui-form__control-extra { + font-size: 12px; + color: #8a8e99; + min-height: 20px; + line-height: 1.5; + text-align: justify; + } } } diff --git a/packages/devui-vue/devui/form/src/components/form-control/form-control.tsx b/packages/devui-vue/devui/form/src/components/form-control/form-control.tsx index 9c9284ab18..62c27ce062 100644 --- a/packages/devui-vue/devui/form/src/components/form-control/form-control.tsx +++ b/packages/devui-vue/devui/form/src/components/form-control/form-control.tsx @@ -1,118 +1,32 @@ -import { defineComponent, ref, computed, onMounted, Teleport } from 'vue'; +import { defineComponent, ref } from 'vue'; import type { SetupContext } from 'vue'; import { uniqueId } from 'lodash'; import { formControlProps, FormControlProps } from './form-control-types'; -import { ShowPopoverErrorMessageEventData } from '../../directives/d-validate-rules'; -import clickoutsideDirective from '../../../../shared/devui-directive/clickoutside'; -import { EventBus, getElOffset } from '../../utils'; -import Icon from '../../../../icon/src/icon'; -import Popover from '../../../../popover/src/popover'; import { useNamespace } from '../../../../shared/hooks/use-namespace'; +import { useFormControl, useFormControlValidate } from './use-form-control'; import './form-control.scss'; -import { useFormControl } from './use-form-control'; - -type positionType = 'top' | 'right' | 'bottom' | 'left'; export default defineComponent({ name: 'DFormControl', - directives: { - clickoutside: clickoutsideDirective, - }, props: formControlProps, setup(props: FormControlProps, ctx: SetupContext) { const formControl = ref(); const uid = uniqueId('dfc-'); - const showPopover = ref(false); - const updateOn = ref('change'); - const tipMessage = ref(''); - const popPosition = ref('bottom'); const ns = useNamespace('form'); const { controlClasses, controlContainerClasses } = useFormControl(props); - let rectInfo: Partial = { - width: 0, - height: 0, - }; - let elOffset = { - left: 0, - top: 0, - }; - let popoverLeftPosition = 0; - let popoverTopPosition = 0; - - onMounted(() => { - const el = document.getElementById(uid); - elOffset = getElOffset(el); - EventBus.on('showPopoverErrorMessage', (data: ShowPopoverErrorMessageEventData) => { - if (uid === data.uid) { - rectInfo = el.getBoundingClientRect(); - showPopover.value = data.showPopover; - tipMessage.value = data.message; - popPosition.value = data.popPosition as any; // todo: 待popover组件positionType完善类型之后再替换类型 - popoverLeftPosition = - popPosition.value === 'top' || popPosition.value === 'bottom' ? rectInfo.right - rectInfo.width / 2 : rectInfo.right; - popoverTopPosition = - popPosition.value === 'top' ? elOffset.top + rectInfo.height / 2 - rectInfo.height : elOffset.top + rectInfo.height / 2; - updateOn.value = data.updateOn ?? 'change'; - } - }); - }); - - const iconData = computed(() => { - switch (props.feedbackStatus) { - case 'pending': - return { name: 'priority', color: '#e9edfa' }; - case 'success': - return { name: 'right-o', color: 'rgb(61, 204, 166)' }; - case 'error': - return { name: 'error-o', color: 'rgb(249, 95, 91)' }; - default: - return { name: '', color: '' }; - } - }); - - const handleClickOutside = () => { - if (updateOn.value !== 'change') { - showPopover.value = false; - } - }; + const { errorMessage } = useFormControlValidate(); return () => ( -
- {showPopover.value && ( - -
- -
-
- )} +
{ctx.slots.default?.()}
- {(props.feedbackStatus || ctx.slots.suffixTemplate?.()) && ( - - {ctx.slots.suffixTemplate?.() ? ( - ctx.slots.suffixTemplate?.() - ) : ( - - )} - - )}
- {props.extraInfo &&
{props.extraInfo}
} +
+ {errorMessage.value &&
{errorMessage.value}
} + {props.extraInfo &&
{props.extraInfo}
} +
); }, diff --git a/packages/devui-vue/devui/form/src/components/form-control/use-form-control.ts b/packages/devui-vue/devui/form/src/components/form-control/use-form-control.ts index 74af252ee2..9d2c84270d 100644 --- a/packages/devui-vue/devui/form/src/components/form-control/use-form-control.ts +++ b/packages/devui-vue/devui/form/src/components/form-control/use-form-control.ts @@ -1,11 +1,12 @@ import { computed, reactive, inject, toRefs } from 'vue'; -import { FORM_TOKEN, IForm } from '../../form-types'; +import { FORM_TOKEN, FormContext } from '../../form-types'; import { FormControlProps, UseFormControl } from './form-control-types'; +import { FormItemContext, FORM_ITEM_TOKEN } from '../form-item/form-item-types'; import { useNamespace } from '../../../../shared/hooks/use-namespace'; export function useFormControl(props: FormControlProps): UseFormControl { - const Form = reactive(inject(FORM_TOKEN) as IForm); - const labelData = reactive(Form.labelData); + const formContext = inject(FORM_TOKEN) as FormContext; + const labelData = reactive(formContext.labelData); const ns = useNamespace('form'); const { feedbackStatus } = toRefs(props); @@ -23,3 +24,10 @@ export function useFormControl(props: FormControlProps): UseFormControl { return { controlClasses, controlContainerClasses }; } + +export function useFormControlValidate() { + const formItemContext = inject(FORM_ITEM_TOKEN) as FormItemContext; + const errorMessage = computed(() => formItemContext.validateMessage); + + return { errorMessage }; +} diff --git a/packages/devui-vue/devui/form/src/components/form-item/form-item-types.ts b/packages/devui-vue/devui/form/src/components/form-item/form-item-types.ts index 99727433ec..7894d19f41 100644 --- a/packages/devui-vue/devui/form/src/components/form-item/form-item-types.ts +++ b/packages/devui-vue/devui/form/src/components/form-item/form-item-types.ts @@ -1,4 +1,11 @@ -import type { ComputedRef, ExtractPropTypes } from 'vue'; +import type { RuleItem, ValidateFieldsError } from 'async-validator'; +import type { ComputedRef, ExtractPropTypes, PropType, InjectionKey, Ref } from 'vue'; + +export type FormItemValidateState = '' | 'error' | 'pending' | 'success'; + +export interface FormRuleItem extends RuleItem { + trigger?: Array; +} export const formItemProps = { field: { @@ -9,10 +16,34 @@ export const formItemProps = { type: Boolean, default: false, }, + required: { + type: Boolean, + default: false, + }, + rules: { + type: [Object, Array] as PropType<[FormRuleItem, Array]>, + }, }; export type FormItemProps = ExtractPropTypes; +export type FormValidateCallback = (isValid: boolean, invalidFields?: ValidateFieldsError) => void; +export type FormValidateResult = Promise; + +export interface FormItemContext extends FormItemProps { + validateState: FormItemValidateState; + validateMessage: string; + validate: (trigger: string, callback?: FormValidateCallback) => FormValidateResult; +} + export interface UseFormItem { itemClasses: ComputedRef>; } + +export interface UseFormItemValidate { + validateState: Ref; + validateMessage: Ref; + validate: (trigger: string, callback?: FormValidateCallback) => FormValidateResult; +} + +export const FORM_ITEM_TOKEN: InjectionKey = Symbol('dFormItem'); diff --git a/packages/devui-vue/devui/form/src/components/form-item/form-item.tsx b/packages/devui-vue/devui/form/src/components/form-item/form-item.tsx index 907f0b7610..5af7901f37 100644 --- a/packages/devui-vue/devui/form/src/components/form-item/form-item.tsx +++ b/packages/devui-vue/devui/form/src/components/form-item/form-item.tsx @@ -1,100 +1,37 @@ -import { defineComponent, reactive, inject, onMounted, onBeforeUnmount, provide, ref } from 'vue'; +import { defineComponent, onMounted, inject, reactive, toRefs, onBeforeUnmount, provide, toRef } from 'vue'; import type { SetupContext } from 'vue'; -import AsyncValidator, { Rules } from 'async-validator'; -import mitt from 'mitt'; -import { dFormEvents, dFormItemEvents, IForm, FORM_TOKEN, FORM_ITEM_TOKEN } from '../../form-types'; -import { formItemProps, FormItemProps } from './form-item-types'; -import { useFormItem } from './use-form-item'; +import { FORM_TOKEN } from '../../form-types'; +import { FormItemContext, formItemProps, FormItemProps, FORM_ITEM_TOKEN } from './form-item-types'; +import { useFormItem, useFormItemRule, useFormItemValidate } from './use-form-item'; import './form-item.scss'; export default defineComponent({ name: 'DFormItem', props: formItemProps, setup(props: FormItemProps, ctx: SetupContext) { - const formItemMitt = mitt(); - const dForm = reactive(inject(FORM_TOKEN, {} as IForm)); - const formData = reactive(dForm.formData); - const initFormItemData = formData[props.field]; - const rules = reactive(dForm.rules); + const formContext = inject(FORM_TOKEN); const { itemClasses } = useFormItem(); - - const resetField = () => { - if (Array.isArray(initFormItemData)) { - formData[props.field] = [...initFormItemData]; - } else { - formData[props.field] = initFormItemData; - } - }; - - const formItem = reactive({ - dHasFeedback: props.dHasFeedback, - field: props.field, - formItemMitt, - resetField, + const { _rules } = useFormItemRule(props); + const { validateState, validateMessage, validate } = useFormItemValidate(props, _rules); + const context: FormItemContext = reactive({ + ...toRefs(props), + validateState, + validateMessage, + validate, }); - provide(FORM_ITEM_TOKEN, formItem); - - const showMessage = ref(false); - const tipMessage = ref(''); - - const validate = (trigger: string) => { - const ruleKey = props.field; - const ruleItem = rules[ruleKey]; - const descriptor: Rules = {}; - descriptor[ruleKey] = ruleItem; - - const validator = new AsyncValidator(descriptor); - - validator - .validate({ [ruleKey]: formData[ruleKey] }) - .then(() => { - showMessage.value = false; - tipMessage.value = ''; - }) - .catch(({ errors }) => { - showMessage.value = true; - tipMessage.value = errors[0].message; - }); - }; - const validateEvents = []; - const addValidateEvents = () => { - if (rules && rules[props.field]) { - const ruleItem = rules[props.field]; - let eventName = ruleItem['trigger']; - - if (Array.isArray(ruleItem)) { - ruleItem.forEach((item) => { - eventName = item['trigger']; - const cb = () => validate(eventName); - validateEvents.push({ eventName: cb }); - formItem.formItemMitt.on(dFormItemEvents[eventName], cb); - }); - } else { - const cb = () => validate(eventName); - validateEvents.push({ eventName: cb }); - ruleItem && formItem.formItemMitt.on(dFormItemEvents[eventName], cb); - } - } - }; - - const removeValidateEvents = () => { - if (rules && rules[props.field] && validateEvents.length > 0) { - validateEvents.forEach((item) => { - formItem.formItemMitt.off(item.eventName, item.cb); - }); - } - }; + provide(FORM_ITEM_TOKEN, context); onMounted(() => { - dForm.formMitt.emit(dFormEvents.addField, formItem); - addValidateEvents(); + if (props.field) { + formContext?.addItemContext(context); + } }); onBeforeUnmount(() => { - dForm.formMitt.emit(dFormEvents.removeField, formItem); - removeValidateEvents(); + formContext?.removeItemContext(context); }); + return () =>
{ctx.slots.default?.()}
; }, }); diff --git a/packages/devui-vue/devui/form/src/components/form-item/use-form-item.ts b/packages/devui-vue/devui/form/src/components/form-item/use-form-item.ts index 86541108ca..e9882c9bfb 100644 --- a/packages/devui-vue/devui/form/src/components/form-item/use-form-item.ts +++ b/packages/devui-vue/devui/form/src/components/form-item/use-form-item.ts @@ -1,11 +1,22 @@ -import { computed, reactive, inject } from 'vue'; -import { FORM_TOKEN, IForm } from '../../form-types'; -import { UseFormItem } from './form-item-types'; +import { computed, reactive, inject, ref } from 'vue'; +import { castArray, get, isFunction } from 'lodash'; +import Schema from 'async-validator'; +import type { ComputedRef } from 'vue'; +import type { RuleItem } from 'async-validator'; +import { FORM_TOKEN, FormContext, ValidateFailure } from '../../form-types'; +import { + FormItemProps, + FormItemValidateState, + UseFormItem, + FormValidateCallback, + FormRuleItem, + UseFormItemValidate, +} from './form-item-types'; import { useNamespace } from '../../../../shared/hooks/use-namespace'; export function useFormItem(): UseFormItem { - const Form = reactive(inject(FORM_TOKEN) as IForm); - const labelData = reactive(Form.labelData); + const formContext = reactive(inject(FORM_TOKEN) as FormContext); + const labelData = reactive(formContext.labelData); const ns = useNamespace('form'); const itemClasses = computed(() => ({ @@ -15,3 +26,107 @@ export function useFormItem(): UseFormItem { return { itemClasses }; } + +export function useFormItemRule(props: FormItemProps) { + const formContext = inject(FORM_TOKEN) as FormContext; + const _rules = computed(() => { + const rules = (props.rules ? castArray(props.rules) : []) as FormRuleItem[]; + + const formRules = formContext.rules; + if (formRules && props.field) { + const _itemRules = get(formRules, props.field, undefined); + if (_itemRules) { + rules.push(...castArray(_itemRules)); + } + } + + if (props.required) { + rules.push({ required: Boolean(props.required) }); + } + + return rules; + }); + + return { _rules }; +} + +export function useFormItemValidate(props: FormItemProps, _rules: ComputedRef): UseFormItemValidate { + const formContext = inject(FORM_TOKEN) as FormContext; + const validateState = ref(''); + const validateMessage = ref(''); + const computedField = computed(() => { + return typeof props.field === 'string' ? props.field : ''; + }); + const fieldValue = computed(() => { + const formData = formContext.formData; + if (!formData || !props.field) { + return; + } + return formData[props.field]; + }); + + const getRuleByTrigger = (triggerVal: string) => { + return _rules.value + .filter((rule) => { + if (!rule.trigger || !triggerVal) { + return true; + } + if (Array.isArray(rule.trigger)) { + return rule.trigger.includes(triggerVal); + } else { + return rule.trigger === triggerVal; + } + }) + .map(({ trigger, ...rule }) => rule); + }; + + const onValidateSuccess = () => { + validateState.value = 'success'; + validateMessage.value = ''; + }; + const onValidateError = ({ errors }: ValidateFailure) => { + validateState.value = 'error'; + validateMessage.value = errors?.[0]?.message || ''; + }; + + const execValidate = async (rules: RuleItem[]) => { + const ruleName = computedField.value; + const validator = new Schema({ + [ruleName]: rules, + }); + + return validator + .validate({ [ruleName]: fieldValue.value }, { firstFields: true }) + .then(() => { + onValidateSuccess(); + return true; + }) + .catch((error: ValidateFailure) => { + onValidateError(error); + return Promise.reject(error); + }); + }; + + const validate = async (trigger: string, callback?: FormValidateCallback) => { + const rules = getRuleByTrigger(trigger); + if (!rules.length) { + callback?.(true); + return true; + } + + validateState.value = 'pending'; + + return execValidate(rules) + .then(() => { + callback?.(true); + return true; + }) + .catch((error: ValidateFailure) => { + const { fields } = error; + callback?.(false, fields); + return isFunction(callback) ? false : Promise.reject(fields); + }); + }; + + return { validateState, validateMessage, validate }; +} diff --git a/packages/devui-vue/devui/form/src/components/form-label/form-label-types.ts b/packages/devui-vue/devui/form/src/components/form-label/form-label-types.ts index a33688bfd6..585fa91265 100644 --- a/packages/devui-vue/devui/form/src/components/form-label/form-label-types.ts +++ b/packages/devui-vue/devui/form/src/components/form-label/form-label-types.ts @@ -1,10 +1,6 @@ import type { ComputedRef, ExtractPropTypes } from 'vue'; export const formLabelProps = { - required: { - type: Boolean, - default: false, - }, helpTips: { type: String, default: '', diff --git a/packages/devui-vue/devui/form/src/components/form-label/form-label.tsx b/packages/devui-vue/devui/form/src/components/form-label/form-label.tsx index 5d7c666467..d4fd2159e6 100644 --- a/packages/devui-vue/devui/form/src/components/form-label/form-label.tsx +++ b/packages/devui-vue/devui/form/src/components/form-label/form-label.tsx @@ -12,7 +12,7 @@ export default defineComponent({ props: formLabelProps, setup(props: FormLabelProps, ctx: SetupContext) { const ns = useNamespace('form'); - const { labelClasses, labelInnerClasses } = useFormLabel(props); + const { labelClasses, labelInnerClasses } = useFormLabel(); return () => ( diff --git a/packages/devui-vue/devui/form/src/components/form-label/use-form-label.ts b/packages/devui-vue/devui/form/src/components/form-label/use-form-label.ts index ec3da25fae..77d38673d2 100644 --- a/packages/devui-vue/devui/form/src/components/form-label/use-form-label.ts +++ b/packages/devui-vue/devui/form/src/components/form-label/use-form-label.ts @@ -1,12 +1,13 @@ -import { computed, inject, toRefs } from 'vue'; -import { FORM_TOKEN, IForm } from '../../form-types'; -import { FormLabelProps, UseFormLabel } from './form-label-types'; +import { computed, inject } from 'vue'; +import { FORM_TOKEN, FormContext } from '../../form-types'; +import { FormItemContext, FORM_ITEM_TOKEN } from '../form-item/form-item-types'; +import { UseFormLabel } from './form-label-types'; import { useNamespace } from '../../../../shared/hooks/use-namespace'; -export function useFormLabel(props: FormLabelProps): UseFormLabel { - const { labelData } = inject(FORM_TOKEN) as IForm; +export function useFormLabel(): UseFormLabel { + const { labelData } = inject(FORM_TOKEN) as FormContext; + const formItemContext = inject(FORM_ITEM_TOKEN) as FormItemContext; const ns = useNamespace('form'); - const { required } = toRefs(props); const labelClasses = computed(() => ({ [`${ns.e('label')}`]: true, @@ -16,7 +17,7 @@ export function useFormLabel(props: FormLabelProps): UseFormLabel { })); const labelInnerClasses = computed(() => ({ - [`${ns.em('label', 'required')}`]: required.value, + [`${ns.em('label', 'required')}`]: formItemContext.required, })); return { labelClasses, labelInnerClasses }; diff --git a/packages/devui-vue/devui/form/src/components/form-operation/form-operation.tsx b/packages/devui-vue/devui/form/src/components/form-operation/form-operation.tsx index 4df8161385..c859608eae 100644 --- a/packages/devui-vue/devui/form/src/components/form-operation/form-operation.tsx +++ b/packages/devui-vue/devui/form/src/components/form-operation/form-operation.tsx @@ -1,12 +1,12 @@ import { defineComponent, computed, reactive, inject } from 'vue'; -import { FORM_TOKEN, IForm, LabelSize } from '../../form-types'; +import { FORM_TOKEN, FormContext, LabelSize } from '../../form-types'; import './form-operation.scss'; export default defineComponent({ name: 'DFormOperation', setup(props, ctx) { - const Form = reactive(inject(FORM_TOKEN) as IForm); - const labelData = reactive(Form.labelData); + const formContext = reactive(inject(FORM_TOKEN) as FormContext); + const labelData = reactive(formContext.labelData); const LabelSizeMap: Record = { sm: 80, md: 100, diff --git a/packages/devui-vue/devui/form/src/composables/use-field-collection.ts b/packages/devui-vue/devui/form/src/composables/use-field-collection.ts new file mode 100644 index 0000000000..439530d337 --- /dev/null +++ b/packages/devui-vue/devui/form/src/composables/use-field-collection.ts @@ -0,0 +1,16 @@ +import { FormContext, UseFieldCollection } from '../form-types'; +import { FormItemContext } from '../components/form-item/form-item-types'; + +export default function useFieldCollection(): UseFieldCollection { + const itemContexts: FormItemContext[] = []; + + const addItemContext: FormContext['addItemContext'] = (field) => { + itemContexts.push(field); + }; + + const removeItemContext: FormContext['removeItemContext'] = (field) => { + itemContexts.splice(itemContexts.indexOf(field), 1); + }; + + return { itemContexts, addItemContext, removeItemContext }; +} diff --git a/packages/devui-vue/devui/form/src/composables/use-form-validation.ts b/packages/devui-vue/devui/form/src/composables/use-form-validation.ts new file mode 100644 index 0000000000..f3f1a143de --- /dev/null +++ b/packages/devui-vue/devui/form/src/composables/use-form-validation.ts @@ -0,0 +1,64 @@ +import { castArray } from 'lodash'; +import type { ValidateFieldsError } from 'async-validator'; +import { UseFormValidation } from '../form-types'; +import { FormItemContext, FormValidateCallback, FormValidateResult } from '../components/form-item/form-item-types'; + +export default function useFormValidation(itemContexts: FormItemContext[]): UseFormValidation { + const getValidateFields = (fields: string[]) => { + if (!itemContexts.length) { + return []; + } + const normalizedFields = castArray(fields); + const filteredFields = normalizedFields.length + ? itemContexts.filter((context) => context.field && normalizedFields.includes(context.field)) + : itemContexts; + + if (!filteredFields.length) { + return []; + } + return filteredFields; + }; + + const execValidateFields = async (fields: string[] = []): FormValidateResult => { + const validateFields = getValidateFields(fields); + + if (!validateFields.length) { + return true; + } + + let errors: ValidateFieldsError = {}; + for (const field of validateFields) { + try { + await field.validate(''); + } catch (err) { + errors = { + ...errors, + ...(err as ValidateFieldsError), + }; + } + } + + if (!Object.keys(errors).length) { + return true; + } + return Promise.reject(errors); + }; + + const validateFields = async (fields: string[] = [], callback: any) => { + try { + const result = await execValidateFields(fields); + if (result) { + callback?.(result); + } + return result; + } catch (err) { + const invalidFields = err as ValidateFieldsError; + callback?.(false, invalidFields); + return !callback && Promise.reject(invalidFields); + } + }; + + const validate = async (callback?: FormValidateCallback): FormValidateResult => validateFields(undefined, callback); + + return { validate, validateFields }; +} diff --git a/packages/devui-vue/devui/form/src/form-types.ts b/packages/devui-vue/devui/form/src/form-types.ts index 772db751ec..a0a9ec052e 100644 --- a/packages/devui-vue/devui/form/src/form-types.ts +++ b/packages/devui-vue/devui/form/src/form-types.ts @@ -1,11 +1,19 @@ -import { Emitter } from 'mitt'; +import type { ValidateError, ValidateFieldsError } from 'async-validator'; +import type { Emitter } from 'mitt'; import type { PropType, ExtractPropTypes, InjectionKey } from 'vue'; +import { FormItemContext, FormRuleItem, FormValidateCallback, FormValidateResult } from './components/form-item/form-item-types'; export type Layout = 'horizontal' | 'vertical'; export type LabelSize = 'sm' | 'md' | 'lg'; export type LabelAlign = 'start' | 'center' | 'end'; export type FormData = Record; +export type FormRules = Partial>>; +export interface ValidateFailure { + errors: ValidateError[] | null; + fields: ValidateFieldsError; +} + export const formProps = { data: { type: Object as PropType, @@ -24,12 +32,7 @@ export const formProps = { default: 'start', }, rules: { - type: Object, - default: {}, - }, - name: { - type: String, - default: '', + type: Object as PropType, }, messageShowType: { type: String as PropType<'popover' | 'text' | 'toast' | 'none'>, @@ -42,8 +45,6 @@ export const dFormEvents = { removeField: 'd.form.removeField', } as const; -export const FORM_ITEM_TOKEN: InjectionKey = Symbol('dFormItem'); - export const dFormItemEvents = { blur: 'd.form.blur', change: 'd.form.change', @@ -56,15 +57,28 @@ export interface IFormLabel { labelAlign: LabelAlign; } -export interface IForm { +export interface FormContext { formData: any; labelData: IFormLabel; formMitt: Emitter; rules: any; messageShowType: string; + addItemContext: (field: FormItemContext) => void; + removeItemContext: (field: FormItemContext) => void; +} + +export interface UseFieldCollection { + itemContexts: FormItemContext[]; + addItemContext: (field: FormItemContext) => void; + removeItemContext: (field: FormItemContext) => void; +} + +export interface UseFormValidation { + validate: (callback?: FormValidateCallback) => FormValidateResult; + validateFields: (fields: string[], callback: any) => FormValidateResult; } -export const FORM_TOKEN: InjectionKey = Symbol('dForm'); +export const FORM_TOKEN: InjectionKey = Symbol('dForm'); export interface IFormItem { dHasFeedback: boolean; diff --git a/packages/devui-vue/devui/form/src/form.tsx b/packages/devui-vue/devui/form/src/form.tsx index 28347bc3cf..f0212521a8 100644 --- a/packages/devui-vue/devui/form/src/form.tsx +++ b/packages/devui-vue/devui/form/src/form.tsx @@ -3,6 +3,8 @@ import mitt from 'mitt'; import { formProps, FormProps, IFormItem, dFormEvents, FORM_TOKEN } from './form-types'; import { EventBus } from './utils'; import { useNamespace } from '../../shared/hooks/use-namespace'; +import useFieldCollection from './composables/use-field-collection'; +import useFormValidation from './composables/use-form-validation'; export default defineComponent({ name: 'DForm', @@ -18,6 +20,8 @@ export default defineComponent({ }); }; const { data, layout, labelSize, labelAlign } = toRefs(props); + const { itemContexts, addItemContext, removeItemContext } = useFieldCollection(); + const { validate, validateFields } = useFormValidation(itemContexts); formMitt.on(dFormEvents.addField, (field: any) => { if (field) { @@ -43,9 +47,16 @@ export default defineComponent({ }, rules: props.rules, messageShowType: 'popover', + addItemContext, + removeItemContext, }) ); + ctx.expose({ + validate, + validateFields, + }); + const onSubmit = (e) => { e.preventDefault(); ctx.emit('submit', e); diff --git a/packages/devui-vue/devui/input/src/input.scss b/packages/devui-vue/devui/input/src/input.scss index 229b6cd039..50ab34d44d 100644 --- a/packages/devui-vue/devui/input/src/input.scss +++ b/packages/devui-vue/devui/input/src/input.scss @@ -1,5 +1,6 @@ @import '../../style/core/form'; @import '../../style/mixins/flex'; +@import '../../styles-var/devui-var.scss'; .devui-input { &__wrap { @@ -22,4 +23,9 @@ @include flex; @include flex-direction; } + + .devui-error { + border-color: $devui-danger-line; + background-color: $devui-danger-bg; + } } diff --git a/packages/devui-vue/devui/input/src/input.tsx b/packages/devui-vue/devui/input/src/input.tsx index 694ace998e..2569b4e238 100644 --- a/packages/devui-vue/devui/input/src/input.tsx +++ b/packages/devui-vue/devui/input/src/input.tsx @@ -1,7 +1,8 @@ import { defineComponent, computed, ref, watch, inject } from 'vue'; import { inputProps, InputType } from './input-types'; +import { dFormItemEvents } from '../../form/src/form-types'; +import { FORM_ITEM_TOKEN, FormItemContext } from '../../form/src/components/form-item/form-item-types'; import './input.scss'; -import { dFormItemEvents, IFormItem, FORM_ITEM_TOKEN } from '../../form/src/form-types'; export default defineComponent({ name: 'DInput', @@ -17,19 +18,18 @@ export default defineComponent({ props: inputProps, emits: ['update:modelValue', 'focus', 'blur', 'change', 'keydown'], setup(props, ctx) { - const formItem = inject(FORM_ITEM_TOKEN, {} as IFormItem); - const hasFormItem = Object.keys(formItem).length > 0; + const formItemContext = inject(FORM_ITEM_TOKEN) as FormItemContext; + // const hasFormItem = Object.keys(formItemContext).length > 0; const sizeCls = computed(() => `devui-input-${props.size}`); const showPwdIcon = ref(false); const inputType = ref('text'); - const inputCls = computed(() => { - return { - error: props.error, - [props.cssClass]: true, - 'devui-input-restore': showPwdIcon.value, - [sizeCls.value]: props.size !== '', - }; - }); + const isValidateError = computed(() => formItemContext?.validateState === 'error'); + const inputCls = computed(() => ({ + 'devui-error': props.error || isValidateError.value, + [props.cssClass]: true, + 'devui-input-restore': showPwdIcon.value, + [sizeCls.value]: props.size !== '', + })); const showPreviewIcon = computed(() => inputType.value === 'password'); watch( () => props.showPassword, @@ -40,20 +40,28 @@ export default defineComponent({ { immediate: true } ); + watch( + () => props.modelValue, + () => { + formItemContext?.validate('change').catch((err) => console.warn(err)); + } + ); + const onInput = ($event: Event) => { ctx.emit('update:modelValue', ($event.target as HTMLInputElement).value); - hasFormItem && formItem.formItemMitt.emit(dFormItemEvents.input); + // hasFormItem && formItem.formItemMitt.emit(dFormItemEvents.input); }, onFocus = () => { ctx.emit('focus'); }, onBlur = () => { ctx.emit('blur'); - hasFormItem && formItem.formItemMitt.emit(dFormItemEvents.blur); + formItemContext?.validate('blur').catch((err) => console.warn(err)); + // hasFormItem && formItem.formItemMitt.emit(dFormItemEvents.blur); }, onChange = ($event: Event) => { ctx.emit('change', ($event.target as HTMLInputElement).value); - hasFormItem && formItem.formItemMitt.emit(dFormItemEvents.change); + // hasFormItem && formItem.formItemMitt.emit(dFormItemEvents.change); }, onKeydown = ($event: KeyboardEvent) => { ctx.emit('keydown', $event); diff --git a/packages/devui-vue/docs/components/form/index.md b/packages/devui-vue/docs/components/form/index.md index 0c335f18d9..8d16b6972f 100644 --- a/packages/devui-vue/docs/components/form/index.md +++ b/packages/devui-vue/docs/components/form/index.md @@ -228,7 +228,7 @@ export default defineComponent({ .form-btn-groups { display: flex; flex-wrap: wrap; - margin-bottom: 8px; + margin-bottom: 16px; } .form-btn { display: flex; @@ -445,11 +445,77 @@ export default defineComponent({ ::: +### 表单校验 + +:::demo + +```vue + + + +``` + +::: + ### Form 参数 | 参数名 | 类型 | 默认值 | 说明 | 跳转 demo | | :---------- | :------------------------ | :----------- | :----------------------------------------------------------------- | :-------------------- | -| name | `string` | '' | 可选,设置表单 name 属性,进行表单提交验证时必选 | [基础用法](#基础用法) | | data | `object` | {} | 必选,表单数据 | [基础用法](#基础用法) | | layout | [Layout](#layout) | 'horizontal' | 可选,设置表单的排列方式 | [垂直排列](#垂直排列) | | label-size | [LabelSize](#labelsize) | 'md' | 可选,设置 label 的宽度,默认为 100px,sm 对应 80px,lg 对应 150px | [表单样式](#表单样式) | @@ -469,9 +535,10 @@ export default defineComponent({ ### FormItem 参数 -| 参数名 | 类型 | 默认值 | 说明 | 跳转 demo | -| :----- | :------- | :----- | :--------------------------------------------------- | :-------------------- | -| field | `string` | '' | 可选,指定验证表单需验证的字段,验证表单时必选该属性 | [基础用法](#基础用法) | +| 参数名 | 类型 | 默认值 | 说明 | 跳转 demo | +| :------- | :-------- | :----- | :--------------------------------------------------- | :-------------------- | +| field | `string` | '' | 可选,指定验证表单需验证的字段,验证表单时必选该属性 | [基础用法](#基础用法) | +| required | `boolean` | false | 可选,表单选项是否必填 | | ### FormItem 插槽 @@ -481,10 +548,9 @@ export default defineComponent({ ### FormLabel 参数 -| 参数名 | 类型 | 默认值 | 说明 | 跳转 demo | -| :-------- | :-------- | :----- | :--------------------------------------------------------- | :-------------------- | -| required | `boolean` | false | 可选,表单选项是否必填 | | -| help-tips | `string` | '' | 可选,表单项帮助指引提示内容,空字符串表示不设置提示内容。 | [基础用法](#基础用法) | +| 参数名 | 类型 | 默认值 | 说明 | 跳转 demo | +| :-------- | :------- | :----- | :--------------------------------------------------------- | :-------------------- | +| help-tips | `string` | '' | 可选,表单项帮助指引提示内容,空字符串表示不设置提示内容。 | [基础用法](#基础用法) | ### FormLabel 插槽