UNPKG

svelte-hook-form

Version:
546 lines (486 loc) 15.3 kB
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 }; } }; };