Source: components/segment_edit/SegmentEdit.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 ClayButton from '@clayui/button';
import ClayLayout from '@clayui/layout';
import {FieldArray, withFormik} from 'formik';
import {debounce, fetch, openModal} from 'frontend-js-web';
import PropTypes from 'prop-types';
import React, {Component} from 'react';

import ThemeContext from '../../ThemeContext.es';
import {
	SOURCES,
	SUPPORTED_CONJUNCTIONS,
	SUPPORTED_OPERATORS,
	SUPPORTED_PROPERTY_TYPES,
} from '../../utils/constants.es';
import {
	applyConjunctionChangeToContributor,
	applyCriteriaChangeToContributors,
	initialContributorsToContributors,
} from '../../utils/contributors.es';
import {initialContributorShape} from '../../utils/types.es';
import {sub} from '../../utils/utils.es';
import ContributorInputs from '../criteria_builder/ContributorInputs.es';
import ContributorsBuilder from '../criteria_builder/ContributorsBuilder.es';
import ClayToggle from '../shared/ClayToggle.es';
import LocalizedInput from '../title_editor/LocalizedInput.es';

class SegmentEdit extends Component {
	static contextType = ThemeContext;

	static propTypes = {
		availableLocales: PropTypes.object.isRequired,
		contributors: PropTypes.arrayOf(initialContributorShape),
		defaultLanguageId: PropTypes.string.isRequired,
		errors: PropTypes.object,
		formId: PropTypes.string,
		handleBlur: PropTypes.func,
		handleChange: PropTypes.func,
		hasUpdatePermission: PropTypes.bool,
		initialMembersCount: PropTypes.number,
		initialSegmentActive: PropTypes.bool,
		initialSegmentName: PropTypes.object,
		locale: PropTypes.string.isRequired,
		portletNamespace: PropTypes.string,
		previewMembersURL: PropTypes.string,
		propertyGroups: PropTypes.array,
		redirect: PropTypes.string.isRequired,
		requestMembersCountURL: PropTypes.string,
		setFieldValue: PropTypes.func,
		setValues: PropTypes.func,
		showInEditMode: PropTypes.bool,
		source: PropTypes.string,
		validateForm: PropTypes.func,
		values: PropTypes.object,
	};

	static defaultProps = {
		contributors: [],
		handleBlur: () => {},
		initialSegmentActive: true,
		initialSegmentName: {},
		portletNamespace: '',
		showInEditMode: false,
	};

	constructor(props) {
		super(props);

		const {
			contributors: initialContributors,
			initialMembersCount,
			propertyGroups,
			showInEditMode,
			values,
		} = props;

		const contributors = initialContributorsToContributors(
			initialContributors,
			propertyGroups
		);

		this.state = {
			contributors,
			disabledSave: this._isQueryEmpty(contributors),
			editing: showInEditMode,
			hasChanged: false,
			membersCount: initialMembersCount,
			validTitle: !!values.name[props.defaultLanguageId],
		};

		this._debouncedFetchMembersCount = debounce(
			this._fetchMembersCount,
			500
		);
	}

	_handleCriteriaEdit = () => {
		this.setState({
			editing: !this.state.editing,
		});
	};

	_handleLocalizedInputChange = (event, newValues, invalid) => {
		this.props.setFieldValue('name', newValues);
		this.setState({
			hasChanged: true,
			validTitle: !invalid,
		});
	};

	_fetchMembersCount = () => {
		const formElement = document.getElementById(this.props.formId);

		const formData = new FormData(formElement);

		fetch(this.props.requestMembersCountURL, {
			body: formData,
			method: 'POST',
		})
			.then((response) => response.json())
			.then((membersCount) => {
				this.setState({
					membersCount,
					membersCountLoading: false,
				});
			})
			.catch(() => {
				this.setState({membersCountLoading: false});

				Liferay.Util.openToast({
					message: Liferay.Language.get(
						'an-unexpected-error-occurred'
					),
					type: 'danger',
				});
			});
	};

