From a3794dc0ea3b2c17406998ba2a874607c286ca00 Mon Sep 17 00:00:00 2001 From: Hetu Nandu Date: Fri, 7 Mar 2025 13:35:16 +0530 Subject: [PATCH] feat: Trigger autocomplete even outside bindings (#39446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Shows autocomplete with results in binding brackets `{{ }}` when the user is typing something outside the binding Fixes #39112 ## Automation /ok-to-test tags="@tag.All" ### :mag: Cypress test results > [!TIP] > 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉 > Workflow run: > Commit: cd3816b05a8e36e7218af2ab6fb4c23046ab99ba > Cypress dashboard. > Tags: `@tag.All` > Spec: >
Thu, 06 Mar 2025 14:46:52 UTC ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [ ] No ## Summary by CodeRabbit - **New Features** - Introduced a new utility function for determining when to show autocomplete suggestions in the code editor. - Enhanced the code editor autocomplete for more context-aware suggestions and improved cursor handling, resulting in a smoother editing experience. - **Refactor** - Standardized input formatting across components to ensure consistent handling of data in widget interactions. --- .../Widget_property_navigation_spec.ts | 2 +- .../JSONForm/JSONForm_CurrencyField_spec.ts | 2 +- .../Widgets/TableV2/AddNewRow1_spec.js | 2 +- .../CodeEditor/codeEditorUtils.ts | 35 ++++++++++++---- .../CodeEditor/hintHelpers.test.ts | 7 ++-- .../CodeEditor/hintHelpers.ts | 7 ++-- .../autocomplete/CodemirrorTernService.ts | 42 +++++++++++++------ .../autocomplete/__tests__/TernServer.test.ts | 7 +++- .../test/__mocks__/CodeMirrorEditorMock.ts | 1 + 9 files changed, 74 insertions(+), 31 deletions(-) diff --git a/app/client/cypress/e2e/Regression/ClientSide/Debugger/Widget_property_navigation_spec.ts b/app/client/cypress/e2e/Regression/ClientSide/Debugger/Widget_property_navigation_spec.ts index acf5e0c562..a287aa1ef7 100644 --- a/app/client/cypress/e2e/Regression/ClientSide/Debugger/Widget_property_navigation_spec.ts +++ b/app/client/cypress/e2e/Regression/ClientSide/Debugger/Widget_property_navigation_spec.ts @@ -83,7 +83,7 @@ describe( _.entityExplorer.DragDropWidgetNVerify(_.draggableWidgets.JSONFORM); _.propPane.EnterJSContext( "sourcedata", - JSON.stringify(schema), + JSON.stringify(schema) + " ", true, false, ); diff --git a/app/client/cypress/e2e/Regression/ClientSide/Widgets/JSONForm/JSONForm_CurrencyField_spec.ts b/app/client/cypress/e2e/Regression/ClientSide/Widgets/JSONForm/JSONForm_CurrencyField_spec.ts index 2284c8b7e8..dc021da784 100644 --- a/app/client/cypress/e2e/Regression/ClientSide/Widgets/JSONForm/JSONForm_CurrencyField_spec.ts +++ b/app/client/cypress/e2e/Regression/ClientSide/Widgets/JSONForm/JSONForm_CurrencyField_spec.ts @@ -23,7 +23,7 @@ describe( entityExplorer.DragDropWidgetNVerify(draggableWidgets.JSONFORM, 300, 100); propPane.EnterJSContext( "sourcedata", - JSON.stringify(schema), + JSON.stringify(schema) + " ", true, false, ); diff --git a/app/client/cypress/e2e/Regression/ClientSide/Widgets/TableV2/AddNewRow1_spec.js b/app/client/cypress/e2e/Regression/ClientSide/Widgets/TableV2/AddNewRow1_spec.js index 2430a981bc..af16058822 100644 --- a/app/client/cypress/e2e/Regression/ClientSide/Widgets/TableV2/AddNewRow1_spec.js +++ b/app/client/cypress/e2e/Regression/ClientSide/Widgets/TableV2/AddNewRow1_spec.js @@ -204,7 +204,7 @@ describe( ); // remove all table data - propPane.UpdatePropertyFieldValue("Table data", `[]`); + propPane.UpdatePropertyFieldValue("Table data", `[] `); // allow adding a row propPane.TogglePropertyState("Allow adding a row", "On"); diff --git a/app/client/src/components/editorComponents/CodeEditor/codeEditorUtils.ts b/app/client/src/components/editorComponents/CodeEditor/codeEditorUtils.ts index 8f9ff47782..3a65273d02 100644 --- a/app/client/src/components/editorComponents/CodeEditor/codeEditorUtils.ts +++ b/app/client/src/components/editorComponents/CodeEditor/codeEditorUtils.ts @@ -3,25 +3,21 @@ import { ENTITY_TYPE } from "ee/entities/AppsmithConsole/utils"; import type { WidgetEntity, ActionEntity } from "ee/entities/DataTree/types"; import { trim } from "lodash"; import { getDynamicStringSegments } from "utils/DynamicBindingUtils"; -import { EditorSize } from "./EditorConfig"; +import { EditorModes, EditorSize } from "./EditorConfig"; import { SlashCommandMenuOnFocusWidgetProps } from "./constants"; -// TODO: Fix this the next time the file is edited -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const removeNewLineChars = (inputValue: any) => { +export const removeNewLineChars = (inputValue: string) => { return inputValue && inputValue.replace(/(\r\n|\n|\r)/gm, ""); }; -// TODO: Fix this the next time the file is edited -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const getInputValue = (inputValue: any) => { +export const getInputValue = (inputValue: unknown): string => { if (typeof inputValue === "object" || typeof inputValue === "boolean") { inputValue = JSON.stringify(inputValue, null, 2); } else if (typeof inputValue === "number" || typeof inputValue === "string") { inputValue += ""; } - return inputValue; + return String(inputValue || ""); }; const computeCursorIndex = (editor: CodeMirror.Editor) => { const cursor = editor.getCursor(); @@ -170,3 +166,26 @@ export function shouldShowSlashCommandMenu( SlashCommandMenuOnFocusWidgetProps[widgetType].includes(propertyPath) ); } + +// Checks if the input value is only one word +export const shouldShowAutocompleteWithBindingBrackets = ( + editor: CodeMirror.Editor, +) => { + const editorMode = editor.getModeAt(editor.getCursor()); + + if (editorMode?.name === EditorModes.SQL) { + return false; + } + + const value = editor.getValue(); + + // Do not show if the value is "/" + if (value.startsWith("/") || value.trim() === "") { + return false; + } + + // Split the value by whitespace + const stringSegments = value.split(/\s+/); + + return stringSegments.length === 1; +}; diff --git a/app/client/src/components/editorComponents/CodeEditor/hintHelpers.test.ts b/app/client/src/components/editorComponents/CodeEditor/hintHelpers.test.ts index 66f1eccbf3..c07c95e1c5 100644 --- a/app/client/src/components/editorComponents/CodeEditor/hintHelpers.test.ts +++ b/app/client/src/components/editorComponents/CodeEditor/hintHelpers.test.ts @@ -64,8 +64,9 @@ describe("hint helpers", () => { toCall: "closeHint" | "showHint"; getLine?: string[]; } + const cases: Case[] = [ - { value: "ABC", cursor: { ch: 3, line: 0 }, toCall: "closeHint" }, + { value: "ABC", cursor: { ch: 3, line: 0 }, toCall: "showHint" }, { value: "{{ }}", cursor: { ch: 3, line: 0 }, toCall: "showHint" }, { value: '{ name: "{{}}" }', @@ -91,7 +92,7 @@ describe("hint helpers", () => { { value: "{test(", cursor: { ch: 1, line: 0 }, - toCall: "closeHint", + toCall: "showHint", }, { value: "justanystring {{}}", @@ -101,7 +102,7 @@ describe("hint helpers", () => { ]; cases.forEach((testCase) => { - MockCodemirrorEditor.getValue.mockReturnValueOnce(testCase.value); + MockCodemirrorEditor.getValue.mockReturnValue(testCase.value); MockCodemirrorEditor.getCursor.mockReturnValue(testCase.cursor); if (testCase.getLine) { diff --git a/app/client/src/components/editorComponents/CodeEditor/hintHelpers.ts b/app/client/src/components/editorComponents/CodeEditor/hintHelpers.ts index da3517d226..cafcc0f2db 100644 --- a/app/client/src/components/editorComponents/CodeEditor/hintHelpers.ts +++ b/app/client/src/components/editorComponents/CodeEditor/hintHelpers.ts @@ -7,6 +7,7 @@ import { EditorModes } from "components/editorComponents/CodeEditor/EditorConfig import { checkIfCursorInsideBinding, isCursorOnEmptyToken, + shouldShowAutocompleteWithBindingBrackets, } from "components/editorComponents/CodeEditor/codeEditorUtils"; import { isEmpty, isString } from "lodash"; import type { getAllDatasourceTableKeys } from "ee/selectors/entitiesSelector"; @@ -45,14 +46,14 @@ export const bindingHintHelper: HintHelper = (editor: CodeMirror.Editor) => { CodemirrorTernService.setEntityInformation(editor, entityInformation); } - let shouldShow = false; + let shouldShow = true; if (additionalData?.isJsEditor) { if (additionalData?.enableAIAssistance) { shouldShow = !isAISlashCommand(editor); - } else { - shouldShow = true; } + } else if (shouldShowAutocompleteWithBindingBrackets(editor)) { + shouldShow = true; } else { shouldShow = checkIfCursorInsideBinding(editor); } diff --git a/app/client/src/utils/autocomplete/CodemirrorTernService.ts b/app/client/src/utils/autocomplete/CodemirrorTernService.ts index 0792838fc8..3b01bb5a75 100644 --- a/app/client/src/utils/autocomplete/CodemirrorTernService.ts +++ b/app/client/src/utils/autocomplete/CodemirrorTernService.ts @@ -21,6 +21,7 @@ import { import AnalyticsUtil from "ee/utils/AnalyticsUtil"; import { findIndex, isString } from "lodash"; import { renderTernTooltipContent } from "./ternDocTooltip"; +import { checkIfCursorInsideBinding } from "components/editorComponents/CodeEditor/codeEditorUtils"; const bigDoc = 250; const cls = "CodeMirror-Tern-"; @@ -516,6 +517,8 @@ class CodeMirrorTernService { const lineValue = this.lineValue(doc); const cursor = cm.getCursor(); const { extraChars } = this.getFocusedDocValueAndPos(doc); + const fieldIsJSAction = + this.fieldEntityInformation.entityType === ENTITY_TYPE.JSACTION; let completions: Completion[] = []; let after = ""; @@ -550,6 +553,7 @@ class CodeMirrorTernService { if (typeof completion === "string") continue; + const isCursorInsideBinding = checkIfCursorInsideBinding(cm); const isKeyword = isCustomKeywordType(completion); const className = typeToIcon(completion.type as string, isKeyword); const dataType = getDataType(completion.type as string); @@ -589,6 +593,11 @@ class CodeMirrorTernService { isEntityName: isCompletionADataTreeEntityName, }; + if (!isCursorInsideBinding && !fieldIsJSAction) { + codeMirrorCompletion.displayText = `{{${codeMirrorCompletion.displayText}}}`; + codeMirrorCompletion.text = `{{${codeMirrorCompletion.text}}}`; + } + if (isKeyword) { codeMirrorCompletion.render = ( element: HTMLElement, @@ -630,16 +639,13 @@ class CodeMirrorTernService { completions.push(codeMirrorCompletion); } - const shouldComputeBestMatch = - this.fieldEntityInformation.entityType !== ENTITY_TYPE.JSACTION; - completions = AutocompleteSorter.sort( completions, { ...this.fieldEntityInformation, token }, this.defEntityInformation.get( this.fieldEntityInformation.entityName || "", ), - shouldComputeBestMatch, + !fieldIsJSAction, ); const indexToBeSelected = completions.length && completions[0].isHeader ? 1 : 0; @@ -747,13 +753,23 @@ class CodeMirrorTernService { libraryNamespace: selected.origin?.split("/")[1], }); + // Check if the completion ends with parentheses () or closing brackets }} const hasParenthesis = selected.text.endsWith("()"); + const endsWithBindingBrackets = selected.text.endsWith("}}"); + // Position cursor handling: + // 1. For functions - place cursor between parentheses e.g. myFunction(|) + // 2. For completions with }} - place cursor before }} e.g. {{Api1.data|}} if (selected.type === AutocompleteDataType.FUNCTION && hasParenthesis) { cm.setCursor({ line: cm.getCursor().line, ch: cm.getCursor().ch - 1, }); + } else if (endsWithBindingBrackets) { + cm.setCursor({ + line: cm.getCursor().line, + ch: cm.getCursor().ch - 2, + }); } }); @@ -1359,17 +1375,17 @@ class CodeMirrorTernService { const CodeMirror = getCodeMirrorNamespaceFromDoc(cm.getDoc()); const inner = CodeMirror.innerMode(cm.getMode(), state); - if (inner.mode.name != "javascript") return false; + if (inner.mode.name === "javascript") { + const lex = inner.state.lexical; - const lex = inner.state.lexical; + if (lex.info === "call") { + const argPos = lex.pos || 0; + const args = this.cachedArgHints?.type?.args || []; + const arg = args[argPos]; + const argType = arg?.type; - if (lex.info === "call") { - const argPos = lex.pos || 0; - const args = this.cachedArgHints?.type?.args || []; - const arg = args[argPos]; - const argType = arg?.type; - - entityInformation.expectedType = getDataType(argType); + entityInformation.expectedType = getDataType(argType); + } } this.fieldEntityInformation = entityInformation; diff --git a/app/client/src/utils/autocomplete/__tests__/TernServer.test.ts b/app/client/src/utils/autocomplete/__tests__/TernServer.test.ts index 7398cff8e6..dc1b5899b9 100644 --- a/app/client/src/utils/autocomplete/__tests__/TernServer.test.ts +++ b/app/client/src/utils/autocomplete/__tests__/TernServer.test.ts @@ -214,7 +214,7 @@ describe("Tern server", () => { MockCodemirrorEditor.getValue.mockReturnValueOnce( testCase.input.codeEditor.value, ); - MockCodemirrorEditor.getCursor.mockReturnValueOnce( + MockCodemirrorEditor.getCursor.mockReturnValue( testCase.input.codeEditor.cursor, ); MockCodemirrorEditor.getDoc.mockReturnValue( @@ -689,6 +689,11 @@ describe("Tern server completion", () => { ch: 30, sticky: null, }); + + MockCodemirrorEditor.getValue.mockReturnValue( + "\t\tconst users = await QueryMod", + ); + MockCodemirrorEditor.getTokenAt.mockResolvedValue(mockToken); CodemirrorTernService.fieldEntityInformation = fieldEntityInformation; CodemirrorTernService.entityDef = entityDef; diff --git a/app/client/test/__mocks__/CodeMirrorEditorMock.ts b/app/client/test/__mocks__/CodeMirrorEditorMock.ts index d417712980..3035c52f80 100644 --- a/app/client/test/__mocks__/CodeMirrorEditorMock.ts +++ b/app/client/test/__mocks__/CodeMirrorEditorMock.ts @@ -19,6 +19,7 @@ export const MockCodemirrorEditor = { getDoc: jest.fn(), getTokenAt: jest.fn(), getMode: jest.fn(), + getModeAt: jest.fn(), constructor: MockCodemirrorNamespace, };