/* eslint-disable import/no-unresolved */ import React, { useEffect, useMemo, useRef, useState, useContext } from 'react'; import { PreviewBox } from './PreviewBox'; import { ToolTip } from '@/Editor/Inspector/Elements/Components/ToolTip'; import { useTranslation } from 'react-i18next'; import { camelCase, isEmpty, noop, get } from 'lodash'; import CodeMirror from '@uiw/react-codemirror'; import { javascript } from '@codemirror/lang-javascript'; import { autocompletion, completionKeymap, completionStatus, acceptCompletion, startCompletion, } from '@codemirror/autocomplete'; import { defaultKeymap } from '@codemirror/commands'; import { keymap } from '@codemirror/view'; import FxButton from '../CodeBuilder/Elements/FxButton'; import cx from 'classnames'; import { DynamicFxTypeRenderer } from './DynamicFxTypeRenderer'; import { isInsideParent, resolveReferences } from './utils'; import { okaidia } from '@uiw/codemirror-theme-okaidia'; import { githubLight } from '@uiw/codemirror-theme-github'; import { getAutocompletion } from './autocompleteExtensionConfig'; import ErrorBoundary from '@/_ui/ErrorBoundary'; import CodeHinter from './CodeHinter'; // import { EditorContext } from '../Context/EditorContextWrapper'; import { removeNestedDoubleCurlyBraces } from '@/_helpers/utils'; import useStore from '@/AppBuilder/_stores/store'; import { shallow } from 'zustand/shallow'; import { getCssVarValue } from '@/Editor/Components/utils'; import { useModuleContext } from '@/AppBuilder/_contexts/ModuleContext'; import { CodeHinterContext } from '../CodeBuilder/CodeHinterContext'; import { createReferencesLookup } from '@/_stores/utils'; import { useQueryPanelKeyHooks } from './useQueryPanelKeyHooks'; import Icon from '@/_ui/Icon/solidIcons/index'; const SingleLineCodeEditor = ({ componentName, fieldMeta = {}, componentId, ...restProps }) => { const { moduleId } = useModuleContext(); const { initialValue, onChange, enablePreview = true, portalProps, paramName } = restProps; const { validation = {} } = fieldMeta; const [showPreview, setShowPreview] = useState(false); const [isFocused, setIsFocused] = useState(false); const [currentValue, setCurrentValue] = useState(''); const [errorStateActive, setErrorStateActive] = useState(false); const [cursorInsidePreview, setCursorInsidePreview] = useState(false); const [showSuggestions, setShowSuggestions] = useState(true); const validationFn = restProps?.validationFn; const componentDefinition = useStore((state) => state.getComponentDefinition(componentId, moduleId), shallow); const parentId = componentDefinition?.component?.parent; const customResolvables = useStore((state) => state.resolvedStore.modules.canvas?.customResolvables, shallow); const customVariables = customResolvables?.[parentId]?.[0] || {}; useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { if (entry.intersectionRatio < 1) { setShowPreview(false); setShowSuggestions(false); } else { setShowSuggestions(true); } }, { root: null, threshold: [1] } // Fires when any part of the element is out of view ); if (wrapperRef.current) { observer.observe(wrapperRef.current); } return () => { if (wrapperRef.current) { observer.unobserve(wrapperRef.current); } }; }, []); const isPreviewFocused = useRef(false); const wrapperRef = useRef(null); const replaceIdsWithName = useStore((state) => state.replaceIdsWithName, shallow); let newInitialValue = initialValue; if (typeof initialValue === 'string' && (initialValue?.includes('components') || initialValue?.includes('queries'))) { newInitialValue = replaceIdsWithName(initialValue); } //! Re render the component when the componentName changes as the initialValue is not updated // const { variablesExposedForPreview } = useContext(EditorContext) || {}; // const customVariables = variablesExposedForPreview?.[componentId] ?? {}; useEffect(() => { if (typeof newInitialValue !== 'string') return; // const [valid, _error] = !isEmpty(validation) // ? resolveReferences(newInitialValue, validation, customVariables) // : [true, null]; //!TODO use the updated new resolver const [valid, _error] = !isEmpty(validation) || validationFn ? resolveReferences(newInitialValue, validation, customVariables, validationFn) : [true, null]; setErrorStateActive(!valid); setCurrentValue(newInitialValue); // eslint-disable-next-line react-hooks/exhaustive-deps }, [componentName, newInitialValue, validationFn]); useEffect(() => { const handleClickOutside = (event) => { if (cursorInsidePreview || portalProps?.isOpen || event.target.closest('.cm-tooltip-autocomplete')) { return; } if (wrapperRef.current && isFocused && !wrapperRef.current.contains(event.target)) { isPreviewFocused.current = false; setIsFocused(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [wrapperRef, isFocused, isPreviewFocused, currentValue, portalProps?.isOpen, cursorInsidePreview]); const isWorkspaceVariable = typeof currentValue === 'string' && (currentValue.includes('%%client') || currentValue.includes('%%server')); const previewRef = useRef(null); return (
{ setCurrentValue(newValue); onChange(newValue); }} >
); }; const EditorInput = ({ currentValue, setCurrentValue, setFocus, validationType, onBlurUpdate, placeholder = '', error, cyLabel = '', componentName, usePortalEditor = true, renderPreview, portalProps, lang, isFocused, componentId, type, delayOnChange = true, // Added this prop to immediately update the onBlurUpdate callback paramLabel = '', disabled = false, previewRef, setShowPreview, onInputChange, wrapperRef, showSuggestions, setCodeEditorView = null, // Function to set the CodeMirror view cursorInsidePreview = false, }) => { const codeHinterContext = useContext(CodeHinterContext); const { suggestionList: paramHints } = createReferencesLookup(codeHinterContext, true); const getSuggestions = useStore((state) => state.getSuggestions, shallow); const [codeMirrorView, setCodeMirrorView] = useState(undefined); const getServerSideGlobalResolveSuggestions = useStore( (state) => state.getServerSideGlobalResolveSuggestions, shallow ); const { queryPanelKeybindings } = useQueryPanelKeyHooks(onBlurUpdate, currentValue, 'singleline'); const isInsideQueryManager = useMemo( () => isInsideParent(wrapperRef?.current, 'query-manager'), [wrapperRef.current] ); function autoCompleteExtensionConfig(context) { const hintsWithoutParamHints = getSuggestions(); const serverHints = getServerSideGlobalResolveSuggestions(isInsideQueryManager); let word = context.matchBefore(/\w*/); const hints = { ...hintsWithoutParamHints, appHints: [...hintsWithoutParamHints.appHints, ...serverHints, ...paramHints], }; const totalReferences = (context.state.doc.toString().match(/{{/g) || []).length; let queryInput = context.state.doc.toString(); const originalQueryInput = queryInput; if (totalReferences > 0) { const currentCursor = context.state.selection.main.head; const currentCursorPos = context.pos; let currentWord = queryInput.substring(currentCursor, currentCursorPos); if (currentWord?.length === 0) { const lastBracesFromPos = queryInput.lastIndexOf('{{', currentCursorPos); currentWord = queryInput.substring(lastBracesFromPos, currentCursorPos); //remove curly braces from the current word as will append it later currentWord = removeNestedDoubleCurlyBraces(currentWord); } if (currentWord.includes(' ')) { currentWord = currentWord.split(' ').pop(); } // remove \n from the current word if it is present currentWord = currentWord.replace(/\n/g, ''); queryInput = '{{' + currentWord + '}}'; } let completions = getAutocompletion(queryInput, validationType, hints, totalReferences, originalQueryInput); return { from: word.from, options: completions, validFor: /^\{\{.*\}\}$/, filter: false, }; } // eslint-disable-next-line react-hooks/exhaustive-deps const overRideFunction = React.useCallback( (context) => autoCompleteExtensionConfig(context), [isInsideQueryManager, paramHints] ); const autoCompleteConfig = autocompletion({ override: [overRideFunction], compareCompletions: (a, b) => { return a.section.rank - b.section.rank && a.label.localeCompare(b.label); }, aboveCursor: false, defaultKeymap: true, positionInfo: () => { return { class: 'cm-completionInfo-top cm-custom-completion-info cm-custom-singleline-completion-info', }; }, maxRenderedOptions: 10, }); const customKeyMaps = [ ...defaultKeymap.filter((keyBinding) => keyBinding.key !== 'Mod-Enter'), // Remove default keybinding for Mod-Enter ...completionKeymap, ]; const customTabKeymap = keymap.of([ { key: 'Tab', run: (view) => { if (completionStatus(view.state)) { return acceptCompletion(view); } if (isOpen) { const { state } = view; const { selection } = state; const { anchor } = selection.main; const tabSize = 2; view?.dispatch({ changes: { from: anchor, insert: ' '.repeat(tabSize) }, selection: { anchor: anchor + tabSize }, }); return true; } }, }, ...queryPanelKeybindings, ]); const handleOnChange = React.useCallback((val) => { setCurrentValue(val); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const handleOnBlur = () => { !cursorInsidePreview && setShowPreview(false); if (!delayOnChange) { setFirstTimeFocus(false); return onBlurUpdate(currentValue); } setTimeout(() => { setFirstTimeFocus(false); onBlurUpdate(currentValue); }, 0); }; const darkMode = localStorage.getItem('darkMode') === 'true'; const theme = darkMode ? okaidia : githubLight; const { handleTogglePopupExapand, isOpen, setIsOpen, forceUpdate } = portalProps; // when full screen editor is closed, show the preview box useEffect(() => { if (isFocused && !isOpen) { setShowPreview(true); } }, [isOpen, isFocused]); const [firstTimeFocus, setFirstTimeFocus] = useState(false); const currentEditorHeightRef = useRef(null); const isInsideQueryPane = !!currentEditorHeightRef?.current?.closest('.query-details'); const showLineNumbers = lang == 'jsx' || type === 'extendedSingleLine' || false; const customClassNames = cx('codehinter-input single-line-codehinter-input', { 'border-danger': error, focused: isFocused, 'focus-box-shadow-active': firstTimeFocus, 'widget-code-editor': componentId, 'disabled-pointerevents': disabled, 'code-editor-query-panel': isInsideQueryPane, 'show-line-numbers': showLineNumbers, }); const handleFocus = () => { setFirstTimeFocus(true); setTimeout(() => { setFocus(true); }, 50); }; // in query panel we are allowing code editor to have dynamic height, this observer is to show/hide preview box based on the visibility of the editor useEffect(() => { if (!isInsideQueryPane) return; const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting && isFocused) { setShowPreview(true); } else { setShowPreview(false); } }, { root: document.querySelector('.query-details'), threshold: 0.01, } ); if (currentEditorHeightRef.current) { observer.observe(currentEditorHeightRef.current); } return () => { if (currentEditorHeightRef.current) { observer.unobserve(currentEditorHeightRef.current); } }; }, [isInsideQueryPane, isFocused]); cyLabel = paramLabel ? paramLabel.toLowerCase().trim().replace(/\s+/g, '-') : cyLabel; return (
{/* sticky element to position the preview box correctly on top without flowing out of container */} {usePortalEditor && ( )}
{ setCodeMirrorView(view); if (setCodeEditorView) { setCodeEditorView(view); } }} value={currentValue} placeholder={placeholder} height={isInsideQueryPane ? '100%' : showLineNumbers ? '400px' : '100%'} width="100%" extensions={ showSuggestions ? [ javascript({ jsx: lang === 'jsx' }), autoCompleteConfig, keymap.of([...customKeyMaps]), customTabKeymap, ] : [javascript({ jsx: lang === 'jsx' })] } onChange={(val) => { setFirstTimeFocus(false); handleOnChange(val); onInputChange && onInputChange(val); }} basicSetup={{ lineNumbers: showLineNumbers, syntaxHighlighting: true, bracketMatching: true, foldGutter: false, highlightActiveLine: false, autocompletion: true, defaultKeymap: false, completionKeymap: true, searchKeymap: false, }} onMouseDown={() => handleFocus()} onBlur={() => handleOnBlur()} className={customClassNames} theme={theme} indentWithTab={false} readOnly={disabled} onKeyDown={(event) => { if (event.key === 'Backspace') { startCompletion(codeMirrorView); } }} />
); }; const DynamicEditorBridge = (props) => { const { initialValue, type, fxActive, paramType = 'code', paramLabel, paramName, fieldMeta, darkMode, className, onFxPress = noop, onChange, styleDefinition, component, onVisibilityChange, isEventManagerParam = false, } = props; const [forceCodeBox, setForceCodeBox] = React.useState(fxActive); const codeShow = paramType === 'code' || forceCodeBox; const HIDDEN_CODE_HINTER_LABELS = ['Table data', 'Column data', 'Text Format', 'Slider type']; const { isFxNotRequired, newLine = false, section = '' } = fieldMeta; const isDeprecated = section === 'deprecated'; const { t } = useTranslation(); const replaceIdsWithName = useStore((state) => state.replaceIdsWithName, shallow); let newInitialValue = initialValue, shouldResolve = true; // This is to handle the case when the initial value is a string and contains components or queries // and we need to replace the ids with names // but we don't want to resolve the references as it needs to be displayed as it is if (paramName === 'generateFormFrom') { if ( typeof initialValue === 'string' && (initialValue?.includes('components') || initialValue?.includes('queries')) ) { newInitialValue = replaceIdsWithName(initialValue); shouldResolve = false; } } const [_, error, value] = type === 'fxEditor' ? (shouldResolve ? resolveReferences(newInitialValue) : [false, '', newInitialValue]) : []; let cyLabel = paramLabel ? paramLabel.toLowerCase().trim().replace(/\s+/g, '-') : props.cyLabel; useEffect(() => { setForceCodeBox(fxActive); }, [component, fxActive]); let modifiedValue = initialValue; if (paramType === 'colorSwatches' && typeof initialValue === 'string' && initialValue?.includes('var(')) { modifiedValue = getCssVarValue(document.documentElement, initialValue); } const renderFx = () => { if (paramType === 'query' || !(paramLabel !== 'Type' && isFxNotRequired === undefined)) { return null; } return (
{ if (codeShow) { setForceCodeBox(false); onFxPress(false); if (paramType === 'colorSwatches') { onChange(modifiedValue); } } else { setForceCodeBox(true); onFxPress(true); } }} dataCy={cyLabel} />
); }; const fxClass = isEventManagerParam ? 'justify-content-start' : 'justify-content-end'; const renderedLabel = () => { return ( <> {paramLabel !== ' ' && !HIDDEN_CODE_HINTER_LABELS.includes(paramLabel) && (
{isDeprecated && ( )}
)} ); }; const renderDynamicFx = () => { if (codeShow) return null; return ( { setForceCodeBox(true); onFxPress(true); }} meta={fieldMeta} cyLabel={cyLabel} styleDefinition={styleDefinition} component={component} onVisibilityChange={onVisibilityChange} /> ); }; return (
{renderedLabel()}
{renderFx()}
{!newLine && renderDynamicFx()}
{newLine && renderDynamicFx()} {codeShow && (
)}
); }; SingleLineCodeEditor.Editor = EditorInput; SingleLineCodeEditor.EditorBridge = DynamicEditorBridge; export default SingleLineCodeEditor;