	_handleQueryChange = (criteriaChange, index) => {
		this.setState((prevState) => {
			const contributors = applyCriteriaChangeToContributors(
				prevState.contributors,
				{
					criteriaChange,
					propertyKey: index,
				}
			);

			return {
				contributors,
				disabledSave: this._isQueryEmpty(contributors),
				hasChanged: true,
				membersCountLoading: true,
			};
		}, this._debouncedFetchMembersCount);
	};

	_handleSegmentNameBlur = (event) => {
		const {handleBlur} = this.props;

		handleBlur(event);
	};

	_handleConjunctionChange = (conjunctionName) => {
		this.setState((prevState) => {
			const contributors = applyConjunctionChangeToContributor(
				prevState.contributors,
				conjunctionName
			);

			return {
				contributors,
				hasChanged: true,
				membersCountLoading: true,
			};
		}, this._debouncedFetchMembersCount);
	};

	/**
	 * Checks if every query in each contributor has a value.
	 * @return {boolean} True if none of the contributor's queries have a value.
	 */
	_isQueryEmpty = (contributors) =>
		contributors.every((contributor) => !contributor.query);

	_renderContributors = () => {
		const {
			locale,
			propertyGroups,
			requestMembersCountURL,
			values,
		} = this.props;

		const {
			contributors,
			editing,
			membersCount,
			membersCountLoading,
		} = this.state;

		const emptyContributors = this._isQueryEmpty(contributors);

		const segmentName = values.name[locale];

		return propertyGroups && contributors ? (
			<ContributorsBuilder
				contributors={contributors}
				editing={editing}
				emptyContributors={emptyContributors}
				membersCount={membersCount}
				membersCountLoading={membersCountLoading}
				onConjunctionChange={this._handleConjunctionChange}
				onPreviewMembers={this._handlePreviewMembers}
				onQueryChange={this._handleQueryChange}
				propertyGroups={propertyGroups}
				requestMembersCountURL={requestMembersCountURL}
				segmentName={segmentName}
				supportedConjunctions={SUPPORTED_CONJUNCTIONS}
				supportedOperators={SUPPORTED_OPERATORS}
				supportedPropertyTypes={SUPPORTED_PROPERTY_TYPES}
			/>
		) : null;
	};

	/**
	 * Decides wether to redirect the user or warn
	 *
	 * @memberof SegmentEdit
	 */
	_handleCancelButton = () => {
		const {hasChanged} = this.state;

		if (hasChanged) {
			const confirmed = confirm(
				Liferay.Language.get('criteria-cancel-confirmation-message')
			);

			if (confirmed) {
				this._redirect();
			}
		}
		else {
			this._redirect();
		}
	};

	/**
	 * This redirects to the `redirect` prop.
	 *
	 * @memberof SegmentEdit
	 */
	_redirect = () => {
		Liferay.Util.navigate(this.props.redirect);
	};

	/**
	 * Opens a modal from to show the view from `this.props.previewMembersURL`
	 *
	 * @memberof SegmentEdit
	 */
	_handlePreviewMembers = () => {
		const {locale, previewMembersURL, values} = this.props;
		const {name} = values;
		const segmentLocalizedName = name[locale];

		openModal({
			id: 'segment-members-dialog',
			size: 'full-screen',
			title: sub(Liferay.Language.get('x-members'), [
				Liferay.Util.escape(segmentLocalizedName),
			]),
			url: previewMembersURL,
		});
	};

	/**
	 * Validates fields with validation and prevents the default form submission
	 * if there are any errors.
	 *
	 * Form submission is defined by the action attribute on the <form> element
	 * outside of this component. Since we are leveraging the <aui:form> taglib
	 * to handle submission and formik to handle value changes and validation,
	 * this method uses formik to validate and prevents the taglib form action
	 * from being called.
	 * @param {Class} event Event to prevent a form submission from occurring.
	 */
	_handleValidate = (event) => {
		const {validateForm} = this.props;

		event.persist();

		validateForm().then((errors) => {
			const errorMessages = Object.values(errors);

			if (errorMessages.length) {
				event.preventDefault();

				errorMessages.forEach((message) => {
					Liferay.Util.openToast({
						message,
						type: 'danger',
					});
				});
			}
		});
	};

