Home Reference Source Repository

src/ObjectShadowImpl.js

import has from "lodash.has";
import omit from "lodash.omit";
import isEqual from "lodash.isequal";
import isPlainObject from "lodash.isplainobject";

import ShadowImpl from "./ShadowImpl";
import reshadow from "./reshadow";


// private variable names
const _automounted = Symbol('automounted');
const _impls = Symbol('impls');
const _mapped = Symbol('mapped');
const _nextMapping = Symbol('nextMapping');
const _size = Symbol('size');
const _shadow = Symbol('shadow');


export default class ObjectShadowImpl extends ShadowImpl {
	constructor(time, property, name, state, parent, shader, prev) {
		super(time, property, name, state, parent, shader, prev);

		this[_mapped] = false;
		this[_impls] = {};
	}

	/*
		Remove invalid but still active children.
	*/
	onReshadow(prev) {
		// Kill invalid() nodes that are still active
		prev._killInvalidButActiveChildren();
	}

	/*
		Gets the child implementation mapping (key : impl) for the next state. Use this method to get the
		mapping when not making changes but just interested in using the next state. This method is more
		efficient when not making changes because it will use the initial state mapping when no state
		changes have been made.
	*/
	childMapping() {
		return this[_nextMapping] || this[_impls];
	}

	/*
		Gets the mapping used for making future state changes.
	*/
	nextMapping() {
		if (this[_nextMapping] === undefined) {
			this.ensureMounted();

			if (!this.isMapped()) {
				this.defineChildProperties();
			}

			this[_nextMapping] = { ...this[_impls] };
		}

		return this[_nextMapping];
	}

	/*
		Removes a child property from further updates. Child will still be accessible for getting the
		current value until next update cycle.

		Parameters:
			child - the child property implementation instance
	*/
	removeChild(child) {
		child.blockFurtherUpdates(true);
	}

	/*
		Removes a child property from further updates. Child will still be accessible for getting the
		current value until next update cycle.

		Parameters:
			key - the child property key
	*/
	removeChildAt(key) {
		const mapping = this.nextMapping();
		const child = mapping[key];

		if (child) {
			this.removeChild(child);
		}
	}

	toJSON() {
		return {
			...super.toJSON(),
			mapped: this[_mapped],
			type: 'KeyedShadowImpl',
		};
	}


	//------------------------------------------------------------------------------------------------------
	// Methods for access and manipulate subproperties.
	//------------------------------------------------------------------------------------------------------

	/*
		Removes all subproperties.
	*/
	clear() {
		return this.update( state => {
				return { name: "clear()", nextState: {} };
			});
	}

	/*
		Removes a subproperty from this state property.
	*/
	delete(key) {
		this.update( state => {
				this.removeChildAt(key);

				return { name: `delete(${key})`, nextState: omit(state, key) };
			});
	}

	/*
		Gets a copy of the implementation objects. Returns an object.
	*/
	entries() {
		return { ...this[_impls] };
	}

	extend(sources) {
		this.update( state => {
				if (state) {
					return {
						name: "extend()",
						nextState: Object.assign({ }, state, ...sources),
						replace: true
					}
				} else {
					return { name: `extend()`, nextState: state };
				}
			});
	}

	/*
		Gets the property implementation for a state property.
	*/
	get(k) {
		return this[_impls][k];
	}

	/*
		Gets if this state property has a subproperty with a specified name.
	*/
	has(k) {
		return has(this[_impls], k);
	}

	/*
		Gets the keys of all managed subproperties. Returns an array.
	*/
	keys() {
		return Object.keys(this[_impls]);
	}

	/*
		Sets a subproperty value. Replaces current state property if one has an identical name.
	*/
	set(k, v) {
		this.update( state => {
				this.removeChildAt(k);

				if (state) {
					return { name: `set(${k})`, nextState: { ...state, [k]: v } };
				} else {
					return { name: `set(${k})`, nextState: state };
				}
			});

		return this;
	}

	size() {
		return this.childCount();
	}

	/*
		Gets subproperty implementation objects. Returns an array.
	*/
	values() {
		return Object.values(this[_impls]);
	}


