Source: data-engine-taglib/src/main/resources/META-INF/resources/data_layout_builder/js/components/rules/editor/Calculator.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 './Calculator.scss';

import ClayButton from '@clayui/button';
import ClayDropDown from '@clayui/drop-down';
import ClayForm from '@clayui/form';
import ClayLayout from '@clayui/layout';
import {FieldStateless, RulesSupport} from 'data-engine-js-components-web';
import Token from 'dynamic-data-mapping-form-builder/js/expressions/Token.es';
import Tokenizer from 'dynamic-data-mapping-form-builder/js/expressions/Tokenizer.es';
import React, {forwardRef, useMemo, useState} from 'react';

import CalculatorButtonArea from './CalculatorButtonArea.es';

function getRepeatableFields(fields) {
	return fields.filter(({repeatable}) => repeatable === true);
}

function getStateBasedOnExpression(expression) {
	let disableDot = false;
	let disableOperators = false;
	const tokens = Tokenizer.tokenize(expression);

	if (
		tokens.length === 0 ||
		(tokens.length > 0 && tokens[tokens.length - 1].type !== Token.LITERAL)
	) {
		disableDot = true;
	}

	if (
		tokens.length > 0 &&
		tokens[tokens.length - 1].type === Token.OPERATOR
	) {
		disableOperators = true;
	}

	return {
		disableDot,
		disableFunctions: false,
		disableNumbers: false,
		disableOperators,
		showOnlyRepeatableFields: false,
	};
}

/**
 * A Token can be have one of the following types:
 *
 * Token.FUNCTION = 'Function';
 * Token.LEFT_PARENTHESIS = 'Left Parenthesis';
 * Token.LITERAL = 'Literal';
 * Token.OPERATOR = 'Operator';
 * Token.RIGHT_PARENTHESIS = 'Right Parenthesis';
 * Token.VARIABLE = 'Variable';
 *
 * See https://github.com/liferay/liferay-portal/blob/e066954b019e5fcf42ca45b69fd4da595ad58029/modules/apps/dynamic-data-mapping/dynamic-data-mapping-form-builder/src/main/resources/META-INF/resources/js/expressions/Token.es.js#L26
 * for more details.
 *
 * Each key in the following dictionary represents the type of a token in which the
 * value is matched with the possible tokens that can add an implicit multiplication.
 */
const TOKEN_TYPE_ACCEPTS_IMPLICIT_MULTIPLICATION = {
	[Token.LEFT_PARENTHESIS]: [
		Token.LEFT_PARENTHESIS,
		Token.LITERAL,
		Token.RIGHT_PARENTHESIS,
		Token.VARIABLE,
	],
	[Token.FUNCTION]: [
		Token.FUNCTION,
		Token.LITERAL,
		Token.RIGHT_PARENTHESIS,
		Token.VARIABLE,
	],
	[Token.VARIABLE]: [Token.VARIABLE, Token.LITERAL],
	[Token.LITERAL]: [Token.VARIABLE, Token.FUNCTION],
	[Token.OPERATOR]: [],
	[Token.RIGHT_PARENTHESIS]: [],
};

function isImplicitMultiplication(lastToken, newToken) {
	return TOKEN_TYPE_ACCEPTS_IMPLICIT_MULTIPLICATION[newToken.type].includes(
		lastToken.type
	);
}

function isSumAction(token) {
	return (
		token.length > 1 &&
		token[token.length - 1].type === Token.LEFT_PARENTHESIS &&
		token[token.length - 2].type === Token.FUNCTION &&
		token[token.length - 2].value === 'sum'
	);
}

function shouldAddImplicitMultiplication(tokens, newToken) {
	const lastToken = tokens[tokens.length - 1];

	return (
		lastToken !== undefined &&
		isImplicitMultiplication(lastToken, newToken) &&
		isSumAction(tokens) !== true
	);
}

function addTokenToExpression({expression, tokenType, tokenValue}) {
	const newToken = new Token(tokenType, tokenValue);
	const tokens = Tokenizer.tokenize(expression);

	if (shouldAddImplicitMultiplication(tokens, newToken)) {
		tokens.push(new Token(Token.OPERATOR, '*'));
	}

	tokens.push(newToken);

	return Tokenizer.stringifyTokens(tokens);
}

function removeTokenFromExpression({expression}) {
	const tokens = Tokenizer.tokenize(expression);

	const removedToken = tokens.pop();

	if (
		removedToken &&
		removedToken.type === Token.LEFT_PARENTHESIS &&
		tokens.length &&
		tokens[tokens.length - 1].type === Token.FUNCTION
	) {
		tokens.pop();
	}

	return Tokenizer.stringifyTokens(tokens);
}