	_renderLocalizedInputs = () => {
		const {defaultLanguageId, portletNamespace, values} = this.props;

		const langs = Object.keys(values.name);

		return langs.map((key) => {
			let returnVal;
			const value = values.name[key];

			if (key === defaultLanguageId) {
				returnVal = (
					<React.Fragment key={key}>
						<input
							name={`${portletNamespace}name_${key}`}
							readOnly
							type="hidden"
							value={value}
						/>
						<input
							name={`${portletNamespace}key`}
							readOnly
							type="hidden"
							value={value}
						/>
						<input
							name={`${portletNamespace}name`}
							readOnly
							type="hidden"
							value={value}
						/>
					</React.Fragment>
				);
			}
			else {
				returnVal = (
					<React.Fragment key={key}>
						<input
							name={`${portletNamespace}name_${key}`}
							readOnly
							type="hidden"
							value={value}
						/>
					</React.Fragment>
				);
			}

			return returnVal;
		});
	};

	render() {
		const {
			availableLocales,
			defaultLanguageId,
			hasUpdatePermission,
			portletNamespace,
			source,
			values,
		} = this.props;

		const {contributors, disabledSave, editing, validTitle} = this.state;

		const {assetsPath} = this.context;

		const disabledSaveButton = disabledSave || !validTitle;

		const placeholder = Liferay.Language.get('untitled-segment');

		return (
			<div className="segment-edit-page-root">
				<input
					name={`${portletNamespace}active`}
					type="hidden"
					value={values.active}
				/>

				<div className="form-header">
					<ClayLayout.ContainerFluid className="form-header-container">
						<div className="form-header-section-left">
							<FieldArray
								name="values.name"
								render={this._renderLocalizedInputs}
							/>

							<LocalizedInput
								availableLanguages={availableLocales}
								defaultLang={defaultLanguageId}
								initialLanguageId={defaultLanguageId}
								initialOpen={false}
								initialValues={values.name}
								onChange={this._handleLocalizedInputChange}
								placeholder={placeholder}
								portletNamespace={portletNamespace}
								readOnly={!editing}
							/>

							<div className="align-self-center">
								<img
									className="lfr-portal-tooltip source-icon"
									data-testid="source-icon"
									src={
										source ===
										SOURCES.ASAH_FARO_BACKEND.name
											? `${assetsPath}${SOURCES.ASAH_FARO_BACKEND.icon}`
											: `${assetsPath}${SOURCES.DEFAULT.icon}`
									}
									title={
										source ===
										SOURCES.ASAH_FARO_BACKEND.name
											? SOURCES.ASAH_FARO_BACKEND.label
											: SOURCES.DEFAULT.label
									}
								/>
							</div>
						</div>

						{hasUpdatePermission && (
							<div className="form-header-section-right">
								<div className="btn-group">
									<div className="btn-group-item mr-2">
										<ClayToggle
											checked={editing}
											className="toggle-editing"
											iconOff="pencil"
											iconOn="pencil"
											onChange={this._handleCriteriaEdit}
										/>
									</div>
								</div>

								<div className="btn-group">
									<div className="btn-group-item">
										<ClayButton
											className="text-capitalize"
											displayType="secondary"
											onClick={this._handleCancelButton}
											small
										>
											{Liferay.Language.get('cancel')}
										</ClayButton>
									</div>

									<div className="btn-group-item">
										<ClayButton
											className="text-capitalize"
											disabled={disabledSaveButton}
											displayType="primary"
											onClick={this._handleValidate}
											small={true}
											type="submit"
										>
											{Liferay.Language.get('save')}
										</ClayButton>
									</div>
								</div>
							</div>
						)}
					</ClayLayout.ContainerFluid>
				</div>

				<div className="form-body">
					<FieldArray
						name="contributors"
						render={this._renderContributors}
					/>
					<ContributorInputs contributors={contributors} />
				</div>
			</div>
		);
	}
}

export default withFormik({
	mapPropsToValues: (props) => ({
		active: props.initialSegmentActive || true,
		contributors: props.contributors || [],
		name: props.initialSegmentName || {},
	}),
	validate: (values) => {
		const errors = {};

		if (!values.name) {
			errors.name = Liferay.Language.get('segment-name-is-required');
		}

		return errors;
	},
})(SegmentEdit);