Source: layout-content-page-editor-web/src/main/resources/META-INF/resources/js/components/fragment_entry_link/FragmentEditableField.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 {PortletBase} from 'frontend-js-web';
import Soy from 'metal-soy';
import {Config} from 'metal-state';

import '../floating_toolbar/image_properties/FloatingToolbarImagePropertiesPanel.es';

import '../floating_toolbar/link/FloatingToolbarLinkPanel.es';

import '../floating_toolbar/mapping/FloatingToolbarMappingPanel.es';

import './FragmentEditableFieldTooltip.es';
import {UPDATE_CONFIG_ATTRIBUTES} from '../../actions/actions.es';
import {
	disableSavingChangesStatusAction,
	enableSavingChangesStatusAction,
	updateLastSaveDateAction
} from '../../actions/saveChanges.es';
import {updateEditableValueContentAction} from '../../actions/updateEditableValue.es';
import {getConnectedComponent} from '../../store/ConnectedComponent.es';
import {
	shouldUpdateOnChangeProperties,
	shouldUpdatePureComponent
} from '../../utils/FragmentsEditorComponentUtils.es';
import {
	editableIsMapped,
	editableIsMappedToAssetEntry,
	editableShouldBeHighlighted,
	getItemPath
} from '../../utils/FragmentsEditorGetUtils.es';
import {setIn} from '../../utils/FragmentsEditorUpdateUtils.es';
import {
	EDITABLE_FIELD_CONFIG_KEYS,
	EDITABLE_FRAGMENT_ENTRY_PROCESSOR,
	FLOATING_TOOLBAR_BUTTONS,
	FRAGMENTS_EDITOR_ITEM_TYPES,
	CREATE_PROCESSOR_EVENT_TYPES
} from '../../utils/constants';
import debouncedAlert from '../../utils/debouncedAlert.es';
import {prefixSegmentsExperienceId} from '../../utils/prefixSegmentsExperienceId.es';
import FloatingToolbar from '../floating_toolbar/FloatingToolbar.es';
import FragmentProcessors from '../fragment_processors/FragmentProcessors.es';
import templates from './FragmentEditableField.soy';

/**
 * @type {number}
 * @review
 */
const EDITABLE_FIELD_CHANGE_DELAY = 500;

/**
 * FragmentEditableField
 */
class FragmentEditableField extends PortletBase {
	/**
	 * @inheritDoc
	 * @review
	 */
	created() {
		this._clearEditor = this._clearEditor.bind(this);
		this._createProcessor = this._createProcessor.bind(this);
		this._handleEditableChanged = this._handleEditableChanged.bind(this);
		this._handleProcessorDestroyed = this._handleProcessorDestroyed.bind(
			this
		);
		this._handleFloatingToolbarButtonClicked = this._handleFloatingToolbarButtonClicked.bind(
			this
		);

		if (['link', 'rich-text', 'text'].includes(this.type)) {
			this._handleEditableChanged = debouncedAlert(
				this._handleEditableChanged.bind(this),
				EDITABLE_FIELD_CHANGE_DELAY
			);
		}
	}

	/**
	 * @inheritDoc
	 * @review
	 */
	disposed() {
		this._destroyProcessors();
		this._disposeFloatingToolbar();
		this.element.removeEventListener('click', this._createProcessor);
	}

	/**
	 * @inheritDoc
	 * @param {!object} state
	 * @returns {object}
	 */
	prepareStateForRender(state) {
		const activable = this._editableIsActivable();

		const defaultSegmentsExperienceId = prefixSegmentsExperienceId(
			this.defaultSegmentsExperienceId
		);
		const segmentsExperienceId = prefixSegmentsExperienceId(
			this.segmentsExperienceId
		);

		const segmentedValue =
			this.editableValues[segmentsExperienceId] ||
			this.editableValues[defaultSegmentsExperienceId] ||
			this.editableValues;

		const translatedValue =
			segmentedValue[this.languageId] ||
			segmentedValue[this.defaultLanguageId];

		const mapped = editableIsMapped(this.editableValues);

		const value = mapped
			? this._mappedFieldValue || this.editableValues.defaultValue
			: translatedValue || this.editableValues.defaultValue;

		const processor =
			FragmentProcessors[this.type] || FragmentProcessors.fallback;

		const content = Soy.toIncDom(
			processor.render(this.content, value, this.editableValues)
		);

		const highlighted = editableShouldBeHighlighted(
			state.activeItemId,
			state.activeItemType,
			state.fragmentEntryLinkId,
			state.layoutData.structure
		);
		const itemId = this._getItemId();

		const translated = !mapped && Boolean(segmentedValue[this.languageId]);

		let nextState = state;

		nextState = setIn(nextState, ['_activable'], activable);
		nextState = setIn(nextState, ['_highlighted'], highlighted);
		nextState = setIn(nextState, ['_mapped'], mapped);
		nextState = setIn(
			nextState,
			['_selected'],
			state.selectedItems.some(
				selectedItem =>
					selectedItem.itemId === itemId &&
					selectedItem.itemType ===
						FRAGMENTS_EDITOR_ITEM_TYPES.editable
			)
		);
		nextState = setIn(nextState, ['_translated'], translated);
		nextState = setIn(nextState, ['content'], content);
		nextState = setIn(nextState, ['itemId'], itemId);
		nextState = setIn(
			nextState,
			['itemTypes'],
			FRAGMENTS_EDITOR_ITEM_TYPES
		);

		return nextState;
	}

