vuecssuiant-designantdreactantantd-vueenterprisefrontendui-designvue-antdvue-antd-uivue3vuecomponent
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
392 lines
10 KiB
392 lines
10 KiB
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 errorList.length |
|
? Promise.reject({ |
|
values, |
|
errorFields: errorList, |
|
outOfDate: lastValidatePromise !== summaryPromise, |
|
}) |
|
: Promise.resolve(values); |
|
}); |
|
|
|
// 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;
|
|
|