UNPKG

validierung

Version:

Vue Composition Function for Form Validation

835 lines (813 loc) 25.1 kB
import { reactive, isVue3, set as set$1, isVue2, isRef, ref, computed, watch, shallowReactive, unref, del } from 'vue-demi'; const isDefined = (value) => value !== null && value !== void 0; const isRecord = (value) => isObject(value) && !Array.isArray(value); const isArray = (value) => Array.isArray(value); const isObject = (value) => typeof value === "object" && value !== null && !(value instanceof File); function deepCopyImpl(toCopy, copy) { for (const [key, value] of Object.entries(toCopy)) { if (isObject(value)) { copy[key] = isArray(value) ? [] : {}; deepCopyImpl(value, copy[key]); } else { copy[key] = value; } } } function deepCopy(toCopy) { if (!isObject(toCopy)) { return toCopy; } const copy = isArray(toCopy) ? [] : {}; deepCopyImpl(toCopy, copy); return copy; } function path(path2, obj) { let value = obj[path2[0]]; for (let i = 0; i < path2.length; i++) { const key = path2[i]; if (value === null || value === void 0) { return void 0; } if (i > 0) { value = value[key]; } } return value; } function set(obj, keys, value) { if (keys.length === 0) { return; } for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; const nextKey = keys[i + 1]; const value2 = obj[key]; if (value2 === void 0) { if (typeof nextKey === "symbol" || Number.isNaN(+nextKey)) { obj[key] = {}; } else { obj[key] = []; } } obj = obj[key]; } if (isVue3) { obj[keys[keys.length - 1]] = value; } else { set$1(obj, keys[keys.length - 1], value); } } let id = 1; function uid() { return id++; } function vue2ReactiveImpl(obj) { if (isObject(obj)) { for (const value of Object.values(obj)) { vue2ReactiveImpl(value); } } if (isArray(obj)) { for (let i = 0; i < obj.length; ++i) { if (isRecord(obj[i]) && !isRef(obj[i])) { reactive(obj[i]); } } } } function vue2Reactive(obj) { if (isVue2 && isObject(obj)) { vue2ReactiveImpl(obj); } return reactive(obj); } function debounce(target, { wait }) { let timerId = null; function cancel() { clearTimeout(timerId); timerId = null; } function debounced(...args) { const effect = () => { timerId = null; target.apply(this, args); }; clearTimeout(timerId); timerId = setTimeout(effect, wait); } debounced.cancel = cancel; return debounced; } class LinkedListNode { constructor(value) { this.next = null; this.prev = null; this.value = value; } } class LinkedList { constructor() { this.first = null; this.last = null; this.count = 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--; } } *[Symbol.iterator]() { for (let node = this.first; node !== null; node = node.next) { yield node; } } } class PromiseCancel { constructor() { this.isRacing = 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, void 0] : [rule.rule, rule.key]; class FormField { constructor(form, uid, name, modelValue, ruleInfos) { this.touched = ref(false); this.dirty = ref(false); this.rulesValidating = ref(0); this.validating = computed(() => this.rulesValidating.value > 0); this.errors = computed(() => this.rawErrors.value.filter(isDefined)); this.hasError = computed(() => this.hasErrors.value.some((e) => e)); this.simpleValidators = []; this.keyedValidators = {}; this.form = form; this.uid = uid; this.name = name; this.modelValue = ref(modelValue); this.rawErrors = ref(ruleInfos.map(() => null)); this.hasErrors = ref(ruleInfos.map(() => false)); this.initialModelValue = deepCopy(this.modelValue.value); this.ruleInfos = ruleInfos.map((info, ruleNumber) => { const [rule, key] = unpackRule(info.rule); const validatorNotDebounced = (force, submit, modelValues = this.modelValue, skipShouldValidate = false) => { if (rule && (skipShouldValidate || this.shouldValidate(ruleNumber, force, submit))) { return this.validate(ruleNumber, modelValues); } }; let validator = validatorNotDebounced; 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 = (force, submit, modelValues = this.modelValue, skipShouldValidate = false) => { if (rule && (skipShouldValidate || this.shouldValidate(ruleNumber, force, submit))) { debounceInvokedTimes++; this.rulesValidating.value++; this.form.rulesValidating.value++; return new Promise((resolve) => { debounceResolve == null ? void 0 : debounceResolve(); debounceResolve = resolve; debouncedValidator(modelValues); }); } }; } const validatorTuple = { ruleNumber, validator, validatorNotDebounced }; if (key) { const keyedValidators = this.keyedValidators[key]; if (keyedValidators === void 0) { this.keyedValidators[key] = rule ? [validatorTuple] : []; } else { rule && keyedValidators.push(validatorTuple); } } else { this.simpleValidators.push(validatorTuple); } return { buffer: new LinkedList(), rule, validator, validatorNotDebounced, vbf: info.vbf, cancelDebounce: () => { if (debouncedValidator) { debounceInvokedTimes = 0; debouncedValidator.cancel(); debounceResolve == null ? void 0 : debounceResolve(); } } }; }); this.watchStopHandle = this.setupWatcher(); } async validate(ruleNumber, modelValues) { var _a; const { rule, buffer } = this.ruleInfos[ruleNumber]; let error; const ruleResult = isRef(modelValues) ? rule(modelValues.value) : rule(...modelValues.map((r) => r.value)); if ((_a = buffer.last) == null ? void 0 : _a.value) { buffer.last.value = false; this.rulesValidating.value--; this.form.rulesValidating.value--; } if (typeof (ruleResult == null ? 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 { if (typeof error === "string" || typeof error === "symbol") { 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) { if (isVue3) { this.rawErrors.value[i] = null; this.hasErrors.value[i] = false; } else { set$1(this.rawErrors.value, i, null); set$1(this.hasErrors.value, i, false); } this.ruleInfos[i].cancelDebounce(); for (const shouldSetError of this.ruleInfos[i].buffer) { shouldSetError.value = false; } } this.watchStopHandle = this.setupWatcher(); } dispose() { if (isVue3) { this.errors.effect.stop(); this.validating.effect.stop(); this.hasError.effect.stop(); } this.watchStopHandle(); } shouldValidateForKey(key, force, submit) { for (let i = 0; i < this.keyedValidators[key].length; ++i) { const shouldValidateResult = this.shouldValidate(this.keyedValidators[key][i].ruleNumber, force, submit); if (!shouldValidateResult) { return false; } } return true; } shouldValidate(ruleNumber, force, submit) { return this.ruleInfos[ruleNumber].vbf({ hasError: this.rawErrors.value[ruleNumber] !== null, touched: this.touched.value, dirty: this.dirty.value, force, submit, value: this.modelValue.value }); } setError(ruleNumber, error) { const isString = typeof error === "string"; const isSymbol = typeof error === "symbol"; if (isString || isSymbol) { if (isVue3) { isString && (this.rawErrors.value[ruleNumber] = error); this.hasErrors.value[ruleNumber] = true; } else { isString && set$1(this.rawErrors.value, ruleNumber, error); set$1(this.hasErrors.value, ruleNumber, true); } throw error; } if (isVue3) { this.rawErrors.value[ruleNumber] = null; this.hasErrors.value[ruleNumber] = false; } else { set$1(this.rawErrors.value, ruleNumber, null); set$1(this.hasErrors.value, ruleNumber, false); } } setupWatcher() { return watch(this.modelValue, () => { this.dirty.value = true; this.form.validate(this.uid); }, { deep: true }); } } class Form { constructor() { this.simpleMap = /* @__PURE__ */ new Map(); this.keyedMap = /* @__PURE__ */ new Map(); this.reactiveFieldMap = shallowReactive(/* @__PURE__ */ new Map()); this.rulesValidating = ref(0); this.submitting = ref(false); this.validating = computed(() => this.rulesValidating.value > 0); this.hasError = computed(() => this.errors.value.length > 0); this.errors = computed(() => { const errors = []; for (const field of this.reactiveFieldMap.values()) { errors.push(...field.errors.value); } return errors; }); } registerField(uid, name, modelValue, ruleInfos) { const field = new FormField(this, uid, name, modelValue, ruleInfos); const simpleEntry = { rollbacks: [], field }; const keysSeen = /* @__PURE__ */ new Set(); ruleInfos.forEach((info) => { const key = unpackRule(info.rule)[1]; if (key) { let keyedEntry = this.keyedMap.get(key); if (keyedEntry === void 0) { this.keyedMap.set(key, { fields: [field], modelValues: [field.modelValue] }); } else if (!keysSeen.has(key)) { keyedEntry.fields.push(field); keyedEntry.modelValues.push(field.modelValue); } keyedEntry = this.keyedMap.get(key); keysSeen.add(key); const rollback = () => { let fieldIndex = -1; let modelValueIndex = -1; for (let i = 0; i < keyedEntry.fields.length; ++i) { if (keyedEntry.fields[i] === field) { fieldIndex = i; } if (keyedEntry.modelValues[i] === field.modelValue) { modelValueIndex = i; } } if (fieldIndex >= 0) { keyedEntry.fields.splice(fieldIndex, 1); } if (modelValueIndex >= 0) { keyedEntry.modelValues.splice(modelValueIndex, 1); } if (keyedEntry.fields.length === 0) { this.keyedMap.delete(key); } }; simpleEntry.rollbacks.push(rollback); } }); this.simpleMap.set(uid, simpleEntry); this.reactiveFieldMap.set(uid, field); return field; } validate(uid, force = false) { const { field } = this.simpleMap.get(uid); return Promise.allSettled([ ...field.simpleValidators.map(({ validator }) => validator(force, false)), ...this.collectValidatorResultsForKeys(field, force, false) ]); } 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) { const simpleEntry = this.simpleMap.get(uid); if (simpleEntry) { simpleEntry.field.dispose(); simpleEntry.rollbacks.forEach((r) => r()); } this.simpleMap.delete(uid); this.reactiveFieldMap.delete(uid); } resetFields() { for (const { field } of this.simpleMap.values()) { field.reset(); } } getField(uid) { const simpleEntry = this.simpleMap.get(uid); if (simpleEntry) { return simpleEntry.field; } } *collectValidatorResultsForKeys(field, force, submit, keys = Object.keys(field.keyedValidators)) { for (let i = 0; i < keys.length; ++i) { const { fields, modelValues } = this.keyedMap.get(keys[i]); if (this.isEveryOtherFieldTouched(field, fields) && field.shouldValidateForKey(keys[i], force, submit)) { for (let j = 0; j < fields.length; ++j) { const keyedValidators = fields[j].keyedValidators[keys[i]]; for (let k = 0; k < keyedValidators.length; ++k) { yield submit ? keyedValidators[k].validatorNotDebounced(force, submit, modelValues, fields[j] === field) : keyedValidators[k].validator(force, submit, modelValues, fields[j] === field); } } } } } *collectValidatorResultsForNames(names) { if (names === void 0) { for (const { field } of this.simpleMap.values()) { field.touched.value = true; for (let i = 0; i < field.simpleValidators.length; ++i) { yield field.simpleValidators[i].validatorNotDebounced(false, true); } } for (const [key, { fields, modelValues }] of this.keyedMap.entries()) { for (let i = 0; i < fields.length; ++i) { const keyedValidators = fields[i].keyedValidators[key]; for (let j = 0; j < keyedValidators.length; ++j) { yield keyedValidators[j].validatorNotDebounced(false, true, modelValues); } } } } else if (names.length > 0) { const uniqueNames = new Set(names); const validatedKeys = /* @__PURE__ */ new Set(); for (const { field } of this.simpleMap.values()) { if (uniqueNames.has(field.name)) { field.touched.value = true; } } for (const { field } of this.simpleMap.values()) { if (uniqueNames.has(field.name)) { for (let i = 0; i < field.simpleValidators.length; ++i) { yield field.simpleValidators[i].validatorNotDebounced(false, true); } for (const key of Object.keys(field.keyedValidators)) { if (!validatedKeys.has(key)) { validatedKeys.add(key); yield* this.collectValidatorResultsForKeys(field, false, true, [ key ]); } } } } } } isEveryOtherFieldTouched(field, fields) { for (let i = 0; i < fields.length; ++i) { if (field !== fields[i] && !fields[i].touched.value) { return false; } } return true; } } const isField = (value) => isRecord(value) ? "$value" in value : false; const isTransformedField = (value) => isRecord(value) ? "$uid" in value && "$value" in value : false; function disposeForm(form, deletedFormData) { for (const value of Object.values(deletedFormData)) { if (isTransformedField(value)) { form.dispose(value.$uid); continue; } if (isObject(value)) { disposeForm(form, value); } } } function getResultFormDataImpl(formData, resultFormData, path, predicate) { let arrayIndexOffset = 0; for (const [key, value] of Object.entries(formData)) { const nextKey = isArray(formData) ? +key - arrayIndexOffset : key; const isTransformedFieldResult = isTransformedField(value); const unpackedValue = isTransformedFieldResult ? deepCopy(value.$value) : unref(value); if (predicate({ key, value: unpackedValue, path })) { resultFormData[nextKey] = unpackedValue; } else { ++arrayIndexOffset; continue; } if (isTransformedFieldResult) { continue; } if (isObject(value)) { resultFormData[nextKey] = isArray(value) ? [] : {}; getResultFormDataImpl(value, resultFormData[nextKey], [...path, key], predicate); } } } function getResultFormData(transformedFormData, predicate = () => true) { const resultFormData = isArray(transformedFormData) ? [] : {}; getResultFormDataImpl(transformedFormData, resultFormData, [], predicate); return resultFormData; } 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]); } }); } class ValidationConfig { constructor() { this.defaultValidationBehavior = null; this.vbfMap = /* @__PURE__ */ new Map(); } getDefaultVbf() { if (this.defaultValidationBehavior === null) { return () => true; } return this.vbfMap.get(this.defaultValidationBehavior); } } const validationConfig = new ValidationConfig(); function mapFieldRules(fieldRules) { const defaultVbf = validationConfig.getDefaultVbf(); return fieldRules.map((fieldRule) => { if (typeof fieldRule === "function") { return { vbf: defaultVbf, rule: fieldRule }; } if (Array.isArray(fieldRule)) { const [first, second, third] = fieldRule; if (typeof second === "number") { return { vbf: defaultVbf, rule: first, debounce: second }; } if (typeof first === "function") { return { vbf: first, rule: second, debounce: third }; } const vbf = validationConfig.vbfMap.get(first); if (vbf !== void 0) { return { vbf, rule: second, debounce: third }; } else if (process.env.NODE_ENV !== "production") { throw new Error(`[validierung] Validation behavior with name '${first}' does not exist. Valid values are: "${[ ...validationConfig.vbfMap.keys() ].join(", ")}"`); } } else { return { vbf: defaultVbf, 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, $validating: formField.validating, $dirty: formField.dirty, $touched: formField.touched, async $validate({ setTouched, force } = {}) { setTouched != null ? setTouched : setTouched = true; force != null ? force : force = true; if (setTouched) { formField.touched.value = true; } await form.validate(uid$1, force); } }; } function transformFormData(form, formData) { for (const [key, value] of Object.entries(formData)) { if (isField(value)) { const transformedField = registerField(form, key, value); formData[key] = transformedField; continue; } if (isObject(value)) { transformFormData(form, value); } } } function useValidation(formData) { const form = new Form(); const promiseCancel = new PromiseCancel(); transformFormData(form, formData); const transformedFormData = vue2Reactive(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(formData2) { promiseCancel.cancelReject(new ValidationError()); if (formData2 === void 0) { form.resetFields(); } else { resetFields(form, formData2, transformedFormData); } }, add(path$1, value) { const lastKey = path$1[path$1.length - 1]; if (lastKey !== void 0) { const box = { [lastKey]: value }; transformFormData(form, box); let transformedValue = box[lastKey]; const valueAtPath = path(path$1, transformedFormData); if (isVue2) { transformedValue = vue2Reactive(transformedValue); } if (Array.isArray(valueAtPath)) { valueAtPath.push(transformedValue); } else { set(transformedFormData, path$1, transformedValue); } } }, remove(path$1) { const lastKey = path$1.pop(); if (lastKey === void 0) { return; } if (path$1.length === 0) { disposeForm(form, transformedFormData[lastKey]); isVue3 ? delete transformedFormData[lastKey] : del(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]); isVue3 ? delete valueAtPath[lastKey] : del(valueAtPath, lastKey); } } } }; } function createValidation(configuration) { return { install() { var _a; for (const [key, validationBehavior] of Object.entries((_a = configuration.validationBehavior) != null ? _a : {})) { validationConfig.vbfMap.set(key, validationBehavior); } if (validationConfig.vbfMap.has(configuration.defaultValidationBehavior)) { validationConfig.defaultValidationBehavior = configuration.defaultValidationBehavior; } else if (process.env.NODE_ENV !== "production") { console.warn(`[validierung] Default validation behavior '${configuration.defaultValidationBehavior}' is not valid. Valid values are: "${[ ...validationConfig.vbfMap.keys() ].join(", ")}"`); } } }; } export { ValidationError, createValidation, useValidation };