Source: layout-content-page-editor-web/src/main/resources/META-INF/resources/js/store/store.es.js

/**
 * Copyright (c) 2000-present Liferay, Inc. All rights reserved.
 *
 * This library is free software; you can redistribute it and/or modify it under
 * the terms of the GNU Lesser General Public License as published by the Free
 * Software Foundation; either version 2.1 of the License, or (at your option)
 * any later version.
 *
 * This library is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
 * details.
 */

import State, {Config} from 'metal-state';

import {DEFAULT_INITIAL_STATE} from './state.es';

/**
 * ID of the development devTool that may be connected to store.
 * We are relying on redux-devtools, so we can continue using
 * them when we move to a proper state-management library.
 *
 * They provide a global hook that is available when the browser
 * has redux-devtools-extension installed:
 *
 * http://extension.remotedev.io/#usage
 *
 * @review
 */
const STORE_DEVTOOLS_ID = '__REDUX_DEVTOOLS_EXTENSION__';

/**
 * Connects a given component to a given store, syncing it's properties with it.
 * @param {Component} component
 * @param {Store} store
 * @review
 */
const connect = function(component, store) {
	component._storeChangeListener = store.on('change', () =>
		syncStoreState(component, store)
	);

	syncStoreState(component, store);
};

/**
 * Disconnects a given component from it's store
 * @param {ConnectedComponent} component
 */
const disconnect = function(component) {
	if (component._storeChangeListener) {
		component._storeChangeListener.removeListener();

		component._storeChangeListener = null;
	}
};

/**
 * Creates a store and links the given components to it.
 * Each component will receive the store as `store` attribute.
 * @param {object} initialState
 * @param {function} reducer
 * @param {string[]} componentIds
 * @return {Store}
 * @review
 */
const createStore = function(initialState, reducer, componentIds = []) {
	const store = new Store(initialState, reducer);

	componentIds.forEach(componentId => {
		Liferay.componentReady(componentId).then(component => {
			component.store = store;

			connect(
				component,
				store
			);
		});
	});

	return store;
};

/**
 * @param {ConnectedComponent} component
 * @param {Store} store
 */
const syncStoreState = function(component, store) {
	const state = store.getState();

	component
		.getStateKeys()
		.filter(key => key in state)
		.filter(key => component[key] !== state[key])
		.forEach(key => {
			component[key] = state[key];
		});
};

/**
 * Redux-like store.
 * Store emits a "change" event with the nextState every time the state has
 * been changed.
 *
 * @review
 */
class Store extends State {
	/**
	 * @param {object} [initialState={}]
	 * @param {function} [reducer]
	 * @review
	 */
	constructor(initialState = {}, reducer = state => state) {
		super();

		this.dispatch = this.dispatch.bind(this);
		this.getState = this.getState.bind(this);

		this._setInitialState(initialState);
		this.registerReducer(reducer);

		if (
			process.env.NODE_ENV === 'development' &&
			STORE_DEVTOOLS_ID in window
		) {
			this._devTools = window[STORE_DEVTOOLS_ID].connect();

			this._devTools.init(this._state);
		}
	}

	/**
	 * @inheritDoc
	 */
	disposed() {
		if (process.env.NODE_ENV === 'development' && this._devTools) {
			this._devTools.disconnect();
		}
	}

	/**
	 * Dispatch an action to the store. Each action is identified by a given
	 * actionType, and can contain an optional payload with any kind of
	 * information.
	 * @param {{type: string}|function(function, function): Promise|void} action
	 * @return {Store}
	 * @review
	 */
	dispatch(action) {
		if (typeof action === 'function') {
			this._dispatchPromise = this._dispatchPromise.then(() =>
				Promise.resolve(action(this.dispatch, this.getState))
			);
		} else {
			this._dispatchPromise = this._dispatchPromise
				.then(() => this._reducer(this._state, action))
				.then(nextState => {
					if (this._state !== nextState) {
						this._state = this._getFrozenState(nextState);

						this.emit('change', this._state);

						if (
							process.env.NODE_ENV === 'development' &&
							this._devTools
						) {
							this._devTools.send(action, this._state);
						}
					}

					return new Promise(resolve => {
						requestAnimationFrame(() => {
							resolve(this);
						});
					});
				});
		}

		return this;
	}

	done(callback) {
		this._dispatchPromise = this._dispatchPromise.then(() =>
			callback(this)
		);

		return this;
	}

	failed(callback) {
		this._dispatchPromise = this._dispatchPromise.catch(error =>
			callback(error)
		);

		return this;
	}

	/**
	 * Returns current state.
	 * Warning: that state cannot be modified anyway.
	 * @return {object} Current state
	 * @review
	 */
	getState() {
		return this._state;
	}

	/**
	 * Set's store reducer.
	 *
	 * A reducer is a function that receives a state, an actionType and
	 * an optional payload with information, and returns a new state without
	 * altering the original one.
	 *
	 * @param {function} reducer
	 * @review
	 */
	registerReducer(reducer) {
		this._reducer = reducer;
	}

	/**
	 * For a given state, returns a frozen copy of it
	 * @param {object} state
	 * @private
	 * @return {object} Frozen state
	 * @review
	 */
	_getFrozenState(state) {
		const differentState =
			!this._state ||
			Object.entries(state).some(
				([key, value]) => this._state[key] !== value
			);

		if (differentState) {
			this._state = state;

			Object.freeze(this._state);
		}

		return this._state;
	}

	/**
	 * Sets the store state to the given state. This function should not be
	 * called after setting the initialState.
	 * The given initial state is combined with DEFAULT_INITIAL_STATE to provide
	 * default values for unknown data.
	 * @param {!Object} initialState
	 * @return {Object}
	 * @private
	 * @review
	 */
	_setInitialState(initialState) {
		if (this._state) {
			throw new Error('State already initialized');
		}

		this._state = this._getFrozenState({
			...DEFAULT_INITIAL_STATE,
			...initialState
		});

		return this._state;
	}
}

/**
 * State definition.
 * @review
 * @static
 * @type {!Object}
 */
Store.STATE = {
	/**
	 * Redux devtools
	 * @instance
	 * @memberOf Store
	 * @private
	 * @review
	 * @type {any|null}
	 */
	_devTools: Config.any()
		.internal()
		.value(null),

	/**
	 * @default Promise.resolve()
	 * @instance
	 * @memberOf Store
	 * @private
	 * @review
	 * @type {Promise}
	 */
	_dispatchPromise: Config.instanceOf(Promise)
		.internal()
		.value(Promise.resolve()),

	/**
	 * @default []
	 * @instance
	 * @memberOf Store
	 * @private
	 * @review
	 * @type {function}
	 */
	_reducer: Config.func()
		.internal()
		.value([]),

	/**
	 * @default null
	 * @instance
	 * @memberOf Store
	 * @private
	 * @review
	 * @type {object}
	 */
	_state: Config.object()
		.internal()
		.value(null)
};

export {connect, disconnect, createStore, Store};
export default Store;