391 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
	
			
		
		
	
	
			391 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
	
| import type { Ref } from 'vue';
 | |
| import { reactive, watch, nextTick, unref, shallowRef, toRaw, ref } 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 { Callbacks, 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<Props>,
 | |
|   rulesRef: Props | Ref<Props> = ref({}),
 | |
|   options?: {
 | |
|     immediate?: boolean;
 | |
|     deep?: boolean;
 | |
|     validateOnRuleChange?: boolean;
 | |
|     debounce?: DebounceSettings;
 | |
|     onValidate?: Callbacks['onValidate'];
 | |
|   },
 | |
| ): {
 | |
|   modelRef: Props | Ref<Props>;
 | |
|   rulesRef: Props | Ref<Props>;
 | |
|   initialModel: Props;
 | |
|   validateInfos: validateInfos;
 | |
|   resetFields: (newValues?: Props) => void;
 | |
|   validate: <T = any>(names?: namesType, option?: validateOptions) => Promise<T>;
 | |
| 
 | |
|   /** This is an internal usage. Do not use in your prod */
 | |
|   validateField: (
 | |
|     name: string,
 | |
|     value: any,
 | |
|     rules: Record<string, unknown>[],
 | |
|     option?: validateOptions,
 | |
|   ) => Promise<RuleError[]>;
 | |
|   mergeValidateInfo: (items: ValidateInfo | ValidateInfo[]) => ValidateInfo;
 | |
|   clearValidate: (names?: namesType) => void;
 | |
| } {
 | |
|   const initialModel = cloneDeep(unref(modelRef));
 | |
|   const validateInfos = reactive<validateInfos>({});
 | |
| 
 | |
|   const rulesKeys = shallowRef([]);
 | |
| 
 | |
|   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: Record<string, unknown>[],
 | |
|     option: validateOptions = {},
 | |
|   ): Promise<RuleError[]> => {
 | |
|     const promise = validateRules(
 | |
|       [name],
 | |
|       value,
 | |
|       rules,
 | |
|       {
 | |
|         validateMessages: defaultValidateMessages,
 | |
|         ...option,
 | |
|       },
 | |
|       !!option.validateFirst,
 | |
|     );
 | |
|     if (!validateInfos[name]) {
 | |
|       return promise.catch((e: any) => e);
 | |
|     }
 | |
|     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) : null;
 | |
|           options?.onValidate?.(
 | |
|             name,
 | |
|             !res.length,
 | |
|             res.length ? toRaw(validateInfos[name].help[0]) : null,
 | |
|           );
 | |
|         }
 | |
|       });
 | |
|     return promise;
 | |
|   };
 | |
| 
 | |
|   const validate = (names?: namesType, option?: validateOptions): Promise<any> => {
 | |
|     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: null,
 | |
|         });
 | |
|     });
 | |
|   };
 | |
| 
 | |
|   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;
 | |
|   let isFirstTime = true;
 | |
|   const modelFn = (model: { [x: string]: any }) => {
 | |
|     const names = [];
 | |
|     rulesKeys.value.forEach(key => {
 | |
|       const prop = getPropByPath(model, key, false);
 | |
|       const oldProp = getPropByPath(oldModel, key, false);
 | |
|       const isFirstValidation = isFirstTime && options?.immediate && prop.isValid;
 | |
| 
 | |
|       if (isFirstValidation || !isEqual(prop.v, oldProp.v)) {
 | |
|         names.push(key);
 | |
|       }
 | |
|     });
 | |
|     validate(names, { trigger: 'change' });
 | |
|     isFirstTime = false;
 | |
|     oldModel = cloneDeep(toRaw(model));
 | |
|   };
 | |
| 
 | |
|   const debounceOptions = options?.debounce;
 | |
| 
 | |
|   let first = true;
 | |
|   watch(
 | |
|     rulesRef,
 | |
|     () => {
 | |
|       rulesKeys.value = rulesRef ? Object.keys(unref(rulesRef)) : [];
 | |
|       if (!first && options && options.validateOnRuleChange) {
 | |
|         validate();
 | |
|       }
 | |
|       first = false;
 | |
|     },
 | |
|     { deep: true, immediate: true },
 | |
|   );
 | |
| 
 | |
|   watch(
 | |
|     rulesKeys,
 | |
|     () => {
 | |
|       const newValidateInfos = {};
 | |
|       rulesKeys.value.forEach(key => {
 | |
|         newValidateInfos[key] = Object.assign({}, validateInfos[key], {
 | |
|           autoLink: false,
 | |
|           required: isRequired(unref(rulesRef)[key]),
 | |
|         });
 | |
|         delete validateInfos[key];
 | |
|       });
 | |
|       for (const key in validateInfos) {
 | |
|         if (Object.prototype.hasOwnProperty.call(validateInfos, key)) {
 | |
|           delete validateInfos[key];
 | |
|         }
 | |
|       }
 | |
|       Object.assign(validateInfos, newValidateInfos);
 | |
|     },
 | |
|     { immediate: true },
 | |
|   );
 | |
|   watch(
 | |
|     modelRef,
 | |
|     debounceOptions && debounceOptions.wait
 | |
|       ? debounce(modelFn, debounceOptions.wait, omit(debounceOptions, ['wait']))
 | |
|       : modelFn,
 | |
|     { immediate: options && !!options.immediate, deep: true },
 | |
|   );
 | |
| 
 | |
|   return {
 | |
|     modelRef,
 | |
|     rulesRef,
 | |
|     initialModel,
 | |
|     validateInfos,
 | |
|     resetFields,
 | |
|     validate,
 | |
|     validateField,
 | |
|     mergeValidateInfo,
 | |
|     clearValidate,
 | |
|   };
 | |
| }
 | |
| 
 | |
| export default useForm;
 |