validated-changeset
Version:
Changesets for your local state
1,554 lines (1,527 loc) • 79.3 kB
JavaScript
import structuredClone from '@ungap/structured-clone';
function isObject(val) {
return (val !== null &&
typeof val === 'object' &&
!(val instanceof Date || val instanceof RegExp) &&
!Array.isArray(val));
}
/* import { IChange } from '../types'; */
const VALUE = Symbol('__value__');
class Change {
constructor(value) {
this[VALUE] = value;
}
}
// TODO: not sure why this function type guard isn't working
const isChange = (maybeChange) => isObject(maybeChange) && VALUE in maybeChange;
function getChangeValue(maybeChange) {
if (isChange(maybeChange)) {
return maybeChange[VALUE];
}
}
class Err {
constructor(value, validation) {
this.value = value;
this.validation = validation;
}
}
/**
* traverse through target and return leaf nodes with `value` property and key as 'person.name'
* Only detects key paths with Changes
*
* @method getKeyValues
* @return {Array} [{ 'person.name': value }]
*/
function getKeyValues(obj, keysUpToValue = []) {
const map = [];
for (let key in obj) {
if (obj[key] && isObject(obj[key])) {
if (isChange(obj[key])) {
map.push({ key: [...keysUpToValue, key].join('.'), value: getChangeValue(obj[key]) });
}
else {
map.push(...getKeyValues(obj[key], [...keysUpToValue, key]));
}
}
}
return map;
}
/**
* traverse through target and return leaf nodes with `value` property and key as 'person.name'
*
* @method getKeyErrorValues
* @return {Array} [{ key: 'person.name', validation: '', value: '' }]
*/
function getKeyErrorValues(obj, keysUpToValue = []) {
let map = [];
for (let key in obj) {
if (obj[key] && isObject(obj[key])) {
if (Object.prototype.hasOwnProperty.call(obj[key], 'value') &&
obj[key] instanceof Err) {
map.push({
key: [...keysUpToValue, key].join('.'),
validation: obj[key].validation,
value: obj[key].value
});
}
else if (key !== 'value') {
map.push(...getKeyErrorValues(obj[key], [...keysUpToValue, key]));
}
}
}
return map;
}
function isPromiseLike(obj) {
return (!!obj &&
!!obj.then &&
!!obj.catch &&
!!obj.finally &&
typeof obj.then === 'function' &&
typeof obj.catch === 'function' &&
typeof obj.finally === 'function');
}
function isPromise(obj) {
return isObject(obj) && isPromiseLike(obj);
}
/**
* Rejects `true` values from an array of validations. Returns `true` when there
* are no errors, or the error object if there are errors.
*
* @private
* @param {Array} validations
* @return {Promise<boolean|Any>}
*/
async function handleValidationsAsync(validations) {
try {
const result = await Promise.all(validations);
const maybeFailed = result.filter((val) => typeof val !== 'boolean' && val);
return maybeFailed.length === 0 || maybeFailed;
}
catch (e) {
return e;
}
}
/**
* Rejects `true` values from an array of validations. Returns `true` when there
* are no errors, or the error object if there are errors.
*
* @private
* @param {Array} validations
* @return {boolean|Any}
*/
function handleValidationsSync(validations) {
const maybeFailed = validations.filter((val) => typeof val !== 'boolean' && val);
return maybeFailed.length === 0 || maybeFailed;
}
/**
* Handles an array of validators and returns Promise.all if any value is a
* Promise.
*
* @public
* @param {Array} validators Array of validator functions
* @param {String} options.key
* @param {Any} options.newValue
* @param {Any} options.oldValue
* @param {Object} options.changes
* @param {Object} options.content
* @return {Promise|boolean|Any}
*/
function handleMultipleValidations(validators, { key, newValue, oldValue, changes, content }) {
let validations = Array.from(validators.map((validator) => {
const isValidatorClass = (maybeClass) => !!maybeClass.validate;
if (validator && isValidatorClass(validator)) {
validator = validator.validate.bind(validator);
}
return validator(key, newValue, oldValue, changes, content);
}));
if (validations.some(isPromise)) {
return Promise.all(validations).then(handleValidationsAsync);
}
return handleValidationsSync(validations);
}
/**
* Handles both single key or nested string keys ('person.name')
*
* @method getDeep
*/
function getDeep(root, path) {
let obj = root;
if (path.indexOf('.') === -1) {
return obj[path];
}
const parts = typeof path === 'string' ? path.split('.') : path;
for (let i = 0; i < parts.length; i++) {
if (obj === undefined || obj === null) {
return undefined;
}
// next iteration has next level
obj = obj[parts[i]];
}
return obj;
}
/**
* Returns subObject while skipping `Change` instances
*
* @method getSubObject
*/
function getSubObject(root, path) {
let obj = root;
if (path.indexOf('.') === -1) {
return obj[path];
}
const parts = typeof path === 'string' ? path.split('.') : path;
for (let i = 0; i < parts.length; i++) {
if (obj === undefined || obj === null) {
return undefined;
}
if (isChange(obj[parts[i]])) {
obj = getChangeValue(obj[parts[i]]);
}
else {
obj = obj[parts[i]];
}
}
return obj;
}
/**
* returns a closure to lookup and validate k/v pairs set on a changeset
*
* @method lookupValidator
* @param validationMap
*/
function lookupValidator(validationMap) {
return ({ key, newValue, oldValue, changes, content }) => {
const validations = validationMap || {};
let validator = getDeep(validations, key);
const isValidatorClass = (maybeClass) => !!maybeClass.validate;
if (validator && isValidatorClass(validator)) {
validator = validator.validate.bind(validator);
}
if (!validator || isObject(validator)) {
return true;
}
let validation;
if (Array.isArray(validator)) {
validation = handleMultipleValidations(validator, {
key,
newValue,
oldValue,
changes,
content
});
}
else {
validation = validator(key, newValue, oldValue, changes, content);
}
return isPromise(validation)
? validation.then((result) => {
return result;
})
: validation;
};
}
// this statefull class holds and notifies
class Notifier {
constructor() {
this.listeners = [];
}
addListener(callback) {
this.listeners.push(callback);
return () => this.removeListener(callback);
}
removeListener(callback) {
for (let i = 0; i < this.listeners.length; i++) {
if (this.listeners[i] === callback) {
this.listeners.splice(i, 1);
return;
}
}
}
trigger(...args) {
this.listeners.forEach((callback) => callback(...args));
}
}
function notifierForEvent(object, eventName) {
if (object._eventedNotifiers === undefined) {
object._eventedNotifiers = {};
}
let notifier = object._eventedNotifiers[eventName];
if (!notifier) {
notifier = object._eventedNotifiers[eventName] = new Notifier();
}
return notifier;
}
function hasKey(record, path, safeGet) {
const keys = path.split('.');
let obj = record;
for (const key of keys) {
if (!obj || !Object.prototype.hasOwnProperty.call(obj, key)) {
return false;
}
obj = safeGet(obj, key);
if (isChange(obj)) {
obj = getChangeValue(obj);
}
}
return true;
}
function pathInChanges(record, path, safeGet) {
if (isChange(record)) {
return false;
}
const keys = path.split('.');
let obj = record;
for (const key of keys) {
if (!obj) {
return false;
}
if (keys[keys.length - 1] !== key && isChange(safeGet(obj, key))) {
return true;
}
obj = safeGet(obj, key);
}
return false;
}
/**
* traverse through target and unset `value` from leaf key so can access normally
* {
* name: Change {
* value: 'Charles'
* }
* }
*
* to
*
* {
* name: 'Charles'
* }
*
* Shallow copy here is fine because we are swapping out the leaf nested object
* rather than mutating a property in something with reference
*
* @method normalizeObject
* @param {Object} target
* @return {Object}
*/
function normalizeObject(target, isObj = isObject) {
if (!target || !isObj(target)) {
return target;
}
if (isChange(target)) {
return getChangeValue(target);
}
let obj = Object.assign({}, target);
for (let key in obj) {
const next = obj[key];
if (next && isObj(next)) {
if (isChange(next)) {
obj[key] = getChangeValue(next);
}
else {
try {
JSON.stringify(next);
}
catch (e) {
break;
}
obj[key] = normalizeObject(next);
}
}
}
return obj;
}
function hasChanges(changes) {
for (let key in changes) {
if (isChange(changes[key])) {
return true;
}
if (isObject(changes[key])) {
const isTruthy = hasChanges(changes[key]);
if (isTruthy) {
return isTruthy;
}
}
}
return false;
}
let getOwnPropertyDescriptors;
if (Object.getOwnPropertyDescriptors !== undefined) {
getOwnPropertyDescriptors = Object.getOwnPropertyDescriptors;
}
else {
getOwnPropertyDescriptors = function (obj) {
let desc = {};
Object.keys(obj).forEach((key) => {
desc[key] = Object.getOwnPropertyDescriptor(obj, key);
});
return desc;
};
}
// keep getters and setters
function pureAssign(...objects) {
return objects.reduce((acc, obj) => {
return Object.defineProperties(acc, getOwnPropertyDescriptors(obj));
}, {});
}
function flatten(validatorMap, obj, keys, keysUpToFunction = []) {
for (let key of keys) {
const value = validatorMap[key];
if (typeof value.validate === 'function') {
// class with .validate function
obj[key] = value;
}
else if (isObject(value)) {
flatten(value, obj, Object.keys(value), [...keysUpToFunction, key]);
}
else if (typeof value === 'function') {
const dotSeparatedKeys = [...keysUpToFunction, key].join('.');
obj[dotSeparatedKeys] = value;
}
else if (Array.isArray(value)) {
const isAllFuncs = value.every((item) => typeof item === 'function' || typeof item.validate === 'function');
if (isAllFuncs) {
const dotSeparatedKeys = [...keysUpToFunction, key].join('.');
obj[dotSeparatedKeys] = value;
}
}
}
return obj;
}
/**
* With nested validations, we flatten to a dot separated 'user.email': validationFunc
* Once doing so, validation will happen with a single level key or dot separated key
*
* @method flattenValidations
* @return {object}
*/
function flattenValidations(validatorMap) {
if (!validatorMap) {
return {};
}
let obj = {};
return flatten(validatorMap, obj, Object.keys(validatorMap));
}
const CHANGESET = '__CHANGESET__';
function isChangeset(changeset) {
return changeset && changeset['__changeset__'] === CHANGESET;
}
function keyInObject(obj, key) {
let [baseKey, ...keys] = key.split('.');
if (!baseKey || !(baseKey in obj)) {
return false;
}
if (!keys.length) {
return baseKey in obj;
}
let value = obj[baseKey];
if (value !== null && typeof value === 'object') {
return keyInObject(obj[baseKey], keys.join('.'));
}
return false;
}
function isArrayObject(obj) {
if (!obj)
return false;
let maybeIndicies = Object.keys(obj);
return maybeIndicies.every((key) => Number.isInteger(parseInt(key, 10)));
}
function arrayToObject(array) {
return array.reduce((obj, item, index) => {
obj[index] = item;
return obj;
}, {});
}
function objectToArray(obj) {
let result = [];
for (let [index, value] of Object.entries(obj)) {
result[parseInt(index, 10)] = value;
}
return result;
}
function split(path) {
const keys = path.split('.');
return keys;
}
function findSiblings(target, keys) {
const [leafKey] = keys.slice(-1);
const remaining = Object.keys(target)
.filter((k) => k !== leafKey)
.reduce((acc, key) => {
acc[key] = target[key];
return acc;
}, Object.create(null));
return Object.assign({}, remaining);
}
function isValidKey(key) {
return key !== '__proto__' && key !== 'constructor' && key !== 'prototype';
}
/**
* TODO: consider
* https://github.com/emberjs/ember.js/blob/822452c4432620fc67a777aba3b150098fd6812d/packages/%40ember/-internals/metal/lib/property_set.ts
*
* Handles both single path or nested string paths ('person.name')
*
* @method setDeep
*/
function setDeep(target, path, value, options = { safeSet: undefined, safeGet: undefined }) {
const keys = split(path).filter(isValidKey);
// We will mutate target and through complex reference, we will mutate the orig
let orig = target;
options.safeSet =
options.safeSet ||
function (obj, key, value) {
return (obj[key] = value);
};
options.safeGet =
options.safeGet ||
function (obj, key) {
return obj ? obj[key] : obj;
};
if (keys.length === 1) {
options.safeSet(target, path, value);
return target;
}
for (let i = 0; i < keys.length; i++) {
let prop = keys[i];
if (Array.isArray(target) && parseInt(prop, 10) < 0) {
throw new Error('Negative indices are not allowed as arrays do not serialize values at negative indices');
}
const isObj = isObject(options.safeGet(target, prop));
const isArray = Array.isArray(options.safeGet(target, prop));
const isComplex = isObj || isArray;
if (!isComplex) {
options.safeSet(target, prop, {});
}
else if (isComplex && isChange(options.safeGet(target, prop))) {
let changeValue = getChangeValue(options.safeGet(target, prop));
if (isObject(changeValue)) {
// if an object, we don't want to lose sibling keys
const siblings = findSiblings(changeValue, keys);
const resolvedValue = isChange(value) ? getChangeValue(value) : value;
const isArrayLike = Array.isArray(target) || isArrayObject(target);
const nestedKeys = isArrayLike
? keys.slice(i + 1, keys.length).join('.') // remove first key segment as well as the index
: keys.slice(1, keys.length).join('.'); // remove first key segment
let newValue;
// if the resolved value was deleted (via setting to null or undefined),
// there is no need to setDeep. We can short-circuit that and set
// newValue directly because of the shallow value
if (isArrayLike && undefined === resolvedValue) {
newValue = resolvedValue;
}
else if (i === keys.length - 1) {
// If last key, this is the final value
newValue = resolvedValue;
}
else {
newValue = setDeep(siblings, nestedKeys, resolvedValue, options);
}
options.safeSet(target, prop, new Change(newValue));
// since we are done with the `path`, we can terminate the for loop and return control
break;
}
else {
// we don't want to merge new changes with a Change instance higher up in the obj tree
// thus we nullify the current Change instance to
options.safeSet(target, prop, {});
}
}
// last iteration, set and return control
if (i === keys.length - 1) {
options.safeSet(target, prop, value);
break;
}
// assign next level of object for next loop
target = options.safeGet(target, prop);
}
return orig;
}
const { keys } = Object;
/**
* Given an array of objects, merge their keys into a new object and
* return the new object.
*/
function mergeNested(...objects) {
let finalObj = {};
objects.forEach((obj) => keys(obj).forEach((key) => setDeep(finalObj, key, obj[key])));
return finalObj;
}
function buildOldValues(content, changes, getDeep) {
const obj = Object.create(null);
for (let change of changes) {
obj[change.key] = getDeep(content, change.key);
}
return obj;
}
function isNonNullObject(value) {
return !!value && typeof value === 'object';
}
function isSpecial(value) {
let stringValue = Object.prototype.toString.call(value);
return stringValue === '[object RegExp]' || stringValue === '[object Date]';
}
function isMergeableObject(value) {
return isNonNullObject(value) && !isSpecial(value);
}
function getEnumerableOwnPropertySymbols(target) {
return Object.getOwnPropertySymbols
? Object.getOwnPropertySymbols(target).filter((symbol) => {
return target.propertyIsEnumerable(symbol);
})
: [];
}
function getKeys(target) {
return Object.keys(target).concat(getEnumerableOwnPropertySymbols(target));
}
function propertyIsOnObject(object, property) {
try {
return property in object;
}
catch (_) {
return false;
}
}
// Protects from prototype poisoning and unexpected merging up the prototype chain.
function propertyIsUnsafe(target, key) {
return (propertyIsOnObject(target, key) && // Properties are safe to merge if they don't exist in the target yet,
// unsafe if they exist up the prototype chain and also unsafe if they're nonenumerable.
!(Object.hasOwnProperty.call(target, key) && Object.propertyIsEnumerable.call(target, key)));
}
/**
* DFS - traverse depth first until find object with `value`. Then go back up tree and try on next key
* Need to exhaust all possible avenues.
*
* @method buildPathToValue
*/
function buildPathToValue(source, options, kv, possibleKeys) {
Object.keys(source).forEach((key) => {
let possible = source[key];
if (possible && isChange(possible)) {
kv[[...possibleKeys, key].join('.')] = getChangeValue(possible);
return;
}
if (possible && typeof possible === 'object') {
buildPathToValue(possible, options, kv, [...possibleKeys, key]);
}
});
return kv;
}
/**
* `source` will always have a leaf key `value` with the property we want to set
*
* @method mergeTargetAndSource
*/
function mergeTargetAndSource(target, source, options) {
options.getKeys(source).forEach((key) => {
// proto poisoning. So can set by nested key path 'person.name'
if (options.propertyIsUnsafe(target, key)) {
// if safeSet, we will find keys leading up to value and set
if (options.safeSet) {
const kv = buildPathToValue(source, options, {}, []);
// each key will be a path nested to the value `person.name.other`
if (Object.keys(kv).length > 0) {
// we found some keys!
for (key in kv) {
const val = kv[key];
options.safeSet(target, key, val);
}
}
}
return;
}
// else safe key on object
if (propertyIsOnObject(target, key) &&
isMergeableObject(source[key]) &&
!isChange(source[key])) {
options.safeSet(target, key, mergeDeep(options.safeGet(target, key), options.safeGet(source, key), options));
}
else {
let next = source[key];
if (next && isChange(next)) {
return options.safeSet(target, key, getChangeValue(next));
}
return options.safeSet(target, key, normalizeObject(next));
}
});
return target;
}
/**
* goal is to mutate target with source's properties, ensuring we dont encounter
* pitfalls of { ..., ... } spread syntax overwriting keys on objects that we merged
*
* This is also adjusted for Ember peculiarities. Specifically `options.setPath` will allows us
* to handle properties on Proxy objects (that aren't the target's own property)
*
* @method mergeDeep
*/
function mergeDeep(target, source, options = {
safeGet: undefined,
safeSet: undefined,
propertyIsUnsafe: undefined,
getKeys: undefined
}) {
options.getKeys = options.getKeys || getKeys;
options.propertyIsUnsafe = options.propertyIsUnsafe || propertyIsUnsafe;
options.safeGet =
options.safeGet ||
function (obj, key) {
return obj[key];
};
options.safeSet =
options.safeSet ||
function (obj, key, value) {
return (obj[key] = value);
};
let sourceIsArray = Array.isArray(source);
let targetIsArray = Array.isArray(target);
let sourceAndTargetTypesMatch = sourceIsArray === targetIsArray;
if (!sourceAndTargetTypesMatch) {
let sourceIsArrayLike = isArrayObject(source);
if (targetIsArray && sourceIsArrayLike) {
return objectToArray(mergeTargetAndSource(arrayToObject(target), source, options));
}
return source;
}
else if (sourceIsArray) {
return source;
}
else if (target === null || target === undefined) {
/**
* If the target was set to null or undefined, we always want to return the source.
* There is nothing to merge.
*
* Without this explicit check, typeof null === typeof {any object-like thing}
* which means that mergeTargetAndSource will be called, and you can't merge with null
*/
return source;
}
else {
return mergeTargetAndSource(target, source, options);
}
}
const objectProxyHandler = {
/**
* Priority of access - changes, content, then check node
* @property get
*/
get(node, key) {
if (typeof key === 'symbol') {
return;
}
let childValue = node.safeGet(node.changes, key);
if (isChange(childValue)) {
return getChangeValue(childValue);
}
if (isObject(childValue)) {
let childNode = node.children[key];
if (childNode === undefined && node.content) {
let childContent = node.safeGet(node.content, key);
// cache it
childNode = node.children[key] = new ObjectTreeNode(childValue, childContent, node.safeGet);
}
// return proxy if object so we can trap further access to changes or content
if (childNode) {
return childNode.proxy;
}
}
if (typeof childValue !== 'undefined') {
// primitive
return childValue;
}
else if (node.content) {
const nodeContent = node.content;
if (node.safeGet(nodeContent, key) !== undefined) {
return node.safeGet(nodeContent, key);
}
}
if (typeof node[key] === 'function' || node.hasOwnProperty(key)) {
return node[key];
}
},
ownKeys(node) {
return Reflect.ownKeys(node.changes);
},
getOwnPropertyDescriptor(node, prop) {
return Reflect.getOwnPropertyDescriptor(node.changes, prop);
},
has(node, prop) {
return Reflect.has(node.changes, prop);
},
set(node, key, value) {
// dont want to set private properties on changes (usually found on outside actors)
if (key.startsWith('_')) {
return Reflect.set(node, key, value);
}
return Reflect.set(node.changes, key, new Change(value));
}
};
function defaultSafeGet(obj, key) {
return obj[key];
}
class ObjectTreeNode {
constructor(changes = {}, content = {}, safeGet = defaultSafeGet, isObject$1 = isObject) {
this.safeGet = safeGet;
this.isObject = isObject$1;
this.changes = changes;
this.content = content;
this.proxy = new Proxy(this, objectProxyHandler);
this.children = Object.create(null);
}
get(key) {
return this.safeGet(this.changes, key);
}
set(key, value) {
return setDeep(this.changes, key, value);
}
unwrap() {
let changes = this.changes;
if (isObject(changes)) {
changes = normalizeObject(changes, this.isObject);
const content = this.content;
if (isObject(content)) {
changes = normalizeObject(changes, this.isObject);
return Object.assign(Object.assign({}, content), changes);
}
else if (Array.isArray(content)) {
changes = normalizeObject(changes, this.isObject);
return objectToArray(mergeDeep(arrayToObject(content), changes));
}
}
return changes;
}
}
/**
* Merges all sources together, excluding keys in excludedKeys.
*
* @param {string[]} excludedKeys
* @param {...object} sources
* @return {object}
*/
function objectWithout(excludedKeys, ...sources) {
return sources.reduce((acc, source) => {
Object.keys(source)
.filter((key) => excludedKeys.indexOf(key) === -1 || !source.hasOwnProperty(key))
.forEach((key) => (acc[key] = source[key]));
return acc;
}, {});
}
function take(originalObj = {}, keysToTake = []) {
let newObj = {};
for (let key in originalObj) {
if (keysToTake.indexOf(key) !== -1) {
newObj[key] = originalObj[key];
}
}
return newObj;
}
const { keys: keys$1 } = Object;
const CONTENT = '_content';
const PREVIOUS_CONTENT = '_previousContent';
const CHANGES = '_changes';
// const ORIGINAL = '_original';
const ERRORS = '_errors';
const ERRORS_CACHE = '_errorsCache';
const OPTIONS = '_options';
const AFTER_ROLLBACK_EVENT = 'afterRollback';
const DEBUG = process.env.NODE_ENV !== 'production';
function assert(msg, property) {
if (DEBUG) {
if (!property) {
throw new Error(msg);
}
}
}
function maybeUnwrapProxy(content) {
return content;
}
function newFormat(obj, original, getDeep) {
let newFormat = {};
for (let item of obj) {
const { key, value } = item;
newFormat[key] = {
current: value,
original: getDeep(original, key)
};
}
return newFormat;
}
// This is intended to provide an alternative changeset structure compatible with `yup`
// This slims down the set of features, including removed APIs and `validate` returns just the `validate(obj)` method call and requires users to manually add errors.
class ValidatedChangeset {
constructor(obj, options = {}) {
this.__changeset__ = CHANGESET;
this._eventedNotifiers = {};
/**
* @property isObject
* @override
*/
this.isObject = isObject;
/**
* @property maybeUnwrapProxy
* @override
*/
this.maybeUnwrapProxy = maybeUnwrapProxy;
/**
* @property setDeep
* @override
*/
this.setDeep = setDeep;
/**
* @property getDeep
* @override
*/
this.getDeep = getDeep;
/**
* @property mergeDeep
* @override
*/
this.mergeDeep = mergeDeep;
this[CONTENT] = obj;
this[PREVIOUS_CONTENT] = undefined;
this[CHANGES] = {};
this[OPTIONS] = options;
this[ERRORS] = {};
this[ERRORS_CACHE] = {};
}
on(eventName, callback) {
const notifier = notifierForEvent(this, eventName);
return notifier.addListener(callback);
}
off(eventName, callback) {
const notifier = notifierForEvent(this, eventName);
return notifier.removeListener(callback);
}
trigger(eventName, ...args) {
const notifier = notifierForEvent(this, eventName);
if (notifier) {
notifier.trigger(...args);
}
}
/**
* @property safeGet
* @override
*/
safeGet(obj, key) {
return obj[key];
}
/**
* @property safeSet
* @override
*/
safeSet(obj, key, value) {
return (obj[key] = value);
}
/**
* @property changes
* @type {Array}
*/
get changes() {
let obj = this[CHANGES];
let original = this[CONTENT];
// foo: {
// original: 0,
// current: 1,
// }
return newFormat(getKeyValues(obj), original, this.getDeep);
}
/**
* @property errors
* @type {Array}
*/
get errors() {
let obj = this[ERRORS];
return getKeyErrorValues(obj);
}
get change() {
let obj = this[CHANGES];
if (hasChanges(this[CHANGES])) {
return normalizeObject(obj);
}
return {};
}
get error() {
return this[ERRORS];
}
get data() {
return this[CONTENT];
}
/**
* @property isValid
* @type {Array}
*/
get isValid() {
return getKeyErrorValues(this[ERRORS]).length === 0;
}
/**
* @property isPristine
* @type {Boolean}
*/
get isPristine() {
let validationKeys = Object.keys(this[CHANGES]);
const userChangesetKeys = this[OPTIONS].changesetKeys;
if (Array.isArray(userChangesetKeys) && userChangesetKeys.length) {
validationKeys = validationKeys.filter((k) => userChangesetKeys.includes(k));
}
if (validationKeys.length === 0) {
return true;
}
return !hasChanges(this[CHANGES]);
}
/**
* @property isInvalid
* @type {Boolean}
*/
get isInvalid() {
return !this.isValid;
}
/**
* @property isDirty
* @type {Boolean}
*/
get isDirty() {
return !this.isPristine;
}
/**
* Stores change on the changeset.
* This approximately works just like the Ember API
*
* @method setUnknownProperty
*/
setUnknownProperty(key, value) {
let config = this[OPTIONS];
let changesetKeys = config.changesetKeys;
if (Array.isArray(changesetKeys) && changesetKeys.length > 0) {
const hasKey = changesetKeys.find((chKey) => key.match(chKey));
if (!hasKey) {
return;
}
}
let content = this[CONTENT];
let oldValue = this.safeGet(content, key);
this._setProperty({ key, value, oldValue });
}
/**
* String representation for the changeset.
*/
get [Symbol.toStringTag]() {
let normalisedContent = pureAssign(this[CONTENT], {});
return `changeset:${normalisedContent.toString()}`;
}
/**
* String representation for the changeset.
*/
toString() {
let normalisedContent = pureAssign(this[CONTENT], {});
return `changeset:${normalisedContent.toString()}`;
}
/**
* Executes the changeset if in a valid state.
*
* @method execute
*/
execute() {
let oldContent;
if (this.isValid && this.isDirty) {
let content = this[CONTENT];
let changes = this[CHANGES];
// keep old values in case of error and we want to rollback
oldContent = buildOldValues(content, getKeyValues(changes), this.getDeep);
// we want mutation on original object
// @tracked
this[CONTENT] = this.mergeDeep(content, changes, {
safeGet: this.safeGet,
safeSet: this.safeSet
});
}
// trigger any registered callbacks by same keyword as method name
this.trigger('execute');
this[CHANGES] = {};
this[PREVIOUS_CONTENT] = oldContent;
return this;
}
unexecute() {
if (this[PREVIOUS_CONTENT]) {
this[CONTENT] = this.mergeDeep(this[CONTENT], this[PREVIOUS_CONTENT], {
safeGet: this.safeGet,
safeSet: this.safeSet
});
}
return this;
}
/**
* Returns the changeset to its pristine state, and discards changes and
* errors.
*
* @method rollback
*/
rollback() {
// Get keys before reset.
let keys = this._rollbackKeys();
// Reset.
this[CHANGES] = {};
this[ERRORS] = {};
this[ERRORS_CACHE] = {};
this._notifyVirtualProperties(keys);
this.trigger(AFTER_ROLLBACK_EVENT);
return this;
}
/**
* Discards any errors, keeping only valid changes.
*
* @public
* @chainable
* @method rollbackInvalid
* @param {String} key optional key to rollback invalid values
* @return {Changeset}
*/
rollbackInvalid(key) {
let errorKeys = keys$1(this[ERRORS]);
if (key) {
this._notifyVirtualProperties([key]);
// @tracked
this[ERRORS] = this._deleteKey(ERRORS, key);
this[ERRORS_CACHE] = this[ERRORS];
if (errorKeys.indexOf(key) > -1) {
this[CHANGES] = this._deleteKey(CHANGES, key);
}
}
else {
this._notifyVirtualProperties();
this[ERRORS] = {};
this[ERRORS_CACHE] = this[ERRORS];
// if on CHANGES hash, rollback those as well
errorKeys.forEach((errKey) => {
this[CHANGES] = this._deleteKey(CHANGES, errKey);
});
}
return this;
}
/**
* @method validate
*/
async validate(cb) {
const changes = this[CHANGES];
const content = this[CONTENT];
// return an object that does not poison original model and provides user with full set of data + changes to validate
return cb(this.mergeDeep(structuredClone(content), changes));
}
/**
* Manually add an error to the changeset. If there is an existing
* error or change for `key`, it will be overwritten.
*
* @method addError
*/
addError(key, error) {
// Construct new `Err` instance.
let newError;
const isIErr = (error) => this.isObject(error) && !Array.isArray(error);
if (isIErr(error)) {
assert('Error must have value.', error.hasOwnProperty('value') || error.value !== undefined);
assert('Error must have validation.', error.hasOwnProperty('validation'));
newError = new Err(error.value, error.validation);
}
else {
let value = this[key];
newError = new Err(value, error);
}
// Add `key` to errors map.
let errors = this[ERRORS];
// @tracked
this[ERRORS] = this.setDeep(errors, key, newError, { safeSet: this.safeSet });
this[ERRORS_CACHE] = this[ERRORS];
// Return passed-in `error`.
return newError;
}
/**
* @method removeError
*/
removeError(key) {
// Remove `key` to errors map.
let errors = this[ERRORS];
// @tracked
this[ERRORS] = this.setDeep(errors, key, null, { safeSet: this.safeSet });
this[ERRORS] = this._deleteKey(ERRORS, key);
this[ERRORS_CACHE] = this[ERRORS];
}
/**
* @method removeError
*/
removeErrors() {
// @tracked
this[ERRORS] = {};
this[ERRORS_CACHE] = this[ERRORS];
}
/**
* Manually push multiple errors to the changeset as an array.
* key maybe in form 'name.short' so need to go deep
*
* @method pushErrors
*/
pushErrors(key, ...newErrors) {
let errors = this[ERRORS];
let existingError = this.getDeep(errors, key) || new Err(null, []);
let validation = existingError.validation;
let value = this[key];
if (!Array.isArray(validation) && Boolean(validation)) {
existingError.validation = [validation];
}
let v = existingError.validation;
validation = [...v, ...newErrors];
let newError = new Err(value, validation);
// @tracked
this[ERRORS] = this.setDeep(errors, key, newError, { safeSet: this.safeSet });
this[ERRORS_CACHE] = this[ERRORS];
return { value, validation };
}
/**
* Creates a snapshot of the changeset's errors and changes.
*
* @method snapshot
*/
snapshot() {
let changes = this[CHANGES];
let errors = this[ERRORS];
return {
changes: this.getChangesForSnapshot(changes),
errors: keys$1(errors).reduce((newObj, key) => {
let e = errors[key];
newObj[key] = { value: e.value, validation: e.validation };
return newObj;
}, {})
};
}
getChangesForSnapshot(changes) {
return keys$1(changes).reduce((newObj, key) => {
newObj[key] = isChange(changes[key])
? getChangeValue(changes[key])
: this.getChangesForSnapshot(changes[key]);
return newObj;
}, {});
}
/**
* Restores a snapshot of changes and errors. This overrides existing
* changes and errors.
*
* @method restore
*/
restore({ changes, errors }) {
let newChanges = this.getChangesFromSnapshot(changes);
let newErrors = keys$1(errors).reduce((newObj, key) => {
let e = errors[key];
newObj[key] = new Err(e.value, e.validation);
return newObj;
}, {});
// @tracked
this[CHANGES] = newChanges;
// @tracked
this[ERRORS] = newErrors;
this[ERRORS_CACHE] = this[ERRORS];
this._notifyVirtualProperties();
return this;
}
getChangesFromSnapshot(changes) {
return keys$1(changes).reduce((newObj, key) => {
newObj[key] = this.getChangeForProp(changes[key]);
return newObj;
}, {});
}
getChangeForProp(value) {
if (!isObject(value)) {
return new Change(value);
}
return keys$1(value).reduce((newObj, key) => {
newObj[key] = this.getChangeForProp(value[key]);
return newObj;
}, {});
}
/**
* Sets property on the changeset.
*/
_setProperty({ key, value, oldValue }) {
let changes = this[CHANGES];
// Happy path: update change map.
if (!isEqual(value, oldValue) || oldValue === undefined) {
// @tracked
let result = this.setDeep(changes, key, new Change(value), {
safeSet: this.safeSet
});
this[CHANGES] = result;
}
else if (keyInObject(changes, key)) {
// @tracked
// remove key if setting back to original
this[CHANGES] = this._deleteKey(CHANGES, key);
}
}
/**
* Notifies virtual properties set on the changeset of a change.
* You can specify which keys are notified by passing in an array.
*
* @private
* @param {Array} keys
* @return {Void}
*/
_notifyVirtualProperties(keys) {
if (!keys) {
keys = this._rollbackKeys();
}
return keys;
}
/**
* Gets the changes and error keys.
*/
_rollbackKeys() {
let changes = this[CHANGES];
let errors = this[ERRORS];
return [...new Set([...keys$1(changes), ...keys$1(errors)])];
}
/**
* Deletes a key off an object and notifies observers.
*/
_deleteKey(objName, key = '') {
let obj = this[objName];
let keys = key.split('.');
if (keys.length === 1 && obj.hasOwnProperty(key)) {
delete obj[key];
}
else if (obj[keys[0]]) {
let [base, ...remaining] = keys;
let previousNode = obj;
let currentNode = obj[base];
let currentKey = base;
// find leaf and delete from map
while (this.isObject(currentNode) && currentKey) {
let curr = currentNode;
if (isChange(curr) || typeof curr.value !== 'undefined' || curr.validation) {
delete previousNode[currentKey];
}
currentKey = remaining.shift();
previousNode = currentNode;
if (currentKey) {
currentNode = currentNode[currentKey];
}
}
}
return obj;
}
get(key) {
// 'person'
// 'person.username'
let [baseKey, ...remaining] = key.split('.');
let changes = this[CHANGES];
let content = this[CONTENT];
if (Object.prototype.hasOwnProperty.call(changes, baseKey)) {
const changesValue = this.getDeep(changes, key);
const isObject = this.isObject(changesValue);
if (!isObject && changesValue !== undefined) {
// if safeGet returns a primitive, then go ahead return
return changesValue;
}
}
// At this point, we may have a changes object, a dot separated key, or a need to access the `key`
// on `this` or `content`
if (Object.prototype.hasOwnProperty.call(changes, baseKey) && hasChanges(changes)) {
let baseChanges = changes[baseKey];
// 'user.name'
const normalizedBaseChanges = normalizeObject(baseChanges);
if (this.isObject(normalizedBaseChanges)) {
const result = this.maybeUnwrapProxy(this.getDeep(normalizedBaseChanges, remaining.join('.')));
// need to do this inside of Change object
// basically anything inside of a Change object that is undefined means it was removed
if (typeof result === 'undefined' &&
pathInChanges(changes, key, this.safeGet) &&
!hasKey(changes, key, this.safeGet) &&
this.getDeep(content, key)) {
return;
}
if (this.isObject(result)) {
if (isChange(result)) {
return getChangeValue(result);
}
const baseContent = this.safeGet(content, baseKey) || {};
const subContent = this.getDeep(baseContent, remaining.join('.'));
const subChanges = getSubObject(changes, key);
// give back an object that can further retrieve changes and/or content
const tree = new ObjectTreeNode(subChanges, subContent, this.getDeep, this.isObject);
return tree.proxy;
}
else if (typeof result !== 'undefined') {
return result;
}
}
// this comes after the isObject check to ensure we don't lose remaining keys
if (isChange(baseChanges) && remaining.length === 0) {
return getChangeValue(baseChanges);
}
}
// return getters/setters/methods on BufferedProxy instance
if (baseKey in this || key in this) {
return this.getDeep(this, key);
}
const subContent = this.maybeUnwrapProxy(this.getDeep(content, key));
if (this.isObject(subContent)) {
let subChanges = this.getDeep(changes, key);
if (!subChanges) {
// if no changes, we need to add the path to the existing changes (mutate)
// so further access to nested keys works
subChanges = this.getDeep(this.setDeep(changes, key, {}), key);
}
// may still access a value on the changes or content objects
const tree = new ObjectTreeNode(subChanges, subContent, this.getDeep, this.isObject);
return tree.proxy;
}
else if (Array.isArray(subContent)) {
let subChanges = this.getDeep(changes, key);
if (!subChanges) {
// return array of contents. Dont need to worry about further access sibling keys in array case
return subContent;
}
if (isObject(subChanges)) {
if (isObject(subContent)) {
subChanges = normalizeObject(subChanges, this.isObject);
return Object.assign(Object.assign({}, subContent), subChanges);
}
else if (Array.isArray(subContent)) {
subChanges = normalizeObject(subChanges, this.isObject);
return objectToArray(mergeDeep(arrayToObject(subContent), subChanges));
}
}
return subChanges;
}
return subContent;
}
set(key, value) {
if (this.hasOwnProperty(key) || keyInObject(this, key)) {
this[key] = value;
}
else {
this.setUnknownProperty(key, value);
}
}
}
/**
* Creates new changesets.
*/
function changeset(obj, options) {
return new ValidatedChangeset(obj, options);
}
function Changeset(obj, options) {
const c = changeset(obj, options);
return new Proxy(c, {
get(targetBuffer, key /*, receiver*/) {
const res = targetBuffer.get(key.toString());
return res;
},
set(targetBuffer, key, value /*, receiver*/) {
targetBuffer.set(key.toString(), value);
return true;
}
});
}
// determine if two values are equal
function isEqual(v1, v2) {
if (v1 instanceof Date && v2 instanceof Date) {
return v1.getTime() === v2.getTime();
}
return v1 === v2;
}
const { keys: keys$2 } = Object;
const CONTENT$1 = '_content';
const PREVIOUS_CONTENT$1 = '_previousContent';
const CHANGES$1 = '_changes';
const ERRORS$1 = '_errors';
const ERRORS_CACHE$1 = '_errorsCache';
const VALIDATOR = '_validator';
const OPTIONS$1 = '_options';
const RUNNING_VALIDATIONS = '_runningValidations';
const BEFORE_VALIDATION_EVENT = 'beforeValidation';
const AFTER_VALIDATION_EVENT = 'afterValidation';
const AFTER_ROLLBACK_EVENT$1 = 'afterRollback';
const defaultValidatorFn = () => true;
const defaultOptions = { skipValidate: false };
const DEBUG$1 = process.env.NODE_ENV !== 'production';
function assert$1(msg, property) {
if (DEBUG$1) {
if (!property) {
throw new Error(msg);
}
}
}
function maybeUnwrapProxy$1(content) {
return content;
}
class BufferedChangeset {
constructor(obj, validateFn = defaultValidatorFn, validationMap = {}, options = {}) {
this.validateFn = validateFn;
this.validationMap = validationMap;
this.__changeset__ = CHANGESET;
this._eventedNotifiers = {};
/**
* @property isObject
* @override
*/
this.isObject = isObject;
/**
* @property maybeUnwrapProxy
* @override
*/
this.maybeUnwrapProxy = maybeUnwrapProxy$1;
/**
* @property setDeep
* @override
*/
this.setDeep = setDeep;
/**
* @property getDeep
* @override
*/
this.getDeep = getDeep;
/**
* @property mergeDeep
* @override
*/
this.mergeDeep = mergeDeep;
this[CONTENT$1] = obj;
this[PREVIOUS_CONTENT$1] = undefined;
this[CHANGES$1] = {};
this[VALIDATOR] = validateFn;
this[OPTIONS$1] = pureAssign(defaultOptions, JSON.parse(JSON.stringify(options)));
this[RUNNING_VALIDATIONS] = {};
let validatorMapKeys = this.validationMap ? keys$2(this.validationMap) : [];
if (this[OPTIONS$1].initValidate && validatorMapKeys.length > 0) {
let errors = this._collectErrors();
this[ERRORS$1] = errors;
this[ERRORS_CACHE$1] = errors;
}
else {
this[ERRORS$1] = {};
this[ERRORS_CACHE$1] = {};
}
}
on(eventName, callback) {
const notifier = notifierForEvent(this, eventName);
return notifier.addListener(callback);
}
off(eventName, callback) {
const notifier = notifierForEvent(this, eventName);
return notifier.removeListener(callback);
}
trigger(eventName, ...args) {
const notifier = notifierForEvent(this, eventName);
if (notifier) {
notifier.trigger(...args);
}
}
/**
* @property safeGet
* @override
*/
safeGet(obj, key) {
return obj[key];
}
/**
* @property safeSet
* @override
*/
safeSet(obj, key, value) {
return (obj[key] =