Source: portlet/PortletInit.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 uuidv1 from 'uuid/v1';

import fetch from './../util/fetch.es';
import isObject from './../util/is_object';
import RenderState from './RenderState.es';
import PortletConstants from './portlet_constants.es';
import {
	decodeUpdateString,
	generateActionUrl,
	getUpdatedPublicRenderParameters,
	getUrl,
	validateArguments,
	validateForm,
	validateParameters,
	validateState,
} from './portlet_util.es';

/**
 * Flag specifying whether history is to be processed
 * (true if browser supports HTML5 session history APIs)
 * @type {boolean}
 * @review
 */

const doHistory = window.history && window.history.pushState;

/**
 * Regex for portlet-level events
 * @type {string}
 * @review
 */

const portletRegex = '^portlet[.].*';

/**
 * Determines if blocking action is currently in process.
 * @type {boolean}
 * @review
 */

let busy = false;

/**
 * An array containing the event listeners.
 * @type {Array.<Object>}
 * @review
 */

const eventListeners = {};

/**
 * An array containing the event listeners currently queued for being dispatched.
 * @type {Array.<Object>}
 * @review
 */

const eventListenersQueue = [];

/**
 * The page render state containing the all portlets and public parameters map.
 * @type {Object}
 * @review
 */

let pageRenderState;

/**
 * PortletInit
 * @review
 */

class PortletInit {
	constructor(portletId) {
		this._portletId = portletId;

		this.constants = {...PortletConstants};

		if (!pageRenderState) {
			pageRenderState = global.portlet.data.pageRenderState;

			this._updateHistory(true);
		}

		this.portletModes = pageRenderState.portlets[
			this._portletId
		].allowedPM.slice(0);

		this.windowStates = pageRenderState.portlets[
			this._portletId
		].allowedWS.slice(0);
	}

	/**
	 * Performs the actual action.
	 * @param {Object} parameters Additional parameters to be set.
	 * @param {HTMLFormElement} element Form to be submitted. May be <code>null</code>.
	 * @protected
	 * @review
	 */

	_executeAction(parameters, element) {
		return new Promise((resolve, reject) => {
			getUrl(pageRenderState, 'ACTION', this._portletId, parameters).then(
				(url) => {
					const options = generateActionUrl(
						this._portletId,
						url,
						element
					);

					fetch(options.url, options)
						.then((res) => res.text())
						.then((text) => {
							const updatedIds = this._updatePageStateFromString(
								text,
								this._portletId
							);

							resolve(updatedIds);
						})
						.catch((error) => {
							reject(error);
						});
				}
			);
		});
	}

	/**
	 * Returns true if an onStateChange listener is registered for the portlet.
	 * @memberof PortletInit
	 * @param {string} portletId The portlet ID.
	 * @return {boolean} Returns true if a listener is registered.
	 * @protected
	 * @review
	 */

	_hasListener(portletId) {
		const eventListenerPortletIds = Object.keys(eventListeners).map(
			(key) => eventListeners[key].id
		);

		return eventListenerPortletIds.includes(portletId);
	}

	/**
	 * Sends an onError event to all registered error event handlers for a given
	 * portlet.
	 * @memberof PortletInit
	 * @param {string} portletId The portlet ID.
	 * @param {string} err The error message.
	 * @protected
	 * @review
	 */

	_reportError(portletId, error) {
		Object.keys(eventListeners).map((key) => {
			const listener = eventListeners[key];

			if (
				listener.id === portletId &&
				listener.type === 'portlet.onError'
			) {
				setTimeout(() => {
					listener.handler('portlet.onError', error);
				});
			}

			return false;
		});
	}

	/**
	 * Callback function that must be called after a partial action has been
	 * started.
	 *
	 * The page state is generated by the portal and transmitted to the client by
	 * the portlet. The portlet client that initiated the partial action must
	 * pass the page state string to this function.
	 *
	 * The callback should only be called once to conclude a partial action sequence.
	 * @memberof PortletInit
	 * @param {string} portletId The portlet ID.
	 * @param {string} updateString The new page state in string form.
	 * @throws {TypeError} Thrown if the parameter is not a string.
	 * @protected
	 * @review
	 */

	_setPageState(portletId, updateString) {
		if (typeof updateString !== 'string') {
			throw new TypeError(`Invalid update string: ${updateString}`);
		}

		this._updatePageState(updateString, portletId).then(
			(updatedIds) => {
				this._updatePortletStates(updatedIds);
			},
			(error) => {
				busy = false;

				this._reportError(portletId, error);
			}
		);
	}