	/**
	 * @inheritDoc
	 * @return {boolean}
	 * @review
	 */
	shouldUpdate(changes) {
		if (this._processorEnabled) {
			return shouldUpdateOnChangeProperties(changes, [
				'activeItemId',
				'activeItemType',
				'languageId',
				'segmentsExperienceId'
			]);
		}

		return shouldUpdatePureComponent(changes);
	}

	/**
	 * @inheritDoc
	 * @review
	 */
	syncActiveItemId() {
		if (
			this._getItemId() === this.activeItemId &&
			this.activeItemType === FRAGMENTS_EDITOR_ITEM_TYPES.editable
		) {
			this._createFloatingToolbar();

			this.element.addEventListener('click', this._createProcessor);
		} else {
			this._disposeFloatingToolbar();
			this._destroyProcessors();

			this.element.removeEventListener('click', this._createProcessor);
		}
	}

	/**
	 * @inheritDoc
	 * @review
	 */
	syncEditableValues() {
		this._loadMappedFieldLabel();
		this._updateMappedFieldValue();

		if (
			!this._processorEnabled &&
			this._getItemId() === this.activeItemId &&
			this.activeItemType === FRAGMENTS_EDITOR_ITEM_TYPES.editable
		) {
			this._createFloatingToolbar();
		}
	}

	/**
	 * Handle getAssetFieldValueURL changed
	 * @inheritDoc
	 * @review
	 */
	syncGetAssetFieldValueURL() {
		this._updateMappedFieldValue();
	}

	/**
	 * Handle hoveredItemId changed
	 * @inheritDoc
	 * @review
	 */
	syncHoveredItemId() {
		if (this.hoveredItemType === FRAGMENTS_EDITOR_ITEM_TYPES.mappedItem) {
			const [classNameId, classPK] = this.hoveredItemId.split('-');

			this._mappedItemHovered =
				this.editableValues.classNameId === classNameId &&
				this.editableValues.classPK === classPK;
		} else {
			this._mappedItemHovered = false;
		}
	}

	/**
	 * Clears the corresponding editor
	 * @private
	 * @review
	 */
	_clearEditor() {
		this._handleEditableChanged('');
	}

	/**
	 * Creates a new instance of FloatingToolbar
	 * @private
	 * @review
	 */
	_createFloatingToolbar() {
		const processor =
			FragmentProcessors[this.type] || FragmentProcessors.fallback;

		let buttons = processor.getFloatingToolbarButtons(this.editableValues);

		if (this.selectedItems.length > 1) {
			buttons = buttons.map(button => {
				if (button.id === FLOATING_TOOLBAR_BUTTONS.map.id) {
					return button;
				}

				return {
					...button,

					cssClass: `${button.cssClass} disabled fragments-editor__floating-toolbar--disabled`
				};
			});
		}

		const config = {
			anchorElement: this.element,
			buttons,
			events: {
				buttonClicked: this._handleFloatingToolbarButtonClicked,
				clearEditor: this._clearEditor,
				createProcessor: this._createProcessor
			},
			item: {
				editableId: this.editableId,
				editableValues: this.editableValues,
				fragmentEntryLinkId: this.fragmentEntryLinkId,
				type: this.type
			},
			itemId: this._getItemId(),
			itemType: FRAGMENTS_EDITOR_ITEM_TYPES.editable,
			portalElement: document.body,
			store: this.store
		};

		if (this._floatingToolbar) {
			this._floatingToolbar.setState(config);
		} else {
			this._floatingToolbar = new FloatingToolbar(config);
		}
	}

	/**
	 * Call destroy method on all processors
	 * @private
	 * @review
	 */
	_destroyProcessors() {
		Object.values(FragmentProcessors).forEach(fragmentProcessor =>
			fragmentProcessor.destroy()
		);
	}

