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: Record[], 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;