///
import {find, findIndex, filter} from '@jaszhix/utils';
function storeError(method: string, key: State.DisconnectKey, message: string): void {
console.warn('Warning: [store -> ' + method + ' -> ' + key + '] ' + message, new Error().stack);
}
function getByPath(key: string, object: State.Data): State.Data | null {
const path = key.split('.');
for (let i = 0; i < path.length; i++) {
object = object[path[i]];
if (!object) return null;
}
return object;
}
function differenceKeys(arr1: string[], arr2: string[]): string[] {
let newKeys = [];
for (let i = 0, len = arr1.length; i < len; i++) {
if (arr2.indexOf(arr1[i]) === -1) {
newKeys.push(arr1[i]);
}
}
return newKeys;
}
function intersectKeys(arr1: string[], arr2: string[]): string[] {
let newKeys = [];
for (let i = 0, len = arr1.length; i < len; i++) {
if (arr2.indexOf(arr1[i]) > -1) {
newKeys.push(arr1[i]);
}
}
return newKeys;
}
function isEqual(obj1: any, obj2: any): boolean {
let keys1, keys2, len, matches;
if (!obj1 || !obj2 || typeof obj1 !== 'object' || typeof obj2 !== 'object') {
return obj1 === obj2;
}
keys1 = Object.keys(obj1);
keys2 = Object.keys(obj2);
len = keys1.length;
matches = 0;
if (keys1.length !== keys2.length) return false;
if (differenceKeys(keys1, keys2).length) return false;
for (let i = 0; i < len; i++) {
let key = keys1[i];
let value1 = obj1[key];
let value2 = obj2[key];
if ((value1 && typeof value1 === 'object' && isEqual(value1, value2) )
|| value1 === value2) {
matches++;
}
}
return matches === len;
}
/**
* init
* Initializes a store instance. It uses private scoping to prevent
* its context from leaking.
*
* @param {object} [state={}]
* @param {array} [listeners=[]] - Not intended to be set manually, but can be overriden.
* See _connect.
* @returns Initial state object with the public API.
*/
function init(
state: Partial,
listeners: State.Listener[] = [],
mergeKeys: string[] = [],
connections = 0
): State.Data {
const publicAPI: State.API = Object.freeze({
get,
set,
setMergeKeys,
exclude,
trigger,
connect,
disconnect,
destroy
});
function getAPIWithObject(object: State.Data): State.Data {
return Object.assign(object, publicAPI);
}
/**
* dispatch
* Responsible for triggering callbacks stored in the listeners queue from set.
*
* @param {object} object
*/
function dispatch(object: Partial) {
let keys = Object.keys(object);
for (let i = 0; i < listeners.length; i++) {
let commonKeys = intersectKeys(keys, listeners[i].keys);
if (commonKeys.length === 0) continue;
if (listeners[i].callback) {
let partialState: Partial = {};
for (let z = 0; z < keys.length; z++) {
partialState[keys[z]] = state[keys[z]];
}
listeners[i].callback(partialState);
}
}
}
/**
* get
* Retrieves a cloned property from the state object.
*
* @param {string} [key=null]
* @returns {object}
*/
function get(key: string = ''): any {
if (!key || key === '*') {
return exclude();
}
if (key.indexOf('.') > -1) {
return getByPath(key, state);
}
return state[key];
}
/**
* set
* Copies a keyed object back into state, and
* calls dispatch to fire any connected callbacks.
*
* @param {object} object
* @param {boolean} forceDispatch
*/
function set(object: Partial, cb?: (() => void) | boolean, force?: boolean): State.API {
let keys = Object.keys(object);
let changed = false;
let changedObject: Partial = {};
force = cb === true || force;
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (!(key in state)) {
storeError('set', key, 'Property not found.');
return publicAPI;
}
let isObject = object[key] && typeof object[key] === 'object';
if (force
|| (isObject && !isEqual(state[key], object[key]))
|| (!isObject && state[key] !== object[key])) {
changed = true;
if (mergeKeys.indexOf(key) > -1) {
Object.assign(state[key], object[key]);
changedObject[key] = state[key];
continue;
}
changedObject[key] = state[key] = object[key];
}
}
if ((changed || force) && listeners.length > 0) {
dispatch(changedObject);
}
if (typeof cb === 'function') setTimeout(cb, 0);
return publicAPI;
}
/**
* setMergeKeys
* Adds a list of keys that should be merged into state child object instead of copied.
* This allows being able to pass partial objects to set(). Since there is a performance
* cost to this operation, it is not the default behavior.
*
* @param {array} keys
* @returns Public API for chaining.
*/
function setMergeKeys(keys: string[] = []): State.API {
for (let i = 0, len = keys.length; i < len; i++) {
const key = keys[i];
const value = state[key];
if (value && typeof value === 'object' && !Array.isArray(value)) {
mergeKeys.push(key);
}
}
return publicAPI;
}
/**
* exclude
* Excludes a string array of keys from the state object.
*
* @param {array} excludeKeys
* @returns Partial or full state object with keys in
* excludeKeys excluded, along with the public API for chaining.
*/
function exclude(excludeKeys: string[] = []): Partial {
let apiKeys = Object.keys(publicAPI);
let stateKeys = Object.keys(state);
let filteredState: Partial = {};
for (let i = 0, len = stateKeys.length; i < len; i++) {
if (apiKeys.indexOf(stateKeys[i]) === -1
&& excludeKeys.indexOf(stateKeys[i]) === -1) {
filteredState[stateKeys[i]] = state[stateKeys[i]];
}
}
return filteredState;
}
/**
* trigger
* Fires a callback event for any matching key in the listener queue.
* It supports passing through unlimited arguments to the callback.
* Useful for setting up actions.
*
* @param {string} key
* @param {any} args
* @returns {any} Return result of the callback.
*/
function trigger(key: string, ...args: any[]): any {
let matchedListeners = filter(listeners, function(listener: State.Listener) {
return listener.keys.indexOf(key) > -1;
});
if (matchedListeners.length === 0) {
storeError('trigger', key, 'Action not found.');
return;
}
for (let i = 0; i < matchedListeners.length; i++) {
if (matchedListeners[i].callback) {
let output = matchedListeners[i].callback(...args);
if (output !== undefined) {
return output;
}
}
}
}
function _connect(keys: string[], callback: State.StateCallback | State.TriggerCallback, id: number, context: object | undefined): void {
let listener: State.Listener | undefined;
if (callback) {
listener = find(listeners, (_listener: State.Listener) => _listener && _listener.callback === callback);
if (context) {
callback = callback.bind(context);
}
}
if (listener) {
let newKeys = differenceKeys(keys, listener.keys);
listener.keys = listener.keys.concat(newKeys);
} else {
listeners.push({keys, callback, id});
}
}
/**
* connect
*
* @param {any} actions - can be a string, array, or an object.
* @param {function} callback - callback to be fired on either state
* property change, or through the trigger method.
* @returns ID of the added listener.
*/
function connect(
actions: State.ConnectActions,
callback?: State.Callback,
context?: object | undefined
): number {
const id = connections++;
let keys: string[];
switch (true) {
case (actions === '*'):
listeners.push({keys: Object.keys(state), callback: callback, id});
break;
case (typeof actions === 'string'):
_connect([actions], callback, id, context);
break;
case (Array.isArray(actions)):
_connect(actions, callback, id, context);
break;
case (typeof actions === 'object'):
keys = Object.keys(actions);
for (let i = 0; i < keys.length; i++) {
_connect([keys[i]], (actions)[keys[i]], id, context);
}
break;
}
return id;
}
function findListenerFn(key: State.DisconnectKey) {
return function(listener: State.Listener) {
for (let i = 0, len = listener.keys.length; i < len; i++) {
if (listener.keys[i] === key) return true;
}
return false;
}
}
function disconnectByKey(key: State.DisconnectKey): void {
let matches = filter(listeners, findListenerFn(key));
let count = matches.length;
if (!matches.length) {
storeError('disconnect', key, 'Invalid disconnect key.');
return;
}
while (count) {
listeners.splice(findIndex(listeners, findListenerFn(key)), 1);
count--;
}
}
/**
* disconnect
* Removes a callback listener from the queue.
*
* @param {string} key
*/
function disconnect(key: State.DisconnectKey): void {
switch (true) {
case (typeof key === 'string'):
disconnectByKey(key);
break;
case (Array.isArray(key)):
key = key;
for (let i = 0; i < key.length; i++) {
disconnectByKey(key[i]);
}
break;
case (typeof key === 'number'):
for (let i = 0; i < listeners.length; i++) {
let index = findIndex(listeners, function(listener: State.Listener) {
return listener.id === key;
});
if (index === -1) continue;
listeners.splice(index, 1);
}
break;
}
}
/**
* destroy
* Assigns undefined to all state properties and listeners. Intended
* to be used at the end of the application life cycle.
*
*/
function destroy() {
for (let i = 0; i < listeners.length; i++) {
// @ts-ignore
listeners[i] = undefined;
}
listeners = [];
}
return getAPIWithObject(state);
}
export {init};