ToolJet/frontend/src/AppBuilder/CodeEditor/PreviewBox.jsx

788 lines
24 KiB
JavaScript

import React, { useEffect, useState } from 'react';
import { computeCoercion, getCurrentNodeType, hasDeepChildren, resolveReferences } from './utils';
import CodeHinter from '.';
import { copyToClipboard } from '@/_helpers/appUtils';
import { Alert } from '@/_ui/Alert/Alert';
import { Button } from '@/components/ui/Button/Button';
import _, { isEmpty } from 'lodash';
import { handleCircularStructureToJSON, hasCircularDependency, verifyConstant } from '@/_helpers/utils';
import Popover from 'react-bootstrap/Popover';
import Card from 'react-bootstrap/Card';
// eslint-disable-next-line import/no-unresolved
import { JsonViewer } from '@textea/json-viewer';
import { reservedKeywordReplacer } from '@/_lib/reserved-keyword-replacer';
import useStore from '@/AppBuilder/_stores/store';
import { shallow } from 'zustand/shallow';
import { Overlay } from 'react-bootstrap';
import { ToolTip } from '@/_components/ToolTip';
import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext';
import { findDefault } from '../_utils/component-properties-validation';
import FixWithAi from './FixWithAi';
const sanitizeLargeDataset = (data, callback) => {
const SIZE_LIMIT_KB = 5 * 1024; // 5 KB in bytes
const estimateSizeOfObject = (object) => {
const visited = new Set();
function sizeOf(obj) {
if (obj === null || typeof obj !== 'object') return 0;
if (visited.has(obj)) return 0;
visited.add(obj);
let bytes = 0;
for (let key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
bytes += key.length * 2;
const value = obj[key];
switch (typeof value) {
case 'boolean':
bytes += 4;
break;
case 'number':
bytes += 8;
break;
case 'string':
bytes += value.length * 2;
break;
case 'object':
if (Array.isArray(value)) {
bytes += value.reduce((acc, el) => acc + sizeOf(el), 0);
} else {
bytes += sizeOf(value);
}
break;
}
}
}
return bytes;
}
return sizeOf(object);
};
function trimTo5KB(str) {
let bytes = 0;
let result = '';
for (let i = 0; i < str.length; i++) {
const charCode = str.charCodeAt(i);
const charBytes = charCode <= 0x7f ? 1 : 2; // basic approximation for UTF-16
if (bytes + charBytes > SIZE_LIMIT_KB) {
break;
}
result += str[i];
bytes += charBytes;
}
return result + '...';
}
const sanitize = (input) => {
if (typeof input === 'string') return trimTo5KB(input);
if (typeof input !== 'object' || input === null) return input;
if (Array.isArray(input)) {
const size = estimateSizeOfObject(input);
callback(size > SIZE_LIMIT_KB);
return data.length > 10 && size > SIZE_LIMIT_KB
? [input[0], `Too large to display: ${input.length - 1} more items`]
: input;
} else {
const sanitizedData = Object.entries(input).reduce((acc, [key, value]) => {
const sizeOfEachElement = estimateSizeOfObject(value);
if (Array.isArray(value) && (data.length > 10 || sizeOfEachElement > SIZE_LIMIT_KB)) {
acc[key] = [value[0], `Too large to display: ${value.length - 1} more items`];
} else {
acc[key] = sanitize(value);
}
return acc;
}, {});
return sanitizedData;
}
};
return sanitize(data);
};
export const PreviewBox = ({
currentValue,
validationSchema,
setErrorStateActive,
setErrorMessage,
customVariables,
isWorkspaceVariable,
validationFn,
}) => {
const { moduleId } = useModuleContext();
const [resolvedValue, setResolvedValue] = useState('');
const [error, setError] = useState(null);
const [coersionData, setCoersionData] = useState(null);
const [largeDataset, setLargeDataset] = useState(false);
const globals = useStore((state) => state.getAllExposedValues(moduleId).constants || {}, shallow);
const secrets = useStore((state) => state.getSecrets(), shallow);
const globalServerConstantsRegex = /\{\{.*globals\.server.*\}\}/;
const getPreviewContent = (content, type) => {
if (content === undefined || content === null) return currentValue;
try {
switch (type) {
case 'Object':
case 'Array':
return JSON.stringify(content);
case 'Boolean':
return content.toString();
default:
return content;
}
} catch (e) {
return undefined;
}
};
let previewType = getCurrentNodeType(resolvedValue);
let previewContent = resolvedValue;
let isGlobalConstant = currentValue && currentValue.includes('{{constants.');
let isSecretConstant = currentValue && currentValue.includes('{{secrets.');
const isServerConstant = currentValue && currentValue.match(globalServerConstantsRegex);
let invalidConstants = null;
let undefinedError = null;
if (isGlobalConstant || isSecretConstant) {
invalidConstants = verifyConstant(currentValue, globals, secrets);
}
if (invalidConstants?.length) {
undefinedError = { type: 'Invalid constants' };
}
const ifCoersionErrorHasCircularDependency = (value) => {
if (hasCircularDependency(value)) {
return JSON.stringify(value, handleCircularStructureToJSON());
}
return value;
};
const content = getPreviewContent(previewContent, previewType);
useEffect(() => {
if (error) {
setErrorStateActive(true);
setErrorMessage(error);
} else {
setErrorStateActive(false);
setErrorMessage(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error]);
useEffect(() => {
const [valid, _error, rawNewValue, rawResolvedValue] = resolveReferences(
currentValue,
validationSchema,
customVariables,
validationFn
);
const completeErrMessage = Array.isArray(_error) ? _error.join('.') : _error;
const resolvedValue = typeof rawResolvedValue === 'function' ? undefined : rawResolvedValue;
const newValue = typeof rawNewValue === 'function' ? undefined : rawNewValue;
const isSecretError =
currentValue?.includes('secrets.') || _error?.includes('ReferenceError: secrets is not defined');
if ((isWorkspaceVariable || !validationSchema || isEmpty(validationSchema)) && !validationFn) {
return setResolvedValue(newValue);
}
// we dont need to add or update the resolved value if the value has deep children
const _resolveValue = sanitizeLargeDataset(resolvedValue, setLargeDataset);
if (valid && !isSecretError) {
const [coercionPreview, typeAfterCoercion, typeBeforeCoercion] = computeCoercion(resolvedValue, newValue);
setResolvedValue(_resolveValue);
setCoersionData({
coercionPreview,
typeAfterCoercion,
typeBeforeCoercion,
});
setError(null);
} else if (!valid && !newValue && !resolvedValue && !isSecretError) {
const err = !error ? `Invalid value for ${validationSchema?.schema?.type}` : `${_error}`;
setError({ message: err, value: resolvedValue, type: 'Invalid', completeErrorMessage: completeErrMessage });
} else {
const jsErrorType = isSecretError
? 'Error'
: _error?.includes('ReferenceError')
? 'ReferenceError'
: _error?.includes('TypeError')
? 'TypeError'
: _error?.includes('SyntaxError')
? 'SyntaxError'
: 'Invalid';
const errValue = ifCoersionErrorHasCircularDependency(_resolveValue);
setError({
message: isServerConstant
? 'Server variables cannot be used in apps'
: isSecretError
? 'secrets cannot be used in apps'
: _error,
value: isSecretError
? 'Undefined'
: jsErrorType === 'Invalid'
? JSON.stringify(errValue, reservedKeywordReplacer)
: resolvedValue,
type: isSecretError ? 'Error' : jsErrorType,
completeErrorMessage: completeErrMessage,
});
setCoersionData(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentValue]);
return (
<>
<PreviewBox.RenderResolvedValue
error={error || undefinedError}
currentValue={currentValue}
previewType={previewType}
resolvedValue={content}
coersionData={coersionData}
withValidation={!isEmpty(validationSchema)}
isWorkspaceVariable={isWorkspaceVariable}
isSecretConstant={isSecretConstant || false}
isLargeDataset={largeDataset}
isServerConstant={isServerConstant}
/>
<CodeHinter.PopupIcon
callback={() => copyToClipboard(error ? error?.value : content)}
icon={'copy'}
tip={'Copy to clipboard'}
/>
</>
);
};
const RenderResolvedValue = ({
error,
previewType,
resolvedValue,
coersionData,
withValidation,
isWorkspaceVariable,
isSecretConstant = false,
isServerConstant = false,
isLargeDataset,
}) => {
const isServerSideGlobalResolveEnabled = useStore(
(state) => !!state?.license?.featureAccess?.serverSideGlobalResolve,
shallow
);
const computeCoersionPreview = (resolvedValue, coersionData) => {
if (coersionData?.typeBeforeCoercion === coersionData?.typeAfterCoercion) return resolvedValue;
if (coersionData?.typeBeforeCoercion === 'array') {
return '[...]' + coersionData?.coercionPreview;
}
if (coersionData?.typeBeforeCoercion === 'object') {
return '{...}' + coersionData?.coercionPreview;
}
return resolvedValue + coersionData?.coercionPreview;
};
const previewValueType = isWorkspaceVariable
? previewType
: withValidation || (coersionData && coersionData?.typeBeforeCoercion)
? `${coersionData?.typeBeforeCoercion} ${
coersionData?.coercionPreview ? `${coersionData?.typeAfterCoercion}` : ''
}`
: previewType;
const previewContent = isServerConstant
? isServerSideGlobalResolveEnabled
? 'Server variables would be resolved at runtime'
: 'Server variables are only available in paid plans'
: isSecretConstant
? 'Values of secret constants are hidden'
: !withValidation
? resolvedValue
: computeCoersionPreview(resolvedValue, coersionData);
const cls = error ? 'codehinter-error-banner' : 'codehinter-success-banner';
return (
<div className={`d-flex flex-column align-content-between flex-wrap`}>
<div className="p-2">
<span className={`badge text-capitalize font-500 ${cls}`}> {error ? error.type : previewValueType}</span>
</div>
<PreviewBox.CodeBlock code={error ? error.value : previewContent} isLargeDataset={isLargeDataset} />
</div>
);
};
function FixIssueTooltipContent() {
return (
<>
<h5 className="tw-font-medium">Auto-fix</h5>
<p className="tw-text-base tw-mb-0">Diagnose and resolve errors instantly to keep your apps running smoothly</p>
</>
);
}
const PreviewContainer = ({
children,
isFocused,
enablePreview,
setCursorInsidePreview,
isPortalOpen,
previewRef,
showPreview,
onAiSuggestionAccept,
...restProps
}) => {
const {
validationSchema,
isWorkspaceVariable,
errorStateActive,
previewPlacement,
validationFn,
componentId,
paramName,
fieldMeta,
setIsFocused,
currentValue,
} = restProps;
const aiFeaturesEnabled = useStore((state) => state.ai?.aiFeaturesEnabled ?? false);
const fetchErrorFixUsingAi = useStore((state) => state.fetchErrorFixUsingAi);
const clearChatHistory = useStore((state) => state.clearChatHistory);
const componentDefinition = useStore((state) => state.getComponentDefinition(componentId), shallow); // TODO: check if moduleId needs to be passed here
const componentName = componentDefinition?.component?.name;
const componentKey = `${componentName} - ${fieldMeta?.displayName}`;
const chatList = useStore((state) => state.fixWithAiSlice?.[componentId]?.[componentKey]?.chatHistory ?? []);
const [errorMessage, setErrorMessage] = useState(null);
const [popoverToShow, setPopoverToShow] = useState('preview'); // preview | fixWithAI
const errMsg = errorMessage?.message ?? null;
const typeofError = getCurrentNodeType(errMsg);
const errorMsg = typeofError === 'Array' ? errMsg[0] : errMsg;
const darkMode = localStorage.getItem('darkMode') === 'true';
useEffect(() => {
!showPreview && setPopoverToShow('preview');
}, [showPreview]);
useEffect(() => {
setPopoverToShow('preview');
if (chatList?.length) {
clearChatHistory(componentId, componentKey);
}
}, [currentValue]);
const fetchFixUsingAi = () => {
const defaultValue = validationSchema?.defaultValue
? validationSchema?.defaultValue
: validationSchema
? findDefault(validationSchema?.schema ?? {}, errorMessage?.value)
: undefined;
const errorData = {
key: componentKey,
componentId: componentId,
message: errorMessage?.completeErrorMessage,
error: {
resolvedProperty: { [paramName]: errorMessage?.value },
effectiveProperty: { [paramName]: defaultValue },
componentId,
},
};
fetchErrorFixUsingAi(errorData, {
componentDisplayName:
componentDefinition?.component?.displayName ?? componentDefinition?.component?.component ?? componentName,
errorPropertyDisplayName: fieldMeta?.displayName,
customErrMessage: errorMessage?.message,
});
};
const handleFixErrorWithAI = () => {
setPopoverToShow('fixWithAI');
if (!componentId || chatList?.length) {
return;
}
fetchFixUsingAi();
};
const fixWithAIPopover = (
<Popover
bsPrefix="fix-with-ai-popover"
id="popover-basic"
className={`${darkMode && 'dark-theme'} tw-z-[9999] tw-w-96`}
onMouseEnter={() => setCursorInsidePreview(true)}
onMouseLeave={() => setCursorInsidePreview(false)}
>
<Popover.Body
style={{
border: '1px solid var(--slate6)',
padding: 0,
boxShadow: ' 0px 4px 8px 0px #3032331A, 0px 0px 1px 0px #3032330D',
}}
>
<FixWithAi
componentId={componentId}
componentKey={componentKey}
onApplyFix={onAiSuggestionAccept}
onRetry={fetchFixUsingAi}
onClose={() => setIsFocused(false)}
/>
</Popover.Body>
</Popover>
);
const popover = (
<Popover
bsPrefix="codehinter-preview-popover"
id="codehinter-preview-box-popover"
className={`${darkMode && 'dark-theme'}`}
style={{
zIndex: 1400,
}}
onMouseEnter={() => setCursorInsidePreview(true)}
onMouseLeave={() => setCursorInsidePreview(false)}
>
<Popover.Body
style={{
border: !isEmpty(validationSchema) && '1px solid var(--slate6)',
padding: isEmpty(validationSchema) && !validationFn && '0px',
boxShadow: ' 0px 4px 8px 0px #3032331A, 0px 0px 1px 0px #3032330D',
width: '250px',
maxWidth: '350px',
}}
>
<div>
{errorStateActive && (
<div className="mb-2">
<Alert
svg="tj-info-error"
cls={`codehinter preview-alert-banner p-2 mb-0 mt-2 bg-red-lt`}
iconCls="align-items-start"
data-cy={``}
imgHeight={18}
imgWidth={18}
>
<div className="d-flex align-items-center">
<div className="">{errorMsg !== 'null' ? errorMsg : 'Invalid'}</div>
</div>
{aiFeaturesEnabled && (
<ToolTip
placement="left"
message={<FixIssueTooltipContent />}
tooltipClassName="[&_.tooltip-inner]:tw-text-left [&_.tooltip-inner]:tw-p-3"
>
<Button
size="medium"
variant="outline"
leadingIcon="tooljetai"
className="mt-2"
onClick={handleFixErrorWithAI}
>
Auto-fix
</Button>
</ToolTip>
)}
</Alert>
</div>
)}
{!isEmpty(validationSchema) && (
<>
<div className="mb-1">
<span
style={{
fontSize: '11px',
fontWeight: '500',
lineHeight: '16px',
letterSpacing: '0em',
color: '#6A727C',
}}
>
Expected
</span>
</div>
<Card className={darkMode && 'bg-slate2'}>
<Card.Body
className="p-1"
style={{
minHeight: '60px',
maxHeight: '100px',
}}
>
<div className="d-flex flex-column align-content-between flex-wrap p-0">
<div className="p-2">
<span
className={`badge bg-light-gray font-500 mute-text text-capitalize`}
style={{ fontSize: '12px', background: 'var(--interactive-default)' }}
>
{validationSchema?.schema?.type}
</span>
</div>
<PreviewBox.CodeBlock code={validationSchema?.defaultValue} isExpectValue={true} />
</div>
</Card.Body>
</Card>
</>
)}
</div>
<div className={`${!isEmpty(validationSchema) && 'mt-2'}`}>
{!isEmpty(validationSchema) && (
<div className={`mb-1`}>
<span
style={{
fontSize: '11px',
fontWeight: '500',
lineHeight: '16px',
letterSpacing: '0em',
color: '#6A727C',
}}
>
Current
</span>
</div>
)}
<Card
className={darkMode && 'bg-slate2'}
style={{
borderColor: errorStateActive ? 'var(--tomato8)' : 'var(--slate6)',
}}
>
<Card.Body
className="p-1 code-hinter-preview-card-body"
style={{
minHeight: '60px',
maxHeight: '240px',
overflowY: 'auto',
}}
>
<PreviewBox isFocused={isFocused} setErrorMessage={setErrorMessage} {...restProps} />
</Card.Body>
</Card>
</div>
{isWorkspaceVariable && <CodeHinter.DepericatedAlert text={'Deprecating soon'} />}
</Popover.Body>
</Popover>
);
return (
<>
{!isPortalOpen && (
<Overlay
placement={previewPlacement || 'left'}
{...(previewRef?.current ? { target: previewRef.current } : {})}
show={showPreview}
rootClose
shouldUpdatePosition={true}
container={document.body}
popperConfig={{
modifiers: [
{
name: 'flip',
options: {
fallbackPlacements: ['top', 'bottom', 'left', 'right'],
flipVariations: true,
allowedAutoPlacements: ['top', 'bottom'],
boundary: 'viewport',
},
},
{
name: 'preventOverflow',
options: {
enabled: true,
boundary: 'viewport',
altAxis: true,
tether: false,
},
},
{
name: 'offset',
options: {
offset: [0, 3],
},
},
{
name: 'detectViewportOverflowObserver',
enabled: true,
phase: 'write',
effect: ({ state, instance }) => {
const popperEl = state?.elements?.popper;
if (!popperEl || typeof IntersectionObserver === 'undefined') return;
let rafId = null;
const io = new IntersectionObserver(
(entries) => {
const ent = entries[0];
if (!ent) return;
// intersectionRatio < 1 => partially/fully out of viewport
if (ent.intersectionRatio < 1) {
if (rafId) return;
rafId = requestAnimationFrame(() => {
try {
instance.update();
} catch (e) {
/* error */
} finally {
rafId = null;
}
});
}
},
{ threshold: [0, 0.01, 0.5, 1] }
);
io.observe(popperEl);
return () => {
io.disconnect();
if (rafId) cancelAnimationFrame(rafId);
};
},
},
],
onFirstUpdate: (state) => {
// Force position update on first render
// This is done to avoid scroll issue
if (state.elements.popper) {
state.elements.popper.style.position = 'fixed';
}
},
}}
>
{(props) => React.cloneElement(popoverToShow === 'fixWithAI' ? fixWithAIPopover : popover, props)}
</Overlay>
)}
{children}
</>
);
};
const PreviewCodeBlock = ({ code, isExpectValue = false, isLargeDataset }) => {
let preview;
if (typeof code === 'string') {
preview = code.trim();
} else if (typeof code === 'symbol') {
preview = code.toString();
} else {
preview = String(code);
}
const shouldTrim = preview.length > 35;
let showJSONTree = false;
if (isExpectValue && shouldTrim) {
preview = preview.substring(0, 35) + '...' + preview.substring(preview.length - 2, preview.length);
}
let prettyPrintedJson = preview;
try {
prettyPrintedJson = JSON.parse(preview);
const typeOfValue = typeof prettyPrintedJson;
if (typeOfValue === 'object' || typeOfValue === 'array') {
showJSONTree = true;
} else {
prettyPrintedJson = preview;
showJSONTree = false;
}
} catch (e) {
prettyPrintedJson = preview;
showJSONTree = false;
}
if (showJSONTree) {
const darkMode = localStorage.getItem('darkMode') === 'true';
const hasDeepChild = hasDeepChildren(prettyPrintedJson);
return (
<div className="preview-json">
<JsonViewer
value={prettyPrintedJson}
displayDataTypes={false}
displaySize={false}
enableClipboard={false}
rootName={false}
theme={darkMode ? 'dark' : 'light'}
groupArraysAfterLength={hasDeepChild ? 10 : 100}
maxDisplayLength={hasDeepChild ? 10 : 50}
/>
</div>
);
}
return (
<div className="p-2 pt-0">
<pre
className="text-secondary"
style={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflowWrap: 'break-word',
display: 'block',
background: 'transparent',
border: 'none',
lineHeight: '1.5',
maxHeight: 'none',
overflow: 'auto',
width: '100%',
fontSize: '12px',
overflowY: 'auto',
padding: '0',
margin: '0',
}}
>
{prettyPrintedJson?.startsWith('{{') && prettyPrintedJson?.endsWith('{{')
? prettyPrintedJson?.replace(/{{/g, '').replace(/}}/g, '')
: prettyPrintedJson}
</pre>
</div>
);
};
PreviewBox.RenderResolvedValue = RenderResolvedValue;
PreviewBox.Container = PreviewContainer;
PreviewBox.CodeBlock = PreviewCodeBlock;