	/**
	 * Disposes an existing instance of FloatingToolbar
	 * @private
	 * @review
	 */
	_disposeFloatingToolbar() {
		if (this._floatingToolbar) {
			this._floatingToolbar.dispose();

			this._floatingToolbar = null;
		}
	}

	/**
	 * Checks whether an editable is activable or not
	 * @private
	 * @review
	 */
	_editableIsActivable() {
		const fragmentEntryLinkIsActive =
			this.fragmentEntryLinkId === this.activeItemId &&
			this.activeItemType === FRAGMENTS_EDITOR_ITEM_TYPES.fragment;

		const siblingIsActive = getItemPath(
			this.activeItemId,
			this.activeItemType,
			this.layoutData.structure
		).some(
			item =>
				item.itemId === this.fragmentEntryLinkId &&
				item.itemType === FRAGMENTS_EDITOR_ITEM_TYPES.fragment
		);

		return fragmentEntryLinkIsActive || siblingIsActive;
	}

	/**
	 * Enables the corresponding processor
	 * @private
	 * @review
	 */
	_createProcessor(event, type = CREATE_PROCESSOR_EVENT_TYPES.editable) {
		if (event) {
			event.preventDefault();
		}

		if (
			!this._processorEnabled &&
			!this.editableValues.fieldId &&
			!this.editableValues.mappedField
		) {
			this._processorEnabled = true;

			this._disposeFloatingToolbar();

			const {init} =
				FragmentProcessors[this.type] || FragmentProcessors.fallback;

			init(
				this.refs.editable,
				this.fragmentEntryLinkId,
				this.portletNamespace,
				this.processorsOptions,
				this._handleEditableChanged,
				this._handleProcessorDestroyed,
				event,
				type
			);
		}
	}

	/**
	 * @private
	 * @return {string} Valid FragmentsEditor itemId for it's
	 * 	fragmentEntryLinkId and editableId
	 * @review
	 */
	_getItemId() {
		return `${this.fragmentEntryLinkId}-${this.editableId}`;
	}

	/**
	 * Callback executed when the exiting processor is destroyed
	 * @private
	 * @review
	 */
	_handleProcessorDestroyed() {
		this._processorEnabled = false;

		if (
			this._getItemId() === this.activeItemId &&
			this.activeItemType === FRAGMENTS_EDITOR_ITEM_TYPES.editable
		) {
			this._createFloatingToolbar();
		}
	}

	/**
	 * Callback executed when an editable value changes
	 * @param {string} newValue
	 * @private
	 */
	_handleEditableChanged(newValue) {
		if (this.type === 'image') {
			this.store
				.dispatch(enableSavingChangesStatusAction())
				.dispatch({
					config: {
						[EDITABLE_FIELD_CONFIG_KEYS.imageSource]: newValue.url,
						[EDITABLE_FIELD_CONFIG_KEYS.imageTitle]: newValue.title
					},
					editableId: this.editableId,
					fragmentEntryLinkId: this.fragmentEntryLinkId,
					type: UPDATE_CONFIG_ATTRIBUTES
				})
				.dispatch(updateLastSaveDateAction())
				.dispatch(disableSavingChangesStatusAction());
		}

		this.store.dispatch(
			updateEditableValueContentAction(
				this.fragmentEntryLinkId,
				EDITABLE_FRAGMENT_ENTRY_PROCESSOR,
				this.editableId,
				newValue
			)
		);
	}

	/**
	 * Callback executed when an floating toolbar button is clicked
	 * @param {Event} event
	 * @param {Object} data
	 * @private
	 */
	_handleFloatingToolbarButtonClicked(event, data) {
		const {type} = data;

		if (type === 'editor') {
			this._createProcessor(event, CREATE_PROCESSOR_EVENT_TYPES.button);
		}
	}

	/**
	 * Load mapped field label
	 * @private
	 * @review
	 */
	_loadMappedFieldLabel() {
		let promise;
		let mappedFieldId;

		if (this.editableValues.mappedField && this.selectedMappingTypes.type) {
			const data = {
				classNameId: this.selectedMappingTypes.type.id
			};

			if (this.selectedMappingTypes.subtype) {
				data.classTypeId = this.selectedMappingTypes.subtype.id;
			}

			mappedFieldId = this.editableValues.mappedField;
			promise = this.fetch(this.mappingFieldsURL, data);
		} else if (
			this.editableValues.classNameId &&
			this.editableValues.classPK &&
			this.editableValues.fieldId &&
			this.getAssetMappingFieldsURL
		) {
			mappedFieldId = this.editableValues.fieldId;
			promise = this.fetch(this.getAssetMappingFieldsURL, {
				classNameId: this.editableValues.classNameId,
				classPK: this.editableValues.classPK
			});
		}

		if (promise) {
			promise
				.then(response => response.json())
				.then(response => {
					const field = response.find(
						field => field.key === mappedFieldId
					);

					if (field) {
						this._mappedFieldLabel = field.label;
					}
				});
		}
	}