	/**
	 * Sets state for the portlet.
	 * returns array of IDs for portlets that were affected by the change,
	 * taking into account the public render parameters.
	 * @memberof PortletInit
	 * @param {Object} state The state to be set.
	 * @return {Array}
	 * @protected
	 * @review
	 */

	_setState(state) {
		const publicRenderParameters = getUpdatedPublicRenderParameters(
			pageRenderState,
			this._portletId,
			state
		);

		const updatedIds = [];

		const parameterKeys = Object.keys(publicRenderParameters);

		parameterKeys.forEach((parameterKey) => {
			const newValue = publicRenderParameters[parameterKey];

			const groupMap = pageRenderState.prpMap[parameterKey];

			const groupKeys = Object.keys(groupMap);

			groupKeys.forEach((groupKey) => {
				if (groupKey !== this._portletId) {
					const parts = groupMap[groupKey].split('|');

					const portletId = parts[0];

					const parameterName = parts[1];

					if (newValue === undefined) {
						delete pageRenderState.portlets[portletId].state
							.parameters[parameterName];
					}
					else {
						pageRenderState.portlets[portletId].state.parameters[
							parameterName
						] = [...newValue];
					}

					updatedIds.push(portletId);
				}
			});
		});

		const portletId = this._portletId;

		// Update state for the initiating portlet.

		pageRenderState.portlets[portletId].state = state;
		updatedIds.push(portletId);

		// Delete render data for all affected portlets in order to avoid dispatching
		// stale render data

		updatedIds.forEach((updatedId) => {
			pageRenderState.portlets[updatedId].renderData.content = null;
		});

		// Update history for back-button support

		this._updateHistory();

		return Promise.resolve(updatedIds);
	}

	/**
	 * Sets up for the action.
	 * @memberof PortletInit
	 * @param {Object} parameters Additional parameters. May be <code>null</code>.
	 * @param {HTMLFormElement} element Form to be submitted May be <code>null</code>.
	 * @throws {AccessDeniedException} Thrown if a blocking operation is already in progress.
	 * @throws {NotInitializedException} Thrown if a portlet ID is provided, but no onStateChange
	 * listener has been registered.
	 * @protected
	 * @review
	 */

	_setupAction(parameters, element) {
		if (this.isInProgress()) {
			throw {
				message: 'Operation is already in progress',
				name: 'AccessDeniedException',
			};
		}

		if (!this._hasListener(this._portletId)) {
			throw {
				message: `No onStateChange listener registered for portlet: ${this._portletId}`,
				name: 'NotInitializedException',
			};
		}

		busy = true;

		return this._executeAction(parameters, element).then(
			(updatedIds) => {
				return this._updatePortletStates(updatedIds).then(
					(updatedIds) => {
						busy = false;

						return updatedIds;
					}
				);
			},
			(error) => {
				busy = false;

				this._reportError(this._portletId, error);
			}
		);
	}

	/**
	 * Called when the page state has been updated to allow
	 * the browser history to be taken care of.
	 * @memberof PortletInit
	 * @param {boolean} replace Replace the state rather than pushing.
	 * @protected
	 * @review
	 */

	_updateHistory(replace) {
		if (doHistory) {
			getUrl(pageRenderState, 'RENDER', null, {}).then((url) => {
				const token = JSON.stringify(pageRenderState);

				if (replace) {
					history.replaceState(token, '');
				}
				else {
					try {
						history.pushState(token, '', url);
					}
					catch (error) {

						// Do nothing

					}
				}
			});
		}
	}

	/**
	 * Update page state passed in after partial action. The list of
	 * ID's of updated portlets is passed back through a promise in order
	 * to decouple the layers.
	 *
	 * @memberof PortletInit
	 * @param {string} updateString The updated render state string.
	 * @review
	 */

	_updatePageState(updateString) {
		return new Promise((resolve, reject) => {
			try {
				const updatedIds = this._updatePageStateFromString(
					updateString,
					this._portletId
				);

				resolve(updatedIds);
			}
			catch (error) {
				reject(
					new Error(`Partial Action decode status: ${error.message}`)
				);
			}
		});
	}

	/**
	 * Updates page state from string and returns array of portlet IDs
	 * to be updated.
	 *
	 * @memberof PortletInit
	 * @param {string} updateString The update string.
	 * @param {string} portletId The portlet ID.
	 * @protected
	 * @review
	 */

