@schoolbelle/common
Version: 
861 lines (851 loc) • 25.7 kB
JavaScript
import { Subject } from 'rxjs';
import { forEach, isArray, isPlainObject, get as get$1, set as set$1, unset, isEqual, cloneDeep, orderBy, isEmpty, values, defaults } from 'lodash-es';
import { get, set, cloneDeep as cloneDeep$1, defaults as defaults$1, isEmpty as isEmpty$1 } from 'lodash';
/**
 * @fileoverview added by tsickle
 * @suppress {checkTypes,extraRequire,missingOverride,missingReturn,unusedPrivateMembers,uselessCode} checked by tsc
 */
/**
 * @param {?} ob
 * @param {?=} delimiter
 * @param {?=} current_key_path
 * @param {?=} paths
 * @return {?}
 */
function getAllLeafPaths(ob, delimiter = '.', current_key_path = [], paths = []) {
    forEach(ob, (/**
     * @param {?} val
     * @param {?} key
     * @return {?}
     */
    (val, key) => {
        key = isArray(ob) ? `${key}` : key;
        // an instance of Class such as a File instance is not a plain object.
        if (isPlainObject(val) || isArray(val)) {
            paths = getAllLeafPaths(val, delimiter, current_key_path.concat([key]), paths);
        }
        else {
            paths.push(current_key_path.concat([key]).join(delimiter));
        }
    }));
    return paths;
}
/**
 * @fileoverview added by tsickle
 * @suppress {checkTypes,extraRequire,missingOverride,missingReturn,unusedPrivateMembers,uselessCode} checked by tsc
 */
/**
 * calculate the difference btwn two objects
 * and return object with key being path and value being an object of to and from ;
 * ex>  say before is {a:{b:{c:1}}} and current is {a:{b:{c:2}}}
 * returned value will be {'a.b.c':{to:2, from:1}}
 *
 * @param {?} current
 * @param {?} before
 * @return {?}
 */
function getAllLeafChanges(current, before) {
    /** @type {?} */
    const diff = {};
    /** @type {?} */
    const allLeafPathsOfCurrent = getAllLeafPaths(current);
    /** @type {?} */
    const allLeafPathsOfBefore = getAllLeafPaths(before);
    allLeafPathsOfCurrent.forEach((/**
     * @param {?} path
     * @return {?}
     */
    path => {
        /** @type {?} */
        let v_from_origin = get(before, path);
        /** @type {?} */
        let v_from_current = get(current, path);
        if (isEmptyStringOrNullOrUndefined(v_from_current) === true && isEmptyStringOrNullOrUndefined(v_from_origin) === true)
            return;
        if (v_from_current !== v_from_origin) {
            set(diff, [path, 'to'], v_from_current);
        }
    }));
    allLeafPathsOfBefore.forEach((/**
     * @param {?} path
     * @return {?}
     */
    path => {
        /** @type {?} */
        let v_from_origin = get(before, path);
        /** @type {?} */
        let v_from_current = get(current, path);
        if (isEmptyStringOrNullOrUndefined(v_from_current) === true && isEmptyStringOrNullOrUndefined(v_from_origin) === true)
            return;
        if (v_from_current !== v_from_origin) {
            set(diff, [path, 'from'], v_from_origin);
        }
    }));
    /**
     * [START file type usage extension]
     *
     *
     * class Foo {
     *  a = "a"
     *  b = "b"
     * }
     * const before = {branch1:{a:"a", b:"b"}, branch2:"c"}; // getAllLeafPaths will return ["branch1.a", "branch1.b", "branch2"]
     * const current = {branch1:new Foo(), , branch2:"c"} // getAllLeafPaths will return ["branch1", "branch2"]
     *
     * result will be :
     * 1. missing to or from in the "diff"
     * 2. sub-paths of a class instance in the "diff"
     *
     * We have to :
     * 1. Fill in the missing part of data
     * 2. Remove all sub-paths of a class instance
     *
     * @type {?}
     */
    const paths = Object.keys(diff);
    Object.entries(diff)
        .forEach((/**
     * @param {?} __0
     * @return {?}
     */
    ([path, update]) => {
        if (update.to !== null && typeof update.to === 'object') {
            // fill the missing data, from.
            update.from = get(before, path);
            // delete sub-paths of a class instance such as "branch1.a", "branch1.b"
            paths.filter((/**
             * @param {?} p
             * @return {?}
             */
            p => p.startsWith(path) && p !== path)).forEach((/**
             * @param {?} p
             * @return {?}
             */
            p => { delete diff[p]; }));
        }
        else if (update.from !== null && typeof update.from === 'object') {
            // fill the missing data, to.
            update.to = get(current, path);
            // delete sub-paths of a class instance such as "branch1.a", "branch1.b"
            paths.filter((/**
             * @param {?} p
             * @return {?}
             */
            p => p.startsWith(path) && p !== path)).forEach((/**
             * @param {?} p
             * @return {?}
             */
            p => { delete diff[p]; }));
        }
    }));
    // [END file type usage extension]
    return diff;
}
/**
 * @param {?} v
 * @return {?}
 */