	/**
	 * Updates mapped field value
	 * @private
	 * @review
	 */
	_updateMappedFieldValue() {
		if (
			this.getAssetFieldValueURL &&
			editableIsMappedToAssetEntry(this.editableValues)
		) {
			this.fetch(this.getAssetFieldValueURL, {
				classNameId: this.editableValues.classNameId,
				classPK: this.editableValues.classPK,
				fieldId: this.editableValues.fieldId
			})
				.then(response => response.json())
				.then(response => {
					const {fieldValue} = response;

					if (fieldValue) {
						if (
							this.type === 'image' &&
							typeof fieldValue.url === 'string'
						) {
							this._mappedFieldValue = fieldValue.url;
						} else {
							this._mappedFieldValue = fieldValue;
						}
					}
				});
		}
	}
}

/**
 * State definition.
 * @review
 * @static
 * @type {!Object}
 */
FragmentEditableField.STATE = {
	/**
	 * Internal FloatingToolbar instance.
	 * @default null
	 * @instance
	 * @memberOf FragmentEditableField
	 * @review
	 * @type {object|null}
	 */
	_floatingToolbar: Config.internal().value(null),

	/**
	 * Translated label of the mapped field
	 * @instance
	 * @memberOf FragmentEditableField
	 * @private
	 * @review
	 * @type {string}
	 */
	_mappedFieldLabel: Config.internal().string(),

	/**
	 * Mapped asset field value
	 * @instance
	 * @memberOf FragmentEditableField
	 * @private
	 * @review
	 * @type {string}
	 */
	_mappedFieldValue: Config.internal().string(),

	/**
	 * Mapped content hovered
	 * @instance
	 * @memberOf FragmentEditableField
	 * @private
	 * @review
	 * @type {boolean}
	 */
	_mappedItemHovered: Config.internal()
		.bool()
		.value(false),

	/**
	 * @instance
	 * @memberOf FragmentEditableField
	 * @private
	 * @review
	 * @type {boolean}
	 */
	_processorEnabled: Config.internal()
		.bool()
		.value(false),

	/**
	 * Editable content to be rendered
	 * @default undefined
	 * @instance
	 * @memberOf FragmentEditableField
	 * @review
	 * @type {!string}
	 */
	content: Config.string().required(),

	/**
	 * Editable ID
	 * @default undefined
	 * @instance
	 * @memberOf FragmentEditableField
	 * @review
	 * @type {!string}
	 */
	editableId: Config.string().required(),

	/**
	 * Editable values
	 * @default undefined
	 * @instance
	 * @memberOf FragmentEditableField
	 * @review
	 * @type {!object}
	 */
	editableValues: Config.object().required(),

	/**
	 * FragmentEntryLink id
	 * @default undefined
	 * @instance
	 * @memberOf FragmentEditableField
	 * @review
	 * @type {!string}
	 */
	fragmentEntryLinkId: Config.string().required(),

	/**
	 * @default undefined
	 * @instance
	 * @memberOf FragmentEditableField
	 * @review
	 * @type {!string}
	 */
	processor: Config.string().required(),

	/**
	 * Set of options that are sent to the processors.
	 * @default undefined
	 * @instance
	 * @memberOf FragmentEditableField
	 * @review
	 * @type {!object}
	 */
	processorsOptions: Config.object().required(),

	/**
	 * Editable type
	 * @default undefined
	 * @instance
	 * @memberOf FragmentEditableField
	 * @review
	 * @type {!string}
	 */
	type: Config.oneOf([
		'html',
		'image',
		'link',
		'rich-text',
		'text'
	]).required()
};

const ConnectedFragmentEditableField = getConnectedComponent(
	FragmentEditableField,
	[
		'activeItemId',
		'activeItemType',
		'defaultLanguageId',
		'defaultSegmentsExperienceId',
		'getAssetFieldValueURL',
		'getAssetMappingFieldsURL',
		'hoveredItemId',
		'hoveredItemType',
		'languageId',
		'layoutData',
		'mappingFieldsURL',
		'portletNamespace',
		'segmentsExperienceId',
		'selectedMappingTypes',
		'selectedItems'
	]
);

Soy.register(ConnectedFragmentEditableField, templates);

export {ConnectedFragmentEditableField, FragmentEditableField};
export default ConnectedFragmentEditableField;