svelte-hook-form
Version:
A better version of form validation.
546 lines (486 loc) • 15.3 kB
text/typescript
import { writable, readable } from "svelte/store";
import type { Readable } from "svelte/store";
import { resolveRule } from "./rule";
import { normalizeObject, toPromise } from "./util";
import type {
FormConfig,
FieldState,
FieldOption,
ValidationRule,
FormState,
ResetFormOption,
FormControl,
Form,
Fields,
NodeElement,
RegisterOption,
SuccessCallback,
ErrorCallback
} from "./types";
import { FormField } from "./field";
import type { FieldPath, FieldValues } from "./paths";
const INPUT_FIELDS = ["INPUT", "SELECT", "TEXTAREA"];
const INPUT_SELECTORS = INPUT_FIELDS.join(",");
const DEFAULT_FORM_STATE = {
dirty: false,
submitCount: 0,
submitting: false,
touched: false,
pending: false,
valid: true
};
const DEFAULT_FIELD_STATE = {
defaultValue: "",
value: "",
pending: false,
dirty: false,
touched: false,
valid: true,
errors: []
};
/**
* Convert string to {@link ValidationRule} object.
*
* @param {string} rule validation rule, eg. "required|min=3"
* @returns {ValidationRule} validation rule object
*/
const _strToValidator = (rule: string): ValidationRule => {
const params = rule.split(/:/g);
const name = params.shift()!;
if (!resolveRule(name))
console.error(`[svelte-hook-form] invalid validation function "${name}"`);
return {
name,
validate: toPromise(resolveRule(name)),
params: params[0] ? params[0].split(",").map((v) => decodeURIComponent(v)) : []
};
};
/**
* Custom hook to manage the entire form.
*
* @param {FormConfig} config - configuration. {@link FormConfig}
* @returns {Form<F>} - individual functions to manage the form state. {@link Form<F>}
*
* @example
* ```svelte
* <script lang="ts">
* const form = useForm<{ name: string }>();
* const { control, onSubmit } = form;
*
* const handleSubmit = onSubmit((data) => {
* // PUT your business here when the form submit is success
* }, () => {
* // handle the error
* });
* </script>
*
* <form on:submit={handleSubmit}>
* <input value="test"/>
* <input />
* {errors.exampleRequired && <span>This field is required</span>}
* <input type="submit" value="Submit" />
* </form>
* ```
*/
export const useForm = <F extends FieldValues>(
config: FormConfig = { validateOnChange: true }
): Form<F> => {
// cache for form fields
const cache: Map<string, FormField> = new Map();
// global state for form
const form$ = writable<FormState>(Object.assign({}, DEFAULT_FORM_STATE));
const anyNonDirty = new Map();
const anyPending = new Map();
const anyNonTouched = new Map();
const anyInvalid = new Map();
// errors should be private variable
const errors$ = writable<Record<string, any>>({});
const _updateForm = () => {
form$.update((v) =>
Object.assign(v, {
valid: anyInvalid.size === 0,
dirty: anyNonDirty.size === 0,
touched: anyNonTouched.size === 0,
pending: anyPending.size > 0
})
);
};
const _useLocalStore = (path: string, state: Partial<FieldState>) => {
const { subscribe, set, update } = writable<FieldState>(
Object.assign({}, DEFAULT_FIELD_STATE, state)
);
let unsubscribe: null | Function;
const _unsubscribeStore = () => {
unsubscribe && unsubscribe();
unsubscribe = null;
};
return {
set,
update,
destroy() {
cache.delete(path); // clean our cache
anyInvalid.delete(path);
anyNonDirty.delete(path);
anyNonTouched.delete(path);
anyPending.delete(path);
_updateForm();
_unsubscribeStore();
},
subscribe(
run: (value: FieldState) => void,
invalidate?: (value?: FieldState) => void
) {
unsubscribe = subscribe(run, invalidate);
return _unsubscribeStore;
}
};
};
const _setStore = (path: string, state: Partial<FieldState> = {}) => {
const store$ = _useLocalStore(path, state);
cache.set(path, new FormField(store$, [], false));
};
const register = <T>(
path: FieldPath<F>,
option: RegisterOption<T> = {}
): Readable<FieldState> => {
const value = option.defaultValue;
const isNotEmpty = value !== undefined && value !== null;
const store$ = _useLocalStore(path, {
defaultValue: value,
value: value,
dirty: isNotEmpty
});
if (path === "") console.error("[svelte-hook-form] missing field name");
let ruleExprs: ValidationRule[] = [];
const { bail = false, rules = [] } = option;
const typeOfRule = typeof rules;
if (typeOfRule === "string") {
ruleExprs = ((rules as string).match(/[^\|]+/g) || []).map((v: string) =>
_strToValidator(v)
);
} else if (Array.isArray(rules)) {
ruleExprs = rules.reduce((acc: ValidationRule[], rule: any) => {
const typeOfVal = typeof rule;
// Skip null, undefined etc
if (!rule) return acc;
if (typeOfVal === "string") {
rule = (rule as string).trim();
rule && acc.push(_strToValidator(rule));
} else if (typeOfVal === "function") {
rule = rule as Function;
if (rule.name === "")
console.error("[svelte-hook-form] validation rule function name is empty");
acc.push({
name: rule.name,
validate: toPromise(<Function>rule),
params: []
});
}
return acc;
}, []);
} else if (typeOfRule !== null && typeOfRule === "object") {
ruleExprs = Object.entries(<object>rules).reduce(
(acc: ValidationRule[], cur: [string, any]) => {
const [name, params] = cur;
acc.push({
name,
validate: toPromise(resolveRule(name)),
params: Array.isArray(params) ? params : [params]
});
return acc;
},
[]
);
} else {
console.error(
`[svelte-hook-form] invalid data type for validation rule ${typeOfRule}!`
);
}
const field = new FormField(store$, ruleExprs, bail);
cache.set(path, field);
// if (isNotEmpty && option.validateOnMount) {
// _validate(field, path, {});
// }
if (config.validateOnChange) {
// on every state change, it will update the form
store$.subscribe((state) => {
if (state.valid) anyInvalid.delete(path);
else if (!state.valid) anyInvalid.set(path, true);
if (state.dirty) anyNonDirty.delete(path);
else if (!state.dirty) anyNonDirty.set(path, true);
if (!state.pending) anyPending.delete(path);
else if (state.pending) anyPending.set(path, true);
if (state.touched) anyNonTouched.delete(path);
else if (!state.touched) anyNonTouched.set(path, true);
_updateForm();
});
}
return {
subscribe: store$.subscribe
};
};
const unregister = (path: string) => {
if (cache.has(path)) {
// clear subscriptions and cache
// cache.get(path)![0].destroy();
}
};
const setValue = (path: string, value: any): void => {
if (!cache.has(path)) {
_setStore(path);
return;
}
if (value.target) {
const target = <HTMLInputElement>value.target;
value = target.value;
} else if (value.currentTarget) {
const target = <HTMLInputElement>value.currentTarget;
value = target.value;
} else if (value instanceof CustomEvent) {
value = value.detail;
}
const field = cache.get(path)!;
if (config.validateOnChange) {
field.validate(value);
} else {
field.setState({ dirty: true, value });
}
};
const setError = (path: FieldPath<F>, errors: string[]): void => {
if (cache.has(path)) {
cache.get(path)!.setState({ errors });
} else {
_setStore(path, { errors });
}
};
const setFocus = (path: string, touched: boolean): void => {
if (cache.has(path)) {
const field = cache.get(path)!;
field.setState({ touched });
if (!touched) field.validate();
}
};
const getValue = <T>(path: string): T | null => {
if (!cache.has(path)) return null;
return <T>cache.get(path)!.state.value;
};
const _useField = (node: Element, option: FieldOption = {}) => {
option = Object.assign({ rules: [], defaultValue: "" }, option);
while (!INPUT_FIELDS.includes(node.nodeName)) {
const el = <NodeElement>node.querySelector(INPUT_SELECTORS);
node = el;
if (el) break;
}
const name = (<NodeElement>node).name || node.id;
if (name === "") console.error("[svelte-hook-form] empty field name or id");
const { rules } = option;
const defaultValue =
(<HTMLInputElement>node).defaultValue ||
(<NodeElement>node).value ||
option.defaultValue;
const state$ = register(name as any, { defaultValue, rules });
const onChange = (e: Event) => {
setValue(name, (<HTMLInputElement>e.currentTarget).value);
};
const onFocus = (focused: boolean) => () => {
setFocus(name, focused);
};
if (defaultValue) {
node.setAttribute("value", defaultValue);
}
const listeners: Array<[string, (e: Event) => void]> = [];
const _attachEvent = (event: string, cb: (e: Event) => void, opts?: object) => {
node.addEventListener(event, cb, opts);
listeners.push([event, cb]);
};
const _detachEvents = () => {
for (let i = 0, len = listeners.length; i < len; i++) {
const [event, cb] = listeners[i];
node.removeEventListener(event, cb);
}
};
_attachEvent("focus", onFocus(true));
_attachEvent("blur", onFocus(false));
if (config.validateOnChange) {
_attachEvent("input", onChange);
_attachEvent("change", onChange);
}
// if (option.validateOnMount) {
// const field = cache.get(name)!;
// // _validate(field, name, { value: defaultValue });
// }
let unsubscribe: null | Function;
if (option.handleChange) {
unsubscribe = state$.subscribe((v: FieldState) => {
(<(state: FieldState, node: Element) => void>option.handleChange)(v, node);
});
}
return {
// Release memory when unmount
destroy() {
_detachEvents();
unregister(name);
unsubscribe && unsubscribe();
}
};
};
const reset = (values?: Fields, option?: ResetFormOption) => {
console.log(values);
const defaultOption = {
dirtyFields: false,
errors: false
};
option = Object.assign(defaultOption, option || {});
if (option.errors) {
errors$.set({}); // reset errors
}
const fields = Array.from(cache.values());
for (let i = 0, len = fields.length; i < len; i++) {
// const [store$] = fields[i];
// store$.update((v) => {
// const { defaultValue } = v;
// return Object.assign({}, DEFAULT_FIELD_STATE, {
// defaultValue,
// value: defaultValue
// });
// });
}
};
const useField = (node: Element, option: FieldOption = {}) => {
let field = _useField(node, option);
return {
update(newOption: FieldOption = {}) {
field.destroy(); // reset
field = _useField(node, newOption);
},
destroy() {
field.destroy();
}
};
};
const onSubmit =
(successCallback: SuccessCallback<F>, errorCallback?: ErrorCallback) =>
async (e: SubmitEvent) => {
e.preventDefault();
e.stopPropagation();
form$.update((v) =>
Object.assign(v, {
valid: false,
submitCount: v.submitCount + 1,
submitting: true
})
);
let data: Record<string, any> = {};
let errors: Record<string, any> = {};
let valid = true;
// Reset the errors
errors$.set(errors);
// Handle the fields cached in the map
const keys = Array.from(cache.keys());
for (const [key, field] of cache.entries()) {
const result = await field.validate();
// Update valid on each loop, if one of the field is invalid,
// the form valid is invalid
valid = valid && result.valid;
if (!result.valid) errors[key] = result.errors;
data = normalizeObject(data, key, field.state.value);
}
// Handle the fields whose never register in the cache
const { elements = [] } = <HTMLFormElement>(e.currentTarget || e.target);
for (let i = 0, len = elements.length; i < len; i++) {
const el = <HTMLInputElement>elements[i];
const name = el.name || el.id;
let value = el.value || "";
if (!name) continue;
// Skip if the key exists in cache
if (keys.includes(name)) continue;
if (config.resolver) {
data = normalizeObject(data, name, value);
continue;
}
// TODO: check checkbox and radio
const { type } = el;
switch (type) {
case "checkbox":
value = el.checked ? value : "";
break;
}
data = normalizeObject(data, name, value);
}
// if (config.resolver) {
// try {
// await config.resolver.validate(data);
// } catch (e) {
// valid = false;
// }
// }
errors$.set(errors);
if (valid) {
await toPromise<void>(successCallback)(<F>data, e);
} else {
errorCallback && errorCallback(errors, e);
}
// Submitting should end only after execute user callback
form$.update((v) => Object.assign(v, { valid, submitting: false }));
};
const validate = (paths: string | string[] = Array.from(cache.keys())) => {
if (!Array.isArray(paths)) paths = [paths];
const promises: Promise<FieldState>[] = [];
let data = {};
for (let i = 0, len = paths.length; i < len; i++) {
if (!cache.has(paths[i])) continue;
// const field = cache.get(paths[i])!;
// const state = get(field[0]);
// promises.push(_validate(field, paths[i], state.value));
// data = normalizeObject(data, paths[i], state.value);
}
return Promise.all(promises).then((result: FieldState[]) => {
return {
valid: result.every((state) => state.valid),
data: data as F
};
});
};
const getValues = (): F => {
let data = {};
const entries = cache.entries();
for (const [key, field] of entries) {
data = normalizeObject(data, key, field.state);
}
return data as F;
};
const context = {
register,
unregister,
setValue,
getValue,
setError,
setFocus,
getValues,
reset,
watch: (key: string) => {
if (!cache.has(key)) {
_setStore(key);
}
return cache.get(key)!.watch;
},
field: useField
} as FormControl<F>;
return {
...context,
control: readable(context),
errors: {
subscribe: errors$.subscribe
},
validate,
onSubmit,
subscribe(run: (value: FormState) => void, invalidate?: (value?: FormState) => void) {
const unsubscribe = form$.subscribe(run, invalidate);
return () => {
// prevent memory leak
unsubscribe();
cache.clear(); // clean our cache
};
}
};
};