diff --git a/package.json b/package.json index feeb33faac..0d7f422c0c 100644 --- a/package.json +++ b/package.json @@ -251,6 +251,7 @@ "react-jsx-parser": "^1.28.4", "react-monaco-editor": "^0.44.0", "react-redux": "^7.2.2", + "react-rnd": "^10.3.5", "react-router-dom": "^5.2.0", "react-virtualized": "^9.22.2", "rehype-stringify": "^9.0.2", diff --git a/redisinsight/ui/src/components/query/DedicatedEditor/DedicatedEditor.tsx b/redisinsight/ui/src/components/query/DedicatedEditor/DedicatedEditor.tsx new file mode 100644 index 0000000000..fe9a5dc0f1 --- /dev/null +++ b/redisinsight/ui/src/components/query/DedicatedEditor/DedicatedEditor.tsx @@ -0,0 +1,231 @@ +import React, { useEffect, useRef } from 'react' +import { compact, findIndex } from 'lodash' +import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api' +import MonacoEditor, { monaco } from 'react-monaco-editor' +import { Rnd } from 'react-rnd' +import cx from 'classnames' +import { EuiButtonIcon } from '@elastic/eui' + +import { + DSL, + DSLNaming, + MonacoLanguage, + MonacoSyntaxLang, +} from 'uiSrc/constants' +import { + decoration, + getMonacoAction, + MonacoAction, + Nullable, + toModelDeltaDecoration +} from 'uiSrc/utils' +import { IEditorMount, ISnippetController } from 'uiSrc/pages/workbench/interfaces' +import { getCypherCompletionProvider } from 'uiSrc/utils/monaco/cypher/completionProvider' +import { + cypherLanguageConfiguration, +} from 'uiSrc/constants/monaco/cypher' +import { getCypherMonarchTokensProvider } from 'uiSrc/utils/monaco/cypher/monarchTokensProvider' + +import styles from './styles.module.scss' + +export interface Props { + value: string + lang: string + onSubmit: (query?: string) => void + onCancel: () => void + onKeyDown?: (e: React.KeyboardEvent, script: string) => void + width: number +} + +const langs: MonacoSyntaxLang = { + [DSL.cypher]: { + name: DSLNaming[DSL.cypher], + id: MonacoLanguage.Cypher, + config: cypherLanguageConfiguration, + completionProvider: getCypherCompletionProvider, + tokensProvider: getCypherMonarchTokensProvider + } +} +let decorations: string[] = [] + +const DedicatedEditor = (props: Props) => { + const { width, value = '', lang, onCancel, onSubmit } = props + const selectedLang = langs[lang] + let contribution: Nullable = null + const monacoObjects = useRef>(null) + let disposeCompletionItemProvider = () => {} + + useEffect(() => + // componentWillUnmount + () => { + contribution?.dispose?.() + disposeCompletionItemProvider() + }, + []) + + useEffect(() => { + if (!monacoObjects.current) return + const commands = value.split('\n') + const { monaco, editor } = monacoObjects.current + const notCommandRegEx = /^\s|\/\// + + const newDecorations = compact(commands.map((command, index) => { + if (!command || notCommandRegEx.test(command)) return null + const lineNumber = index + 1 + + return toModelDeltaDecoration( + decoration(monaco, `decoration_${lineNumber}`, lineNumber, 1, lineNumber, 1) + ) + })) + + decorations = editor.deltaDecorations( + decorations, + newDecorations + ) + }, [value]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + onCancel() + } + } + + const handleSubmit = () => { + const { editor } = monacoObjects?.current || {} + onSubmit(editor?.getValue() || '') + } + + const onKeyDownMonaco = (e: monacoEditor.IKeyboardEvent) => { + // trigger parameter hints + if (e.keyCode === monaco.KeyCode.Enter || e.keyCode === monaco.KeyCode.Space) { + onExitSnippetMode() + } + } + + const onExitSnippetMode = () => { + if (!monacoObjects.current) return + const { editor } = monacoObjects?.current + + if (contribution?.isInSnippet?.()) { + const { lineNumber = 0, column = 0 } = editor?.getPosition() ?? {} + editor.setSelection(new monaco.Selection(lineNumber, column, lineNumber, column)) + contribution?.cancel?.() + } + } + + const editorDidMount = ( + editor: monacoEditor.editor.IStandaloneCodeEditor, + monaco: typeof monacoEditor + ) => { + monacoObjects.current = { editor, monaco } + + // hack for exit from snippet mode after click Enter until no answer from monaco authors + // https://github.com/microsoft/monaco-editor/issues/2756 + contribution = editor.getContribution('snippetController2') + + editor.focus() + + editor.onKeyDown(onKeyDownMonaco) + + setupMonacoLang(monaco) + editor.addAction( + getMonacoAction(MonacoAction.Submit, () => handleSubmit(), monaco) + ) + } + + const setupMonacoLang = (monaco: typeof monacoEditor) => { + const languages = monaco.languages.getLanguages() + + const selectedLang = langs[lang] + if (!selectedLang) return + + const isLangRegistered = findIndex(languages, { id: selectedLang.id }) > -1 + if (!isLangRegistered) { + monaco.languages.register({ id: selectedLang.id }) + } + + monaco.languages.setLanguageConfiguration(selectedLang.id, selectedLang.config) + + disposeCompletionItemProvider = monaco.languages.registerCompletionItemProvider( + selectedLang.id, + selectedLang.completionProvider() + ).dispose + + monaco.languages.setMonarchTokensProvider( + selectedLang.id, + selectedLang.tokensProvider() + ) + } + + const options: monacoEditor.editor.IStandaloneEditorConstructionOptions = { + tabCompletion: 'on', + wordWrap: 'on', + padding: { top: 10 }, + automaticLayout: true, + formatOnPaste: false, + suggest: { + preview: false, + showStatusBar: false, + showIcons: true, + }, + minimap: { + enabled: false + }, + overviewRulerLanes: 0, + hideCursorInOverviewRuler: true, + overviewRulerBorder: false, + lineNumbersMinChars: 4 + } + + return ( + +
+
+
+ +
+
+ { selectedLang.name } +
+ + +
+
+
+ + ) +} + +export default React.memo(DedicatedEditor) diff --git a/redisinsight/ui/src/components/query/DedicatedEditor/styles.module.scss b/redisinsight/ui/src/components/query/DedicatedEditor/styles.module.scss new file mode 100644 index 0000000000..fb16d13feb --- /dev/null +++ b/redisinsight/ui/src/components/query/DedicatedEditor/styles.module.scss @@ -0,0 +1,87 @@ +.rnd { + position: fixed; + z-index: 100; +} +.container { + height: 100%; + word-break: break-word; + text-align: left; + letter-spacing: 0; + background-color: var(--monacoBgColor); + color: var(--euiTextSubduedColor) !important; + border: 1px solid var(--euiColorPrimary); + border-radius: 4px; + padding-left: 6px; + padding-right: 6px; + box-shadow: 0 5px 15px var(--controlsBoxShadowColor); +} + +.containerPlaceholder { + display: flex; + background-color: var(--monacoBgColor); + color: var(--euiTextSubduedColor) !important; + border: 1px solid var(--euiColorLightShade); + border-radius: 4px; + overflow: hidden; + > div { + border: 1px solid var(--euiColorLightShade); + background-color: var(--euiColorEmptyShade); + padding: 8px 20px; + width: 100%; + } +} + +.input { + height: calc(100% - 46px); + width: 100%; + background-color: var(--rsInputColor); +} + +#script { + font: normal normal bold 14px/17px Inconsolata !important; + color: var(--textColorShade); + caret-color: var(--euiColorFullShade); + min-width: 5px; + display: inline; +} + +:global(.draggable-area) { + height: 20px; + width: 100%; + cursor: grab; + background-color: var(--monacoBgColor); + border-radius: 4px 4px 0 0; +} + +.actions { + height: 26px; + width: 100%; + display: flex; + align-items: center; + padding: 6px 12px; + background-color: var(--monacoBgColor); + border-radius: 0 0 4px 4px; + justify-content: space-between; +} + +.declineBtn:hover { + color: var(--euiColorColorDanger) !important; +} + +.applyBtn { + margin-left: 6px; + &:hover { + color: var(--euiColorPrimary) !important; + } +} + +.submitButton { + color: var(--rsSubmitBtn) !important; + width: 44px !important; + height: 44px !important; + + svg { + width: 24px; + height: 24px; + } +} diff --git a/redisinsight/ui/src/components/query/Query/Query.tsx b/redisinsight/ui/src/components/query/Query/Query.tsx index c5c8e76092..86f05389ce 100644 --- a/redisinsight/ui/src/components/query/Query/Query.tsx +++ b/redisinsight/ui/src/components/query/Query/Query.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useRef } from 'react' +import React, { useContext, useEffect, useRef, useState } from 'react' import { useSelector } from 'react-redux' import { compact, findIndex } from 'lodash' import cx from 'classnames' @@ -11,10 +11,14 @@ import { MonacoLanguage, redisLanguageConfig, KEYBOARD_SHORTCUTS, + DSLNaming, } from 'uiSrc/constants' import { actionTriggerParameterHints, + createSyntaxWidget, decoration, + findArgIndexByCursor, + findCompleteQuery, getMonacoAction, getRedisCompletionProvider, getRedisMonarchTokensProvider, @@ -28,8 +32,11 @@ import { ThemeContext } from 'uiSrc/contexts/themeContext' import { appRedisCommandsSelector } from 'uiSrc/slices/app/redis-commands' import { IEditorMount, ISnippetController } from 'uiSrc/pages/workbench/interfaces' import { CommandExecutionUI } from 'uiSrc/slices/interfaces' +import { darkTheme, lightTheme } from 'uiSrc/constants/monaco/cypher' import { workbenchResultsSelector } from 'uiSrc/slices/workbench/wb-results' +import DedicatedEditor from 'uiSrc/components/query/DedicatedEditor/DedicatedEditor' + import styles from './styles.module.scss' export interface Props { @@ -37,17 +44,28 @@ export interface Props { loading: boolean setQueryEl: Function setQuery: (script: string) => void + setIsCodeBtnDisabled: (value: boolean) => void onSubmit: (query?: string) => void onKeyDown?: (e: React.KeyboardEvent, script: string) => void } +const SYNTAX_CONTEXT_ID = 'syntaxWidgetContext' +const argInQuotesRegExp = /^['"](.|[\r\n])*['"]$/ let decorations: string[] = [] let execHistoryPos: number = 0 let execHistory: CommandExecutionUI[] = [] const Query = (props: Props) => { - const { query = '', setQuery, onKeyDown, onSubmit, setQueryEl } = props + const { query = '', setQuery, onKeyDown, onSubmit, setQueryEl, setIsCodeBtnDisabled = () => {} } = props let contribution: Nullable = null + const [isDedicatedEditorOpen, setIsDedicatedEditorOpen] = useState(false) + const isWidgetOpen = useRef(false) + const input = useRef(null) + const isWidgetEscaped = useRef(false) + const selectedArg = useRef('') + const syntaxCommand = useRef(null) + const isDedicatedEditorOpenRef = useRef(isDedicatedEditorOpen) + let syntaxWidgetContext: Nullable> = null const { commandsArray: REDIS_COMMANDS_ARRAY, spec: REDIS_COMMANDS_SPEC } = useSelector(appRedisCommandsSelector) const { items: execHistoryItems } = useSelector(workbenchResultsSelector) @@ -93,6 +111,11 @@ const Query = (props: Props) => { ) }, [query]) + useEffect(() => { + setIsCodeBtnDisabled(isDedicatedEditorOpen) + isDedicatedEditorOpenRef.current = isDedicatedEditorOpen + }, [isDedicatedEditorOpen]) + const onChange = (value: string = '') => { setQuery(value) @@ -122,11 +145,22 @@ const Query = (props: Props) => { // trigger parameter hints only ones between command and arguments in the same line const isTriggerHints = lineContent.split(' ').length < (2 + matchedCommand.split(' ').length) - if (isTriggerHints) { + if (isTriggerHints && !isWidgetOpen.current) { actionTriggerParameterHints(editor) } } + const onTriggerContentWidget = (position: Nullable, language: string = ''): monaco.editor.IContentWidget => ({ + getId: () => 'syntax.content.widget', + getDomNode: () => createSyntaxWidget(`Use ${language} Editor`, 'Shift+Space'), + getPosition: () => ({ + position, + preference: [ + monaco.editor.ContentWidgetPositionPreference.BELOW + ] + }) + }) + const onQuickHistoryAccess = () => { if (!monacoObjects.current) return const { editor } = monacoObjects?.current @@ -164,6 +198,56 @@ const Query = (props: Props) => { } } + const onKeyChangeCursorMonaco = (e: monaco.editor.ICursorPositionChangedEvent) => { + if (!monacoObjects.current) return + const { editor } = monacoObjects?.current + const model = editor.getModel() + + isWidgetOpen.current && hideSyntaxWidget(editor) + + if (!model || isDedicatedEditorOpenRef.current) { + return + } + + const command = findCompleteQuery(model, e.position, REDIS_COMMANDS_SPEC, REDIS_COMMANDS_ARRAY) + if (!command) { + isWidgetEscaped.current = false + return + } + + const queryArgIndex = command.info?.arguments?.findIndex((arg) => arg.dsl) || -1 + const cursorPosition = command.commandCursorPosition || 0 + if (!command.args?.length || queryArgIndex < 0) { + isWidgetEscaped.current = false + return + } + + const argIndex = findArgIndexByCursor(command.args, command.fullQuery, cursorPosition) + if (argIndex === null) { + isWidgetEscaped.current = false + return + } + + const queryArg = command.args[argIndex] + const argDSL = command.info?.arguments?.[argIndex]?.dsl || '' + if (!argIndex) { + isWidgetEscaped.current = false + return + } + + if (queryArgIndex === argIndex && argInQuotesRegExp.test(queryArg)) { + if (isWidgetEscaped.current) return + const lang = DSLNaming[argDSL] ?? null + lang && showSyntaxWidget(editor, e.position, lang) + selectedArg.current = queryArg + syntaxCommand.current = { + ...command, + lang: argDSL, + argToReplace: queryArg + } + } + } + const onExitSnippetMode = () => { if (!monacoObjects.current) return const { editor } = monacoObjects?.current @@ -175,6 +259,59 @@ const Query = (props: Props) => { } } + const hideSyntaxWidget = (editor: monacoEditor.editor.IStandaloneCodeEditor) => { + editor.removeContentWidget(onTriggerContentWidget(null)) + syntaxWidgetContext?.set(false) + isWidgetOpen.current = false + } + + const showSyntaxWidget = ( + editor: monacoEditor.editor.IStandaloneCodeEditor, + position: monacoEditor.Position, + language: string + ) => { + editor.addContentWidget(onTriggerContentWidget(position, language)) + isWidgetOpen.current = true + syntaxWidgetContext?.set(true) + } + + const onCancelDedicatedEditor = () => { + setIsDedicatedEditorOpen(false) + if (!monacoObjects.current) return + const { editor } = monacoObjects?.current + + editor.updateOptions({ readOnly: false }) + } + + const updateArgFromDedicatedEditor = (value: string = '') => { + if (syntaxCommand.current) { + if (!monacoObjects.current) return + const { editor } = monacoObjects?.current + + const model = editor.getModel() + if (!model) return + + const wrapQuote = syntaxCommand.current.argToReplace[0] + const replaceCommand = syntaxCommand.current.fullQuery.replace( + syntaxCommand.current.argToReplace, + `${wrapQuote}${value}${wrapQuote}` + ) + editor.updateOptions({ readOnly: false }) + editor.executeEdits(null, [ + { + range: new monaco.Range( + syntaxCommand.current.commandPosition.startLine, + 0, + syntaxCommand.current.commandPosition.endLine, + model.getLineLength(syntaxCommand.current.commandPosition.endLine) + 1 + ), + text: replaceCommand + } + ]) + setIsDedicatedEditorOpen(false) + } + } + const editorDidMount = ( editor: monacoEditor.editor.IStandaloneCodeEditor, monaco: typeof monacoEditor @@ -185,15 +322,28 @@ const Query = (props: Props) => { // https://github.com/microsoft/monaco-editor/issues/2756 contribution = editor.getContribution('snippetController2') + syntaxWidgetContext = editor.createContextKey(SYNTAX_CONTEXT_ID, false) editor.focus() setQueryEl(editor) editor.onKeyDown(onKeyDownMonaco) + editor.onDidChangeCursorPosition(onKeyChangeCursorMonaco) setupMonacoRedisLang(monaco) editor.addAction( getMonacoAction(MonacoAction.Submit, (editor) => handleSubmit(editor.getValue()), monaco) ) + + editor.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Space, () => { + setIsDedicatedEditorOpen(true) + editor.updateOptions({ readOnly: true }) + hideSyntaxWidget(editor) + }, SYNTAX_CONTEXT_ID) + + editor.addCommand(monaco.KeyCode.Escape, () => { + hideSyntaxWidget(editor) + isWidgetEscaped.current = true + }, SYNTAX_CONTEXT_ID) } const setupMonacoRedisLang = (monaco: typeof monacoEditor) => { @@ -209,7 +359,7 @@ const Query = (props: Props) => { disposeSignatureHelpProvider = monaco.languages.registerSignatureHelpProvider( MonacoLanguage.Redis, - getRedisSignatureHelpProvider(REDIS_COMMANDS_SPEC, REDIS_COMMANDS_ARRAY) + getRedisSignatureHelpProvider(REDIS_COMMANDS_SPEC, REDIS_COMMANDS_ARRAY, isWidgetOpen) ).dispose monaco.languages.setLanguageConfiguration(MonacoLanguage.Redis, redisLanguageConfig) @@ -234,45 +384,61 @@ const Query = (props: Props) => { lineNumbersMinChars: 4 } + if (monaco?.editor) { + monaco.editor.defineTheme('dark', darkTheme) + monaco.editor.defineTheme('light', lightTheme) + } + return ( -
-
- -
-
- - {`${KEYBOARD_SHORTCUTS.workbench.runQuery?.label}:\u00A0\u00A0`} - -
- ) - } - > - handleSubmit()} - iconType="playFilled" - className={cx(styles.submitButton)} - aria-label="submit" - data-testid="btn-submit" + <> +
+
+ - +
+
+ + {`${KEYBOARD_SHORTCUTS.workbench.runQuery?.label}:\u00A0\u00A0`} + +
+ ) + } + > + handleSubmit()} + iconType="playFilled" + className={cx(styles.submitButton)} + aria-label="submit" + data-testid="btn-submit" + /> + +
-
+ {isDedicatedEditorOpen && ( + + )} + ) } -export default Query +export default React.memo(Query) diff --git a/redisinsight/ui/src/components/query/QueryWrapper.tsx b/redisinsight/ui/src/components/query/QueryWrapper.tsx index cee857fabd..7702da0085 100644 --- a/redisinsight/ui/src/components/query/QueryWrapper.tsx +++ b/redisinsight/ui/src/components/query/QueryWrapper.tsx @@ -15,11 +15,12 @@ export interface Props { loading: boolean setQuery: (script: string) => void setQueryEl: Function + setIsCodeBtnDisabled: (value: boolean) => void onKeyDown?: (e: React.KeyboardEvent, script: string) => void onSubmit: (value?: string) => void } const QueryWrapper = (props: Props) => { - const { query = '', loading, setQuery, setQueryEl, onKeyDown, onSubmit } = props + const { query = '', loading, setQuery, setQueryEl, setIsCodeBtnDisabled, onKeyDown, onSubmit } = props const { instanceId = '' } = useParams<{ instanceId: string }>() const { loading: isCommandsLoading, @@ -72,10 +73,11 @@ const QueryWrapper = (props: Props) => { loading={loading} setQuery={setQuery} setQueryEl={setQueryEl} + setIsCodeBtnDisabled={setIsCodeBtnDisabled} onKeyDown={onKeyDown} onSubmit={handleSubmit} /> ) } -export default QueryWrapper +export default React.memo(QueryWrapper) diff --git a/redisinsight/ui/src/constants/commands.ts b/redisinsight/ui/src/constants/commands.ts index 082741fc0b..b328764fdb 100644 --- a/redisinsight/ui/src/constants/commands.ts +++ b/redisinsight/ui/src/constants/commands.ts @@ -20,6 +20,7 @@ export interface ICommandArg { command?: string; multiple?: boolean; variadic?: boolean; + dsl?: string; } export interface ICommandArgGenerated extends ICommandArg { @@ -102,3 +103,17 @@ export enum CommandRSSearchArgument { WithPayloads = 'WITHPAYLOADS', WithSortKeys = 'WITHSORTKEYS', } + +export enum DSL { + cypher = 'cypher', + lua = 'lua' +} + +export interface IDSLNaming { + [key: string]: string +} + +export const DSLNaming: IDSLNaming = { + [DSL.cypher]: 'Cypher', + [DSL.lua]: 'Lua' +} diff --git a/redisinsight/ui/src/constants/monaco.ts b/redisinsight/ui/src/constants/monaco.ts index 68140fb489..71f52f1789 100644 --- a/redisinsight/ui/src/constants/monaco.ts +++ b/redisinsight/ui/src/constants/monaco.ts @@ -1,5 +1,18 @@ +import * as monacoEditor from 'monaco-editor' + +export interface MonacoSyntaxLang { + [key: string]: { + name: string + id: string + config: monacoEditor.languages.LanguageConfiguration, + completionProvider: () => monacoEditor.languages.CompletionItemProvider, + tokensProvider: () => monacoEditor.languages.IMonarchLanguage + } +} + export enum MonacoLanguage { Redis = 'redisLanguage', + Cypher = 'cypherLanguage' } export const MONACO_MANUAL = '// Workbench is the advanced Redis command-line interface that allows to send commands to Redis, read and visualize the replies sent by the server.\n' diff --git a/redisinsight/ui/src/constants/monaco/cypher/functions.ts b/redisinsight/ui/src/constants/monaco/cypher/functions.ts new file mode 100644 index 0000000000..8d9f9081e2 --- /dev/null +++ b/redisinsight/ui/src/constants/monaco/cypher/functions.ts @@ -0,0 +1,407 @@ +export default [ + { + name: 'all', + signature: '(variable :: VARIABLE IN list :: LIST OF ANY? WHERE predicate :: ANY?) :: (BOOLEAN?)', + description: 'Returns true if the predicate holds for all elements in the given list.' + }, + { + name: 'any', + signature: '(variable :: VARIABLE IN list :: LIST OF ANY? WHERE predicate :: ANY?) :: (BOOLEAN?)', + description: 'Returns true if the predicate holds for at least one element in the given list.' + }, + { + name: 'exists', + signature: '(input :: ANY?) :: (BOOLEAN?)', + description: 'Returns true if a match for the pattern exists in the graph, or if the specified property exists in the node, relationship or map.' + }, + { + name: 'isEmpty', + signature: '(input :: LIST? OF ANY? | MAP? | STRING?) :: (BOOLEAN?)', + description: 'Checks whether a list/map/string is empty.' + }, + { + name: 'none', + signature: '(variable :: VARIABLE IN list :: LIST OF ANY? WHERE predicate :: ANY?) :: (BOOLEAN?)', + description: 'Returns true if the predicate holds for no element in the given list.' + }, + { + name: 'single', + signature: '(variable :: VARIABLE IN list :: LIST OF ANY? WHERE predicate :: ANY?) :: (BOOLEAN?)', + description: 'Returns true if the predicate holds for exactly one of the elements in the given list.' + }, + { + name: 'coalesce', + signature: '(input :: ANY?) :: (ANY?)', + description: 'Returns the first non-null value in a list of expressions.' + }, + { + name: 'endNode', + signature: '(input :: RELATIONSHIP?) :: (NODE?)', + description: 'Returns the end node of a relationship.' + }, + { + name: 'head', + signature: '(list :: LIST? OF ANY?) :: (ANY?)', + description: 'Returns the first element in a list.' + }, + { + name: 'id', + signature: '(input :: NODE? | RELATIONSHIP?) :: (INTEGER?)', + description: 'Returns the id of a node/relationship.' + }, + { + name: 'last', + signature: '(list :: LIST? OF ANY?) :: (ANY?)', + description: 'Returns the last element in a list.' + }, + { + name: 'length', + signature: '(input :: PATH?) :: (INTEGER?)', + description: 'Returns the length of a path.' + }, + { + name: 'properties', + signature: '(input :: MAP? | NODE? | RELATIONSHIP?) :: (MAP?)', + description: 'Returns a map containing all the properties of a map/node/relationship.' + }, + { + name: 'randomUUID', + signature: '() :: (STRING?)', + description: 'Generates a random UUID.' + }, + { + name: 'size', + signature: '(input :: LIST? OF ANY?) :: (INTEGER?)', + description: 'Returns the number of items in a list.' + }, + { + name: 'startNode', + signature: '(input :: RELATIONSHIP?) :: (NODE?)', + description: 'Returns the start node of a relationship.' + }, + { + name: 'timestamp', + signature: '() :: (INTEGER?)', + description: 'Returns the difference, measured in milliseconds, between the current time and midnight, January 1, 1970 UTC.' + }, + { + name: 'toBoolean', + signature: '(input :: STRING? | BOOLEAN? | INTEGER?) :: (BOOLEAN?)', + description: 'Converts a string value to a boolean value.' + }, + { + name: 'toBooleanOrNull', + signature: '(input :: ANY?) :: (BOOLEAN?)', + description: 'Converts a value to a boolean value, or null if the value cannot be converted.' + }, + { + name: 'toFloat', + signature: '(input :: NUMBER? | STRING?) :: (FLOAT?)', + description: 'Converts a number value to a floating point value.' + }, + { + name: 'toFloatOrNull', + signature: '(input :: ANY?) :: (FLOAT?)', + description: 'Converts a value to a floating point value, or null if the value cannot be converted.' + }, + { + name: 'toInteger', + signature: '(input :: NUMBER? | BOOLEAN? | STRING?) :: (INTEGER?)', + description: 'Converts a number value to an integer value.' + }, + { + name: 'toIntegerOrNull', + signature: '(input :: ANY?) :: (INTEGER?)', + description: 'Converts a value to an integer value, or null if the value cannot be converted.' + }, + { + name: 'type', + signature: '(input :: RELATIONSHIP?) :: (STRING?)', + description: 'Returns the string representation of the relationship type.' + }, + { + name: 'avg', + signature: '(input :: DURATION? | FLOAT? | INTEGER?) :: (DURATION? | FLOAT? | INTEGER?)', + description: 'Returns the average of a set of duration values.' + }, + { + name: 'collect', + signature: '(input :: ANY?) :: (LIST? OF ANY?)', + description: 'Returns a list containing the values returned by an expression.' + }, + { + name: 'count', + signature: '(input :: ANY?) :: (INTEGER?)', + description: 'Returns the number of values or rows.' + }, + { + name: 'max', + signature: '(input :: ANY?) :: (ANY?)', + description: 'Returns the maximum value in a set of values.' + }, + { + name: 'min', + signature: '(input :: ANY?) :: (ANY?)', + description: 'Returns the minimum value in a set of values.' + }, + { + name: 'percentileCont', + signature: '(input :: FLOAT?, percentile :: FLOAT?) :: (FLOAT?)', + description: 'Returns the percentile of a value over a group using linear interpolation.' + }, + { + name: 'percentileDisc', + signature: '(input :: FLOAT? | INTEGER?, percentile :: FLOAT?) :: (FLOAT? | INTEGER?)', + description: 'Returns the nearest value to the given percentile over a group using a rounding method.' + }, + { + name: 'stDev', + signature: '(input :: FLOAT?) :: (FLOAT?)', + description: 'Returns the standard deviation for the given value over a group for a sample of a population.' + }, + { + name: 'stDevp', + signature: '(input :: FLOAT?) :: (FLOAT?)', + description: 'Returns the standard deviation for the given value over a group for an entire population.' + }, + { + name: 'sum', + signature: '(input :: DURATION? | FLOAT? | INTEGER?) :: (DURATION? | FLOAT? | INTEGER?)', + description: 'Returns the sum of a set of numeric values.' + }, + { + name: 'keys', + signature: '(input :: MAP? | NODE? | RELATIONSHIP?) :: (LIST? OF STRING?)', + description: 'Returns a list containing the string representations for all the property names of a node, relationship, or map.' + }, + { + name: 'labels', + signature: '(input :: NODE?) :: (LIST? OF STRING?)', + description: 'Returns a list containing the string representations for all the labels of a node.' + }, + { + name: 'nodes', + signature: '(input :: PATH?) :: (LIST? OF NODE?)', + description: 'Returns a list containing all the nodes in a path.' + }, + { + name: 'range', + signature: '(start :: INTEGER?, end :: INTEGER?, step? :: INTEGER?) :: (LIST? OF INTEGER?)', + description: 'Returns a list comprising all integer values within a specified range.' + }, + { + name: 'relationships', + signature: '(input :: PATH?) :: (LIST? OF RELATIONSHIP?)', + description: 'Returns a list containing all the relationships in a path.' + }, + { + name: 'reverse', + signature: '(input :: LIST? OF ANY?) :: (LIST? OF ANY?)', + description: 'Returns a list in which the order of all elements in the original list have been reversed.' + }, + { + name: 'tail', + signature: '(input :: LIST? OF ANY?) :: (LIST? OF ANY?)', + description: 'Returns all but the first element in a list.' + }, + { + name: 'toBooleanList', + signature: '(input :: LIST? OF ANY?) :: (LIST? OF BOOLEAN?)', + description: 'Converts a list of values to a list of boolean values. If any values are not convertible to boolean they will be null in the list returned.' + }, + { + name: 'toFloatList', + signature: '(input :: LIST? OF ANY?) :: (LIST? OF FLOAT?)', + description: 'Converts a list of values to a list of floating point values. If any values are not convertible to floating point they will be null in the list returned.' + }, + { + name: 'toIntegerList', + signature: '(input :: LIST? OF ANY?) :: (LIST? OF INTEGER?)', + description: 'Converts a list of values to a list of integer values. If any values are not convertible to integer they will be null in the list returned.' + }, + { + name: 'toStringList', + signature: '(input :: LIST? OF ANY?) :: (LIST? OF STRING?)', + description: 'Converts a list of values to a list of string values. If any values are not convertible to string they will be null in the list returned.' + }, + { + name: 'abs', + signature: '(input :: FLOAT? | INTEGER?) :: (FLOAT? | INTEGER?)', + description: 'Returns the absolute value of a floating point number.' + }, + { + name: 'ceil', + signature: '(input :: FLOAT?) :: (FLOAT?)', + description: 'Returns the smallest floating point number that is greater than or equal to a number and equal to a mathematical integer.' + }, + { + name: 'floor', + signature: '(input :: FLOAT?) :: (FLOAT?)', + description: 'Returns the largest floating point number that is less than or equal to a number and equal to a mathematical integer.' + }, + { + name: 'rand', + signature: '() :: (FLOAT?)', + description: 'Returns a random floating point number in the range from 0 (inclusive) to 1 (exclusive); i.e. [0,1).' + }, + { + name: 'round', + signature: '(input :: FLOAT?, precision? :: NUMBER?, mode? :: STRING?) :: (FLOAT?)', + description: 'Returns the value of a number rounded to the nearest integer.' + }, + { + name: 'sign', + signature: '(input :: FLOAT? | INTEGER?) :: (INTEGER?)', + description: 'Returns the signum of a floating point number: 0 if the number is 0, -1 for any negative number, and 1 for any positive number.' + }, + { + name: 'e', + signature: '() :: (FLOAT?)', + description: 'Returns the base of the natural logarithm, e.' + }, + { + name: 'exp', + signature: '(input :: FLOAT?) :: (FLOAT?)', + description: 'Returns e^n, where e is the base of the natural logarithm, and n is the value of the argument expression.' + }, + { + name: 'log', + signature: '(input :: FLOAT?) :: (FLOAT?)', + description: 'Returns the natural logarithm of a number.' + }, + { + name: 'log10', + signature: '(input :: FLOAT?) :: (FLOAT?)', + description: 'Returns the common logarithm (base 10) of a number.' + }, + { + name: 'sqrt', + signature: '(input :: FLOAT?) :: (FLOAT?)', + description: 'Returns the square root of a number.' + }, + { + name: 'acos', + signature: '(input :: FLOAT?) :: (FLOAT?)', + description: 'Returns the arccosine of a number in radians.' + }, + { + name: 'asin', + signature: '(input :: FLOAT?) :: (FLOAT?)', + description: 'Returns the arcsine of a number in radians.' + }, + { + name: 'atan', + signature: '(input :: FLOAT?) :: (FLOAT?)', + description: 'Returns the arctangent of a number in radians.' + }, + { + name: 'atan2', + signature: '(y :: FLOAT?, x :: FLOAT?) :: (FLOAT?)', + description: 'Returns the arctangent2 of a set of coordinates in radians.' + }, + { + name: 'cos', + signature: '(input :: FLOAT?) :: (FLOAT?)', + description: 'Returns the cosine of a number.' + }, + { + name: 'cot', + signature: '(input :: FLOAT?) :: (FLOAT?)', + description: 'Returns the cotangent of a number.' + }, + { + name: 'degrees', + signature: '(input :: FLOAT?) :: (FLOAT?)', + description: 'Converts radians to degrees.' + }, + { + name: 'haversin', + signature: '(input :: FLOAT?) :: (FLOAT?)', + description: 'Returns half the versine of a number.' + }, + { + name: 'pi', + signature: '() :: (FLOAT?)', + description: 'Returns the mathematical constant pi.' + }, + { + name: 'radians', + signature: '(input :: FLOAT?) :: (FLOAT?)', + description: 'Converts degrees to radians.' + }, + { + name: 'sin', + signature: '(input :: FLOAT?) :: (FLOAT?)', + description: 'Returns the sine of a number.' + }, + { + name: 'tan', + signature: '(input :: FLOAT?) :: (FLOAT?)', + description: 'Returns the tangent of a number.' + }, + { + name: 'left', + signature: '(original :: STRING?, length :: INTEGER?) :: (STRING?)', + description: 'Returns a string containing the specified number of leftmost characters of the original string.' + }, + { + name: 'lTrim', + signature: '(input :: STRING?) :: (STRING?)', + description: 'Returns the original string with leading whitespace removed.' + }, + { + name: 'replace', + signature: '(original :: STRING?, search :: STRING?, replace :: STRING?) :: (STRING?)', + description: 'Returns a string in which all occurrences of a specified search string in the original string have been replaced by another (specified) replace string.' + }, + { + name: 'reverse', + signature: '(input :: STRING?) :: (STRING?)', + description: 'Returns a string in which the order of all characters in the original string have been reversed.' + }, + { + name: 'right', + signature: '(original :: STRING?, length :: INTEGER?) :: (STRING?)', + description: 'Returns a string containing the specified number of rightmost characters of the original string.' + }, + { + name: 'rTrim', + signature: '(input :: STRING?) :: (STRING?)', + description: 'Returns the original string with trailing whitespace removed.' + }, + { + name: 'split', + signature: '(original :: STRING?, splitDelimiter :: STRING? | LIST? OF STRING?) :: (LIST? OF STRING?)', + description: 'Returns a list of strings resulting from the splitting of the original string around matches of (any) the given delimiter.' + }, + { + name: 'substring', + signature: '(original :: STRING?, start :: INTEGER?, length? :: INTEGER?) :: (STRING?)', + description: 'Returns a substring of (length \'length\') the original string, beginning with a 0-based index start.' + }, + { + name: 'toLower', + signature: '(input :: STRING?) :: (STRING?)', + description: 'Returns the original string in lowercase.' + }, + { + name: 'toString', + signature: '(input :: ANY?) :: (STRING?)', + description: 'Converts an integer, float, boolean, point or temporal type (i.e. Date, Time, LocalTime, DateTime, LocalDateTime or Duration) value to a string.' + }, + { + name: 'toStringOrNull', + signature: '(input :: ANY?) :: (STRING?)', + description: 'Converts an integer, float, boolean, point or temporal type (i.e. Date, Time, LocalTime, DateTime, LocalDateTime or Duration) value to a string, or null if the value cannot be converted.' + }, + { + name: 'toUpper', + signature: '(input :: STRING?) :: (STRING?)', + description: 'Returns the original string in uppercase.' + }, + { + name: 'trim', + signature: '(input :: STRING?) :: (STRING?)', + description: 'Returns the original string with leading and trailing whitespace removed.' + } +] diff --git a/redisinsight/ui/src/constants/monaco/cypher/index.ts b/redisinsight/ui/src/constants/monaco/cypher/index.ts new file mode 100644 index 0000000000..e10017be46 --- /dev/null +++ b/redisinsight/ui/src/constants/monaco/cypher/index.ts @@ -0,0 +1,2 @@ +export * from './monacoCypher' +export * from './theme' diff --git a/redisinsight/ui/src/constants/monaco/cypher/monacoCypher.ts b/redisinsight/ui/src/constants/monaco/cypher/monacoCypher.ts new file mode 100644 index 0000000000..ad3b210762 --- /dev/null +++ b/redisinsight/ui/src/constants/monaco/cypher/monacoCypher.ts @@ -0,0 +1,242 @@ +import * as monacoEditor from 'monaco-editor' +import cypherFunctions from './functions' + +interface CypherFunction { + name: string + signature: string + description: string +} + +export const cypherLanguageConfiguration: monacoEditor.languages.LanguageConfiguration = { + brackets: [ + ['(', ')'], + ['{', '}'], + ['[', ']'], + ["'", "'"], + ['"', '"'] + ], + comments: { + blockComment: ['/*', '*/'], + lineComment: '//' + } +} + +export const KEYWORDS = [ + 'ACCESS', + 'ACTIVE', + 'ADMIN', + 'ADMINISTRATOR', + 'ALLSHORTESTPATHS', + 'ALTER', + 'AND', + 'AS', + 'ASC', + 'ASCENDING', + 'ASSERT', + 'ASSIGN', + 'BOOSTED', + 'BRIEF', + 'BTREE', + 'BUILT', + 'BY', + 'CALL', + 'CASE', + 'CATALOG', + 'CHANGE', + 'COMMIT', + 'CONSTRAINT', + 'CONSTRAINTS', + 'CONTAINS', + 'COPY', + 'CREATE', + 'CSV', + 'CURRENT', + 'CYPHER', + 'DATABASE', + 'DATABASES', + 'DBMS', + 'DEFAULT', + 'DEFINED', + 'DELETE', + 'DENY', + 'DESC', + 'DESCENDING', + 'DETACH', + 'DISTINCT', + 'DROP', + 'EACH', + 'ELEMENT', + 'ELEMENTS', + 'ELSE', + 'END', + 'ENDS', + 'EXECUTABLE', + 'EXECUTE', + 'EXISTENCE', + 'EXPLAIN', + 'EXTRACT', + 'FALSE', + 'FIELDTERMINATOR', + 'FILTER', + 'FOR', + 'FOREACH', + 'FROM', + 'FULLTEXT', + 'FUNCTION', + 'FUNCTIONS', + 'GRANT', + 'GRAPH', + 'GRAPHS', + 'HEADERS', + 'HOME', + 'IF', + 'IN', + 'INDEX', + 'INDEXES', + 'IS', + 'JOIN', + 'KEY', + 'LABEL', + 'LIMIT', + 'LOAD', + 'LOOKUP', + 'MANAGEMENT', + 'MATCH', + 'MERGE', + 'NAME', + 'NAMES', + 'NEW', + 'NODE', + 'NOT', + 'NULL', + 'OF', + 'ON', + 'OPTIONAL', + 'OPTIONS', + 'OR', + 'ORDER', + 'OUTPUT', + 'PASSWORD', + 'PERIODIC', + 'POPULATED', + 'PRIVILEGES', + 'PROCEDURE', + 'PROCEDURES', + 'PROFILE', + 'PROPERTY', + 'READ', + 'REDUCE', + 'REL', + 'RELATIONSHIP', + 'REMOVE', + 'RENAME', + 'REQUIRED', + 'RETURN', + 'REVOKE', + 'ROLE', + 'ROLES', + 'SCAN', + 'SET', + 'SHORTESTPATH', + 'SHOW', + 'SKIP', + 'START', + 'STARTS', + 'STATUS', + 'STOP', + 'SUSPENDED', + 'THEN', + 'TO', + 'TRAVERSE', + 'TRUE', + 'TYPES', + 'UNION', + 'UNIQUE', + 'UNWIND', + 'USER', + 'USERS', + 'USING', + 'VERBOSE', + 'WHEN', + 'WHERE', + 'WITH', + 'WRITE', + 'XOR', + 'YIELD', +] + +export const FUNCTIONS: CypherFunction[] = cypherFunctions + +export const STRINGS: string[] = ['stringliteral', 'urlhex'] + +export const NUMBERS: string[] = [ + 'hexinteger', + 'decimalinteger', + 'octalinteger', + 'hexdigit', + 'digit', + 'nonzerodigit', + 'nonzerooctdigit', + 'octdigit', + 'zerodigit', + 'exponentdecimalreal', + 'regulardecimalreal' +] + +export const OPERATORS: string[] = [ + 'identifierstart', + 'identifierpart', + "';'", + "':'", + "'-'", + "'=>'", + "'://'", + "'/'", + "'.'", + "'@'", + "'#'", + "'?'", + "'&'", + "'='", + "'+'", + "'{'", + "','", + "'}'", + "'['", + "']'", + "'('", + "')'", + "'+='", + "'|'", + "'*'", + "'..'", + "'%'", + "'^'", + "'=~'", + "'<>'", + "'!='", + "'<'", + "'>'", + "'<='", + "'>='", + "'$'", + "'\u27E8'", + "'\u3008'", + "'\uFE64'", + "'\uFF1C'", + "'\u27E9'", + "'\u3009'", + "'\uFE65'", + "'\uFF1E'", + "'\u00AD'", + "'\u2010'", + "'\u2011'", + "'\u2012'", + "'\u2013'", + "'\u2014'", + "'\u2015'", + "'\u2212'", + "'\uFE58'", + "'\uFE63'", + "'\uFF0D'" +] diff --git a/redisinsight/ui/src/constants/monaco/cypher/theme.ts b/redisinsight/ui/src/constants/monaco/cypher/theme.ts new file mode 100644 index 0000000000..b03f2811f8 --- /dev/null +++ b/redisinsight/ui/src/constants/monaco/cypher/theme.ts @@ -0,0 +1,23 @@ +import { monaco } from 'react-monaco-editor' + +export const darkThemeRules = [ + { token: 'function', foreground: 'BFBC4E' } +] + +export const lightThemeRules = [ + { token: 'function', foreground: '795E26' } +] + +export const darkTheme: monaco.editor.IStandaloneThemeData = { + base: 'vs-dark', + inherit: true, + rules: darkThemeRules, + colors: {} +} + +export const lightTheme: monaco.editor.IStandaloneThemeData = { + base: 'vs', + inherit: true, + rules: lightThemeRules, + colors: {} +} diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/EnablementArea.tsx b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/EnablementArea.tsx index 64159df645..6f7fb4cbfc 100644 --- a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/EnablementArea.tsx +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/EnablementArea.tsx @@ -20,13 +20,14 @@ import styles from './styles.module.scss' const padding = parseInt(styles.paddingHorizontal) export interface Props { - items: Record; - loading: boolean; - openScript: (script: string, path?: string, name?: string) => void; - onOpenInternalPage: (page: IInternalPage) => void; + items: Record + loading: boolean + openScript: (script: string, path?: string, name?: string) => void + onOpenInternalPage: (page: IInternalPage) => void + isCodeBtnDisabled?: boolean } -const EnablementArea = ({ items, openScript, loading, onOpenInternalPage }: Props) => { +const EnablementArea = ({ items, openScript, loading, onOpenInternalPage, isCodeBtnDisabled }: Props) => { const { search } = useLocation() const history = useHistory() const dispatch = useDispatch() @@ -100,7 +101,7 @@ const EnablementArea = ({ items, openScript, loading, onOpenInternalPage }: Prop ))) return ( - +
{ loading ? ( diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/Code/Code.tsx b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/Code/Code.tsx index 696ff68dfb..86a167cc46 100644 --- a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/Code/Code.tsx +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/Code/Code.tsx @@ -14,7 +14,7 @@ export interface Props { const Code = ({ children, ...rest }: Props) => { const { search } = useLocation() - const { setScript } = useContext(EnablementAreaContext) + const { setScript, isCodeBtnDisabled } = useContext(EnablementAreaContext) const loadContent = () => { const pagePath = new URLSearchParams(search).get('guide') @@ -27,7 +27,7 @@ const Code = ({ children, ...rest }: Props) => { } return ( - + ) } diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/CodeButton/CodeButton.tsx b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/CodeButton/CodeButton.tsx index bc8c7e366f..8b53f8507b 100644 --- a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/CodeButton/CodeButton.tsx +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementArea/components/CodeButton/CodeButton.tsx @@ -4,12 +4,13 @@ import { EuiButton } from '@elastic/eui' import styles from './styles.module.scss' export interface Props { - onClick: () => void; - label: string; - isLoading?: boolean; - className?: string; + onClick: () => void + label: string + isLoading?: boolean + disabled?: boolean + className?: string } -const CodeButton = ({ onClick, label, isLoading, className, ...rest }: Props) => ( +const CodeButton = ({ onClick, label, isLoading, className, disabled, ...rest }: Props) => ( className={[className, styles.button].join(' ')} textProps={{ className: styles.buttonText }} data-testid={`preselect-${label}`} + disabled={disabled} {...rest} > {label} diff --git a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementAreaWrapper.tsx b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementAreaWrapper.tsx index 103812199d..fb9f7d2662 100644 --- a/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementAreaWrapper.tsx +++ b/redisinsight/ui/src/pages/workbench/components/enablement-area/EnablementAreaWrapper.tsx @@ -17,13 +17,14 @@ import { IInternalPage } from '../../contexts/enablementAreaContext' import styles from './styles.module.scss' export interface Props { - isMinimized: boolean; - setIsMinimized: (value: boolean) => void; - scriptEl: Nullable; - setScript: (script: string) => void; + isMinimized: boolean + setIsMinimized: (value: boolean) => void + scriptEl: Nullable + setScript: (script: string) => void + isCodeBtnDisabled?: boolean } -const EnablementAreaWrapper = React.memo(({ isMinimized, setIsMinimized, scriptEl, setScript }: Props) => { +const EnablementAreaWrapper = ({ isMinimized, setIsMinimized, scriptEl, setScript, isCodeBtnDisabled }: Props) => { const { loading, items } = useSelector(workbenchEnablementAreaSelector) const { instanceId = '' } = useParams<{ instanceId: string }>() const dispatch = useDispatch() @@ -86,10 +87,11 @@ const EnablementAreaWrapper = React.memo(({ isMinimized, setIsMinimized, scriptE loading={loading} openScript={openScript} onOpenInternalPage={onOpenInternalPage} + isCodeBtnDisabled={isCodeBtnDisabled} /> ) -}) +} -export default EnablementAreaWrapper +export default React.memo(EnablementAreaWrapper) diff --git a/redisinsight/ui/src/pages/workbench/components/wb-view/WBView/WBView.tsx b/redisinsight/ui/src/pages/workbench/components/wb-view/WBView/WBView.tsx index 2effdad54b..9646c03453 100644 --- a/redisinsight/ui/src/pages/workbench/components/wb-view/WBView/WBView.tsx +++ b/redisinsight/ui/src/pages/workbench/components/wb-view/WBView/WBView.tsx @@ -55,6 +55,7 @@ const WBView = (props: Props) => { const [isMinimized, setIsMinimized] = useState( (localStorageService?.get(BrowserStorageItem.isEnablementAreaMinimized) ?? 'false') === 'true' ) + const [isCodeBtnDisabled, setIsCodeBtnDisabled] = useState(false) const { panelSizes: { vertical } } = useSelector(appContextWorkbench) @@ -84,6 +85,7 @@ const WBView = (props: Props) => { setIsMinimized={setIsMinimized} setScript={setScript} scriptEl={scriptEl} + isCodeBtnDisabled={isCodeBtnDisabled} />
@@ -104,6 +106,7 @@ const WBView = (props: Props) => { loading={loading} setQuery={setScript} setQueryEl={setScriptEl} + setIsCodeBtnDisabled={setIsCodeBtnDisabled} onSubmit={onSubmit} /> diff --git a/redisinsight/ui/src/pages/workbench/contexts/enablementAreaContext.tsx b/redisinsight/ui/src/pages/workbench/contexts/enablementAreaContext.tsx index 965ded1d7c..0150c3d671 100644 --- a/redisinsight/ui/src/pages/workbench/contexts/enablementAreaContext.tsx +++ b/redisinsight/ui/src/pages/workbench/contexts/enablementAreaContext.tsx @@ -3,6 +3,7 @@ import React from 'react' interface IContext { setScript: (script: string, path?: string, name?: string) => void; openPage: (page: IInternalPage) => void; + isCodeBtnDisabled?: boolean } export interface IInternalPage { path: string, @@ -10,7 +11,8 @@ export interface IInternalPage { } export const defaultValue = { setScript: (script: string) => script, - openPage: (page: IInternalPage) => page + openPage: (page: IInternalPage) => page, + isCodeBtnDisabled: false } const EnablementAreaContext = React.createContext(defaultValue) export const EnablementAreaProvider = EnablementAreaContext.Provider diff --git a/redisinsight/ui/src/styles/base/_monaco.scss b/redisinsight/ui/src/styles/base/_monaco.scss index 9603888079..386ac1b7c9 100644 --- a/redisinsight/ui/src/styles/base/_monaco.scss +++ b/redisinsight/ui/src/styles/base/_monaco.scss @@ -45,3 +45,23 @@ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } } + +.monaco-widget { + display: flex; + align-items: center; + justify-content: space-around; + background: var(--euiColorEmptyShade); + border: 1px solid var(--euiColorLightestShade); + width: 220px; + height: 30px; + padding: 5px; + font-size: 12px; + + &__title { + color: var(--euiTextSubduedColor); + } + + &__shortcut { + color: var(--euiColorMediumShade); + } +} diff --git a/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss index 9123a1e2d6..ac00b75f0a 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss @@ -113,6 +113,8 @@ --overlayPromoNYColor: #{$overlayPromoNYColor}; + --monacoBgColor: #{$monacoBgColor}; + // KeyTypes --typeHashColor: #{$typeHashColor}; --typeListColor: #{$typeListColor}; diff --git a/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss b/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss index 686b1533c8..ffa57e0aef 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss @@ -73,6 +73,8 @@ $commandGroupBadgeColor: #3F4B5F; $overlayPromoNYColor: #0000001a; +$monacoBgColor: #1e1e1e; + // Types colors $typeHashColor: #364CFF; $typeListColor: #008556; diff --git a/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss b/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss index ca63f58810..41c26098bb 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss @@ -115,6 +115,8 @@ --overlayPromoNYColor: #{$overlayPromoNYColor}; + --monacoBgColor: #{$monacoBgColor}; + // KeyTypes --typeHashColor: #{$typeHashColor}; --typeListColor: #{$typeListColor}; diff --git a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss index d8d05da59d..6ba7416821 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss @@ -70,6 +70,8 @@ $callOutBackgroundColor: #E9EDFA; $overlayPromoNYColor: #ffffff1a; +$monacoBgColor: #fffffe; + // Types colors $typeHashColor: #CDDDF8; $typeListColor: #A5D4C3; diff --git a/redisinsight/ui/src/utils/monaco/cypher/completionProvider.ts b/redisinsight/ui/src/utils/monaco/cypher/completionProvider.ts new file mode 100644 index 0000000000..9c71ef17e1 --- /dev/null +++ b/redisinsight/ui/src/utils/monaco/cypher/completionProvider.ts @@ -0,0 +1,48 @@ +import * as monacoEditor from 'monaco-editor' +import { FUNCTIONS, KEYWORDS } from 'uiSrc/constants/monaco/cypher/monacoCypher' + +export const getCypherCompletionProvider = (): monacoEditor.languages.CompletionItemProvider => ({ + provideCompletionItems: ( + model: monacoEditor.editor.IModel, + position: monacoEditor.Position + ): monacoEditor.languages.CompletionList => { + const word = model.getWordUntilPosition(position) + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + endColumn: word.endColumn, + startColumn: word.startColumn + } + + // display suggestions only for words that don't belong to a folding area + if (!model.getValueInRange(range).startsWith(' ')) { + const keywordsSuggestions = KEYWORDS.map((item: string) => ( + { + label: item, + kind: monacoEditor.languages.CompletionItemKind.Keyword, + insertText: item, + range, + sortText: `a${item}`, + } + )) + + const functionsSuggestions = FUNCTIONS.map((item: any) => ( + { + label: item.name, + detail: item.signature, + kind: monacoEditor.languages.CompletionItemKind.Function, + documentation: item.description, + insertText: item.name, + range, + sortText: `b${item.name}`, + insertTextRules: monacoEditor.languages.CompletionItemInsertTextRule.InsertAsSnippet + } + )) + + return { + suggestions: [...keywordsSuggestions, ...functionsSuggestions] + } + } + return { suggestions: [] } + } +}) diff --git a/redisinsight/ui/src/utils/monaco/cypher/monarchTokensProvider.ts b/redisinsight/ui/src/utils/monaco/cypher/monarchTokensProvider.ts new file mode 100644 index 0000000000..a158e4522b --- /dev/null +++ b/redisinsight/ui/src/utils/monaco/cypher/monarchTokensProvider.ts @@ -0,0 +1,79 @@ +import * as monacoEditor from 'monaco-editor' +import { FUNCTIONS, KEYWORDS, OPERATORS } from 'uiSrc/constants/monaco/cypher/monacoCypher' + +const STRING_DOUBLE = 'string.double' +const functions = FUNCTIONS.map((f) => f.name) + +export const getCypherMonarchTokensProvider = (): monacoEditor.languages.IMonarchLanguage => ( + { + defaultToken: '', + tokenPostfix: '.cypher', + ignoreCase: true, + brackets: [ + { open: '[', close: ']', token: 'delimiter.square' }, + { open: '(', close: ')', token: 'delimiter.parenthesis' }, + { open: '{', close: '}', token: 'delimiter.curly' }, + ], + keywords: KEYWORDS, + operators: OPERATORS, + functions, + strings: [], + tokenizer: { + root: [ + { include: '@whitespace' }, + { include: '@numbers' }, + { include: '@keyword' }, + { include: '@function' }, + [/[;,.]/, 'delimiter'], + // eslint-disable-next-line + [/[{}()\[\]]/, '@brackets'], + [ + /[\w@#$]+/, + { + cases: { + '@keywords': 'keyword', + '@functions': 'function', + '@default': 'identifier', + }, + }, + ], + { include: '@strings' }, + ], + keyword: [ + [ + `\\b(${KEYWORDS.join('|')})\\b`, + 'keyword' + ] + ], + function: [ + [ + `\\b(${functions.join('|')})(?=\\s*\\()`, + 'function' + ] + ], + strings: [ + [/'/, { token: 'string', next: '@string' }], + [/"/, { token: STRING_DOUBLE, next: '@stringDouble' }], + ], + string: [ + [/[^']+/, 'string'], + [/''/, 'string'], + [/'/, { token: 'string', next: '@pop' }], + ], + stringDouble: [ + [/[^"]+/, STRING_DOUBLE], + [/""/, STRING_DOUBLE], + [/"/, { token: STRING_DOUBLE, next: '@pop' }], + ], + whitespace: [ + [/\s+/, 'white'], + [/\/\/.*$/, 'comment'], + ], + numbers: [ + [/0[xX][0-9a-fA-F]*/, 'number'], + [/[$][+-]*\d*(\.\d*)?/, 'number'], + [/((\d+(\.\d*)?)|(\.\d+))([eE][-+]?\d+)?/, 'number'], + ] + }, + } +) diff --git a/redisinsight/ui/src/utils/monacoInterfaces.ts b/redisinsight/ui/src/utils/monacoInterfaces.ts index 5e972387b4..a2410fd0a8 100644 --- a/redisinsight/ui/src/utils/monacoInterfaces.ts +++ b/redisinsight/ui/src/utils/monacoInterfaces.ts @@ -6,3 +6,13 @@ export interface IMonacoCommand { info?: ICommand position?: monacoEditor.Position } + +export interface IMonacoQuery { + name: string + fullQuery: string + args?: string[] + info?: ICommand + commandPosition: any + position?: monacoEditor.Position + commandCursorPosition?: number +} diff --git a/redisinsight/ui/src/utils/monacoRedisSignatureHelpProvider.ts b/redisinsight/ui/src/utils/monacoRedisSignatureHelpProvider.ts index 882308b024..8e58a86c24 100644 --- a/redisinsight/ui/src/utils/monacoRedisSignatureHelpProvider.ts +++ b/redisinsight/ui/src/utils/monacoRedisSignatureHelpProvider.ts @@ -1,11 +1,15 @@ +import { MutableRefObject } from 'react' import * as monacoEditor from 'monaco-editor' import { isNull } from 'lodash' import { ICommands } from 'uiSrc/constants' import { generateArgsNames } from 'uiSrc/utils/commands' import { findCommandEarlier } from './monacoUtils' -export const getRedisSignatureHelpProvider = (commandsSpec: ICommands, commandsArray: string[]): -monacoEditor.languages.SignatureHelpProvider => +export const getRedisSignatureHelpProvider = ( + commandsSpec: ICommands, + commandsArray: string[], + isBlockedRef?: MutableRefObject +): monacoEditor.languages.SignatureHelpProvider => // generate signature help provider ({ // signatureHelpTriggerCharacters: [' '], @@ -16,7 +20,7 @@ monacoEditor.languages.SignatureHelpProvider => ) => { const command = findCommandEarlier(model, position, commandsSpec, commandsArray) - if (isNull(command)) { + if (isNull(command) || isBlockedRef?.current) { return null } diff --git a/redisinsight/ui/src/utils/monacoUtils.ts b/redisinsight/ui/src/utils/monacoUtils.ts index 1b6560205b..1fa6c6b6fc 100644 --- a/redisinsight/ui/src/utils/monacoUtils.ts +++ b/redisinsight/ui/src/utils/monacoUtils.ts @@ -1,7 +1,7 @@ import * as monacoEditor from 'monaco-editor' import { isEmpty, isUndefined, reject } from 'lodash' import { ICommands } from 'uiSrc/constants' -import { IMonacoCommand } from './monacoInterfaces' +import { IMonacoCommand, IMonacoQuery } from './monacoInterfaces' import { Nullable } from './types' import { getCommandRepeat, isRepeatCountCorrect } from './commands' @@ -85,3 +85,112 @@ export const findCommandEarlier = ( return command } + +export const findCompleteQuery = ( + model: monacoEditor.editor.ITextModel, + position: monacoEditor.Position, + commandsSpec: ICommands = {}, + commandsArray: string[] = [] +): Nullable => { + const { lineNumber } = position + let commandName = '' + let fullQuery = '' + const notCommandRegEx = /^\s|\/\// + const commandPosition = { + startLine: 0, + endLine: 0 + } + + // find command and args in the previous lines if current line is argument + // eslint-disable-next-line for-direction + for (let previousLineNumber = lineNumber; previousLineNumber > 0; previousLineNumber--) { + commandName = model.getLineContent(previousLineNumber) ?? '' + const lineBeforePosition = previousLineNumber === lineNumber + ? commandName.slice(0, position.column - 1) + : commandName + fullQuery = lineBeforePosition + fullQuery + commandPosition.startLine = previousLineNumber + + if (!notCommandRegEx.test(commandName)) { + break + } + + fullQuery = `\n${fullQuery}` + } + + const matchedCommand = commandsArray + .find((command) => commandName?.trim().toUpperCase().startsWith(command.toUpperCase())) + + if (isUndefined(matchedCommand)) { + return null + } + + const commandCursorPosition = fullQuery.length + // find args in the next lines + const linesCount = model.getLineCount() + for (let nextLineNumber = lineNumber; nextLineNumber <= linesCount; nextLineNumber++) { + const lineContent = model.getLineContent(nextLineNumber) ?? '' + + if (nextLineNumber !== lineNumber && !notCommandRegEx.test(lineContent)) { + break + } + + commandPosition.endLine = nextLineNumber + const lineAfterPosition = nextLineNumber === lineNumber + ? lineContent.slice(position.column - 1, model.getLineLength(lineNumber)) + : lineContent + + if (nextLineNumber !== lineNumber) { + fullQuery += '\n' + } + + fullQuery += lineAfterPosition + } + + const args = fullQuery + .replace(matchedCommand, '') + .match(/(?:[^\s"']+|["][^"]*["]|['][^']*['])+/g) + + return { + position, + commandPosition, + commandCursorPosition, + fullQuery, + args, + name: matchedCommand, + info: commandsSpec[matchedCommand] + } as IMonacoQuery +} + +export const findArgIndexByCursor = ( + args: string[] = [], + fullQuery: string, + cursorPosition: number +): Nullable => { + let argIndex = null + for (let i = 0; i < args.length; i++) { + const part = args[i] + const searchIndex = fullQuery?.indexOf(part) || 0 + if (searchIndex < cursorPosition && searchIndex + part.length > cursorPosition) { + argIndex = i + break + } + } + return argIndex +} + +export const createSyntaxWidget = (text: string, shortcutText: string) => { + const widget = document.createElement('div') + const title = document.createElement('span') + title.classList.add('monaco-widget__title') + title.innerHTML = text + + const shortcut = document.createElement('span') + shortcut.classList.add('monaco-widget__shortcut') + shortcut.innerHTML = shortcutText + + widget.append(title, shortcut) + widget.classList.add('monaco-widget') + + return widget +} diff --git a/redisinsight/ui/src/utils/tests/monacoUtils.spec.ts b/redisinsight/ui/src/utils/tests/monacoUtils.spec.ts index 6f2aa26a47..6849869e22 100644 --- a/redisinsight/ui/src/utils/tests/monacoUtils.spec.ts +++ b/redisinsight/ui/src/utils/tests/monacoUtils.spec.ts @@ -1,4 +1,9 @@ -import { multilineCommandToOneLine, removeMonacoComments, splitMonacoValuePerLines } from 'uiSrc/utils' +import { + multilineCommandToOneLine, + removeMonacoComments, + splitMonacoValuePerLines, + findArgIndexByCursor +} from 'uiSrc/utils' describe('removeMonacoComments', () => { const cases = [ @@ -100,3 +105,45 @@ describe('splitMonacoValuePerLines', () => { } ) }) + +describe.only('findArgIndexByCursor', () => { + const cases = [ + [ + ['get', 'foo', 'bar'], + 'get foo bar', + 10, + 2 + ], + [ + ['get', 'foo', 'bar'], + 'get foo bar', + 5, + 1 + ], + [ + ['get', 'foo', 'bar'], + 'get foo \n bar', + 17, + 2 + ], + [ + ['get', 'foo', 'bar'], + 'get foo \n\n\n bar', + 19, + 2 + ], + [ + ['get', 'foo', 'bar'], + 'get foo \n\n\n bar', + 25, + null + ], + ] + test.each(cases)( + 'given %p as args, %p as fullQuery, %p as cursor position, returns %p', + (args: string[], fullQuery: string, cursorPosition: number, expectedResult) => { + const result = findArgIndexByCursor(args, fullQuery, cursorPosition) + expect(result).toEqual(expectedResult) + } + ) +}) diff --git a/yarn.lock b/yarn.lock index 5bdf08b7b7..0a75a2fc7d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5244,7 +5244,7 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -classnames@*, classnames@^2.2.6, classnames@^2.3.1: +classnames@*, classnames@^2.2.5, classnames@^2.2.6, classnames@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e" integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA== @@ -7606,6 +7606,11 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= +fast-memoize@^2.5.1: + version "2.5.2" + resolved "https://registry.yarnpkg.com/fast-memoize/-/fast-memoize-2.5.2.tgz#79e3bb6a4ec867ea40ba0e7146816f6cdce9b57e" + integrity sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw== + fastest-levenshtein@^1.0.12: version "1.0.12" resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.12.tgz#9990f7d3a88cc5a9ffd1f1745745251700d497e2" @@ -13161,6 +13166,13 @@ rc@^1.2.7, rc@^1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" +re-resizable@6.9.1: + version "6.9.1" + resolved "https://registry.yarnpkg.com/re-resizable/-/re-resizable-6.9.1.tgz#6be082b55d02364ca4bfee139e04feebdf52441c" + integrity sha512-KRYAgr9/j1PJ3K+t+MBhlQ+qkkoLDJ1rs0z1heIWvYbCW/9Vq4djDU+QumJ3hQbwwtzXF6OInla6rOx6hhgRhQ== + dependencies: + fast-memoize "^2.5.1" + react-ace@^7.0.5: version "7.0.5" resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-7.0.5.tgz#798299fd52ddf3a3dcc92afc5865538463544f01" @@ -13209,6 +13221,14 @@ react-dom@^17.0.1: object-assign "^4.1.1" scheduler "^0.20.1" +react-draggable@4.4.3: + version "4.4.3" + resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-4.4.3.tgz#0727f2cae5813e36b0e4962bf11b2f9ef2b406f3" + integrity sha512-jV4TE59MBuWm7gb6Ns3Q1mxX8Azffb7oTtDtBgFkxRvhDp38YAARmRplrj0+XGkhOJB5XziArX+4HUUABtyZ0w== + dependencies: + classnames "^2.2.5" + prop-types "^15.6.0" + react-dropzone@^11.2.0: version "11.4.2" resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-11.4.2.tgz#1eb99e9def4cc7520f4f58e85c853ce52c483d56" @@ -13365,6 +13385,15 @@ react-remove-scroll@^2.4.1: use-callback-ref "^1.2.3" use-sidecar "^1.0.1" +react-rnd@^10.3.5: + version "10.3.5" + resolved "https://registry.yarnpkg.com/react-rnd/-/react-rnd-10.3.5.tgz#b66e5e06f1eb6823e72eb4b552081b4b9241b139" + integrity sha512-LWJP+l5bp76sDPKrKM8pwGJifI6i3B5jHK4ONACczVMbR8ycNGA75ORRqpRuXGyKawUs68s1od05q8cqWgQXgw== + dependencies: + re-resizable "6.9.1" + react-draggable "4.4.3" + tslib "2.3.0" + react-router-dom@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.2.0.tgz#9e65a4d0c45e13289e66c7b17c7e175d0ea15662" @@ -15614,6 +15643,11 @@ tslib@2.0.3: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.3.tgz#8e0741ac45fc0c226e58a17bfc3e64b9bc6ca61c" integrity sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ== +tslib@2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e" + integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg== + tslib@^1.0.0, tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"