	_updatePageStateFromString(updateString, portletId) {
		const portlets = decodeUpdateString(pageRenderState, updateString);
		const updatedIds = [];

		let stateUpdated = false;

		// Update portlets and collect IDs of affected portlets.

		const entries = Object.entries(portlets);

		entries.forEach(([key, portletData]) => {
			pageRenderState.portlets[key] = portletData;
			updatedIds.push(key);
			stateUpdated = true;
		});

		// portletId will be null or undefined when called from onpopstate routine.
		// In that case, don't update history.

		if (stateUpdated && portletId) {
			this._updateHistory();
		}

		return updatedIds;
	}

	/**
	 *
	 * Accepts an object containing changed render states.
	 * Updates the state for each portlet present.
	 *
	 * @memberof PortletInit
	 * @param {Array} updatedIds Array of portlet IDs to be updated.
	 * @return {Promsise.<string>}
	 * @protected
	 * @review
	 */

	_updatePortletStates(updatedIds) {
		return new Promise((resolve) => {
			if (updatedIds.length === 0) {
				busy = false;
			}
			else {
				updatedIds.forEach((updatedId) => {
					this._updateStateForPortlet(updatedId);
				});
			}

			resolve(updatedIds);
		});
	}

	/**
	 * Updates the page render state
	 *
	 * @memberof PortletInit
	 * @param {Object} state The new state to be set.
	 * @protected
	 * @review
	 */

	_updateState(state) {
		if (busy) {
			throw {
				message: 'Operation in progress',
				name: 'AccessDeniedException',
			};
		}
		else if (!this._hasListener(this._portletId)) {
			throw {
				message: `No onStateChange listener registered for portlet: ${this._portletId}`,
				name: 'NotInitializedException',
			};
		}

		busy = true;

		this._setState(state)
			.then((updatedIds) => {
				this._updatePortletStates(updatedIds);
			})
			.catch((error) => {
				busy = false;
				this._reportError(this._portletId, error);
			});
	}

	/**
	 * Calls the portlet onStateChange method in an asynchronous manner in order
	 * to decouple the public API. This method is intended for use after
	 * portlet client registers an onStateChange listener.
	 *
	 * @memberof PortletInit
	 * @param {string} portletId The portlet ID.
	 * @protected
	 * @review
	 */

	_updateStateForPortlet(portletId) {
		const updateQueueIds = eventListenersQueue.map((item) => item.handle);

		const entries = Object.entries(eventListeners);

		entries.forEach(([key, eventData]) => {
			if (eventData.type !== 'portlet.onStateChange') {
				return;
			}

			if (eventData.id === portletId && !updateQueueIds.includes(key)) {
				eventListenersQueue.push(eventData);
			}
		});

		if (eventListenersQueue.length > 0) {
			setTimeout(() => {
				busy = true;

				while (eventListenersQueue.length > 0) {
					const eventData = eventListenersQueue.shift();
					const handler = eventData.handler;
					const id = eventData.id;

					if (!pageRenderState.portlets[id]) {
						continue;
					}

					const renderData = pageRenderState.portlets[id].renderData;
					const renderState = new RenderState(
						pageRenderState.portlets[id].state
					);

					if (renderData && renderData.content) {
						handler(
							'portlet.onStateChange',
							renderState,
							renderData
						);
					}
					else {
						handler('portlet.onStateChange', renderState);
					}
				}

				busy = false;
			});
		}
	}

	/**
	 * Initiates a portlet action using the specified action parameters and
	 * element arguments.
	 *
	 * @memberof PortletInit
	 * @param {PortletParameters} parameters Action parameters to be added to the URL
	 * @param {HTMLFormElement} element DOM element of form to be submitted
	 * @return {Promise} A Promise object that is resolved with no argument when the action request has completed.
	 * @throws {TypeError} Thrown if the input parameters are invalid
	 * @throws {AccessDeniedException} Thrown if a blocking operation is already in progress.
	 * @throws {NotInitializedException} Thrown if a portlet ID is provided, but no onStateChange listener has been registered.
	 * @review
	 */

	action(...args) {
		let actionParameters = null;
		let argCount = 0;
		let element = null;

		args.forEach((arg) => {
			if (arg instanceof HTMLFormElement) {
				if (element !== null) {
					throw new TypeError(
						`Too many [object HTMLFormElement] arguments: ${arg}, ${element}`
					);
				}

				element = arg;
			}
			else if (isObject(arg)) {
				validateParameters(arg);

				if (actionParameters !== null) {
					throw new TypeError('Too many parameters arguments');
				}

				actionParameters = arg;
			}
			else if (arg !== undefined) {
				const type = Object.prototype.toString.call(arg);

				throw new TypeError(
					`Invalid argument type. Argument ${
						argCount + 1
					} is of type ${type}`
				);
			}
			argCount++;
		});

		if (element) {
			validateForm(element);
		}

		return this._setupAction(actionParameters, element)
			.then((val) => {
				Promise.resolve(val);
			})
			.catch((error) => {
				Promise.reject(error);
			});
	}

