UNPKG

svelte-specma

Version:

Svelte store for data validation using Specma

907 lines (781 loc) 24.4 kB
import { writable, readable, derived, get as get$1 } from 'svelte/store'; import fastEquals from 'fast-deep-equal'; import { tick } from 'svelte'; function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } function _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; } const identity$1 = x => x; const noop = () => {}; function flexDerived(initialStores = [], fn = identity$1, initialValue) { /* Used for ordering values */ let _stores = initialStores; /* Store of stores. When last subscriber unsubscribes, * will stop all stores subs. */ const $stores = writable(initialStores); /* Callback function can use a second argument, a `publish` function to set result asynchronously. * If not used, the return value of the function is the actual result to publish. * Otherwise, the return value might be a function to call before each execution * and when store is unsubscribed. */ const auto = fn.length < 2; /* Use a readable store to manage subscribers. */ const mainStore = readable(initialValue, publish => { let initialized = false; let cleanup = noop; let pending = 0; let unsubs = new Map(); let values = new Map(); /* Unsubscribe saved stores not included in a new list of stores * and create a new Map of unsub by store, reusing old ones when they exist. */ function updateUnsubs(stores = []) { stopUnusedSubs(stores); unsubs = new Map(stores.map(store => [store, unsubs.get(store)])); } function stopUnusedSubs(usedStores = []) { unsubs.forEach((unsub, store) => { if (!usedStores.includes(store) && unsub) unsub(); }); } function stopSubscriptions() { unsubs.forEach(unsub => unsub && unsub()); } /* Create a new map of values by store, reusing old ones when they exist. */ function updateValues(stores = []) { values = new Map(stores.map(store => [store, values.get(store)])); } /* Recompute and publish the combined result on values, * using `_stores` to ensure values order consistency. */ function sync() { if (pending) return; cleanup(); const vals = _stores.reduce((acc, store) => values.has(store) ? [...acc, values.get(store)] : acc, []); const result = fn(vals, publish); if (auto) { publish(result); } else { cleanup = typeof result === "function" ? result : noop; } } const unsubscribe = $stores.subscribe(stores => { updateUnsubs(stores); updateValues(stores); /* Subscribe to each store if not alreay done. * When a any store value changes, * combined result will be updated and published. */ _stores = []; stores.forEach((store, i) => { _stores[i] = store; if (!unsubs.get(store)) { unsubs.set(store, store.subscribe(value => { values.set(store, value); pending &= ~(1 << i); if (initialized) sync(); }, () => { pending |= 1 << i; })); } }); if (initialized) sync(); }); initialized = true; sync(); return function stop() { unsubscribe(); stopSubscriptions(); cleanup(); }; }); /* Exclude one or mode stores from the list. */ function exclude(...stores) { $stores.update(prev => prev.filter(store => !stores.includes(store))); } /* Include one or more stores into the list. */ function include(...stores) { $stores.update(prev => [...prev, ...stores]); } return { exclude, include, set: $stores.set, stores: $stores, subscribe: mainStore.subscribe, update: $stores.update }; } const identity = x => x; const isColl = x => ["array", "map", "object"].includes(typeOf(x)); const isFunc = x => typeof x === "function"; const isStore = x => x && x.subscribe && isFunc(x.subscribe); const typeOf = obj => ({}).toString.call(obj).split(" ")[1].slice(0, -1).toLowerCase(); function entries(coll) { const fn = { array: () => coll.map((v, i) => [i, v]), map: () => [...coll.entries()], object: () => Object.entries(coll) }[typeOf(coll)]; return fn ? fn(coll) : []; } function fromEntries(entries, toType) { const fn = { array: () => entries.map(([, val]) => val), map: () => new Map(entries), object: () => Object.fromEntries(entries) }[toType]; return fn ? fn() : fromEntries(entries, "map"); } function values(coll) { const fn = { array: () => [...coll], map: () => [...coll.values()], object: () => Object.values(coll) }[typeOf(coll)]; return fn ? fn(coll) : []; } function keys(coll) { const fn = { array: () => coll.map((v, i) => i), map: () => [...coll.keys()], object: () => Object.keys(coll) }[typeOf(coll)]; return fn ? fn(coll) : []; } /* Merge multiple collections of identical type with right to left arguments precedence. */ function merge(...args) { const colls = args.filter(isColl); const type = typeOf(colls[0]); if (!colls.every(coll => typeOf(coll) === type)) { const collTypes = colls.map(typeOf).join(", "); throw new TypeError(`Collections must be of same type. Received '${collTypes}'.`); } const fn = { array: () => colls.reduce((acc, coll) => { if (coll.length >= acc.length) return coll; return [...coll, ...acc.slice(coll.length)]; }, []), map: () => new Map(colls.map(coll => coll.entries())), object: () => Object.assign({}, ...colls) }[type]; if (!fn) throw new Error(`'merge' not implemented yet for ${type}`); return fn(); } function genRandomId() { return (Math.random() * 1e9).toFixed(0); } function get(key, coll) { const fn = { array: () => coll[key], map: () => coll.get(key), object: () => coll[key] }[typeOf(coll)]; return fn ? fn(key, coll) : undefined; } function getPath(path = [], value) { return path.reduce((parent, key) => get(key, parent), value); } function countPathAncestors(str = "") { return (str.match(/\.\.\/|\.\.$/g) || []).length; } function keepForwardPath(str = "") { return str.split("/").reduce((acc, node) => { if (!node || node.startsWith(".")) return acc; const index = parseInt(node, 10); return [...acc, isNaN(index) ? node : index]; }, []); } function equals(a, b, eqBy = defaultEqBy) { const [_a, _b] = [a, b].map(eqBy); return _a === _b || fastEquals(_a, _b); } function defaultEqBy(x) { if (x instanceof Date) return x.valueOf(); if (isColl(x)) return removeUndefined(x); return x; } function removeUndefined(x) { if (!isColl(x)) return x; return fromEntries(entries(x).reduce((acc, [key, val]) => val === undefined ? acc : [...acc, [key, removeUndefined(val)]], []), typeOf(x)); } function deriveState(coll) { const storesEntries = entries(coll); return { coll, collType: typeOf(coll), storesEntries, keys: storesEntries.map(([key]) => key), stores: storesEntries.map(([, store]) => store) }; } function collDerived(initialColl, fn = identity, initialValue) { let state = deriveState(initialColl); /* The callback passed to `flexDerived` can have arity 1 or 2. * The arity should match the one of the provided function. * When using arity 2, a set function is provided so that * result can be used asynchronously */ const auto = fn.length < 2; const index$stores = $stores => fromEntries($stores.map((store, idx) => [state.keys[idx], store]), state.collType); const values = flexDerived(state.stores, auto ? $stores => fn(index$stores($stores)) : ($stores, _set) => fn(index$stores($stores), _set), initialValue); function set(newColl) { state = deriveState(newColl); values.set(state.stores); } return { set, subscribe: values.subscribe }; } const ALWAYS_VALID = { valid: true }; const REQUIRED_SPECMA_FNS = ["and", "getMessage", "getPred", "getSpread", "isOpt", "validatePred"]; const CONFIG_ERROR_MSG = "SvelteSpecma must be configured with a valid Specma version."; let specma = undefined; function ensureConfigured() { if (!specma) { throw new TypeError(CONFIG_ERROR_MSG); } } function configure(specmaFns) { if (!specmaFns || REQUIRED_SPECMA_FNS.some(key => typeof specmaFns[key] !== "function")) { throw new TypeError(CONFIG_ERROR_MSG); } specma = specmaFns; } /* Limited version of `writable` store that updates only * if the new set value is different by value (deep equality) */ function wriableByValue(initialValue, ...rest) { let _value = initialValue; const store = writable(initialValue, ...rest); return { set(newValue) { if (fastEquals(newValue, _value)) return; _value = newValue; store.set(_value); }, subscribe: store.subscribe }; } const alwaysTrue = () => true; const isMissing = x => [undefined, null, ""].includes(x); const defaultChangePred = (a, b) => !equals(a, b); const reqSpec = x => !isMissing(x) || specma.getMessage("isRequired"); function predSpecable(initialValue, { changePred = defaultChangePred, id, required, spec, onSubmit } = {}, _extra = {}) { ensureConfigured(); const { and, getPred, validatePred } = specma; const { getAncestor } = _extra; const pred = getPred(spec) || alwaysTrue; const isRequired = !!required; const ownSpec = isRequired ? and(reqSpec, pred) : pred; const contextStores = {}; const context = collDerived(contextStores); function addContext(relPath) { /* If `getFrom` has already been called once, * context store is already tracking the value. */ if (contextStores[relPath]) return; const ancestor = getAncestor(countPathAncestors(relPath)); if (!ancestor) return; const pathSinceAncestor = keepForwardPath(relPath); contextStores[relPath] = derived(ancestor, ($ancestor, set) => { const ancestorValue = $ancestor.value; if (!ancestorValue) return; const curr = contextStores[relPath].value; const next = getPath(pathSinceAncestor, ancestorValue); if (!equals(curr, next)) { contextStores[relPath].value = next; set(next); } }); context.set(contextStores); /* If context has just been created, it won't be accessible * in the derived store at first. * In that case, return the static store value. */ return getPath(pathSinceAncestor, get$1(ancestor).value); } let currPromise; let _initialValue = initialValue; const active = writable(false); const submitting = writable(false); const value = wriableByValue(_initialValue); const store = derived([active, value, context, submitting], ([$active, $value, $context, $submitting], set) => { currPromise = undefined; function getFrom(relPath) { if (!contextStores[relPath]) { return addContext(relPath); } return $context[relPath]; } const shouldValidate = $active && ($value !== undefined || required); const result = enhanceResult(shouldValidate ? validatePred(ownSpec, $value, getFrom) : ALWAYS_VALID); const baseArgs = { active: $active, changePred, initialValue: _initialValue, id, result, submitting: $submitting, value: $value }; currPromise = result.promise; set(interpretState(baseArgs)); if (result.valid === null) { result.promise.then(resolvedResult => { /* Promise might be outdated */ if (result.promise !== currPromise) return; set(interpretState(_extends({}, baseArgs, { result: resolvedResult }))); }); } }); async function activate(bool = true) { active.set(bool); await tick(); const res = await currPromise; return res.valid; } async function submit() { if (!onSubmit) return; submitting.set(true); const valid = await activate(); if (valid) { const currValue = get$1(value); await onSubmit(currValue); } submitting.set(false); } return { id, isRequired, spec: pred, activate, reset(newValue = _initialValue) { _initialValue = newValue; this.activate(false); this.set(newValue); }, set: (newValue, shouldActivate = false) => { value.set(newValue); if (shouldActivate) activate(); }, submit, subscribe: store.subscribe }; } function enhanceResult(res) { return _extends({}, res, { promise: res.promise ? res.promise.then(promised => enhanceResult(promised)) : Promise.resolve(res) }); } function interpretState({ active, changePred, id, initialValue, result, submitting, value }) { const changed = changePred(value, initialValue); return { active, changed, error: result.valid === false && result.reason, id, initialValue, promise: result.promise || Promise.resolve(result), submitting, valid: !!result.valid, validating: result.valid === null, value: changed ? value : initialValue }; } function collSpecable(initialValue, { changePred, fields, getId, id, required, spec, onSubmit } = {}, _extra = {}) { ensureConfigured(); const { getPred, getSpread, isOpt } = specma; let collValue = initialValue; // For static properties let isUndef = collValue === undefined; const { getAncestor } = _extra; const collDefiner = [fields, spec, initialValue].find(isColl); const collType = typeOf(collDefiner); const isRequired = required && !isOpt(required); const spreadGetId = getSpread(getId); const spreadSpec = getSpread(spec); const spreadFields = getSpread(fields); const spreadRequired = getSpread(required); const isSpread = spreadSpec || spreadFields || spreadRequired || spreadGetId || collType === "array"; const valueKeys = isSpread ? keys(initialValue) : []; const allKeys = new Set(fields ? [...keys(fields), ...valueKeys] : [...keys(spec), ...keys(required), ...valueKeys]); const ownGetId = getPred(getId); const idGen = (v, k) => { if (ownGetId) return ownGetId(v, k); if (collType === "array") return genRandomId(); return k; }; const ownSpecable = predSpecable(initialValue, { changePred: getPred(changePred), id, required: isRequired, spec }, _extra); const createChildEntry = (key, val) => { const subChangePred = get(key, changePred) || getSpread(changePred); const subVal = val; const subSpec = get(key, spec) || spreadSpec; const subGetId = get(key, getId) || spreadGetId; const subId = idGen(subVal, key); const subFields = get(key, fields) || spreadFields; const subRequired = get(key, required) || spreadRequired; const subStore = _extra.specable(subVal, { spec: subSpec, changePred: subChangePred, id: subId, getId: subGetId, fields: subFields, required: subRequired }, { getAncestor: n => n <= 1 || !getAncestor ? ownSpecable : getAncestor(n - 1) }); return [key, _extends({}, subStore, { id: subId })]; }; let childrenStores = fromEntries([...allKeys].map(key => createChildEntry(key, get(key, initialValue))), collType); const children = writable(childrenStores); const submitting = writable(false); const derivedValue = collDerived(childrenStores, $childrenStores => { if (isUndef) return undefined; const $childrenEntries = entries($childrenStores); const $childrenValues = $childrenEntries.map(([key, state]) => [key, state.value]); const childrenValue = fromEntries($childrenValues, collType); const value = isSpread ? childrenValue : merge(collValue, childrenValue); isUndef = value === undefined; return value; }); const aggregateStatusStores = () => [submitting, ownSpecable, ...values(childrenStores)]; const status = flexDerived(aggregateStatusStores(), $statusStores => { const [$submitting, $ownSpecable, ...$children] = $statusStores; const combined = isUndef ? $ownSpecable : [$ownSpecable, ...$children].reduce(combineChildren); if (combined.active !== false) ownSpecable.activate(); const { value, error } = $ownSpecable; const details = Object.fromEntries([["_", $ownSpecable], ...(isUndef ? [] : $children.map(child => [child.id, child]))]); const errors = detailsToErrors(details, id); const collErrors = errors.filter(({ isColl }) => isColl); return _extends({}, combined, { id, initialValue: $ownSpecable.initialValue, value, error, errors, collErrors, details, submitting: $submitting }); }); function setChildrenStores(newChildrenStores) { childrenStores = newChildrenStores; children.set(newChildrenStores); derivedValue.set(newChildrenStores); status.set(aggregateStatusStores()); } function addChildren(coll) { if (!coll) return; const newEntries = keys(coll).map(key => createChildEntry(key, get(key, coll))); const updatedStores = fromEntries([...entries(childrenStores), ...newEntries], collType); setChildrenStores(updatedStores); } function removeChildrenById(idsToRemove = []) { if (idsToRemove.length < 1) return; const updatedStores = fromEntries(entries(childrenStores).filter(([, store]) => !idsToRemove.includes(store.id)), collType); setChildrenStores(updatedStores); } function setValue(coll, { partial = false, reset = false } = {}) { const setMethod = reset ? "reset" : "set"; collValue = !reset && partial && !isSpread ? merge(collValue, coll) : coll; isUndef = collValue === undefined; ownSpecable[setMethod](collValue); const childrenEntries = entries(childrenStores); childrenEntries.forEach(([key, store]) => { const newValue = get(key, coll); if (partial && newValue === undefined) return; store[setMethod](newValue, partial); }); if (!isSpread) return; /* If collection allows spread children... */ /* Add `coll` entries that are not yet part of the children stores. */ const childrenKeys = keys(childrenStores); const missingChildrenEntries = entries(coll).filter(([key]) => !childrenKeys.includes(key)); if (missingChildrenEntries.length > 0) { addChildren(fromEntries(missingChildrenEntries, collType)); } if (partial) return; /* If update is not partial, remove children stores that do not store * a collection value anymore (garbage collection). */ const collKeys = keys(coll); const unusedIds = childrenEntries.reduce((acc, [key, childStore]) => { return collKeys.includes(key) ? acc : [...acc, childStore.id]; }, []); removeChildrenById(unusedIds); } function activate(bool = true) { const storesToActivate = [ownSpecable, ...(isUndef ? [] : values(childrenStores))]; const promises = storesToActivate.map(store => { const promise = store.activate(bool); return promise.then(valid => { if (valid) return valid; throw valid; }); }); return Promise.all(promises).then(() => true).catch(() => false); } async function submit() { if (!onSubmit) return; submitting.set(true); const valid = await activate(); if (valid) { const currValue = get$1(ownSpecable).value; await onSubmit(currValue); } submitting.set(false); } return { id, isRequired, spec, stores: childrenStores, activate, add(coll) { if (coll !== undefined) isUndef = false; addChildren(coll); return this; }, getChild(path = []) { const reduced = path.reduce((acc, key) => { const { children } = acc; if (!children) return { res: null }; const childStore = get(key, children); if (!childStore) return { res: null }; return { res: childStore, children: childStore.getChildren ? childStore.getChildren() : [] }; }, { children: childrenStores }); return reduced.res; }, getChildren() { return childrenStores; }, remove(idsToRemove = []) { removeChildrenById(idsToRemove); return this; }, reset(newInitialValue = initialValue) { setValue(newInitialValue, { reset: true }); activate(false); return this; }, set(coll, partial = false, shouldActivate = false) { setValue(coll, { partial }); if (shouldActivate) activate(); return this; }, update: fn => { setChildrenStores(fn(childrenStores)); return this; }, children: { subscribe: children.subscribe }, submit, subscribe: fn => { const unsub1 = derivedValue.subscribe(value => ownSpecable.set(value)); const unsub2 = status.subscribe(fn); return () => { unsub1(); unsub2(); }; } }; } function combineChildren(a, b) { const validating = a.validating || b.validating; return { active: a.active === b.active ? b.active : null, changed: a.changed || b.changed, valid: validating ? null : a.valid && b.valid, validating }; } const liftError = parentId => _ref => { let { path, error } = _ref, rest = _objectWithoutPropertiesLoose(_ref, ["path", "error"]); const newPath = parentId === undefined ? path : [parentId, ...path]; return _extends({}, rest, { path: newPath, which: newPath.join("."), error }); }; function detailsToErrors(details, parentId) { return Object.entries(details).flatMap(([key, status]) => { if (!status.details) { if (!status.error) return []; if (key === "_") { return [{ path: [], error: status.error, isColl: true }]; } return [summarizeStatusError(status)]; } const subErrors = detailsToErrors(status.details, details.id); return subErrors.map(liftError(status.id)); }).map(liftError(parentId)); } function summarizeStatusError({ id, error }) { return { path: [id], which: id, error }; } function register(el, storeOrArgs) { let args = normalizeArgs(storeOrArgs); if (!el || !args.store) return; let unsub; listen(); function blurHandler() { args.store.activate(); } function inputHandler(e) { args.store.set(args.toValue(e.target.value)); } function listen() { unsub = args.store.subscribe(({ value }) => { const elValue = args.toInput(value); if (el.value !== elValue) el.value = elValue; }); el.addEventListener("blur", blurHandler); el.addEventListener("input", inputHandler); } function unlisten() { unsub(); el.removeEventListener("blur", blurHandler); el.removeEventListener("input", inputHandler); } return { destroy: unlisten, update(newArgs) { unlisten(); if (!newArgs) return; args = normalizeArgs(newArgs); listen(); } }; } function normalizeArgs(storeOrArgs) { if (!storeOrArgs) return {}; if (!Array.isArray(storeOrArgs)) { return { store: storeOrArgs, toInput: (x = "") => x, toValue: identity }; } const [store, { toInput = identity, toValue = identity } = {}] = storeOrArgs; return { store, toInput, toValue }; } function specable(initialValue, options = {}, _extra) { if (isStore(initialValue)) return initialValue; const collCandidate = options.fields || options.spec || initialValue; if (isColl(collCandidate)) { return collSpecable(initialValue, options, _extends({}, _extra, { specable })); } return predSpecable(initialValue, options, _extra); } export { collSpecable, configure, predSpecable, register, specable }; //# sourceMappingURL=svelte-specma.modern.js.map