UNPKG

vue3-form-validation

Version:
1,056 lines (1,036 loc) 35.7 kB
import { isRef, ref, computed, reactive, watch, shallowReactive, unref } from 'vue'; class LinkedListNode { constructor(value) { Object.defineProperty(this, "value", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "next", { enumerable: true, configurable: true, writable: true, value: null }); Object.defineProperty(this, "prev", { enumerable: true, configurable: true, writable: true, value: null }); this.value = value; } } class LinkedList { constructor() { Object.defineProperty(this, "first", { enumerable: true, configurable: true, writable: true, value: null }); Object.defineProperty(this, "last", { enumerable: true, configurable: true, writable: true, value: null }); Object.defineProperty(this, "count", { enumerable: true, configurable: true, writable: true, value: 0 }); } addFirst(value) { const node = new LinkedListNode(value); if (this.count === 0) { this.first = node; this.last = node; } else { node.next = this.first; this.first.prev = node; this.first = node; } this.count++; return node; } addLast(value) { const node = new LinkedListNode(value); if (this.count === 0) { this.first = node; this.last = node; } else { node.prev = this.last; this.last.next = node; this.last = node; } this.count++; return node; } remove(node) { if (this.count === 0) { return; } if (node === this.first) { this.removeFirst(); } else if (node === this.last) { this.removeLast(); } else { node.prev.next = node.next; node.next.prev = node.prev; node.next = null; node.prev = null; this.count--; } } removeFirst() { if (this.count === 0) { return; } if (this.count === 1) { this.first = null; this.last = null; this.count--; } else { this.first = this.first.next; this.first.prev.next = null; this.first.prev = null; this.count--; } } removeLast() { if (this.count === 0) { return; } if (this.count === 1) { this.first = null; this.last = null; this.count--; } else { this.last = this.last.prev; this.last.next.prev = null; this.last.next = null; this.count--; } } *nodesForwards() { let node = this.first; for (; node !== null; node = node.next) { yield node; } } *nodesBackwards() { let node = this.last; for (; node !== null; node = node.prev) { yield node; } } } const isDefined = (x) => x !== null && x !== undefined; const isRecord = (x) => typeof x === 'object' && x !== null && !Array.isArray(x); const isArray = (x) => Array.isArray(x); const isObject = (x) => typeof x === 'object' && x !== null; function* deepIterator(obj, predicate = () => false) { const stack = new LinkedList(); stack.addLast({ current: obj, parent: null, parentKey: '', path: [] }); while (stack.count > 0) { const { current, parent, parentKey, path } = stack.last.value; stack.removeLast(); let pushedItemsOnStack = false; if (isObject(current) && !isRef(current) && !predicate(current)) { const entries = Object.entries(current); pushedItemsOnStack = entries.length > 0; for (let i = entries.length - 1; i >= 0; i--) { const [key, value] = entries[i]; stack.addLast({ current: value, parent: current, parentKey: key, path: [...path, key] }); } } if (isObject(parent)) { yield { key: parentKey, value: parent[parentKey], parent, path, isLeaf: !pushedItemsOnStack }; } } } function set(obj, keys, value) { if (keys.length === 0) { return; } let o = obj; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; const nextKey = keys[i + 1]; const value = o[key]; if (value === undefined) { if (Number.isNaN(+nextKey)) { o[key] = {}; } else { o[key] = []; } } o = o[key]; } o[keys[keys.length - 1]] = value; } function deepCopy(toClone) { if (isObject(toClone)) { const copy = isArray(toClone) ? [] : {}; for (const { value, path, isLeaf } of deepIterator(toClone)) { if (isLeaf) { set(copy, path, value); } } return copy; } return toClone; } const trySet = (map) => ({ success, failure }) => (key, value) => { const _value = map.get(key); if (_value) { failure === null || failure === void 0 ? void 0 : failure(_value); } else { map.set(key, value); success === null || success === void 0 ? void 0 : success(value); } }; const tryGet = (map) => ({ success, failure }) => (key) => { const value = map.get(key); if (value) { success(value); } else { failure === null || failure === void 0 ? void 0 : failure(); } }; function path(path, obj) { let value = obj[path[0]]; for (let i = 0; i < path.length; i++) { const key = path[i]; if (value === null || value === undefined) { return undefined; } if (i > 0) { value = value[key]; } } return value; } let id = 1; function uid() { return id++; } function debounce(target, { wait }) { let timerId = null; function cancel() { clearTimeout(timerId); } function debounced(...args) { const effect = () => { timerId = null; target.apply(this, args); }; clearTimeout(timerId); timerId = setTimeout(effect, wait); } debounced.cancel = cancel; return debounced; } class PromiseCancel { constructor() { Object.defineProperty(this, "promise", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "resolve", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "reject", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "isRacing", { enumerable: true, configurable: true, writable: true, value: false }); this.assign(); } cancelResolve(value) { if (this.isRacing) { this.isRacing = false; this.resolve(value); this.assign(); } } cancelReject(reason) { if (this.isRacing) { this.isRacing = false; this.reject(reason); this.assign(); } } race(...promises) { this.isRacing = true; return Promise.race([this.promise, ...promises]); } assign() { this.promise = new Promise((resolve, reject) => { this.resolve = resolve; this.reject = reject; }); } } class ValidationError extends Error { constructor() { super('One or more validation errors occurred.'); } } const isSimpleRule = (rule) => typeof rule === 'function'; const unpackRule = (rule) => (isSimpleRule(rule) ? rule : rule.rule); class FormField { constructor(form, uid, name, modelValue, ruleInfos) { Object.defineProperty(this, "watchStopHandle", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "form", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "ruleInfos", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "rulesValidating", { enumerable: true, configurable: true, writable: true, value: ref(0) }); Object.defineProperty(this, "initialModelValue", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "uid", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "name", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "touched", { enumerable: true, configurable: true, writable: true, value: ref(false) }); Object.defineProperty(this, "dirty", { enumerable: true, configurable: true, writable: true, value: ref(false) }); Object.defineProperty(this, "modelValue", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "rawErrors", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "errors", { enumerable: true, configurable: true, writable: true, value: computed(() => this.rawErrors.filter(isDefined)) }); Object.defineProperty(this, "validating", { enumerable: true, configurable: true, writable: true, value: computed(() => this.rulesValidating.value > 0) }); Object.defineProperty(this, "hasError", { enumerable: true, configurable: true, writable: true, value: computed(() => this.errors.value.length > 0) }); this.form = form; this.uid = uid; this.name = name; this.modelValue = ref(modelValue); this.rawErrors = reactive(ruleInfos.map(() => null)); this.initialModelValue = deepCopy(this.modelValue.value); this.ruleInfos = ruleInfos.map((info, ruleNumber) => { let validator; const validatorNotDebounced = (modelValues, force, submit) => { if (this.shouldValidate(ruleNumber, force, submit) === true) { return this.validate(ruleNumber, modelValues); } }; let debouncedValidator; let debounceInvokedTimes = 0; let debounceResolve; if (info.debounce) { debouncedValidator = debounce(modelValues => { debounceResolve(this.validate(ruleNumber, modelValues)); this.rulesValidating.value -= debounceInvokedTimes; this.form.rulesValidating.value -= debounceInvokedTimes; debounceInvokedTimes = 0; }, { wait: info.debounce }); validator = (modelValues, force, submit) => { if (this.shouldValidate(ruleNumber, force, submit) === true) { debounceInvokedTimes++; this.rulesValidating.value++; this.form.rulesValidating.value++; return new Promise(resolve => { debounceResolve === null || debounceResolve === void 0 ? void 0 : debounceResolve(); debounceResolve = resolve; debouncedValidator(modelValues, force, submit); }); } }; } else { validator = validatorNotDebounced; } return { buffer: new LinkedList(), rule: unpackRule(info.rule), validator, validatorNotDebounced, validationBehavior: info.validationBehavior, cancelDebounce: () => { if (debouncedValidator) { debounceInvokedTimes = 0; debouncedValidator.cancel(); debounceResolve === null || debounceResolve === void 0 ? void 0 : debounceResolve(); } } }; }); this.watchStopHandle = this.setupWatcher(); } async validate(ruleNumber, modelValues) { var _a; const { rule, buffer } = this.ruleInfos[ruleNumber]; if (!rule) { return; } let error; const ruleResult = rule(...modelValues); if ((_a = buffer.last) === null || _a === void 0 ? void 0 : _a.value) { buffer.last.value = false; this.rulesValidating.value--; this.form.rulesValidating.value--; } if (typeof (ruleResult === null || ruleResult === void 0 ? void 0 : ruleResult.then) === 'function') { const shouldSetError = buffer.addLast(true); this.rulesValidating.value++; this.form.rulesValidating.value++; try { error = await ruleResult; } catch (err) { error = err; } buffer.remove(shouldSetError); if (shouldSetError.value) { this.rulesValidating.value--; this.form.rulesValidating.value--; this.setError(ruleNumber, error); } else { /** * This branch is reached in one of two cases: * 1. While this rule was validating the same async rule was invoked again. * 2. While this rule was validating the field was reset. * * In both cases, no error is to be set but the promise should still reject * if the rule returns a string. */ if (typeof error === 'string') { throw error; } } } else { error = ruleResult; this.setError(ruleNumber, error); } } reset(resetValue = this.initialModelValue) { this.watchStopHandle(); this.touched.value = false; this.dirty.value = false; this.modelValue.value = deepCopy(resetValue); this.rulesValidating.value = 0; this.form.rulesValidating.value = 0; for (let i = 0; i < this.ruleInfos.length; i++) { this.rawErrors[i] = null; this.ruleInfos[i].cancelDebounce(); for (const shouldSetError of this.ruleInfos[i].buffer.nodesForwards()) { shouldSetError.value = false; } } this.watchStopHandle = this.setupWatcher(); } dispose() { this.errors.effect.stop(); this.validating.effect.stop(); this.hasError.effect.stop(); this.watchStopHandle(); } shouldValidate(ruleNumber, force, submit) { return this.ruleInfos[ruleNumber].validationBehavior({ hasError: this.rawErrors[ruleNumber] !== null, touched: this.touched.value, dirty: this.dirty.value, force, submit, value: this.modelValue.value }); } setError(ruleNumber, error) { if (typeof error === 'string') { this.rawErrors[ruleNumber] = error; throw error; } else { this.rawErrors[ruleNumber] = null; } } setupWatcher() { return watch(this.modelValue, () => { this.dirty.value = true; this.form.validate(this.uid); }, { deep: true }); } } class Form { constructor() { Object.defineProperty(this, "simpleValidators", { enumerable: true, configurable: true, writable: true, value: new Map() }); Object.defineProperty(this, "keyedValidators", { enumerable: true, configurable: true, writable: true, value: new Map() }); Object.defineProperty(this, "reactiveFields", { enumerable: true, configurable: true, writable: true, value: shallowReactive(new Map()) }); Object.defineProperty(this, "tryGetSimpleValidators", { enumerable: true, configurable: true, writable: true, value: tryGet(this.simpleValidators) }); Object.defineProperty(this, "trySetKeyedValidators", { enumerable: true, configurable: true, writable: true, value: trySet(this.keyedValidators) }); Object.defineProperty(this, "tryGetKeyedValidators", { enumerable: true, configurable: true, writable: true, value: tryGet(this.keyedValidators) }); Object.defineProperty(this, "rulesValidating", { enumerable: true, configurable: true, writable: true, value: ref(0) }); Object.defineProperty(this, "submitting", { enumerable: true, configurable: true, writable: true, value: ref(false) }); Object.defineProperty(this, "validating", { enumerable: true, configurable: true, writable: true, value: computed(() => this.rulesValidating.value > 0) }); Object.defineProperty(this, "hasError", { enumerable: true, configurable: true, writable: true, value: computed(() => this.errors.value.length > 0) }); Object.defineProperty(this, "errors", { enumerable: true, configurable: true, writable: true, value: computed(() => { const errors = []; for (const field of this.reactiveFields.values()) { errors.push(...field.errors.value); } return errors; }) }); } registerField(uid, name, modelValue, ruleInfos) { const field = new FormField(this, uid, name, modelValue, ruleInfos); const simpleValidators = { validators: [], validatorsNotDebounced: [], meta: { field, keys: [], rollbacks: [] } }; ruleInfos.forEach(({ rule }, ruleNumber) => { const validator = field.ruleInfos[ruleNumber].validator; const validatorNotDebounced = field.ruleInfos[ruleNumber].validatorNotDebounced; if (isSimpleRule(rule)) { simpleValidators.validators.push(validator); simpleValidators.validatorsNotDebounced.push(validatorNotDebounced); } else { const keyedValidator = { validator, validatorNotDebounced, meta: { field } }; const rollback = () => { this.tryGetKeyedValidators({ success: keyedValidators => { keyedValidators.delete(keyedValidator); if (keyedValidators.size === 0) { this.keyedValidators.delete(rule.key); } } })(rule.key); }; simpleValidators.meta.keys.push(rule.key); simpleValidators.meta.rollbacks.push(rollback); this.trySetKeyedValidators({ failure: keyedValidators => keyedValidators.add(keyedValidator) })(rule.key, new Set([keyedValidator])); } }); this.simpleValidators.set(uid, simpleValidators); this.reactiveFields.set(uid, field); return field; } validate(uid, force = false) { const { validators, meta } = this.simpleValidators.get(uid); return Promise.allSettled([ ...validators.map(validator => validator([meta.field.modelValue.value], force, false)), ...this.collectValidatorResultsForKeysDebounced(meta.keys, force) ]); } async validateAll(names) { const settledResults = await Promise.allSettled(this.collectValidatorResultsForNames(names)); for (const result of settledResults) { if (result.status === 'rejected') { throw new ValidationError(); } } } dispose(uid) { this.tryGetSimpleValidators({ success: ({ meta }) => { meta.field.dispose(); meta.rollbacks.forEach(r => r()); } })(uid); this.simpleValidators.delete(uid); this.reactiveFields.delete(uid); } resetFields() { for (const { meta } of this.simpleValidators.values()) { meta.field.reset(); } } getField(uid) { const simpleValidators = this.simpleValidators.get(uid); if (simpleValidators) { return simpleValidators.meta.field; } } /** * Should only be called from `validateAll` * (`force` and `submit` will default to `false` and `true`). * * @param keys The keys of the rules to validate */ *collectValidatorResultsForKeys(keys) { for (const key of keys) { const keyedValidators = this.keyedValidators.get(key); const values = [...keyedValidators.values()]; const modelValues = values.map(({ meta }) => meta.field.modelValue.value); for (const { validatorNotDebounced } of values) { yield validatorNotDebounced(modelValues, false, true); } } } /** * Should only be called from `validate` * (`submit` will default to `false`). * It will also check if every field of a key is touched before * invoking the validator and not use the debounced version. * * @param keys The keys of the rules to validate */ *collectValidatorResultsForKeysDebounced(keys, force) { for (const key of keys) { const keyedValidators = this.keyedValidators.get(key); if (this.isEveryFieldTouched(keyedValidators)) { const values = [...keyedValidators.values()]; const modelValues = values.map(({ meta }) => meta.field.modelValue.value); for (const { validator } of values) { yield validator(modelValues, force, false); } } } } *collectValidatorResultsForNames(names) { if (names === undefined) { for (const { validatorsNotDebounced, meta } of this.simpleValidators.values()) { meta.field.touched.value = true; for (const validator of validatorsNotDebounced) { yield validator([meta.field.modelValue.value], false, true); } } yield* this.collectValidatorResultsForKeys(this.keyedValidators.keys()); } else if (names.length > 0) { const uniqueNames = new Set(names); for (const { validatorsNotDebounced, meta } of this.simpleValidators.values()) { if (uniqueNames.has(meta.field.name)) { meta.field.touched.value = true; for (const validator of validatorsNotDebounced) { yield validator([meta.field.modelValue.value], false, true); } yield* this.collectValidatorResultsForKeys(this.keyedValidators.keys()); } } } } isEveryFieldTouched(keyedValidators) { for (const { meta } of keyedValidators) { if (!meta.field.touched.value) { return false; } } return true; } } class ValidationConfig { constructor() { Object.defineProperty(this, "defaultValidationBehavior", { enumerable: true, configurable: true, writable: true, value: null }); Object.defineProperty(this, "validationBehavior", { enumerable: true, configurable: true, writable: true, value: new Map() }); } getDefaultValidationBehavior() { if (this.defaultValidationBehavior === null) { return () => true; } return this.validationBehavior.get(this.defaultValidationBehavior); } } const VALIDATION_CONFIG = new ValidationConfig(); const isField = (x) => isRecord(x) ? '$value' in x : false; const isTransformedField = (x) => isRecord(x) ? '$uid' in x && '$value' in x : false; function mapFieldRules(fieldRules) { const defaultValidationBehavior = VALIDATION_CONFIG.getDefaultValidationBehavior(); return fieldRules.map(fieldRule => { if (typeof fieldRule === 'function') { return { validationBehavior: defaultValidationBehavior, rule: fieldRule }; } if (Array.isArray(fieldRule)) { const [first, second, third] = fieldRule; if (typeof second === 'number') { return { validationBehavior: defaultValidationBehavior, rule: first, debounce: second }; } if (typeof first === 'function') { return { validationBehavior: first, rule: second, debounce: third }; } const validationBehavior = VALIDATION_CONFIG.validationBehavior.get(first); if (validationBehavior !== undefined) { return { validationBehavior, rule: second, debounce: third }; } else { throw new Error(`[useValidation] Validation behavior with name '${first}' does not exist. Valid values are: ${[ ...VALIDATION_CONFIG.validationBehavior.keys() ].join(', ')}`); } } else { return { validationBehavior: defaultValidationBehavior, rule: fieldRule }; } }); } function registerField(form, name, field) { const { $value, $rules, ...fieldExtraProperties } = field; const rules = $rules ? mapFieldRules($rules) : []; const uid$1 = uid(); const formField = form.registerField(uid$1, name, $value, rules); return { ...fieldExtraProperties, $uid: uid$1, $value: formField.modelValue, $errors: formField.errors, $hasError: formField.hasError, $rawErrors: formField.rawErrors, $validating: formField.validating, $dirty: formField.dirty, $touched: formField.touched, async $validate({ setTouched, force } = {}) { setTouched !== null && setTouched !== void 0 ? setTouched : (setTouched = true); force !== null && force !== void 0 ? force : (force = true); if (setTouched) { formField.touched.value = true; } await form.validate(uid$1, force); } }; } function transformFormData(form, formData) { for (const { key, value, parent } of deepIterator(formData)) { if (isField(value)) { const transformedField = registerField(form, key, value); parent[key] = transformedField; } } } function getResultFormData(transformedFormData, predicate = () => true) { const result = {}; for (const { key, value, path, isLeaf } of deepIterator(transformedFormData, isTransformedField)) { if (isLeaf) { const unpackedValue = isTransformedField(value) ? value.$value : unref(value); if (predicate({ key, value: unpackedValue, path }) === true) { set(result, path, deepCopy(unpackedValue)); } } } return result; } function disposeForm(form, deletedFormData) { for (const { value } of deepIterator({ box: deletedFormData }, isTransformedField)) { if (isTransformedField(value)) { form.dispose(value.$uid); } } } function resetFields(form, data, transformedFormData) { Object.entries(data).forEach(([key, value]) => { const transformedValue = transformedFormData[key]; if (isTransformedField(transformedValue)) { const field = form.getField(transformedValue.$uid); field.reset(value); return; } if (isObject(value)) { resetFields(form, value, transformedFormData[key]); } }); } /** * Vue composition function for form validation. * * @remarks * For type inference in `useValidation` make sure to define the structure of your * form data upfront and pass it as the generic parameter `FormData`. * * @param formData - The structure of your form data * * @example * ``` * type FormData = { * name: Field<string>, * password: Field<string> * } * * const { form } = useValidation<FormData>({ * name: { * $value: '', * $rules: [] * }, * password: { * $value: '', * $rules: [] * } * }) * ``` */ function useValidation(formData) { const form = new Form(); const promiseCancel = new PromiseCancel(); transformFormData(form, formData); const transformedFormData = reactive(formData); return { form: transformedFormData, submitting: form.submitting, validating: form.validating, hasError: form.hasError, errors: form.errors, async validateFields({ names, predicate } = {}) { form.submitting.value = true; const resultFormData = getResultFormData(transformedFormData, predicate); try { await promiseCancel.race(form.validateAll(names)); } finally { form.submitting.value = false; } return resultFormData; }, resetFields(formData) { promiseCancel.cancelReject(new ValidationError()); if (formData === undefined) { form.resetFields(); } else { resetFields(form, formData, transformedFormData); } }, add(path$1, value) { const lastKey = path$1[path$1.length - 1]; if (lastKey !== undefined) { const box = { [lastKey]: value }; transformFormData(form, box); const transformedValue = box[lastKey]; const valueAtPath = path(path$1, transformedFormData); if (Array.isArray(valueAtPath)) { valueAtPath.push(transformedValue); } else { set(transformedFormData, path$1, transformedValue); } } }, remove(path$1) { const lastKey = path$1.pop(); if (lastKey !== undefined) { if (path$1.length === 0) { disposeForm(form, transformedFormData[lastKey]); delete transformedFormData[lastKey]; } else { const valueAtPath = path(path$1, transformedFormData); if (Array.isArray(valueAtPath)) { const deletedFormData = valueAtPath.splice(+lastKey, 1); disposeForm(form, deletedFormData); } else { disposeForm(form, valueAtPath[lastKey]); delete valueAtPath[lastKey]; } } } } }; } /** * Configure the validation behavior of `useValidation`. * * @param configuration - The form validation configuration */ function createValidation(configuration) { return { install() { var _a; for (const [key, validationBehavior] of Object.entries((_a = configuration.validationBehavior) !== null && _a !== void 0 ? _a : {})) { VALIDATION_CONFIG.validationBehavior.set(key, validationBehavior); } if (VALIDATION_CONFIG.validationBehavior.has(configuration.defaultValidationBehavior)) { VALIDATION_CONFIG.defaultValidationBehavior = configuration.defaultValidationBehavior; } else { console.warn(`[useValidation] Default validation behavior '${configuration.defaultValidationBehavior}' is not valid. Valid values are`, VALIDATION_CONFIG.validationBehavior.keys()); } } }; } export { ValidationError, createValidation, useValidation };