function FieldsDropdown({items, onFieldSelected = () => {}, ...otherProps}) {
	const [active, setActive] = useState(false);

	return (
		<ClayDropDown
			active={active}
			onActiveChange={setActive}
			{...otherProps}
		>
			<ClayDropDown.ItemList>
				{items.map((item, i) => {
					if (!item.separator) {
						return (
							<ClayDropDown.Item
								aria-label={item.label}
								key={item.fieldReference}
								onClick={() => {
									onFieldSelected(item);
									setActive(false);
								}}
							>
								{item.label}

								{item.fieldReference && (
									<span className="calculate-field-reference">
										{` ${Liferay.Language.get(
											'field-reference'
										)}: ${item.fieldReference}`}
									</span>
								)}
							</ClayDropDown.Item>
						);
					}

					return <ClayDropDown.Divider key={i} />;
				})}
			</ClayDropDown.ItemList>
		</ClayDropDown>
	);
}

const Calculator = forwardRef(
	(
		{expression: initialExpression, fields, functions, onChange, options},
		ref
	) => {
		const [expression, setExpression] = useState(initialExpression);

		const {
			disableDot,
			disableFunctions,
			disableNumbers,
			disableOperators,
			showOnlyRepeatableFields,
		} = useMemo(() => getStateBasedOnExpression(expression), [expression]);

		const repeatableFields = useMemo(() => getRepeatableFields(options), [
			options,
		]);

		const value = useMemo(() => {
			const newMaskedExpression = expression.replace(/[[\]]/g, '');

			return (
				RulesSupport.replaceFieldNameByFieldLabel(
					newMaskedExpression,
					fields
				) ?? newMaskedExpression
			);
		}, [expression, fields]);

		const dropdownItems = showOnlyRepeatableFields
			? repeatableFields
			: options;

		const updateExpression = (newExpression) => {
			setExpression(newExpression);
			onChange(newExpression);
		};

		const handleButtonClick = ({tokenType, tokenValue}) => {
			if (tokenValue === 'backspace') {
				const newExpression = removeTokenFromExpression({expression});

				updateExpression(newExpression);
			}
			else {
				const newExpression = addTokenToExpression({
					expression,
					tokenType,
					tokenValue,
				});

				updateExpression(newExpression);
			}
		};

		const handleFunctionSelected = ({value}) => {
			const expressionWithFunction = addTokenToExpression({
				expression,
				tokenType: Token.FUNCTION,
				tokenValue: value,
			});
			const newExpression = addTokenToExpression({
				expression: expressionWithFunction,
				tokenType: Token.LEFT_PARENTHESIS,
				tokenValue: '(',
			});

			updateExpression(newExpression);
		};

		const handleFieldSelected = ({fieldName}) => {
			const newExpression = addTokenToExpression({
				expression,
				tokenType: Token.VARIABLE,
				tokenValue: fieldName,
			});

			updateExpression(newExpression);
		};

		return (
			<ClayLayout.Row ref={ref}>
				<ClayLayout.Col className="no-padding" md={12}>
					<div className="calculate-container">
						<ClayLayout.Col
							className="calculate-container-calculator-component"
							md={3}
						>
							<div className="liferay-ddm-form-builder-calculator">
								<FieldsDropdown
									className="calculator-add-field-button-container"
									items={dropdownItems}
									onFieldSelected={handleFieldSelected}
									trigger={
										<ClayButton
											className="btn-lg calculator-add-field-button ddm-button"
											disabled={!dropdownItems.length}
										>
											{Liferay.Language.get('add-field')}
										</ClayButton>
									}
								/>

								<CalculatorButtonArea
									disableDot={disableDot}
									disableFunctions={disableFunctions}
									disableNumbers={disableNumbers}
									disableOperators={disableOperators}
									functions={functions}
									onClick={handleButtonClick}
									onFunctionSelected={handleFunctionSelected}
								/>
							</div>
						</ClayLayout.Col>

						<ClayLayout.Col
							className="calculate-container-fields"
							md="9"
						>
							<ClayForm.Group>
								<FieldStateless
									displayStyle="multiline"
									name="calculator-expression"
									placeholder={Liferay.Language.get(
										'the-expression-will-be-displayed-here'
									)}
									readOnly
									showLabel={false}
									type="text"
									value={value}
								/>
							</ClayForm.Group>
						</ClayLayout.Col>
					</div>
				</ClayLayout.Col>
			</ClayLayout.Row>
		);
	}
);

Calculator.displayName = 'Calculator';

export default Calculator;