diff --git a/components/_util/transition.tsx b/components/_util/transition.tsx index a7c088cde..1894aff8b 100644 --- a/components/_util/transition.tsx +++ b/components/_util/transition.tsx @@ -1,4 +1,5 @@ -import { BaseTransitionProps, CSSProperties, getCurrentInstance, onUpdated, Ref } from 'vue'; +import type { BaseTransitionProps, CSSProperties, Ref } from 'vue'; +import { getCurrentInstance, onUpdated } from 'vue'; import { defineComponent, nextTick, Transition as T, TransitionGroup as TG } from 'vue'; export const getTransitionProps = (transitionName: string, opt: object = {}) => { diff --git a/components/form/index.tsx b/components/form/index.tsx index ec5401688..66b6d8c86 100644 --- a/components/form/index.tsx +++ b/components/form/index.tsx @@ -1,6 +1,7 @@ import type { App, Plugin } from 'vue'; import Form, { formProps } from './Form'; import FormItem, { formItemProps } from './FormItem'; +import useForm from './useForm'; export type { FormProps } from './Form'; export type { FormItemProps } from './FormItem'; @@ -12,8 +13,11 @@ Form.install = function (app: App) { return app; }; -export { FormItem, formItemProps, formProps }; +export { FormItem, formItemProps, formProps, useForm }; + +Form.useForm = useForm; export default Form as typeof Form & Plugin & { readonly Item: typeof Form.Item; + readonly useForm: typeof useForm; }; diff --git a/components/form/interface.ts b/components/form/interface.ts index 0c2652839..2e0380bab 100644 --- a/components/form/interface.ts +++ b/components/form/interface.ts @@ -90,7 +90,7 @@ export interface ValidateErrorEntity { } export interface FieldError { - name: InternalNamePath; + name: InternalNamePath | string; errors: string[]; } diff --git a/components/form/useForm.ts b/components/form/useForm.ts new file mode 100644 index 000000000..236031dd3 --- /dev/null +++ b/components/form/useForm.ts @@ -0,0 +1,360 @@ +import type { Ref } from 'vue'; +import { computed } from 'vue'; +import { reactive, watch, nextTick, unref } from 'vue'; +import cloneDeep from 'lodash-es/cloneDeep'; +import intersection from 'lodash-es/intersection'; +import isEqual from 'lodash-es/isEqual'; +import debounce from 'lodash-es/debounce'; +import omit from 'lodash-es/omit'; +import { validateRules } from './utils/validateUtil'; +import { defaultValidateMessages } from './utils/messages'; +import { allPromiseFinish } from './utils/asyncUtil'; +import type { RuleError, ValidateMessages } from './interface'; +import type { ValidateStatus } from './FormItem'; + +interface DebounceSettings { + leading?: boolean; + + wait?: number; + + trailing?: boolean; +} + +function isRequired(rules: any[]) { + let isRequired = false; + if (rules && rules.length) { + rules.every((rule: { required: any }) => { + if (rule.required) { + isRequired = true; + return false; + } + return true; + }); + } + return isRequired; +} + +function toArray(value: string | string[]) { + if (value === undefined || value === null) { + return []; + } + + return Array.isArray(value) ? value : [value]; +} + +export interface Props { + [key: string]: any; +} + +export interface validateOptions { + validateFirst?: boolean; + validateMessages?: ValidateMessages; + trigger?: 'change' | 'blur' | string | string[]; +} + +type namesType = string | string[]; +export interface ValidateInfo { + autoLink?: boolean; + required?: boolean; + validateStatus?: ValidateStatus; + help?: any; +} + +export interface validateInfos { + [key: string]: ValidateInfo; +} + +function getPropByPath(obj: Props, path: string, strict: boolean) { + let tempObj = obj; + path = path.replace(/\[(\w+)\]/g, '.$1'); + path = path.replace(/^\./, ''); + + const keyArr = path.split('.'); + let i = 0; + for (let len = keyArr.length; i < len - 1; ++i) { + if (!tempObj && !strict) break; + const key = keyArr[i]; + if (key in tempObj) { + tempObj = tempObj[key]; + } else { + if (strict) { + throw new Error('please transfer a valid name path to validate!'); + } + break; + } + } + return { + o: tempObj, + k: keyArr[i], + v: tempObj ? tempObj[keyArr[i]] : null, + isValid: tempObj && keyArr[i] in tempObj, + }; +} + +function useForm( + modelRef: Props | Ref, + rulesRef?: Props | Ref, + options?: { + immediate?: boolean; + deep?: boolean; + validateOnRuleChange?: boolean; + debounce?: DebounceSettings; + }, +): { + modelRef: Props | Ref; + rulesRef: Props | Ref; + initialModel: Props; + validateInfos: validateInfos; + resetFields: (newValues?: Props) => void; + validate: (names?: namesType, option?: validateOptions) => Promise; + validateField: ( + name?: string, + value?: any, + rules?: [Record], + option?: validateOptions, + ) => Promise; + mergeValidateInfo: (items: ValidateInfo | ValidateInfo[]) => ValidateInfo; + clearValidate: (names?: namesType) => void; +} { + const initialModel = cloneDeep(unref(modelRef)); + let validateInfos: validateInfos = {}; + + const rulesKeys = computed(() => { + return Object.keys(unref(rulesRef)); + }); + + rulesKeys.value.forEach(key => { + validateInfos[key] = { + autoLink: false, + required: isRequired(unref(rulesRef)[key]), + }; + }); + validateInfos = reactive(validateInfos); + const resetFields = (newValues: Props) => { + Object.assign(unref(modelRef), { + ...cloneDeep(initialModel), + ...newValues, + }); + nextTick(() => { + Object.keys(validateInfos).forEach(key => { + validateInfos[key] = { + autoLink: false, + required: isRequired(unref(rulesRef)[key]), + }; + }); + }); + }; + const filterRules = (rules = [], trigger: string[]) => { + if (!trigger.length) { + return rules; + } else { + return rules.filter(rule => { + const triggerList = toArray(rule.trigger || 'change'); + return intersection(triggerList, trigger).length; + }); + } + }; + + let lastValidatePromise = null; + const validateFields = (names: string[], option: validateOptions = {}, strict: boolean) => { + // Collect result in promise list + const promiseList: Promise<{ + name: string; + errors: string[]; + }>[] = []; + const values = {}; + for (let i = 0; i < names.length; i++) { + const name = names[i]; + const prop = getPropByPath(unref(modelRef), name, strict); + if (!prop.isValid) continue; + values[name] = prop.v; + const rules = filterRules(unref(rulesRef)[name], toArray(option && option.trigger)); + if (rules.length) { + promiseList.push( + validateField(name, prop.v, rules, option || {}) + .then(() => ({ + name, + errors: [], + warnings: [], + })) + .catch((ruleErrors: RuleError[]) => { + const mergedErrors: string[] = []; + const mergedWarnings: string[] = []; + + ruleErrors.forEach(({ rule: { warningOnly }, errors }) => { + if (warningOnly) { + mergedWarnings.push(...errors); + } else { + mergedErrors.push(...errors); + } + }); + + if (mergedErrors.length) { + return Promise.reject({ + name, + errors: mergedErrors, + warnings: mergedWarnings, + }); + } + + return { + name, + errors: mergedErrors, + warnings: mergedWarnings, + }; + }), + ); + } + } + + const summaryPromise = allPromiseFinish(promiseList); + lastValidatePromise = summaryPromise; + + const returnPromise = summaryPromise + .then(() => { + if (lastValidatePromise === summaryPromise) { + return Promise.resolve(values); + } + return Promise.reject([]); + }) + .catch((results: any[]) => { + const errorList = results.filter( + (result: { errors: string | any[] }) => result && result.errors.length, + ); + return Promise.reject({ + values, + errorFields: errorList, + outOfDate: lastValidatePromise !== summaryPromise, + }); + }); + + // Do not throw in console + returnPromise.catch((e: any) => e); + + return returnPromise; + }; + const validateField = ( + name: string, + value: any, + rules: any, + option: validateOptions, + ): Promise => { + const promise = validateRules( + [name], + value, + rules, + { + validateMessages: defaultValidateMessages, + ...option, + }, + !!option.validateFirst, + ); + validateInfos[name].validateStatus = 'validating'; + promise + .catch((e: any) => e) + .then((results: RuleError[] = []) => { + if (validateInfos[name].validateStatus === 'validating') { + const res = results.filter(result => result && result.errors.length); + validateInfos[name].validateStatus = res.length ? 'error' : 'success'; + validateInfos[name].help = res.length ? res.map(r => r.errors) : ''; + } + }); + return promise; + }; + + const validate = (names?: namesType, option?: validateOptions): Promise => { + let keys = []; + let strict = true; + if (!names) { + strict = false; + keys = rulesKeys.value; + } else if (Array.isArray(names)) { + keys = names; + } else { + keys = [names]; + } + const promises = validateFields(keys, option || {}, strict); + // Do not throw in console + promises.catch((e: any) => e); + return promises; + }; + + const clearValidate = (names?: namesType) => { + let keys = []; + if (!names) { + keys = rulesKeys.value; + } else if (Array.isArray(names)) { + keys = names; + } else { + keys = [names]; + } + keys.forEach(key => { + validateInfos[key] && + Object.assign(validateInfos[key], { + validateStatus: '', + help: '', + }); + }); + }; + + const mergeValidateInfo = (items: ValidateInfo[] | ValidateInfo) => { + const info = { autoLink: false } as ValidateInfo; + const help = []; + const infos = Array.isArray(items) ? items : [items]; + for (let i = 0; i < infos.length; i++) { + const arg = infos[i] as ValidateInfo; + if (arg?.validateStatus === 'error') { + info.validateStatus = 'error'; + arg.help && help.push(arg.help); + } + info.required = info.required || arg?.required; + } + info.help = help; + return info; + }; + let oldModel = initialModel; + const modelFn = (model: { [x: string]: any }) => { + const names = []; + rulesKeys.value.forEach(key => { + const prop = getPropByPath(model, key, false); + const oldProp = getPropByPath(oldModel, key, false); + if (!isEqual(prop.v, oldProp.v)) { + names.push(key); + } + }); + validate(names, { trigger: 'change' }); + oldModel = cloneDeep(model); + }; + const debounceOptions = options?.debounce; + watch( + modelRef, + debounceOptions && debounceOptions.wait + ? debounce(modelFn, debounceOptions.wait, omit(debounceOptions, ['wait'])) + : modelFn, + { immediate: options && !!options.immediate, deep: true }, + ); + + watch( + rulesRef, + () => { + if (options && options.validateOnRuleChange) { + validate(); + } + }, + { deep: true }, + ); + + return { + modelRef, + rulesRef, + initialModel, + validateInfos, + resetFields, + validate, + validateField, + mergeValidateInfo, + clearValidate, + }; +} + +export default useForm; diff --git a/v2-doc b/v2-doc index 4c2982755..7fd620c1f 160000 --- a/v2-doc +++ b/v2-doc @@ -1 +1 @@ -Subproject commit 4c298275518d5790a58d26f2ed9b83ee5ba1dba4 +Subproject commit 7fd620c1f8c9f063af8048472d90cd7ed6c5bdd6