function isEmptyStringOrNullOrUndefined(v) {
    return v === '' || v === null || v === undefined;
}
/**
 * @fileoverview added by tsickle
 * @suppress {checkTypes,extraRequire,missingOverride,missingReturn,unusedPrivateMembers,uselessCode} checked by tsc
 */
/**
 * @param {?} val
 * @return {?}
 */
function shakeDeadLeaves(val) {
    if (val === null)
        return null;
    if (val === undefined)
        return null;
    // if (val instanceof File || val instanceof Blob) return val; // instance of File or Blob
    if (val && typeof val === 'object' && !isPlainObject(val))
        return val; // instance of a class including File, Blob, and many other
    if (val && typeof val === 'object' && Object.keys(val).length === 0)
        return null; // {}
    if (typeof val !== 'object')
        return val;
    else {
        for (let key in val) {
            val[key] = shakeDeadLeaves(val[key]);
            if (val[key] === null)
                delete val[key];
        }
        if (Object.keys(val).length === 0)
            return null;
        else
            return val;
    }
}
/**
 * @fileoverview added by tsickle
 * @suppress {checkTypes,extraRequire,missingOverride,missingReturn,unusedPrivateMembers,uselessCode} checked by tsc
 */
class ObjectChangeTracker {
    /**
     * @param {?=} root
     * @param {?=} pathFromRoot
     */
    constructor(root = undefined, pathFromRoot = []) {
        this.contentUpdateEvent = new Subject();
        /**
         * this is where it keep its model.
         * The only root tracker keeps it.
         * Child trackers send all changes to their root tracker and the root tracker writes all changes to this property.
         *
         */
        this._value = {};
        this._origin = {};
        this._to = {};
        this._from = {};
        this._trackers = new Map();
        this._root = root || this;
        this._pathFromRoot = pathFromRoot;
        if (this.root !== this) {
            this.trackers.set(this.pathFromRoot.join('.'), this);
        }
    }
    /**
     * @return {?}
     */
    get root() {
        if (this._root === this)
            return this;
        else
            return this._root.root;
    }
    /**
     * @return {?}
     */
    get pathFromRoot() {
        if (this._root === this)
            return [];
        else {
            return this._root.pathFromRoot.concat(this._pathFromRoot);
        }
    }
    ;
    /**
     * @return {?}
     */
    get onContentUpdate() {
        return this.contentUpdateEvent.asObservable();
    }
    /**
     * return the whole model if root or else "branch" of model.
     * Watch out for modifying this data cause it may return reference to the model itself.
     * @return {?}
     */
    get value() {
        if (this._root === this)
            return this._value;
        else
            return get$1(this.root._value, this.pathFromRoot);
    }
    /**
     * @return {?}
     */
    get isChanged() {
        /** @type {?} */
        const to = this.to;
        /** @type {?} */
        const from = this.from;
        return ((to && (typeof to === 'object' && Object.keys(to).length !== 0))
            || (from && (typeof from === 'object' && Object.keys(from).length !== 0))
            || !isEqual(to, from));
    }
    /**
     * @return {?}
     */
    get origin() {
        if (this.root === this)
            return this._origin;
        else
            return get$1(this.root.origin, this.pathFromRoot);
    }
    /**
     * @param {?} v
     * @return {?}
     */
    set origin(v) {
        if (typeof v === 'undefined')
            v = null;
        if (this.root === this)
            this._origin = v;
        else
            set$1(this.root.origin, this.pathFromRoot, v);
    }
    /**
     * @return {?}
     */
    get to() {
        if (this.root === this)
            return this._to;
        else
            return get$1(this.root.to, this.pathFromRoot);
    }
    /**
     * @param {?} v
     * @return {?}
     */
    set to(v) {
        if (this.root === this)
            this._to = v || {};
        else {
            if (v === null) {
                unset(this.root.to, this.pathFromRoot);
                this.root.to = shakeDeadLeaves(this.root.to);
            }
            else
                set$1(this.root.to, this.pathFromRoot, v);
        }
    }
    /**
     * @return {?}
     */
    get from() {
        if (this.root === this)
            return this._from;
        else
            return get$1(this.root.from, this.pathFromRoot);
    }
    /**
     * @param {?} v
     * @return {?}
     */
    set from(v) {
        if (this.root === this)
            this._from = v || {};
        else {
            if (v === null) {
                unset(this.root.from, this.pathFromRoot);
                this.root.from = shakeDeadLeaves(this.root.from);
            }
            else
                set$1(this.root.from, this.pathFromRoot, v);
        }
    }
    /**
     * @return {?}
     */
    get parent() {
        if (this.root === this)
            return this;
        else
            return this.root.getChildTracker(this.pathFromRoot.slice(0, -1));
    }
    /**
     * @return {?}
     */
    get trackers() {
        if (this.root === this)
            return this._trackers;
        else
            return this.root.trackers;
    }
    /**
     * @param {?} path
     * @return {?}
     */
    get(path) {
        if (typeof path === 'string')
            path = path.split('.').filter((/**
             * @param {?} step
             * @return {?}
             */
            step => step));
        path = this.pathFromRoot.concat(path);
        /** @type {?} */
        let value;
        if (path.length === 0)
            value = this.root._value;
        else {
            value = get$1(this.root._value, path);
        }
        // array data type handling
        if (!isEmpty(value) && typeof value === 'object' && !Array.isArray(value)) {
            /** @type {?} */
            let keys = Object.keys(value);
            if (typeof value["" + 0] !== "undefined" && typeof value["" + (keys.length - 1)] !== "undefined" && !keys.find((/**
             * @param {?} key
             * @return {?}
             */
            key => parseInt("" + key) === NaN))) {
                return values(value);
            }
            else {
                return value;
            }
        }
        return value;
        // if (path.length === 0) return cloneDeep(this.root._value);
        // else {
        //   return cloneDeep(get(this.root._value, path));
        // }
    }
    /**
     * @param {?} path
     * @param {?} value
     * @param {?=} options
     * @return {?}
     */
    set(path, value, options = undefined) {
        options = defaults(options, { push: 'up', updateDiff: true });
        if (typeof value === 'undefined')
            value = null;
        if (typeof path === 'string')
            path = path.split('.').filter((/**
             * @param {?} step
             * @return {?}
             */
            step => step));
        path = this.pathFromRoot.concat(path);
        this.updateData(path, value);
        if (options.updateOrigin)
            this.updateOrigin(path, value);
        if (options.updateDiff)
            this.updateDiff();
        if (options.push !== 'none')
            this.notifyChangeThruTree(path, options);
    }
    /**
     * @param {?=} origin
     * @param {?=} options
     * @return {?}
     */
    load(origin = {}, options = undefined) {
        if (this.root !== this)
            throw new Error('load is only allowed for root');
        options = defaults(options, { push: 'up', updateDiff: true });
        this.origin = cloneDeep(origin);
        this.updateData([], origin);
        if (options.updateDiff)
            this.updateDiff();
        if (options.push !== 'none')
            this.notifyChangeThruTree([], options);
    }
    /**
     * @protected
     * @return {?}
     */
    updateDiff() {
        /** @type {?} */
        let origin = this.origin;
        /** @type {?} */
        let current = this.value;
        /** @type {?} */
        let to = {};
        /** @type {?} */
        let from = {};
        /** @type {?} */
        const changes = getAllLeafChanges(current, origin);
        for (let path in changes) {
            set$1(to, path, changes[path].to);
            set$1(from, path, changes[path].from);
        }
        shakeDeadLeaves(to);
        shakeDeadLeaves(from);
        this.to = isEmpty(to) ? null : to;
        this.from = isEmpty(from) ? null : from;
    }
    /**
     *
     * @protected
     * @param {?} path
     * @param {?} v_n
     * set 'null' means unset
     * unset value will return as 'undefined'
     *
     * @return {?}
     */
    updateData(path, v_n) {
        /** @type {?} */
        let v_o = get$1(this._value, path);
        if (isEqual(v_n, v_o))
            return;
        if (v_n === null) {
            if (path.length === 0)
                this.root._value = v_n;
            else
                unset(this.root._value, path);
        }
        else {
            if (path.length === 0)
                this.root._value = cloneDeep(v_n);
            else
                set$1(this.root._value, path, cloneDeep(v_n));
        }
    }
    /**
     * @protected
     * @param {?} path
     * @param {?} v_n
     * @return {?}
     */
    updateOrigin(path, v_n) {
        /** @type {?} */
        let v_o = get$1(this._origin, path);
        if (isEqual(v_n, v_o))
            return;
        if (v_n === null) {
            if (path.length === 0)
                this.root._origin = v_n;
            else
                unset(this.root._origin, path);
        }
        else {
            if (path.length === 0)
                this.root._origin = v_n instanceof File ? v_n : cloneDeep(v_n);
            else
                set$1(this.root._origin, path, v_n instanceof File ? v_n : cloneDeep(v_n));
        }
    }
    /**
     * @param {?=} paths
     * @param {?=} options
     * @return {?}
     */
    notifyChangeThruTree(paths = [], options = undefined) {
        options = Object.assign({ push: 'up' }, options);
        /** @type {?} */
        const tracker = paths.length === 0 ? this : this.trackers.get(paths.join('.'));
        if (['down', 'both'].includes(options.push)) {
            orderBy(Array.from(this.trackers.keys()), (/**
             * @param {?} key
             * @return {?}
             */
            key => key.split('.').length), 'desc')
                .filter((/**
             * @param {?} key
             * @return {?}
             */
            key => key.match(paths.join('.'))))
                .map((/**
             * @param {?} key
             * @return {?}
             */
            key => this.trackers.get(key)))
                .filter((/**
             * @param {?} childTracker
             * @return {?}
             */
            childTracker => childTracker))
                .forEach((/**
             * @param {?} childTracker
             * @return {?}
             */
            childTracker => childTracker.contentUpdateEvent.next({ to: childTracker.to, from: childTracker.from })));
        }
        if (!['none'].includes(options.push) && tracker)
            tracker.contentUpdateEvent.next({ to: tracker.to, from: tracker.from });
        if (['up', 'both'].includes(options.push)) {
            paths.map((/**
             * @param {?} path
             * @param {?} index
             * @return {?}
             */
            (path, index) => paths.slice(0, paths.length - index).join('.')))
                .map((/**
             * @param {?} subpaths
             * @return {?}
             */
            subpaths => this.trackers.get(subpaths)))
                .filter((/**
             * @param {?} childTracker
             * @return {?}
             */
            childTracker => childTracker))
                .forEach((/**
             * @param {?} childTracker
             * @return {?}
             */
            childTracker => {
                childTracker.contentUpdateEvent.next({ to: childTracker.to, from: childTracker.from });
            }));
            this.root.contentUpdateEvent.next({ to: this.root.to, from: this.root.from });
        }
    }
    /**
     * @param {?} path
     * @return {?}
     */
    getChildTracker(path) {
        if (typeof path === 'string')
            path = path.split('.').filter((/**
             * @param {?} step
             * @return {?}
             */
            step => step));
        /** @type {?} */
        const fullpath = this.pathFromRoot.concat(path);
        if (fullpath.length === 0) {
            return this;
        }
        else {
            /** @type {?} */
            let childRef = this.trackers.get(fullpath.join('.'));
            if (!childRef) {
                childRef = new ObjectChangeTracker(this, path);
                this.trackers.set(fullpath.join('/'), childRef);
            }
            return childRef;
        }
    }
    /**
     * @param {?=} path
     * @return {?}
     */
    destroyTracker(path = []) {
        if (typeof path === 'string')
            path = path.split('.').filter((/**
             * @param {?} step
             * @return {?}
             */
            step => step));
        path = this.pathFromRoot.concat(path);
        /** @type {?} */
        let tracker;
        if (path.length === 0)
            tracker = this;
        else
            tracker = this.trackers.get(path.join('.'));
        if (tracker) {
            tracker.contentUpdateEvent.complete();
            this.trackers.delete(path.join('.'));
        }
    }
}
/**
 * @fileoverview added by tsickle
 * @suppress {checkTypes,extraRequire,missingOverride,missingReturn,unusedPrivateMembers,uselessCode} checked by tsc
 */
