ToolJet/frontend/src/Editor/CodeBuilder/CodeHinter.jsx
Sherfin Shamsudeen 4a21eeb9f6
feat: Add transformations feature to table columns (#8287)
* Add transformations feature to table columns

* Make table transformations listen to current state

* Move column transformation to below "key" in column properties

* Properly exposed currentData and currentPageData of table alongwith transformations
2024-02-06 10:38:44 +05:30

602 lines
21 KiB
JavaScript

import React, { useEffect, useState, useRef, useContext } from 'react';
import { useSpring, config, animated } from 'react-spring';
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
import Tooltip from 'react-bootstrap/Tooltip';
import CodeMirror from '@uiw/react-codemirror';
import 'codemirror/mode/handlebars/handlebars';
import 'codemirror/mode/javascript/javascript';
import 'codemirror/mode/sql/sql';
import 'codemirror/addon/hint/show-hint';
import 'codemirror/addon/display/placeholder';
import 'codemirror/addon/search/match-highlighter';
import 'codemirror/addon/hint/show-hint.css';
import 'codemirror/theme/base16-light.css';
import 'codemirror/theme/duotone-light.css';
import 'codemirror/theme/monokai.css';
import { onBeforeChange, handleChange } from './utils';
import { resolveReferences, hasCircularDependency, handleCircularStructureToJSON } from '@/_helpers/utils';
import useHeight from '@/_hooks/use-height-transition';
import usePortal from '@/_hooks/use-portal';
import { Color } from './Elements/Color';
import { Json } from './Elements/Json';
import { Select } from './Elements/Select';
import { Toggle } from './Elements/Toggle';
import { AlignButtons } from './Elements/AlignButtons';
import { TypeMapping } from './TypeMapping';
import { Number } from './Elements/Number';
import { BoxShadow } from './Elements/BoxShadow';
import FxButton from './Elements/FxButton';
import { ToolTip } from '../Inspector/Elements/Components/ToolTip';
import { toast } from 'react-hot-toast';
import { EditorContext } from '@/Editor/Context/EditorContextWrapper';
import { camelCase } from 'lodash';
import { useTranslation } from 'react-i18next';
import cx from 'classnames';
import { Alert } from '@/_ui/Alert/Alert';
import { useCurrentState } from '@/_stores/currentStateStore';
import ClientServerSwitch from './Elements/ClientServerSwitch';
import Switch from './Elements/Switch';
import Checkbox from './Elements/Checkbox';
import Slider from './Elements/Slider';
import { Input } from './Elements/Input';
import { Icon } from './Elements/Icon';
import { Visibility } from './Elements/Visibility';
import { validateProperty } from '../component-properties-validation';
const HIDDEN_CODE_HINTER_LABELS = ['Table data', 'Column data'];
const AllElements = {
Color,
Json,
Toggle,
Select,
AlignButtons,
Number,
BoxShadow,
ClientServerSwitch,
Slider,
Switch,
Input,
Checkbox,
Icon,
Visibility,
};
export function CodeHinter({
initialValue,
onChange,
onVisibilityChange,
mode,
theme,
lineNumbers,
placeholder,
ignoreBraces,
enablePreview,
height,
minHeight,
lineWrapping,
componentName = null,
usePortalEditor = true,
className,
width = '',
paramName,
paramLabel,
type,
fieldMeta,
onFxPress,
fxActive,
component,
popOverCallback,
cyLabel = '',
callgpt = () => null,
isCopilotEnabled = false,
currentState: _currentState,
isIcon = false,
paramUpdated,
staticText,
}) {
const darkMode = localStorage.getItem('darkMode') === 'true';
const options = {
lineNumbers: lineNumbers ?? false,
lineWrapping: lineWrapping ?? true,
singleLine: true,
mode: mode || 'handlebars',
tabSize: 2,
theme: theme ? theme : darkMode ? 'monokai' : 'default',
readOnly: false,
highlightSelectionMatches: true,
placeholder,
};
const currentState = useCurrentState();
const [realState, setRealState] = useState(currentState);
const [currentValue, setCurrentValue] = useState('');
const [prevCurrentValue, setPrevCurrentValue] = useState(null);
const [resolvedValue, setResolvedValue] = useState(null);
const [resolvingError, setResolvingError] = useState(null);
const [isFocused, setFocused] = useState(false);
const [heightRef, currentHeight] = useHeight();
const isPreviewFocused = useRef(false);
const wrapperRef = useRef(null);
// Todo: Remove this when workspace variables are deprecated
const isWorkspaceVariable =
typeof currentValue === 'string' && (currentValue.includes('%%client') || currentValue.includes('%%server'));
const slideInStyles = useSpring({
config: { ...config.stiff },
from: { opacity: 0, height: 0 },
to: {
opacity: isFocused ? 1 : 0,
height: isFocused ? currentHeight + (isWorkspaceVariable ? 30 : 0) : 0,
},
});
const { t } = useTranslation();
const { variablesExposedForPreview } = useContext(EditorContext);
const prevCountRef = useRef(false);
function getPropertyDefinition(paramName, component) {
if (component?.properties?.hasOwnProperty(`${paramName}`)) {
return component.properties?.[paramName];
} else if (component?.styles?.hasOwnProperty(`${paramName}`)) {
return component?.styles?.[paramName];
} else if (component?.general?.hasOwnProperty(`${paramName}`)) {
return component?.general?.[paramName];
} else if (component?.generalStyles?.hasOwnProperty(`${paramName}`)) {
return component?.generalStyles?.[paramName];
} else {
return {};
}
}
const checkTypeErrorInRunTime = (preview) => {
const propertyDefinition = getPropertyDefinition(paramName, component?.component);
const resolvedProperty = Object.keys(component?.component?.definition || {}).reduce((accumulator, currentKey) => {
if (
component?.component?.definition?.[currentKey]?.hasOwnProperty(paramName) ||
(paramName === 'tooltip' &&
currentKey === 'general' &&
!component?.component?.definition?.[currentKey]?.hasOwnProperty(paramName))
//added second condition because initilly general is empty object and hence it was not going inside if statement and thus codehinter was always receiving undefined for initial render and thus showing error message in the preview
) {
accumulator[`${paramName}`] = resolveReferences(preview, currentState);
}
return accumulator;
}, {});
const [_valid, errorMessages] = validateProperty(resolvedProperty, propertyDefinition, paramName);
return [_valid, errorMessages];
};
const getPreviewAndErrorFromValue = (value) => {
const customResolvables = getCustomResolvables();
const [preview, error] = resolveReferences(value, realState, null, customResolvables, true, true);
return [preview, error];
};
useEffect(() => {
setCurrentValue(initialValue);
const [preview, error] = getPreviewAndErrorFromValue(initialValue);
const [_valid] = checkTypeErrorInRunTime(preview);
if (!_valid || error) setResolvingError(true);
return () => {
setPrevCurrentValue(null);
setResolvedValue(null);
setResolvingError(null);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (_currentState) {
setRealState(_currentState);
} else {
setRealState(currentState);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify({ currentState, _currentState })]);
useEffect(() => {
const handleClickOutside = (event) => {
if (isOpen) {
return;
}
if (wrapperRef.current && isFocused && !wrapperRef.current.contains(event.target) && prevCountRef.current) {
isPreviewFocused.current = false;
setFocused(false);
prevCountRef.current = false;
} else if (isFocused) {
prevCountRef.current = true;
} else if (!isFocused && prevCountRef.current) prevCountRef.current = false;
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [wrapperRef, isFocused, isPreviewFocused, currentValue, prevCountRef, isOpen]);
useEffect(() => {
let globalPreviewCopy = null;
let globalErrorCopy = null;
if (enablePreview && isFocused && JSON.stringify(currentValue) !== JSON.stringify(prevCurrentValue)) {
const [preview, error] = getPreviewAndErrorFromValue(currentValue);
// checking type error if any in run time
const [_valid, errorMessages] = checkTypeErrorInRunTime(preview);
setPrevCurrentValue(currentValue);
if (error || !_valid || typeof preview === 'function') {
globalPreviewCopy = null;
globalErrorCopy = error || errorMessages?.[errorMessages?.length - 1];
setResolvingError(error || errorMessages?.[errorMessages?.length - 1]);
setResolvedValue(null);
} else {
globalPreviewCopy = preview;
globalErrorCopy = null;
setResolvingError(null);
setResolvedValue(preview);
}
}
return () => {
if (enablePreview && isFocused && JSON.stringify(currentValue) !== JSON.stringify(prevCurrentValue)) {
setPrevCurrentValue(null);
setResolvedValue(globalPreviewCopy);
setResolvingError(globalErrorCopy);
}
};
}, [JSON.stringify({ currentValue, realState, isFocused })]);
// eslint-disable-next-line react-hooks/exhaustive-deps
// }, [JSON.stringify({ currentValue, realState, isFocused })]);
function valueChanged(editor, onChange, ignoreBraces) {
if (editor.getValue()?.trim() !== currentValue) {
handleChange(editor, onChange, ignoreBraces, realState, componentName, getCustomResolvables());
setCurrentValue(editor.getValue()?.trim());
}
}
const getPreviewContent = (content, type) => {
try {
switch (type) {
case 'object':
return JSON.stringify(content);
case 'boolean':
return content.toString();
default:
return content;
}
} catch (e) {
return undefined;
}
};
const focusPreview = () => (isPreviewFocused.current = true);
const unFocusPreview = () => (isPreviewFocused.current = false);
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
};
const getCustomResolvables = () => {
if (variablesExposedForPreview.hasOwnProperty(component?.id)) {
if (component?.component?.component === 'Table' && fieldMeta?.name) {
return {
...variablesExposedForPreview[component?.id],
cellValue: variablesExposedForPreview[component?.id]?.rowData?.[fieldMeta?.name],
rowData: { ...variablesExposedForPreview[component?.id]?.rowData },
};
}
return variablesExposedForPreview[component.id];
}
return {};
};
const getPreview = () => {
if (!enablePreview) return;
const themeCls = darkMode ? 'bg-dark py-1' : 'bg-light py-1';
const preview = resolvedValue;
const error = resolvingError;
if (resolvingError !== null && resolvedValue === null && error) {
const err = String(error);
const errorMessage = err.includes('.run()')
? `${err} in ${componentName ? componentName.split('::')[0] + "'s" : 'fx'} field`
: err;
return (
<animated.div className={isOpen ? themeCls : null} style={{ ...slideInStyles, overflow: 'hidden' }}>
<div ref={heightRef} className="dynamic-variable-preview bg-red-lt px-1 py-1">
<div>
<div className="heading my-1">
<span>Error</span>
</div>
{errorMessage}
</div>
</div>
</animated.div>
);
}
let previewType = typeof preview;
let previewContent = preview;
if (hasCircularDependency(preview)) {
previewContent = JSON.stringify(preview, handleCircularStructureToJSON());
previewType = typeof previewContent;
}
const content = getPreviewContent(previewContent, previewType);
return (
<animated.div
className={isOpen ? themeCls : null}
style={{ ...slideInStyles, overflow: 'hidden' }}
onMouseEnter={() => focusPreview()}
onMouseLeave={() => unFocusPreview()}
>
<div ref={heightRef} className="dynamic-variable-preview bg-green-lt px-1 py-1">
<div>
<div className="d-flex my-1">
<div className="flex-grow-1" style={{ fontWeight: 700, textTransform: 'capitalize' }}>
{previewType}
</div>
{isFocused && (
<div className="preview-icons position-relative">
<CodeHinter.PopupIcon callback={() => copyToClipboard(content)} icon="copy" tip="Copy to clipboard" />
</div>
)}
</div>
{content}
</div>
</div>
{/* Todo: Remove this when workspace variables are deprecated */}
{enablePreview && isWorkspaceVariable && (
<CodeHinter.DepericatedAlertForWorkspaceVariable text={'Deprecating soon'} />
)}
</animated.div>
);
};
enablePreview = enablePreview ?? true;
const [isOpen, setIsOpen] = React.useState(false);
const handleToggle = () => {
const changeOpen = (newOpen) => {
setIsOpen(newOpen);
if (typeof popOverCallback === 'function') popOverCallback(newOpen);
};
if (!isOpen) {
changeOpen(true);
}
return new Promise((resolve) => {
const element = document.getElementsByClassName('portal-container');
if (element) {
const checkPortalExits = element[0]?.classList.contains(componentName);
if (checkPortalExits === false) {
const parent = element[0].parentNode;
parent.removeChild(element[0]);
}
changeOpen(false);
resolve();
}
}).then(() => {
changeOpen(true);
forceUpdate();
});
};
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
const defaultClassName =
className === 'query-hinter' || className === 'custom-component' || undefined ? '' : 'code-hinter';
const ElementToRender = AllElements[TypeMapping[type]];
const [forceCodeBox, setForceCodeBox] = useState(fxActive);
const codeShow = (type ?? 'code') === 'code' || forceCodeBox;
cyLabel = paramLabel ? paramLabel.toLowerCase().trim().replace(/\s+/g, '-') : cyLabel;
return (
<div ref={wrapperRef} className={cx({ 'codeShow-active': codeShow, 'd-flex': paramLabel == 'Tooltip' })}>
<div
className={cx('d-flex justify-content-between')}
style={{
marginRight: paramLabel == 'Tooltip' && '40px',
alignItems: paramLabel == 'Tooltip' ? 'flex-start' : 'center',
}}
>
{paramLabel && !HIDDEN_CODE_HINTER_LABELS.includes(paramLabel) && (
<div className={`field ${options.className}`} data-cy={`${cyLabel}-widget-parameter-label`}>
<ToolTip
label={t(`widget.commonProperties.${camelCase(paramLabel)}`, paramLabel)}
meta={fieldMeta}
labelClass={`tj-text-xsm color-slate12 ${codeShow ? 'label-hinter-margin' : 'mb-0'} ${
darkMode && 'color-whitish-darkmode'
}`}
// bold={!AllElements.hasOwnProperty(TypeMapping[type]) ? true : false}
/>
</div>
)}
<div className={`${(type ?? 'code') === 'code' ? 'd-none' : ''} `}>
<div
style={{ width: width, marginBottom: codeShow ? '0.5rem' : '0px' }}
className="d-flex align-items-center"
>
<div className="col-auto pt-0 fx-common">
{paramLabel !== 'Type' &&
paramLabel !== ' ' &&
paramLabel !== 'Padding' && ( //add some key if these extends
<FxButton
active={codeShow}
onPress={() => {
if (codeShow) {
setForceCodeBox(false);
onFxPress(false);
} else {
setForceCodeBox(true);
onFxPress(true);
}
}}
dataCy={cyLabel}
/>
)}
</div>
{!codeShow && (
<ElementToRender
value={resolveReferences(initialValue, realState)}
onChange={(value) => {
if (value !== currentValue) {
onChange(value);
setCurrentValue(value);
}
}}
onVisibilityChange={(value) => {
if (value !== currentValue) {
onVisibilityChange(value);
setCurrentValue(value);
}
}}
paramName={paramName}
paramLabel={paramLabel}
forceCodeBox={() => {
setForceCodeBox(true);
onFxPress(true);
}}
meta={fieldMeta}
cyLabel={cyLabel}
isIcon={isIcon}
staticText={staticText}
component={component}
/>
)}
</div>
</div>
</div>
<div
className={`row${height === '150px' || height === '300px' ? ' tablr-gutter-x-0' : ''} custom-row`}
style={{ width: paramLabel == 'Tooltip' ? '100%' : width, display: codeShow ? 'flex' : 'none' }}
>
<div className={`col code-hinter-col`}>
<div className="d-flex">
<div className="code-hinter-wrapper position-relative" style={{ width: '100%' }}>
<div
className={`${defaultClassName} ${className || 'codehinter-default-input'} ${
paramName && resolvingError && 'border-danger'
}`}
key={componentName}
style={{
height: height || 'auto',
minHeight,
maxHeight: '320px',
overflow: 'auto',
fontSize: ' .875rem',
}}
data-cy={`${cyLabel}-input-field`}
>
{usePortalEditor && (
<CodeHinter.PopupIcon
callback={handleToggle}
icon="portal-open"
tip="Pop out code editor into a new window"
transformation={componentName === 'transformation'}
/>
)}
<CodeHinter.Portal
isCopilotEnabled={isCopilotEnabled}
isOpen={isOpen}
callback={setIsOpen}
componentName={componentName}
key={componentName}
customComponent={getPreview}
forceUpdate={forceUpdate}
optionalProps={{ styles: { height: 300 }, cls: className }}
darkMode={darkMode}
selectors={{ className: 'preview-block-portal' }}
dragResizePortal={true}
callgpt={callgpt}
>
<CodeMirror
value={typeof initialValue === 'string' ? initialValue : ''}
realState={realState}
scrollbarStyle={null}
height={'100%'}
onFocus={() => setFocused(true)}
onBlur={(editor, e) => {
e?.stopPropagation();
const value = editor?.getValue()?.trimEnd();
onChange(value);
if (!isPreviewFocused?.current) {
setFocused(false);
}
}}
onChange={(editor) => valueChanged(editor, onChange, ignoreBraces)}
onBeforeChange={(editor, change) => onBeforeChange(editor, change, ignoreBraces)}
options={options}
viewportMargin={Infinity}
/>
</CodeHinter.Portal>
</div>
{enablePreview && !isOpen && getPreview()}
</div>
</div>
</div>
</div>
</div>
);
}
// eslint-disable-next-line no-unused-vars
function CodeHinterInputField() {
return <></>;
}
const PopupIcon = ({ callback, icon, tip, transformation = false }) => {
const size = transformation ? 20 : 12;
return (
<div className="d-flex justify-content-end w-100 position-absolute" style={{ top: 0 }}>
<OverlayTrigger
trigger={['hover', 'focus']}
placement="top"
delay={{ show: 800, hide: 100 }}
overlay={<Tooltip id="button-tooltip">{tip}</Tooltip>}
>
<img
className="svg-icon m-2 popup-btn"
src={`assets/images/icons/${icon}.svg`}
width={size}
height={size}
onClick={(e) => {
e.stopPropagation();
callback();
}}
/>
</OverlayTrigger>
</div>
);
};
const Portal = ({ children, ...restProps }) => {
const renderPortal = usePortal({ children, ...restProps });
return <React.Fragment>{renderPortal}</React.Fragment>;
};
const DepericatedAlertForWorkspaceVariable = ({ text }) => {
return (
<Alert
svg="tj-info-warning"
cls="codehinter workspace-variables-alert-banner p-1 mb-0"
data-cy={``}
imgHeight={18}
imgWidth={18}
>
<div className="d-flex align-items-center">
<div class="">{text}</div>
</div>
</Alert>
);
};
CodeHinter.PopupIcon = PopupIcon;
CodeHinter.Portal = Portal;
CodeHinter.DepericatedAlertForWorkspaceVariable = DepericatedAlertForWorkspaceVariable;