	/**
	 * Adds a listener function for specified event type.
	 *
	 * @memberof PortletInit
	 * @param {string} type The type of listener
	 * @param {function} handler Function called when event occurs
	 * @return {Object} A handle that can be used to remove the event listener
	 * @throws {TypeError} Thrown if the input parameters are invalid
	 * @review
	 */

	addEventListener(type, handler) {
		if (arguments.length > 2) {
			throw new TypeError(
				'Too many arguments passed to addEventListener'
			);
		}

		if (typeof type !== 'string' || typeof handler !== 'function') {
			throw new TypeError('Invalid arguments passed to addEventListener');
		}

		const id = this._portletId;

		if (type.startsWith('portlet.')) {
			if (
				type !== 'portlet.onStateChange' &&
				type !== 'portlet.onError'
			) {
				throw new TypeError(
					`The system event type is invalid: ${type}`
				);
			}
		}

		const handle = uuidv1();

		const listener = {
			handle,
			handler,
			id,
			type,
		};

		eventListeners[handle] = listener;

		if (type === 'portlet.onStateChange') {
			this._updateStateForPortlet(this._portletId);
		}

		return handle;
	}

	/**
	 * Returns a promise for a resource URL with parameters set appropriately
	 * for the page state according to the resource parameters, cacheability
	 * option, and resource ID provided.
	 * @memberof PortletInit
	 * @param {Object} parameters Resource parameters to be added to the URL
	 * @param {string} cache Cacheability option. The strings defined under
	 * {@link PortletConstants} should be used to specifiy cacheability.
	 * @param {string} resourceId Resource ID.
	 * @return {Promise} A Promise object. Returns a string representing the
	 * resource URL on successful resolution. Returns an Error object containing
	 * a descriptive message on failure.
	 * @throws {TypeError} Thrown if the input parameters are invalid
	 * @review
	 */

	createResourceUrl(parameters, cache, resourceId) {
		if (arguments.length > 3) {
			throw new TypeError('Too many arguments. 3 arguments are allowed.');
		}

		if (parameters) {
			if (isObject(parameters)) {
				validateParameters(parameters);
			}
			else {
				throw new TypeError(
					'Invalid argument type. Resource parameters must be a parameters object.'
				);
			}
		}

		let cacheability = null;

		if (cache) {
			if (typeof cache === 'string') {
				if (
					cache === 'cacheLevelPage' ||
					cache === 'cacheLevelPortlet' ||
					cache === 'cacheLevelFull'
				) {
					cacheability = cache;
				}
				else {
					throw new TypeError(
						`Invalid cacheability argument: ${cache}`
					);
				}
			}
			else {
				throw new TypeError(
					'Invalid argument type. Cacheability argument must be a string.'
				);
			}
		}

		if (!cacheability) {
			cacheability = 'cacheLevelPage';
		}

		if (resourceId && typeof resourceId !== 'string') {
			throw new TypeError(
				'Invalid argument type. Resource ID argument must be a string.'
			);
		}

		return getUrl(
			pageRenderState,
			'RESOURCE',
			this._portletId,
			parameters,
			cacheability,
			resourceId
		);
	}

	/**
	 * Dispatches a client event.
	 * @memberof PortletInit
	 * @param {string} type The type of listener.
	 * @param {any} payload The payload to be delivered.
	 * @return {number} The number of events queued for delivery.
	 * @throws {TypeError} Thrown if the type is a system event type.
	 * @review
	 */

	dispatchClientEvent(type, payload) {
		validateArguments(arguments, 2, 2, ['string']);

		if (type.match(new RegExp(portletRegex))) {
			throw new TypeError('The event type is invalid: ' + type);
		}

		return Object.keys(eventListeners).reduce((amount, key) => {
			const listener = eventListeners[key];

			if (type.match(listener.type)) {
				listener.handler(type, payload);
				amount++;
			}

			return amount;
		}, 0);
	}

	/**
	 * Tests whether a blocking operation is in progress.
	 * @memberof PortletInit
	 * @return {boolean}
	 * @review
	 */

	isInProgress() {
		return busy;
	}

	/**
	 * Creates and returns a new PortletParameters object.
	 * @memberof PortletInit
	 * @param {?Object} optParameters The optional parameters to be copied.
	 * @return {Object} The new parameters object.
	 * @review
	 */