/**
 * @fileoverview added by tsickle
 * @suppress {checkTypes,extraRequire,missingOverride,missingReturn,unusedPrivateMembers,uselessCode} checked by tsc
 */
/** @type {?} */
const DEFAULT_MAX_SIZE = 100;
class ObjectChangeTrackerWithHistory extends ObjectChangeTracker {
    /**
     * @param {?=} root
     * @param {?=} pathFromRoot
     */
    constructor(root = undefined, pathFromRoot = []) {
        super(root, pathFromRoot);
        this._trackers = new Map();
        /**
         * maximum length of history stack
         */
        this.MAX_SIZE = DEFAULT_MAX_SIZE;
        /**
         * current position in the history stack
         */
        this.position = 0;
        /**
         * history stack
         */
        this.stack = [];
    }
    /**
     * @return {?}
     */
    get root() {
        if (this._root === this)
            return this;
        else
            return this._root.root;
    }
    /**
     * @return {?}
     */
    get parent() {
        if (this.root === this)
            return this;
        else
            return this.root.getChildTracker(this.pathFromRoot.slice(0, -1));
    }
    /**
     * @return {?}
     */
    get trackers() {
        if (this.root === this)
            return this._trackers;
        else
            return this.root.trackers;
    }
    /**
     * @param {?} path
     * @return {?}
     */
    getChildTracker(path) {
        if (typeof path === 'string')
            path = path.split('.').filter((/**
             * @param {?} step
             * @return {?}
             */
            step => step));
        /** @type {?} */
        const fullpath = this.pathFromRoot.concat(path);
        if (fullpath.length === 0) {
            return this;
        }
        else {
            /** @type {?} */
            let childRef = this.trackers.get(fullpath.join('.'));
            if (!childRef) {
                childRef = new ObjectChangeTrackerWithHistory(this, path);
                this.trackers.set(fullpath.join('/'), childRef);
            }
            return childRef;
        }
    }
    /**
     *
     * @return {?}
     */
    save() {
        if (this.root !== this)
            throw new Error('save is only allowed for root');
        // 0. get diff
        /** @type {?} */
        const before = this.before;
        /** @type {?} */
        const current = this.value;
        /** @type {?} */
        const diff = getAllLeafChanges(current, before)
        // 1. Pushing a new item should remove the history items that stand after the current position, if there is(are).
        ;
        // 1. Pushing a new item should remove the history items that stand after the current position, if there is(are).
        if (this.stack.length > this.position)
            this.stack.splice(this.position);
        // 2. Push to the stack.
        this.stack.push(diff);
        // 3. Rotate the list if it grows too big.
        if (this.stack.length > this.MAX_SIZE)
            this.stack.splice(0, this.stack.length - this.MAX_SIZE);
        // 4. move up the position if possible.
        if (this.position < this.MAX_SIZE)
            this.position++;
        // 5. update before
        for (let path in diff) {
            set(this.before, path, diff[path].to);
        }
        this.updateDiff();
    }
    /**
     * @param {?} path
     * @param {?} value
     * @param {?=} options
     * @return {?}
     */
    set(path, value, options = undefined) {
        options = defaults$1(options, { saveToHistory: true });
        super.set(path, value, options);
        if (options.saveToHistory)
            this.root.save();
    }
    /**
     * reset history related properties.
     * @protected
     * @return {?}
     */
    reset() {
        this.before = null;
        this.position = 0;
        this.stack.splice(0, this.stack.length);
    }
    /**
     * @param {?=} origin
     * @param {?=} options
     * @return {?}
     */
    load(origin = {}, options = undefined) {
        super.load(origin, options);
        this.reset();
        this.before = cloneDeep$1(origin);
    }
    /**
     * @return {?}
     */
    undo() {
        if (this.root !== this)
            throw new Error('undo is only allowed for root');
        if (this.position <= 0)
            return false;
        // decrement position
        this.position--;
        // get the target history snapshot
        /** @type {?} */
        const diff = this.stack[this.position];
        // update current and before value 
        /** @type {?} */
        const current = this._value || {};
        /** @type {?} */
        const before = this.before || {};
        // recalculate current and before
        for (let path in diff) {
            set(current, path, diff[path].from); // go back, model! 
            set(before, path, diff[path].from); // update before
        }
        shakeDeadLeaves(current);
        shakeDeadLeaves(before);
        // store current and before
        this._value = isEmpty$1(current) ? null : current;
        this.before = isEmpty$1(before) ? null : before;
        // update diff
        this.updateDiff();
        // notify
        for (let path in diff) {
            this.notifyChangeThruTree(path.split('.'), { push: 'up' });
        }
        return true;
    }
    /**
     * @return {?}
     */
    redo() {
        if (this.root !== this)
            throw new Error('redo is only allowed for root');
        if (this.position >= this.stack.length)
            return false;
        // increment position
        this.position++;
        // get the target history snapshot
        /** @type {?} */
        const diff = this.stack[this.position - 1];
        // recalculate current and before
        /** @type {?} */
        const current = this._value || {};
        /** @type {?} */
        const before = this.before || {};
        for (let path in diff) {
            set(current, path, diff[path].to); // go forward, model! 
            set(before, path, diff[path].to); // update before
        }
        shakeDeadLeaves(current);
        shakeDeadLeaves(before);
        // store current and before
        this._value = isEmpty$1(current) ? null : current;
        this.before = isEmpty$1(before) ? null : before;
        // update diff
        this.updateDiff();
        // notify
        for (let path in diff) {
            this.notifyChangeThruTree(path.split('.'));
        }
        return true;
    }
}
/**
 * @fileoverview added by tsickle
 * @suppress {checkTypes,extraRequire,missingOverride,missingReturn,unusedPrivateMembers,uselessCode} checked by tsc
 */
/**
 * @fileoverview added by tsickle
 * @suppress {checkTypes,extraRequire,missingOverride,missingReturn,unusedPrivateMembers,uselessCode} checked by tsc
 */
export { ObjectChangeTracker, ObjectChangeTrackerWithHistory, getAllLeafPaths, shakeDeadLeaves };
//# sourceMappingURL=schoolbelle-common-object-change-tracker.js.map