Skip to content

Commit cdbe8ee

Browse files
committed
feat: add useForm
1 parent b6688c4 commit cdbe8ee

File tree

5 files changed

+369
-4
lines changed

5 files changed

+369
-4
lines changed

components/_util/transition.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { BaseTransitionProps, CSSProperties, getCurrentInstance, onUpdated, Ref } from 'vue';
1+
import type { BaseTransitionProps, CSSProperties, Ref } from 'vue';
2+
import { getCurrentInstance, onUpdated } from 'vue';
23
import { defineComponent, nextTick, Transition as T, TransitionGroup as TG } from 'vue';
34

45
export const getTransitionProps = (transitionName: string, opt: object = {}) => {

components/form/index.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { App, Plugin } from 'vue';
22
import Form, { formProps } from './Form';
33
import FormItem, { formItemProps } from './FormItem';
4+
import useForm from './useForm';
45

56
export type { FormProps } from './Form';
67
export type { FormItemProps } from './FormItem';
@@ -12,8 +13,11 @@ Form.install = function (app: App) {
1213
return app;
1314
};
1415

15-
export { FormItem, formItemProps, formProps };
16+
export { FormItem, formItemProps, formProps, useForm };
17+
18+
Form.useForm = useForm;
1619
export default Form as typeof Form &
1720
Plugin & {
1821
readonly Item: typeof Form.Item;
22+
readonly useForm: typeof useForm;
1923
};

components/form/interface.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ export interface ValidateErrorEntity<Values = any> {
9090
}
9191

9292
export interface FieldError {
93-
name: InternalNamePath;
93+
name: InternalNamePath | string;
9494
errors: string[];
9595
}
9696

components/form/useForm.ts

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
import type { Ref } from 'vue';
2+
import { computed } from 'vue';
3+
import { reactive, watch, nextTick, unref } from 'vue';
4+
import cloneDeep from 'lodash-es/cloneDeep';
5+
import intersection from 'lodash-es/intersection';
6+
import isEqual from 'lodash-es/isEqual';
7+
import debounce from 'lodash-es/debounce';
8+
import omit from 'lodash-es/omit';
9+
import { validateRules } from './utils/validateUtil';
10+
import { defaultValidateMessages } from './utils/messages';
11+
import { allPromiseFinish } from './utils/asyncUtil';
12+
import type { RuleError, ValidateMessages } from './interface';
13+
import type { ValidateStatus } from './FormItem';
14+
15+
interface DebounceSettings {
16+
leading?: boolean;
17+
18+
wait?: number;
19+
20+
trailing?: boolean;
21+
}
22+
23+
function isRequired(rules: any[]) {
24+
let isRequired = false;
25+
if (rules && rules.length) {
26+
rules.every((rule: { required: any }) => {
27+
if (rule.required) {
28+
isRequired = true;
29+
return false;
30+
}
31+
return true;
32+
});
33+
}
34+
return isRequired;
35+
}
36+
37+
function toArray(value: string | string[]) {
38+
if (value === undefined || value === null) {
39+
return [];
40+
}
41+
42+
return Array.isArray(value) ? value : [value];
43+
}
44+
45+
export interface Props {
46+
[key: string]: any;
47+
}
48+
49+
export interface validateOptions {
50+
validateFirst?: boolean;
51+
validateMessages?: ValidateMessages;
52+
trigger?: 'change' | 'blur' | string | string[];
53+
}
54+
55+
type namesType = string | string[];
56+
export interface ValidateInfo {
57+
autoLink?: boolean;
58+
required?: boolean;
59+
validateStatus?: ValidateStatus;
60+
help?: any;
61+
}
62+
63+
export interface validateInfos {
64+
[key: string]: ValidateInfo;
65+
}
66+
67+
function getPropByPath(obj: Props, path: string, strict: boolean) {
68+
let tempObj = obj;
69+
path = path.replace(/\[(\w+)\]/g, '.$1');
70+
path = path.replace(/^\./, '');
71+
72+
const keyArr = path.split('.');
73+
let i = 0;
74+
for (let len = keyArr.length; i < len - 1; ++i) {
75+
if (!tempObj && !strict) break;
76+
const key = keyArr[i];
77+
if (key in tempObj) {
78+
tempObj = tempObj[key];
79+
} else {
80+
if (strict) {
81+
throw new Error('please transfer a valid name path to validate!');
82+
}
83+
break;
84+
}
85+
}
86+
return {
87+
o: tempObj,
88+
k: keyArr[i],
89+
v: tempObj ? tempObj[keyArr[i]] : null,
90+
isValid: tempObj && keyArr[i] in tempObj,
91+
};
92+
}
93+
94+
function useForm(
95+
modelRef: Props | Ref<Props>,
96+
rulesRef?: Props | Ref<Props>,
97+
options?: {
98+
immediate?: boolean;
99+
deep?: boolean;
100+
validateOnRuleChange?: boolean;
101+
debounce?: DebounceSettings;
102+
},
103+
): {
104+
modelRef: Props | Ref<Props>;
105+
rulesRef: Props | Ref<Props>;
106+
initialModel: Props;
107+
validateInfos: validateInfos;
108+
resetFields: (newValues?: Props) => void;
109+
validate: <T = any>(names?: namesType, option?: validateOptions) => Promise<T>;
110+
validateField: (
111+
name?: string,
112+
value?: any,
113+
rules?: [Record<string, unknown>],
114+
option?: validateOptions,
115+
) => Promise<RuleError[]>;
116+
mergeValidateInfo: (items: ValidateInfo | ValidateInfo[]) => ValidateInfo;
117+
clearValidate: (names?: namesType) => void;
118+
} {
119+
const initialModel = cloneDeep(unref(modelRef));
120+
let validateInfos: validateInfos = {};
121+
122+
const rulesKeys = computed(() => {
123+
return Object.keys(unref(rulesRef));
124+
});
125+
126+
rulesKeys.value.forEach(key => {
127+
validateInfos[key] = {
128+
autoLink: false,
129+
required: isRequired(unref(rulesRef)[key]),
130+
};
131+
});
132+
validateInfos = reactive(validateInfos);
133+
const resetFields = (newValues: Props) => {
134+
Object.assign(unref(modelRef), {
135+
...cloneDeep(initialModel),
136+
...newValues,
137+
});
138+
nextTick(() => {
139+
Object.keys(validateInfos).forEach(key => {
140+
validateInfos[key] = {
141+
autoLink: false,
142+
required: isRequired(unref(rulesRef)[key]),
143+
};
144+
});
145+
});
146+
};
147+
const filterRules = (rules = [], trigger: string[]) => {
148+
if (!trigger.length) {
149+
return rules;
150+
} else {
151+
return rules.filter(rule => {
152+
const triggerList = toArray(rule.trigger || 'change');
153+
return intersection(triggerList, trigger).length;
154+
});
155+
}
156+
};
157+
158+
let lastValidatePromise = null;
159+
const validateFields = (names: string[], option: validateOptions = {}, strict: boolean) => {
160+
// Collect result in promise list
161+
const promiseList: Promise<{
162+
name: string;
163+
errors: string[];
164+
}>[] = [];
165+
const values = {};
166+
for (let i = 0; i < names.length; i++) {
167+
const name = names[i];
168+
const prop = getPropByPath(unref(modelRef), name, strict);
169+
if (!prop.isValid) continue;
170+
values[name] = prop.v;
171+
const rules = filterRules(unref(rulesRef)[name], toArray(option && option.trigger));
172+
if (rules.length) {
173+
promiseList.push(
174+
validateField(name, prop.v, rules, option || {})
175+
.then(() => ({
176+
name,
177+
errors: [],
178+
warnings: [],
179+
}))
180+
.catch((ruleErrors: RuleError[]) => {
181+
const mergedErrors: string[] = [];
182+
const mergedWarnings: string[] = [];
183+
184+
ruleErrors.forEach(({ rule: { warningOnly }, errors }) => {
185+
if (warningOnly) {
186+
mergedWarnings.push(...errors);
187+
} else {
188+
mergedErrors.push(...errors);
189+
}
190+
});
191+
192+
if (mergedErrors.length) {
193+
return Promise.reject({
194+
name,
195+
errors: mergedErrors,
196+
warnings: mergedWarnings,
197+
});
198+
}
199+
200+
return {
201+
name,
202+
errors: mergedErrors,
203+
warnings: mergedWarnings,
204+
};
205+
}),
206+
);
207+
}
208+
}
209+
210+
const summaryPromise = allPromiseFinish(promiseList);
211+
lastValidatePromise = summaryPromise;
212+
213+
const returnPromise = summaryPromise
214+
.then(() => {
215+
if (lastValidatePromise === summaryPromise) {
216+
return Promise.resolve(values);
217+
}
218+
return Promise.reject([]);
219+
})
220+
.catch((results: any[]) => {
221+
const errorList = results.filter(
222+
(result: { errors: string | any[] }) => result && result.errors.length,
223+
);
224+
return Promise.reject({
225+
values,
226+
errorFields: errorList,
227+
outOfDate: lastValidatePromise !== summaryPromise,
228+
});
229+
});
230+
231+
// Do not throw in console
232+
returnPromise.catch((e: any) => e);
233+
234+
return returnPromise;
235+
};
236+
const validateField = (
237+
name: string,
238+
value: any,
239+
rules: any,
240+
option: validateOptions,
241+
): Promise<RuleError[]> => {
242+
const promise = validateRules(
243+
[name],
244+
value,
245+
rules,
246+
{
247+
validateMessages: defaultValidateMessages,
248+
...option,
249+
},
250+
!!option.validateFirst,
251+
);
252+
validateInfos[name].validateStatus = 'validating';
253+
promise
254+
.catch((e: any) => e)
255+
.then((results: RuleError[] = []) => {
256+
if (validateInfos[name].validateStatus === 'validating') {
257+
const res = results.filter(result => result && result.errors.length);
258+
validateInfos[name].validateStatus = res.length ? 'error' : 'success';
259+
validateInfos[name].help = res.length ? res.map(r => r.errors) : '';
260+
}
261+
});
262+
return promise;
263+
};
264+
265+
const validate = (names?: namesType, option?: validateOptions): Promise<any> => {
266+
let keys = [];
267+
let strict = true;
268+
if (!names) {
269+
strict = false;
270+
keys = rulesKeys.value;
271+
} else if (Array.isArray(names)) {
272+
keys = names;
273+
} else {
274+
keys = [names];
275+
}
276+
const promises = validateFields(keys, option || {}, strict);
277+
// Do not throw in console
278+
promises.catch((e: any) => e);
279+
return promises;
280+
};
281+
282+
const clearValidate = (names?: namesType) => {
283+
let keys = [];
284+
if (!names) {
285+
keys = rulesKeys.value;
286+
} else if (Array.isArray(names)) {
287+
keys = names;
288+
} else {
289+
keys = [names];
290+
}
291+
keys.forEach(key => {
292+
validateInfos[key] &&
293+
Object.assign(validateInfos[key], {
294+
validateStatus: '',
295+
help: '',
296+
});
297+
});
298+
};
299+
300+
const mergeValidateInfo = (items: ValidateInfo[] | ValidateInfo) => {
301+
const info = { autoLink: false } as ValidateInfo;
302+
const help = [];
303+
const infos = Array.isArray(items) ? items : [items];
304+
for (let i = 0; i < infos.length; i++) {
305+
const arg = infos[i] as ValidateInfo;
306+
if (arg?.validateStatus === 'error') {
307+
info.validateStatus = 'error';
308+
arg.help && help.push(arg.help);
309+
}
310+
info.required = info.required || arg?.required;
311+
}
312+
info.help = help;
313+
return info;
314+
};
315+
let oldModel = initialModel;
316+
const modelFn = (model: { [x: string]: any }) => {
317+
const names = [];
318+
rulesKeys.value.forEach(key => {
319+
const prop = getPropByPath(model, key, false);
320+
const oldProp = getPropByPath(oldModel, key, false);
321+
if (!isEqual(prop.v, oldProp.v)) {
322+
names.push(key);
323+
}
324+
});
325+
validate(names, { trigger: 'change' });
326+
oldModel = cloneDeep(model);
327+
};
328+
const debounceOptions = options?.debounce;
329+
watch(
330+
modelRef,
331+
debounceOptions && debounceOptions.wait
332+
? debounce(modelFn, debounceOptions.wait, omit(debounceOptions, ['wait']))
333+
: modelFn,
334+
{ immediate: options && !!options.immediate, deep: true },
335+
);
336+
337+
watch(
338+
rulesRef,
339+
() => {
340+
if (options && options.validateOnRuleChange) {
341+
validate();
342+
}
343+
},
344+
{ deep: true },
345+
);
346+
347+
return {
348+
modelRef,
349+
rulesRef,
350+
initialModel,
351+
validateInfos,
352+
resetFields,
353+
validate,
354+
validateField,
355+
mergeValidateInfo,
356+
clearValidate,
357+
};
358+
}
359+
360+
export default useForm;

0 commit comments

Comments
 (0)