	newParameters(optParameters = {}) {
		const newParameters = {};

		Object.keys(optParameters).forEach((key) => {
			if (Array.isArray(optParameters[key])) {
				newParameters[key] = [...optParameters[key]];
			}
		});

		return newParameters;
	}

	/**
	 * Creates and returns a new RenderState object.
	 * @memberof PortletInit
	 * @param {?RenderState} optState An optional RenderState object to be copied.
	 * @return {RenderState} The new RenderState object.
	 * @review
	 */

	newState(optState) {
		return new RenderState(optState);
	}

	/**
	 * Removes a previously added listener function designated by the handle.
	 * The handle must be the same object previously returned by the
	 * addEventListener function.
	 * @memberof PortletInit
	 * @param {Object} handle The handle of the listener to be removed.
	 * @throws {TypeError} Thrown if the input parameters are invalid.
	 * @throws {AccessDeniedException} Thrown if the event listener associated
	 * with this handle was registered by a different portlet.
	 * @review
	 */

	removeEventListener(handle) {
		if (arguments.length > 1) {
			throw new TypeError(
				'Too many arguments passed to removeEventListener'
			);
		}

		if (handle === undefined || handle === null) {
			throw new TypeError(
				`The event handle provided is ${typeof handle}`
			);
		}

		let found = false;

		if (isObject(eventListeners[handle])) {
			if (eventListeners[handle].id === this._portletId) {
				delete eventListeners[handle];

				const l = eventListenersQueue.length;

				for (let i = 0; i < l; i++) {
					const eventData = eventListenersQueue[i];

					if (eventData && eventData.handle === handle) {
						eventListenersQueue.splice(i, 1);
					}
				}

				found = true;
			}
		}

		if (!found) {
			throw new TypeError(
				"The event listener handle doesn't match any listeners."
			);
		}
	}

	/**
	 * Sets the render state, which consists of the public and private render
	 * parameters, the portlet mode, and the window state.
	 * @memberof PortletInit
	 * @param {RenderState} state The new state to be set.
	 * @throws {TypeError} Thrown if the input parameters are invalid.
	 * @throws {AccessDeniedException} Thrown if a blocking operation is already in progress.
	 * @throws {NotInitializedException} Thrown if a portlet ID is provided, but no onStateChange
	 * listener has been registered.
	 * @review
	 */

	setRenderState(state) {
		validateArguments(arguments, 1, 1, ['object']);

		if (
			pageRenderState.portlets &&
			pageRenderState.portlets[this._portletId]
		) {
			const portletData = pageRenderState.portlets[this._portletId];

			validateState(state, portletData);
			this._updateState(state);
		}
	}

	/**
	 * Starts partial action processing and returns a {@link PartialActionInit} object to the caller.
	 * @memberof PortletInit
	 * @param {PortletParameters} actionParameters Action parameters to be added to the URL.
	 * @return {Promise} A Promise. Returns a {@link PortletActionInit} object
	 * containing a partial action URL and the _setPageState callback function
	 * on successful resolution. Returns an Error object containing a
	 * descriptive message on failure.
	 * @throws {TypeError} Thrown if the input parameters are invalid.
	 * @throws {AccessDeniedException} Thrown if a blocking operation is already in progress.
	 * @throws {NotInitializedException} Thrown if a portlet ID is provided, but
	 * no onStateChange listener has been registered.
	 * @review
	 */

	startPartialAction(actionParameters) {
		const instance = this;

		let parameters = null;

		if (arguments.length > 1) {
			throw new TypeError('Too many arguments. 1 arguments are allowed');
		}
		else if (actionParameters !== undefined) {
			if (isObject(actionParameters)) {
				validateParameters(actionParameters);

				parameters = actionParameters;
			}
			else {
				throw new TypeError(
					`Invalid argument type. Argument is of type ${typeof actionParameters}`
				);
			}
		}

		if (busy === true) {
			throw {
				message: 'Operation in progress',
				name: 'AccessDeniedException',
			};
		}
		else if (!this._hasListener(this._portletId)) {
			throw {
				message: `No onStateChange listener registered for portlet: ${this._portletId}`,
				name: 'NotInitializedException',
			};
		}

		busy = true;

		const partialActionInitObject = {
			setPageState(updateString) {
				instance._setPageState(instance._portletId, updateString);
			},
			url: '',
		};

		return getUrl(
			pageRenderState,
			'PARTIAL_ACTION',
			this._portletId,
			parameters
		).then((url) => {
			partialActionInitObject.url = url;

			return partialActionInitObject;
		});
	}
}

export {PortletInit};
export default PortletInit;