	//------------------------------------------------------------------------------------------------------
	//	Methods that must be implemented by subclasses
	//------------------------------------------------------------------------------------------------------

	defaults(state) {
		// bail if new state is not an object
		if (!isPlainObject(state)) {
			// nothing to do
			return ;
		}

		const impls = this.childMapping();
		const keys = Object.keys(impls);
		const stateKeys = Object.keys(state);
		var key, stateValue, child;

		for (let i=0, len=stateKeys.length; i<len; i++) {
			key = stateKeys[i];
			stateValue = state[key];
			child = impls[key];

			if (child) {
				child.defaults(stateValue);
			} else {
				this.set(key, stateValue);
			}
		}
	}

	merge(state) {
		// bail if new state is not an object
		if (!isPlainObject(state)) {
			return this.assign(state);
		}

		const impls = this.childMapping();
		const keys = Object.keys(impls);
		const stateKeys = Object.keys(state);
		var key, stateValue, child;

		for (let i=0, len=stateKeys.length; i<len; i++) {
			key = stateKeys[i];
			child = impls[key];
			stateValue = state[key];

			if (child) {
				child.merge(stateValue);
			} else {
				this.set(key, stateValue);
			}
		}
	}


	//------------------------------------------------------------------------------------------------------
	//	Methods that subclasses with children must implement
	//------------------------------------------------------------------------------------------------------

	automountChildren(prev) {
		if (this[_mapped] || this[_automounted]) { return }

		this[_automounted] = true;

		const state = this.state();
		const shader = this.shader(state);

		for (let name in state) {
			if (shader.isAutomount(name)) {
				this.defineChildProperty(name, shader, state, prev, true);
			}
		}
	}

	/*
		Subclasses should implement this method in such a way as not to trigger a mapping.
	*/
	childCount() {
		if (!this.state()) { return 0 }

		if (this[_size] === undefined) {
			this[_size] = Object.keys(this.state()).length;
		}

		return this[_size];
	}

	/*
		Gets the implementation objects managed by this property.
	*/
	children() {
		return Object.values(this[_impls]);
	}

	/*
		Maps all child properties onto this property using Object.defineProperty().
	*/
	defineChildProperties(prev, inCtor) {
		if (this[_mapped]) { return }

		this[_mapped] = true;

		const state = this.state();
		const shader = this.shader(state);
		var child, elementShader;

		for (let name in state) {
			if (!state.hasOwnProperty(name)) { continue }

			elementShader = shader.shaderFor(name, state);

			if (!elementShader) {
				console.warn(`KeyedShadowImpl.defineChildProperties() - no shader found - name=${name}, path=${this.dotPath()}`);
				continue;
			}

			this.defineChildProperty(name, elementShader, state, prev, inCtor);
		}

		if (isPlainObject(state)) {
			shader.shadowUndefinedProperties(state, this, (name, shader) => {
					this.defineChildProperty(name, shader, state, prev, inCtor);
				});
		}
	}

	defineChildProperty(name, elementShader, state, prev, inCtor=false) {
		// ensure not already defined
		if (this[_impls][name]) { return }

		const prevChild = prev && prev[_impls][name];
		var child;

		if (prevChild && !prevChild.replaced()) {
			child = reshadow(this.time(), state, prevChild, this);
		} else {
			child = elementShader.shadowProperty(this.time(), name, state, this);
		}

		if (child) {
			this[_impls][name] = child;

			if (!prevChild && !inCtor) {
				child.didShadow(this.time());
			}
		}
	}

	/*
		Gets a child implementation matching a property name or undefined if no such property exists.
	*/
	getChild(name) {
		return this.get(name);
	}

	isMapped() {
		return this[_mapped];
	}

	/*
		Gets the keys/indices for this property.

		Implementation note: Subclasses should implement this method in such a way as not to trigger a mapping.
	*/
	keys() {
		return Object.keys(this.state());
	}

	_killInvalidButActiveChildren() {
		// kill any unvisited children from previous implementation
		const prevNames = Object.keys(this[_impls]);
		var child;

		for (let i=0, len=prevNames.length; i<len; i++) {
			child = this[_impls][prevNames[i]];

			if (!child.isValid() && child.isActive()) {
				child.obsoleteTree();
			}
		}
	}
}