UNPKG

isomorphic-validation

Version:

Isomorphic javascript form validation library.

1,943 lines (1,652 loc) 49.3 kB
const SINGLE = Symbol('Validation.single'); const GROUPED = Symbol('Validation.grouped'); const GLUED = Symbol('Validation.glued'); const PROPNAME = 'value'; const INITVAL = ''; var preventCyclicSubscription = ((registry) => function preventCyclicSubsription(subscriberID, subjectID) { const subscriberSubscriptions = registry.get(subscriberID) || new Set(); const subjectSubscriptions = registry.get(subjectID) || new Set(); if (subscriberID === subjectID) { throw new Error('Self subscription'); } if (subjectSubscriptions.has(subscriberID)) { throw new Error('Cyclic subscription'); } subscriberSubscriptions.add(subjectID); subjectSubscriptions.forEach((id) => { subscriberSubscriptions.add(id); }); registry.set(subscriberID, subscriberSubscriptions); })(new Map()); function isFunction(arg) { return typeof arg === 'function' && arg.toString().slice(0, 5) !== 'class'; } function acceptOnlyFunction(value) { if (!isFunction(value)) { throw new Error(`The passed in value is not a function: ${value}`); } return value; } function Functions(iterable = [][Symbol.iterator]()) { const fns = [...iterable].map(acceptOnlyFunction); function push(...fnsToAdd) { if ( !Array.prototype.push.call( fns, ...fnsToAdd.flat(Infinity).map(acceptOnlyFunction), ) ) { const { warn } = console; warn('Expected functions to be passed in, received nothing.'); } return this; } function run(...args) { return fns.map((fn) => fn(...args)); } return Object.defineProperties(fns, { push: { value: push }, run: { value: run }, [Symbol.toStringTag]: { value: Functions.name }, }); } function ObserverAnd(initVal = false) { if (!ObserverAnd.slotCount) { ObserverAnd.slotCount = 0; } const ID = ++ObserverAnd.slotCount; const onChangedCBs = Functions(); const slots = new Map().set(ID, initVal); let sum = initVal ? ID : 0; let depSum = ID; let ownValue = sum === depSum; let oldValue = initVal; return { subscribe(subject = ObserverAnd()) { const subjectID = subject.getID(); preventCyclicSubscription(ID, subjectID); if (!slots.has(subjectID)) { depSum += subjectID; slots.set(subjectID, false); subject.onChanged(this.update); this.update(subject.getValue(), undefined, subjectID); // unsubscribe from ourselves // from now on our own state depends only on the subjects if (slots.has(ID)) { this.update(true); slots.delete(ID); } } return this; }, update(value = false, args = undefined, id = ID) { if (value === true) { if (slots.get(id) === false) { sum += id; slots.set(id, value); } } else if (slots.get(id) === true) { sum -= id; slots.set(id, value); } oldValue = ownValue; ownValue = sum === depSum; if (ownValue !== oldValue) { onChangedCBs.run(value, args, ID); } return ownValue; }, getID() { return ID; }, getValue: () => ownValue, onChanged: onChangedCBs.push, [Symbol.toStringTag]: ObserverAnd.name, }; } const setPrototypeOf = (obj, proto) => { Reflect.setPrototypeOf(obj, proto); return obj; }; function StateCallbacks(CBs = StateCallbacks({})) { let { startedCBs, validCBs, invalidCBs, changedCBs, validatedCBs, restoredCBs, errorCBs, } = CBs ? CBs.valueOf() : {}; let argForStartedCBs; let argForValidCBs; let argForInvalidCBs; let argForChangedCBs; let argForValidatedCBs; let argForRestoredCBs; startedCBs = Functions(startedCBs); validCBs = Functions(validCBs); invalidCBs = Functions(invalidCBs); changedCBs = Functions(changedCBs); validatedCBs = Functions(validatedCBs); restoredCBs = Functions(restoredCBs); errorCBs = Functions(errorCBs); return { runStarted() { startedCBs.run(argForStartedCBs); }, runValid() { validCBs.run(argForValidCBs); }, runInvalid() { invalidCBs.run(argForInvalidCBs); }, runChanged() { changedCBs.run(argForChangedCBs); }, runValidated() { validatedCBs.run(argForValidatedCBs); }, runRestored() { restoredCBs.run(argForRestoredCBs); }, setArg(arg) { [ argForStartedCBs, argForValidCBs, argForInvalidCBs, argForChangedCBs, argForValidatedCBs, argForRestoredCBs, ] = [ setPrototypeOf({ type: 'started' }, arg), setPrototypeOf({ type: 'valid' }, arg), setPrototypeOf({ type: 'invalid' }, arg), setPrototypeOf({ type: 'changed' }, arg), setPrototypeOf({ type: 'validated' }, arg), setPrototypeOf({ type: 'restored' }, arg), ]; }, valueOf() { return { startedCBs, validCBs, invalidCBs, changedCBs, validatedCBs, restoredCBs, errorCBs, }; }, started: startedCBs.push, valid: validCBs.push, invalid: invalidCBs.push, changed: changedCBs.push, validated: validatedCBs.push, restored: restoredCBs.push, error: errorCBs.push, runError: errorCBs.run, [Symbol.toStringTag]: 'StateCallbacks', }; } function ManyToManyMap() { const values = new Set(); // for faster access to all unique values const map = new Map(); const orderedSet = new Set(); // for consistency of mapping and merging order const api = { add(key, value, keepOrder = true) { values.add(value); if (map.has(key)) { const set = map.get(key); const { size } = set; set.add(value); if (size !== set.size) { if (keepOrder) orderedSet.add([key, value]); } } else { map.set(key, new Set().add(value)); if (keepOrder) orderedSet.add([key, value]); } return this; }, changeKey(oldKey, newKey) { if (oldKey === newKey) { throw new Error('Old key must not be the same as new key'); } if (map.has(oldKey)) { map.get(oldKey).forEach((value) => api.add(newKey, value, false)); map.delete(oldKey); orderedSet.forEach((entry) => { if (entry[0] === oldKey) { entry[0] = newKey; } }); } else { throw new Error('There is no such old key'); } return this; }, getAll() { return values; }, mergeWith(mtmm = ManyToManyMap()) { mtmm.forEach((value, key) => this.add(key, value)); return this; }, forEach(cbfunction = (/* value, key, values */) => {}) { orderedSet.forEach(([key, value]) => { cbfunction(value, key, map); }); }, map(cbfunction = (/* value, key, values */) => {}) { const mtmm = ManyToManyMap(); this.forEach((value, key, valuesSet) => mtmm.add(key, cbfunction(value, key, valuesSet)), ); return mtmm; }, has: map.has.bind(map), get: map.get.bind(map), keys: map.keys.bind(map), values: map.values.bind(map), entries: map.entries.bind(map), [Symbol.iterator]: map[Symbol.iterator].bind(map), }; Object.defineProperties(api, { [Symbol.toStringTag]: { value: ManyToManyMap.name, configurable: true, }, size: { get: () => map.size }, }); Reflect.setPrototypeOf(api, map); return api; } function acceptOnlyFunctionOrPredicate(value) { if (!isFunction(value) && Object(value)[Symbol.toStringTag] !== 'Predicate') { throw new Error('Neither a function nor a Predicate was passed in.'); } return value; } const SERVER = Symbol('server'); const CLIENT = Symbol('client'); const getEnv = () => typeof document !== 'undefined' ? CLIENT : typeof process !== 'undefined' ? SERVER : undefined; const ENV = getEnv(); const IS_SERVER = ENV === SERVER; const IS_CLIENT = ENV === CLIENT; const ifSide = (onServer, onClient) => { if (IS_CLIENT) { return onClient; } if (IS_SERVER) { return onServer; } throw new Error("Couldn't define if it is a client or a server."); }; function makeIsomorphicAPI( api = {}, { serverPropName = 'server', clientPropName = 'client', selfPropName = 'isomorphic', redefine = false, excludeFunctions = ['valueOf'], } = {}, ) { const propNames = new Set([serverPropName, clientPropName, selfPropName]); if (propNames.size !== 3) { throw new Error('Isomorphic API property names must be unique'); } if (!redefine) { propNames.forEach((propName) => { if (propName in api) { throw new Error( `Property name "${propName}" exists in ${JSON.stringify(api)}.` + ` Set another property name or redefine=true`, ); } }); } const exclude = new Set([...excludeFunctions, ...propNames]); let originalAPI = api; function ignored() { if (this !== ignored) originalAPI = this; return ignored; } function original() { if (this !== ignored) originalAPI = this; return originalAPI; } const makePropDescrForExecEnv = (isInExecEnv) => ({ get: isInExecEnv ? original : ignored, configurable: true, }); Reflect.setPrototypeOf( ignored, new Proxy(api, { get(target, propName, receiver) { if (!exclude.has(propName)) { return ignored; } return Reflect.get(target, propName, receiver); }, }), ); return Object.defineProperties(api, { [serverPropName]: makePropDescrForExecEnv(IS_SERVER), [clientPropName]: makePropDescrForExecEnv(IS_CLIENT), [selfPropName]: makePropDescrForExecEnv(true), }); } function Predicate(fnOrPred, anyDataObj) { acceptOnlyFunctionOrPredicate(fnOrPred); let stateCBs; let anyData; const fn = ({ stateCBs, anyData } = fnOrPred.valueOf()).valueOf(); stateCBs = StateCallbacks(stateCBs); const predicate = { valueOf() { return { stateCBs, anyData: { ...anyData, ...anyDataObj }, valueOf: () => fn, }; }, valid: stateCBs.valid, invalid: stateCBs.invalid, changed: stateCBs.changed, validated: stateCBs.validated, started: stateCBs.started, restored: stateCBs.restored, error: stateCBs.error, // !consider for adding: deferred (or delayed), canceled??? [Symbol.toStringTag]: 'Predicate', }; return makeIsomorphicAPI(predicate); } // const randomID = (prefix = '') => prefix + (Math.random()*1e6).toString().replace('.', '_'); var indexedName = (function indexedName() { let counter = 0; return (name = '', delim = '_') => name + delim + counter++; })(); const pathError = (obj, path) => { throw new Error( `There is no path '${path}' in object ${JSON.stringify(obj)}`, ); }; const traverse = (object, propName) => object[propName]; function getByPath( obj = {}, path = '', delim = '.', isPath = path.includes(delim), ) { if (isPath) { try { // ! split it in ValidatableItem in init() return path.split(delim).reduce(traverse, obj); } catch { pathError(obj, path); } } return obj[path]; } function setByPath( obj = {}, path = '', value = '', delim = '.', isPath = path.includes(delim), ) { if (isPath) { const arr = path.split(delim); const lastIdx = arr.length - 1; arr.reduce((object, propName, idx) => { if (idx === lastIdx) { object[propName] = value; } return object[propName]; }, obj); } else { obj[path] = value; } } ValidatableItem.keepValid = (items = [], isValid = false) => { if (isValid === true) { items.forEach((item) => item.saveValue()); } else { items.forEach((item) => item.restoreValue()); } return !isValid; }; function ValidatableItem(obj = {}, path = '', initVal = undefined) { const values = new Map(); const delim = '.'; let ownObj; let ownPath; let ownInitVal; let isPath; let isInitVal; let savedValue; const init = (object = ownObj, pathName = ownPath, initValue = undefined) => { [ownObj, ownPath, ownInitVal, isPath, savedValue] = [ object, pathName, initValue, pathName.includes(delim), initValue, ]; }; init(obj, path, initVal); return { setObject: init, getObject: () => ownObj, getPath: () => ownPath, getInitValue: () => ownInitVal, getValue: (key) => key ? values.get(key) : getByPath(ownObj, ownPath, delim, isPath), saveValue: () => { savedValue = getByPath(ownObj, ownPath, delim, isPath); }, restoreValue: () => { const isInitValue = isInitVal === undefined ? getByPath(ownObj, ownPath, delim, isPath) === ownInitVal : isInitVal; if (!isInitValue) { setByPath(ownObj, ownPath, savedValue, delim, isPath); } else { savedValue = ownInitVal; } }, preserveValue(key = Symbol('ValidatableItem.value')) { const currValue = getByPath(ownObj, ownPath, delim, isPath); values.set(key, currValue); isInitVal = currValue === ownInitVal || currValue === undefined; return key; }, clearValue(key) { isInitVal = undefined; return values.delete(key); }, isInitValue() { return isInitVal; }, clone: () => ValidatableItem(ownObj, ownPath, ownInitVal), [Symbol.toStringTag]: ValidatableItem.name, }; } const retrieveIfHasOrCreate = (map, key, creatorFn, ...args) => { if (!map.has(key)) { map.set(key, creatorFn(...args)); } return map.get(key); }; function CloneRegistry() { const cloneRegistry = new Map(); return { cloneOnce(item, registry) { return retrieveIfHasOrCreate(cloneRegistry, item, () => item.clone(registry), ); }, cloneMapOnce(items = [], registry = CloneRegistry()) { return retrieveIfHasOrCreate(cloneRegistry, items, () => items.map((item) => registry.cloneOnce(item, registry)), ); }, }; } function acceptOnlyBoolean(value) { if (typeof value !== 'boolean') { throw new Error( 'The returned value of a predicate must be a Boolean ' + 'or a Promise that resolves to a Boolean.', ); } return value; } // !consider for adding firing canceled??? and deferred (or delayed) events function debounceP(fn = Function.prototype, delay = 0) { let timeout; let promise; let resolveFn = () => {}; let rejectFn = () => {}; const suffix = '_DP'; const debouncedFnName = fn.name + suffix; const resolvers = new Map(); const rejecters = new Map(); const resolve = (res) => { resolveFn(res); promise = null; }; const reject = (err) => { rejectFn(err); promise = null; }; const emptyFn = () => { promise = null; }; const deferredFn = (id, ...args) => { try { const result = fn(...args); if (result.then) { result.then( (res) => (resolvers.get(id) || emptyFn)(res), (err) => (rejecters.get(id) || emptyFn)(err), ); } else { resolve(result); } } catch (err) { reject(err); } }; const debouncedFn = { [debouncedFnName]: (...args) => { resolvers.clear(); rejecters.clear(); clearTimeout(timeout); const id = Symbol('debouncedFn.callID'); timeout = setTimeout(deferredFn, delay, id, ...args); resolvers.set(id, resolve); rejecters.set(id, reject); if (!promise) { promise = new Promise((res, rej) => { resolveFn = res; rejectFn = rej; }); } return promise; }, }[debouncedFnName]; debouncedFn.cancel = (retVal) => { clearTimeout(timeout); resolve(retVal); promise = null; }; debouncedFn.valueOf = () => ({ fn, delay, valueOf: () => fn }); return debouncedFn; } function tryCatch( tryFn = Function.prototype, catchFn = Function.prototype, // catches error if enabled enableCatchFn = () => true, // enables catching errors catchFn2 = Function.prototype, // the second level of error catching in case catchFn or onCatchedCB is also faulty onCatchedCB = Function.prototype, // executes on error regardles of enabling catching, does not catch the error promisifySyncErrors = false, ) { let allowNext = false; let fallbackValue = null; function next() { // ignore catchFn2 allowNext = true; } function catcher(err) { const res = catchFn(err, next); if (allowNext) throw err; return res; } function catcher2(err) { if (allowNext) { if (promisifySyncErrors) { return Promise.reject(err); } throw err; } fallbackValue = catchFn2(err); return fallbackValue; } function callback(err) { fallbackValue = onCatchedCB(err); return fallbackValue; } function tryCatchWrapper(...args) { allowNext = false; fallbackValue = null; try { const result = tryFn(...args); if (result.then) { result .catch(callback) .catch(catcher2) .catch(() => {}); // the last catch is because catcher2 forwards the error if next() was called in the catchFn if (enableCatchFn()) { return result .catch(catcher) .catch(catcher2) .then((res) => fallbackValue || res); // in case catchFn is also faulty } } return result; } catch (err) { try { callback(err); } catch (err2) { catcher2(err2); } if (enableCatchFn()) { try { catcher(err); return fallbackValue; } catch (err2) { // in case catchFn is also faulty return catcher2(err2); } } if (promisifySyncErrors) { return Promise.reject(err); } throw err; } } return Object.defineProperty(tryCatchWrapper, 'name', { value: `${tryFn.name}_TC`, }); } function preventPropNamesClash(src = {}, dst = {}) { Object.keys(src).forEach((propName) => { if (propName in dst) { throw new Error( `The property "${propName}" overrides one in ${JSON.stringify(dst)}`, ); } }); } function PredicateGroupsRepresentation(obs = ObserverAnd()) { const pgs = ManyToManyMap(); const view = Object.getPrototypeOf(pgs); Object.defineProperties(view, { isValid: { get: obs.getValue }, [Symbol.iterator]: { value() { const values = []; view.forEach((value, key) => values.push([key, value])); return values[Symbol.iterator](); }, }, forEach: { value(cbfunction = (/* value, key, values */) => {}) { [...view.entries()].forEach(([key, set]) => { set.forEach((predicates) => { predicates.forEach((predicate) => cbfunction(predicate, key, view)); }); }); }, }, toJSON: { value() { return [...pgs].reduce( (acc, [key, set]) => { acc[key.name || indexedName('object')] = [...set]; return acc; }, { // name: representation[Symbol.toStringTag], isValid: this.isValid, }, ); }, }, }); return Object.defineProperties(pgs, { toRepresentation: { value() { return view; }, }, }); } function ObservablePredicatesRepresentation(obs = ObserverAnd()) { return Object.defineProperties([], { isValid: { get: obs.getValue, configurable: true, }, toJSON: { value() { return { name: this[Symbol.toStringTag], length: this.length, ...this, isValid: obs.getValue(), }; }, }, [Symbol.toStringTag]: { value: 'PredicateGroup' }, }); } function ObservablePredicateRepresentation( obs = ObserverAnd(), predicate = Predicate(), fnName = '', anyData = {}, ) { const representation = Object.defineProperties( {}, { isValid: { get: obs.getValue }, toJSON: { value() { return { name: this[Symbol.toStringTag], ...anyData, isValid: obs.getValue(), }; }, }, ...Object.getOwnPropertyDescriptors(predicate), [Symbol.toStringTag]: { value: fnName }, }, ); preventPropNamesClash(anyData, representation); return Object.assign(representation, anyData); } // !JSON representation should be generated in advance. // ! when toJSON is being called, the representation should be ready to use function ValidationResult(representation = new Map()) { return Object.defineProperties(representation, { target: { writable: true, configurable: true }, type: { writable: true }, [Symbol.toStringTag]: { value: 'ValidationResult' }, }); } function acceptOnlyPredicate(value) { if (Object(value)[Symbol.toStringTag] !== 'Predicate') { throw new Error('Not a Predicate was passed in.'); } return value; } const makeRunStateCBsFn = (CBs = StateCallbacks()) => (value) => { if (value) { CBs.runValid(); } else { CBs.runInvalid(); } CBs.runValidated(); return value; }; function ObservablePredicate( predicate = Predicate(), items = [], keepValid = false, initState = false, debounce = 0, validatableItem = ValidatableItem(), // an item the predicate will be associated with, for setting validation result target ) { acceptOnlyPredicate(predicate); let stateCBs; let anyData; const fn = ({ stateCBs, anyData } = predicate.valueOf()).valueOf(); const fnName = fn.name || indexedName('predicate'); const obs = ObserverAnd(initState); // optional predicates are valid by default const onInvalidCBs = Functions(); const notifySubscribers = obs.update; const runStateCBs = makeRunStateCBsFn(stateCBs); const setValidity = (value) => { if (!value) { onInvalidCBs.run(); } return runStateCBs(value); }; const representation = ObservablePredicateRepresentation( obs, predicate, fnName, anyData, ); const groupRepresentation = ObservablePredicatesRepresentation(obs); const pgsRepresentation = PredicateGroupsRepresentation(obs); const validationResult = ValidationResult( pgsRepresentation.toRepresentation(), ); groupRepresentation.push(representation); items.forEach((item) => pgsRepresentation.add(item.getObject(), groupRepresentation), ); Object.defineProperties(validationResult, { // getter, because the object can be changed through Validation().bind() method target: { get: validatableItem.getObject }, }); stateCBs.setArg(validationResult); const predicateFn = debounce ? debounceP(fn, debounce) : fn; obs.onChanged(stateCBs.runChanged); function predicatePostExec(result, forbidInvalid, callID) { acceptOnlyBoolean(result); notifySubscribers(result); if (forbidInvalid) { if (ValidatableItem.keepValid(items, result)) { stateCBs.runRestored(); items.forEach((item) => item.preserveValue(callID)); return obsPredicate(!forbidInvalid, callID, true); } } return setValidity(result); } function obsPredicate( forbidInvalid = keepValid, callID = undefined, revalidate = false, skipOptional = false, invalidate = false, // invalidate if belongs to a non-optional group and the validatable value equals to the initial value ) { if (!revalidate) { stateCBs.runStarted(); } if (skipOptional) { return predicatePostExec(true, forbidInvalid, callID); } if (invalidate) { return predicatePostExec(false, forbidInvalid, callID); } const result = predicateFn(...items.map((item) => item.getValue(callID))); if (result && result.then) { return result.then((res) => predicatePostExec(res, forbidInvalid, callID), ); } return predicatePostExec(result, forbidInvalid, callID); } const obsPredicateTC = tryCatch( obsPredicate, stateCBs.runError, // the catch function () => stateCBs.valueOf().errorCBs.length, // enable the catch function if error state callbacks were added () => false, // if the catch function is also faulty, return false and swallow the error () => obsPredicateTC.invalidate(), // invalidate on any error occurance ); return Object.defineProperties(obsPredicateTC, { toRepresentation: { value: () => representation }, invalidate: { value: () => { if (debounce) predicateFn.cancel(false); return setValidity(notifySubscribers(false)); }, }, clone: { value: (registry = CloneRegistry()) => ObservablePredicate( Predicate(predicate), items.map((item) => registry.cloneOnce(item)), keepValid, initState, debounce, registry.cloneOnce(validatableItem), ), }, getID: { value: obs.getID }, getValue: { value: obs.getValue }, onChanged: { value: obs.onChanged }, onInvalid: { value: onInvalidCBs.push }, name: { value: `${fnName}_OP` }, [Symbol.toStringTag]: { value: 'ObservablePredicate' }, }); } function runPredicatesQueue( predicates = [][Symbol.iterator](), nexts = [][Symbol.iterator](), itemsToCheck = [], ) { const promises = []; let resolve; let reject; const finish = () => Promise.all(promises).then((results) => resolve(results)); const runPromiseQueue = (predicateIt, nextIt) => { let promise; let predicate; while ((predicate = predicateIt.next().value)) { try { promises.push( (promise = Promise.resolve(predicate(...itemsToCheck)).catch(reject)), ); } catch (err) { reject(err); return; } if (!nextIt.next().value) { promise.then((pRes) => pRes ? runPromiseQueue(predicateIt, nextIt) : finish(), ); return; } } finish(); }; const retVal = new Promise((res, rej) => { resolve = res; reject = rej; }); runPromiseQueue(predicates[Symbol.iterator](), nexts[Symbol.iterator]()); return retVal; } function acceptOnlyObservablePredicate(value) { if (Object(value)[Symbol.toStringTag] !== 'ObservablePredicate') { throw new Error('Not an ObservablePredicate was passed in.'); } return value; } const glue = (predicate = ObservablePredicate(), gluedPredicates = []) => { if (gluedPredicates.length) { const glued = (...args) => { const res = predicate(...args); gluedPredicates.forEach((gluedPredicate) => gluedPredicate(...args)); return res; }; return Object.defineProperties(glued, { name: { value: `${predicate.name}_GL` }, valueOf: { value: () => ({ gluedPredicates, valueOf: () => predicate }), }, }); } return predicate; }; function ObservablePredicates( item = ValidatableItem(), optional = false, ) { const obs = ObserverAnd(optional); // optional groups are valid by default const predicates = Functions(); const queueRules = []; let withQueueRules = false; let lastStopPredicate; const representation = ObservablePredicatesRepresentation(obs); return Object.defineProperties( { add( predicate = ObservablePredicate(), { next = true } = {}, gluedPredicates = [], ) { acceptOnlyObservablePredicate(predicate); obs.subscribe(predicate); predicates.push(glue(predicate, gluedPredicates)); queueRules.push(next); withQueueRules = withQueueRules || !next; representation.push(predicate.toRepresentation()); if (lastStopPredicate) { lastStopPredicate.onInvalid(predicate.invalidate); } if (!next) { lastStopPredicate = predicate; } return this; }, run(...args) { const isInitValue = item.isInitValue(); const skip = optional && isInitValue; const invalidate = !optional && isInitValue; args.push(undefined, skip, invalidate); // [forbidInvalid, callID, revalidate, skipOptional, invalidate] return withQueueRules ? runPredicatesQueue(predicates, queueRules, args) : Promise.all(predicates.run(...args)); }, clone(registry = CloneRegistry()) { return predicates .map((predicate, idx) => { let gluedPredicates = []; const origPredicate = ({ gluedPredicates } = predicate.valueOf()).valueOf(); const clonedPredicate = registry.cloneOnce(origPredicate, registry); const clonedGluedPredicates = registry.cloneMapOnce( gluedPredicates, registry, ); return [ clonedPredicate, { next: queueRules[idx] }, clonedGluedPredicates, ]; }) .reduce( (ops, predWithParams) => ops.add(...predWithParams), ObservablePredicates(registry.cloneOnce(item), optional), ); }, toRepresentation() { return representation; }, isOptional() { return optional; }, getItem: () => item, getID: obs.getID, getValue: obs.getValue, onChanged: obs.onChanged, [Symbol.toStringTag]: ObservablePredicates.name, }, { isValid: { get: obs.getValue }, }, ); } function PredicateGroups( pgs = ManyToManyMap(), stateCBs = StateCallbacks(), TYPE = SINGLE, ) { const obs = ObserverAnd(); const representation = PredicateGroupsRepresentation(obs); const view = representation.toRepresentation(); const validationResult = ValidationResult(view); if (TYPE === SINGLE) { Object.defineProperties(validationResult, { // getter, because the object can be changed through Validation().bind() method target: { get: () => pgs.keys().next().value }, }); } stateCBs.setArg(validationResult); obs.onChanged(stateCBs.runChanged); return Object.defineProperties( { add(key, predicateGroup = ObservablePredicates()) { obs.subscribe(predicateGroup); pgs.add(key, predicateGroup); representation.add(key, predicateGroup.toRepresentation()); return this; }, run(id, callID) { const predicateGroups = id !== undefined ? pgs.get(id) : pgs.getAll(); return Promise.all( Array.from(predicateGroups, (predicateGroup) => predicateGroup.run(undefined, callID), ), ).then((res) => !res.flat().some((value) => value !== true)); // ! probably slow }, clone(registry = CloneRegistry()) { const newPgs = PredicateGroups( undefined, StateCallbacks(stateCBs), TYPE, ); pgs .map((group) => registry.cloneOnce(group, registry)) .forEach((group, key) => newPgs.add(key, group)); return newPgs; }, changeKey(oldKey, newKey) { pgs.changeKey(oldKey, newKey); representation.changeKey(oldKey, newKey); return this; }, enableCatch() { return stateCBs.valueOf().errorCBs.length; }, result() { return validationResult; }, setGroupTarget(target) { validationResult.target = target; }, toRepresentation: representation.toRepresentation, valid: stateCBs.valid, invalid: stateCBs.invalid, changed: stateCBs.changed, validated: stateCBs.validated, started: stateCBs.started, error: stateCBs.error, catchCBs: stateCBs.runError, startCBs: stateCBs.runStarted, runCBs: makeRunStateCBsFn(stateCBs), map: pgs.map, forEach: pgs.forEach, mergeWith: pgs.mergeWith, getAll: pgs.getAll, has: pgs.has.bind(pgs), get: pgs.get.bind(pgs), [Symbol.toStringTag]: PredicateGroups.name, }, { isValid: { get: obs.getValue }, }, ); } function addObservablePredicate( predicate = Predicate(), items = ManyToManyMap(), { TYPE = SINGLE, next = true, debounce = 0, keepValid = false, groups } = {}, ) { if (TYPE === GLUED) { // create ObservablePredicate the amount of groups number const gluedOPs = [...groups].map((predicateGroup) => ObservablePredicate( Predicate(predicate), [...items.getAll()], keepValid, predicateGroup.isOptional(), // init state for an optional predicate debounce, predicateGroup.getItem(), // A validatable item the predicate group associated with (used as "target" in ObservablePredicate -> ValidationResult) ), ); let i = 0; return function forGlued(predicateGroup /* key */) { predicateGroup.add( gluedOPs[i], { next }, gluedOPs.filter((_, idx) => idx !== i), ); if (groups.size === ++i) { i = 0; } }; } return function forSingleOrGrouped(predicateGroup, key) { predicateGroup.add( ObservablePredicate( Predicate(predicate), [...items.get(key)], keepValid, predicateGroup.isOptional(), // init state for an optional predicate debounce, predicateGroup.getItem(), // A validatable item the predicate group associated with (used as "target" in ObservablePredicate -> ValidationResult) ), { next }, ); }; } function firstEntry(map = new Map()) { return map.entries().next().value; } const defaultMapper = (req, form) => { const { body } = req; Object.keys(body).forEach((fieldName) => { form[fieldName].value = body[fieldName]; }); }; // express middleware const createMiddlewareFn = (form, validation, dataMapper) => () => { let mapper = dataMapper; Object.defineProperty(validation, 'dataMapper', { value(Mapper) { if (!form) { throw new Error( 'Calling the dataMapper method on a validation' + ' that is not associated with a form. ' + 'Create a validation profile first.', ); } if (!isFunction(Mapper)) { throw new Error('The data mapper must be a function.'); } mapper = Mapper; return this; }, configurable: true, // to work with Proxy in makeIsomorphicAPI }); if (!form) { return () => { throw new Error( 'Using a validation as a middleware' + ' that is not associated with a form. ' + 'Create a validation profile first.', ); }; } return async (req, res, next) => { try { mapper(req, form); } catch (err) { next(err); return; } req.validationResult = await validation.validate().catch(next); next(); }; }; const createEventHandlerFn = (validation) => () => { Object.defineProperty(validation, 'dataMapper', { value() { const { warn } = console; warn('The dataMapper method does nothing on the client side'); return this; }, configurable: true, // to work with Proxy in makeIsomorphicAPI }); return (event) => validation.validate(event ? event.target : undefined); }; const makeValidationHandlerFn = (form) => (validation) => { const middleware = ifSide( // server side createMiddlewareFn(form, validation, defaultMapper), // client side createEventHandlerFn(validation), )(); Reflect.setPrototypeOf(middleware, validation); return middleware; }; function preventDebounceOnServer(value) { if (IS_SERVER && value !== 0) { throw new Error('Parameter "debounce" is for the client side only.'); } return value; } function ValidationBuilder({ pgs = PredicateGroups(), items = ManyToManyMap(), containedGroups = new ManyToManyMap(), TYPE = SINGLE, validations = [], } = {}) { const api = makeValidationHandlerFn(null)({ constraint( validator = Predicate(), { next = true, debounce = 0, keepValid = false, ...anyData } = {}, ) { preventDebounceOnServer(debounce); const predicate = Predicate(validator, { ...anyData }); pgs.forEach( addObservablePredicate(predicate, items, { TYPE, next, debounce, keepValid, groups: pgs.getAll(), }), ); return this; }, validate(target) { if (target !== undefined && !pgs.has(target)) { const { warn } = console; warn( `There are no predicates associated with the target: ${[] .concat( Object(target).name || [], JSON.stringify(target) || typeof target, Object(target)[Symbol.toStringTag] || [], ) .join(' ')}.`, ); return Promise.resolve( Object.create(pgs.result(), { isValid: { value: null }, type: { value: 'validated' }, }), ); } // all items will be preserved regardless of the target // not the most optimal way, but fixes the bug with validating a glued validation // by target through a grouping validation const validatableItems = items.getAll(); // target !== undefined ? items.get(target) : items.getAll(); const callID = Symbol('callID'); validatableItems.forEach((item) => item.preserveValue(callID)); if (TYPE !== SINGLE) { pgs.setGroupTarget(target); } // ! a better solution would be to run all grouping validations' started callbacks first pgs.startCBs(); // run startCBs of the grouping validation first const containedPgsSet = containedGroups.get(target) || containedGroups.getAll(); containedPgsSet.forEach((containedPgs) => { if (containedPgs !== pgs) { containedPgs.startCBs(); } }); return pgs.run(target, callID).then((res) => { validatableItems.forEach((item) => item.clearValue(callID)); containedPgsSet.forEach((containedPgs) => { containedPgs.runCBs(containedPgs.isValid); }); return Object.create(pgs.result(), { isValid: { value: res }, type: { value: 'validated' }, }); }); }, bind(newObj = {}, { path = undefined, initValue = undefined } = {}) { if (TYPE !== SINGLE) { throw new Error('Only single validation can be bound'); } const [oldObj, set] = firstEntry(items); const validatableItem = [...set][0]; // firstItemFromEntrie const newPath = path !== undefined ? path : validatableItem.getPath(); const newInitVal = initValue !== undefined ? initValue : validatableItem.getInitValue(); validatableItem.setObject(newObj, newPath, newInitVal); items.changeKey(oldObj, newObj); pgs.changeKey(oldObj, newObj); containedGroups.changeKey(oldObj, newObj); return this; }, valueOf() { return { pgs, items, containedGroups, TYPE }; }, constraints: pgs.toRepresentation(), validations, valid: pgs.valid, invalid: pgs.invalid, changed: pgs.changed, validated: pgs.validated, started: pgs.started, error: pgs.error, }); Object.defineProperties(api, { [Symbol.toStringTag]: { value: 'Validation' }, isValid: Object.getOwnPropertyDescriptor(pgs, 'isValid'), }); api.validate = tryCatch( api.validate, pgs.catchCBs, pgs.enableCatch, () => Promise.resolve(pgs.result()), // if the catch function is also faulty, return ValidationResult and swallow the error () => Promise.resolve(pgs.result()), // return ValidationResult on any error occurance true, // promisify sync errors ); return makeIsomorphicAPI(api); } function accepOnlyValidation(arg) { // validation...client... or /validation...server... might have been passed in const validation = Object(Object(arg).isomorphic); if (validation[Symbol.toStringTag] !== 'Validation') { throw new Error('Not a Validation was passed in'); } return validation; } function makeGroupValidationsFn(TYPE = GROUPED) { return function groupValidations(...validationsToAdd) { const pgs = PredicateGroups(undefined, undefined, TYPE); const items = ManyToManyMap(); const containedGroups = ManyToManyMap(); const validations = [...new Set(validationsToAdd.flat(Infinity))].map( accepOnlyValidation, ); validations .map((validation) => { const { pgs: vPgs, items: vItems, containedGroups: vContainedGroups, } = validation.valueOf(); pgs.mergeWith(vPgs); items.mergeWith(vItems); containedGroups.mergeWith(vContainedGroups); return vItems; }) .forEach((vItems) => { if (TYPE === GLUED) { vItems.mergeWith(items); } }); containedGroups.forEach((_, key) => { containedGroups.add(key, pgs); }); return ValidationBuilder({ pgs, items, containedGroups, TYPE, validations, }); }; } function memoize( fn = Function.prototype, defaults = () => [], limit = Infinity, ) { const argsIdxs = new Map(); const resIdxs = new Map(); const counter = () => argsIdxs.size; const retrieveIfHas = (map = new Map()) => (value = Function.prototype, ...args) => (key) => map.has(key) ? map.get(key) : map.set(key, value(...args)).get(key); const mergeWithDefaults = (args) => { const params = []; const defaultParams = defaults(); const length = Math.max(args.length, defaultParams.length); for (let i = 0; i < length; i++) { params[i] = args[i] !== undefined ? args[i] : defaultParams[i]; } return params; }; const remember = (Fn) => Object.defineProperty( (...args) => { const params = mergeWithDefaults(args); return retrieveIfHas(resIdxs)(Fn, ...params)( params .map(retrieveIfHas(argsIdxs)(counter)) .slice(0, limit) .join(','), ); }, 'name', { value: `${fn.name}_MEM` }, ); const memoized = remember(fn); memoized.remember = (res, ...args) => remember(() => res)(...args); memoized.forget = (...args) => resIdxs.delete( mergeWithDefaults(args) .map((arg) => argsIdxs.get(arg)) .join(','), ); return memoized; } const buildValidation = memoize( (pgs, items, containedGroups, TYPE, validations) => ValidationBuilder({ pgs, items, containedGroups, TYPE, validations, }), undefined, 4, // parameter validations is not accounted since it is always a new set ); function clone({ validation, registry = CloneRegistry() }) { const isomorphicValidation = accepOnlyValidation(validation); const { pgs, items, containedGroups, TYPE } = isomorphicValidation.valueOf(); const { validations } = isomorphicValidation; // Set() const clonedPgs = registry.cloneOnce(pgs, registry); const clonedItems = registry.cloneMapOnce(items, registry); const clonedContainedGroups = registry.cloneMapOnce( containedGroups, registry, ); const clonedValidations = [...validations] .map((v) => ({ validation: v, registry })) .map(clone); return buildValidation( clonedPgs, clonedItems, clonedContainedGroups, TYPE, clonedValidations, ); } function acceptOnlyNotEmptyString(value) { if (typeof value !== 'string' || value.length < 1) { throw new Error('Form field name must be a not empty string.'); } return value; } function createDummyObj(fromObj) { return new Proxy( Object.defineProperty(() => createDummyObj(), 'name', { writable: true, }), { get(target, property, receiver) { if (!Reflect.has(target, property)) { Reflect.defineProperty(target, property, { writable: true, value: createDummyObj(), }); Reflect.defineProperty(target, Symbol.toPrimitive, { writable: true, value: () => '', }); } return Reflect.get(target, property, receiver); }, }, ); } const dummyObject = createDummyObj(); function FormField(fieldName = '', propChain = [], initValue = INITVAL) { this.name = fieldName; propChain.reduce((acc, propName, idx) => { const isLast = idx === propChain.length - 1; return Object.defineProperty(acc, propName, { value: isLast ? initValue : {}, writable: isLast, })[propName]; }, this); } FormField.prototype = dummyObject; function FormFields(fieldNames, paths, initValues, delim) { [].concat(fieldNames).forEach((fieldName, idx) => { acceptOnlyNotEmptyString(fieldName); const path = paths[idx]; const initValue = initValues[idx]; const propChain = path ? path.split(delim) : [PROPNAME]; const formField = new FormField(fieldName, propChain, initValue); Object.defineProperty(this, fieldName, { value: formField, enumerable: true, }); }); } FormFields.prototype = dummyObject; const createValidatableFormFn = (selector, fieldNames, paths, initValues, delim) => () => { const fieldsCollection = new FormFields( fieldNames, paths, initValues, delim, ); const form = Object.create( dummyObject, Object.getOwnPropertyDescriptors(fieldsCollection), ); Object.defineProperty(form, 'selector', { value: selector }); Object.defineProperty(form, 'elements', { value: fieldsCollection }); Object.defineProperty(form, Symbol.toStringTag, { value: ValidatableForm.name, }); return form; }; const getFormBySelectorFn = (selector) => () => { const htmlForm = document.querySelector(selector); if (!htmlForm) { throw new Error( `Cannot find a form with the specified selector: ${selector}`, ); } return htmlForm; }; function ValidatableForm( selector = '', fieldNames = [], paths = [], initValues = [], delim = '.', ) { return ifSide( // server side createValidatableFormFn(selector, fieldNames, paths, initValues, delim), // client side getFormBySelectorFn(selector), )(); } const cloneValidation = (validation) => clone({ validation, registry: CloneRegistry() }); const bind = (form, fieldNames) => (validation, idx) => validation.bind(form.elements[fieldNames[idx]]); const getItems = (validation) => validation.valueOf().items; const toPathsAndInitValues = ([paths, initValues], validatableItem) => [ [...paths, validatableItem.getPath()], [...initValues, validatableItem.getInitValue()], ]; const firstItemFromEntrie = ([, set]) => [...set][0]; const assignValidations = (validations) => (validationGroup, fieldName, idx) => { const validation = validations[idx]; if (validation) { Object.defineProperty(validationGroup, fieldName, { get: () => validation, enumerable: true, configurable: true, // to work with Proxy in makeIsomorphicAPI }); } return validationGroup; }; const profile = (form, validation) => Object.defineProperties( { form, validation }, { 0: { value: form }, 1: { value: validation }, length: { value: 2 }, [Symbol.iterator]: { value: Array.prototype[Symbol.iterator] }, [Symbol.toStringTag]: { value: 'ValidationProfile' }, }, ); function createProfile( selector = '', fieldNames = [], validations = [], ) { const [paths, initValues] = [] .concat(validations) .map(accepOnlyValidation) .map(getItems) .map(firstEntry) .map(firstItemFromEntrie) .reduce(toPathsAndInitValues, [[], []]); const validatableForm = ValidatableForm( selector, fieldNames, paths, initValues, ); const clonedValidations = [] .concat(validations) .map(cloneValidation) .map(bind(validatableForm, fieldNames)) .map(makeValidationHandlerFn(validatableForm)); const groupedValidations = [] .concat(fieldNames) .reduce( assignValidations(clonedValidations), makeGroupValidationsFn(GROUPED)(clonedValidations), ); return profile( validatableForm, makeValidationHandlerFn(validatableForm)(groupedValidations), ); } function createValidation( obj = { value: 'default' }, { path = PROPNAME, initValue = INITVAL, optional = false } = {}, ) { const pgs = PredicateGroups(); const items = ManyToManyMap(); const containedGroups = ManyToManyMap(); const TYPE = SINGLE; const item = ValidatableItem(obj, path, initValue); pgs.add(obj, ObservablePredicates(item, optional)); items.add(obj, item); containedGroups.add(obj, pgs); return ValidationBuilder({ pgs, items, containedGroups, TYPE }); } createValidation.group = makeGroupValidationsFn(GROUPED); createValidation.glue = makeGroupValidationsFn(GLUED); createValidation.clone = (validation) => clone({ validation, registry: CloneRegistry() }); createValidation.profile = createProfile; export { Predicate, createValidation as Validation };