feat: Trigger autocomplete even outside bindings (#39446)

## Description

Shows autocomplete with results in binding brackets `{{ <result> }}`
when the user is typing something outside the binding

Fixes #39112

## Automation

/ok-to-test tags="@tag.All"

### 🔍 Cypress test results
<!-- This is an auto-generated comment: Cypress test results  -->
> [!TIP]
> 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉
> Workflow run:
<https://github.com/appsmithorg/appsmith/actions/runs/13699728311>
> Commit: cd3816b05a8e36e7218af2ab6fb4c23046ab99ba
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=13699728311&attempt=1"
target="_blank">Cypress dashboard</a>.
> Tags: `@tag.All`
> Spec:
> <hr>Thu, 06 Mar 2025 14:46:52 UTC
<!-- end of auto-generated comment: Cypress test results  -->


## Communication
Should the DevRel and Marketing teams inform users about this change?
- [ ] Yes
- [ ] No


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Hetu Nandu 2025-03-07 13:35:16 +05:30 committed by GitHub
parent 7927d762c8
commit a3794dc0ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 74 additions and 31 deletions

View File

@ -83,7 +83,7 @@ describe(
_.entityExplorer.DragDropWidgetNVerify(_.draggableWidgets.JSONFORM); _.entityExplorer.DragDropWidgetNVerify(_.draggableWidgets.JSONFORM);
_.propPane.EnterJSContext( _.propPane.EnterJSContext(
"sourcedata", "sourcedata",
JSON.stringify(schema), JSON.stringify(schema) + " ",
true, true,
false, false,
); );

View File

@ -23,7 +23,7 @@ describe(
entityExplorer.DragDropWidgetNVerify(draggableWidgets.JSONFORM, 300, 100); entityExplorer.DragDropWidgetNVerify(draggableWidgets.JSONFORM, 300, 100);
propPane.EnterJSContext( propPane.EnterJSContext(
"sourcedata", "sourcedata",
JSON.stringify(schema), JSON.stringify(schema) + " ",
true, true,
false, false,
); );

View File

@ -204,7 +204,7 @@ describe(
); );
// remove all table data // remove all table data
propPane.UpdatePropertyFieldValue("Table data", `[]`); propPane.UpdatePropertyFieldValue("Table data", `[] `);
// allow adding a row // allow adding a row
propPane.TogglePropertyState("Allow adding a row", "On"); propPane.TogglePropertyState("Allow adding a row", "On");

View File

@ -3,25 +3,21 @@ import { ENTITY_TYPE } from "ee/entities/AppsmithConsole/utils";
import type { WidgetEntity, ActionEntity } from "ee/entities/DataTree/types"; import type { WidgetEntity, ActionEntity } from "ee/entities/DataTree/types";
import { trim } from "lodash"; import { trim } from "lodash";
import { getDynamicStringSegments } from "utils/DynamicBindingUtils"; import { getDynamicStringSegments } from "utils/DynamicBindingUtils";
import { EditorSize } from "./EditorConfig"; import { EditorModes, EditorSize } from "./EditorConfig";
import { SlashCommandMenuOnFocusWidgetProps } from "./constants"; import { SlashCommandMenuOnFocusWidgetProps } from "./constants";
// TODO: Fix this the next time the file is edited export const removeNewLineChars = (inputValue: string) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const removeNewLineChars = (inputValue: any) => {
return inputValue && inputValue.replace(/(\r\n|\n|\r)/gm, ""); return inputValue && inputValue.replace(/(\r\n|\n|\r)/gm, "");
}; };
// TODO: Fix this the next time the file is edited export const getInputValue = (inputValue: unknown): string => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const getInputValue = (inputValue: any) => {
if (typeof inputValue === "object" || typeof inputValue === "boolean") { if (typeof inputValue === "object" || typeof inputValue === "boolean") {
inputValue = JSON.stringify(inputValue, null, 2); inputValue = JSON.stringify(inputValue, null, 2);
} else if (typeof inputValue === "number" || typeof inputValue === "string") { } else if (typeof inputValue === "number" || typeof inputValue === "string") {
inputValue += ""; inputValue += "";
} }
return inputValue; return String(inputValue || "");
}; };
const computeCursorIndex = (editor: CodeMirror.Editor) => { const computeCursorIndex = (editor: CodeMirror.Editor) => {
const cursor = editor.getCursor(); const cursor = editor.getCursor();
@ -170,3 +166,26 @@ export function shouldShowSlashCommandMenu(
SlashCommandMenuOnFocusWidgetProps[widgetType].includes(propertyPath) 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;
};

View File

@ -64,8 +64,9 @@ describe("hint helpers", () => {
toCall: "closeHint" | "showHint"; toCall: "closeHint" | "showHint";
getLine?: string[]; getLine?: string[];
} }
const cases: Case[] = [ 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: "{{ }}", cursor: { ch: 3, line: 0 }, toCall: "showHint" },
{ {
value: '{ name: "{{}}" }', value: '{ name: "{{}}" }',
@ -91,7 +92,7 @@ describe("hint helpers", () => {
{ {
value: "{test(", value: "{test(",
cursor: { ch: 1, line: 0 }, cursor: { ch: 1, line: 0 },
toCall: "closeHint", toCall: "showHint",
}, },
{ {
value: "justanystring {{}}", value: "justanystring {{}}",
@ -101,7 +102,7 @@ describe("hint helpers", () => {
]; ];
cases.forEach((testCase) => { cases.forEach((testCase) => {
MockCodemirrorEditor.getValue.mockReturnValueOnce(testCase.value); MockCodemirrorEditor.getValue.mockReturnValue(testCase.value);
MockCodemirrorEditor.getCursor.mockReturnValue(testCase.cursor); MockCodemirrorEditor.getCursor.mockReturnValue(testCase.cursor);
if (testCase.getLine) { if (testCase.getLine) {

View File

@ -7,6 +7,7 @@ import { EditorModes } from "components/editorComponents/CodeEditor/EditorConfig
import { import {
checkIfCursorInsideBinding, checkIfCursorInsideBinding,
isCursorOnEmptyToken, isCursorOnEmptyToken,
shouldShowAutocompleteWithBindingBrackets,
} from "components/editorComponents/CodeEditor/codeEditorUtils"; } from "components/editorComponents/CodeEditor/codeEditorUtils";
import { isEmpty, isString } from "lodash"; import { isEmpty, isString } from "lodash";
import type { getAllDatasourceTableKeys } from "ee/selectors/entitiesSelector"; import type { getAllDatasourceTableKeys } from "ee/selectors/entitiesSelector";
@ -45,14 +46,14 @@ export const bindingHintHelper: HintHelper = (editor: CodeMirror.Editor) => {
CodemirrorTernService.setEntityInformation(editor, entityInformation); CodemirrorTernService.setEntityInformation(editor, entityInformation);
} }
let shouldShow = false; let shouldShow = true;
if (additionalData?.isJsEditor) { if (additionalData?.isJsEditor) {
if (additionalData?.enableAIAssistance) { if (additionalData?.enableAIAssistance) {
shouldShow = !isAISlashCommand(editor); shouldShow = !isAISlashCommand(editor);
} else {
shouldShow = true;
} }
} else if (shouldShowAutocompleteWithBindingBrackets(editor)) {
shouldShow = true;
} else { } else {
shouldShow = checkIfCursorInsideBinding(editor); shouldShow = checkIfCursorInsideBinding(editor);
} }

View File

@ -21,6 +21,7 @@ import {
import AnalyticsUtil from "ee/utils/AnalyticsUtil"; import AnalyticsUtil from "ee/utils/AnalyticsUtil";
import { findIndex, isString } from "lodash"; import { findIndex, isString } from "lodash";
import { renderTernTooltipContent } from "./ternDocTooltip"; import { renderTernTooltipContent } from "./ternDocTooltip";
import { checkIfCursorInsideBinding } from "components/editorComponents/CodeEditor/codeEditorUtils";
const bigDoc = 250; const bigDoc = 250;
const cls = "CodeMirror-Tern-"; const cls = "CodeMirror-Tern-";
@ -516,6 +517,8 @@ class CodeMirrorTernService {
const lineValue = this.lineValue(doc); const lineValue = this.lineValue(doc);
const cursor = cm.getCursor(); const cursor = cm.getCursor();
const { extraChars } = this.getFocusedDocValueAndPos(doc); const { extraChars } = this.getFocusedDocValueAndPos(doc);
const fieldIsJSAction =
this.fieldEntityInformation.entityType === ENTITY_TYPE.JSACTION;
let completions: Completion<TernCompletionResult>[] = []; let completions: Completion<TernCompletionResult>[] = [];
let after = ""; let after = "";
@ -550,6 +553,7 @@ class CodeMirrorTernService {
if (typeof completion === "string") continue; if (typeof completion === "string") continue;
const isCursorInsideBinding = checkIfCursorInsideBinding(cm);
const isKeyword = isCustomKeywordType(completion); const isKeyword = isCustomKeywordType(completion);
const className = typeToIcon(completion.type as string, isKeyword); const className = typeToIcon(completion.type as string, isKeyword);
const dataType = getDataType(completion.type as string); const dataType = getDataType(completion.type as string);
@ -589,6 +593,11 @@ class CodeMirrorTernService {
isEntityName: isCompletionADataTreeEntityName, isEntityName: isCompletionADataTreeEntityName,
}; };
if (!isCursorInsideBinding && !fieldIsJSAction) {
codeMirrorCompletion.displayText = `{{${codeMirrorCompletion.displayText}}}`;
codeMirrorCompletion.text = `{{${codeMirrorCompletion.text}}}`;
}
if (isKeyword) { if (isKeyword) {
codeMirrorCompletion.render = ( codeMirrorCompletion.render = (
element: HTMLElement, element: HTMLElement,
@ -630,16 +639,13 @@ class CodeMirrorTernService {
completions.push(codeMirrorCompletion); completions.push(codeMirrorCompletion);
} }
const shouldComputeBestMatch =
this.fieldEntityInformation.entityType !== ENTITY_TYPE.JSACTION;
completions = AutocompleteSorter.sort( completions = AutocompleteSorter.sort(
completions, completions,
{ ...this.fieldEntityInformation, token }, { ...this.fieldEntityInformation, token },
this.defEntityInformation.get( this.defEntityInformation.get(
this.fieldEntityInformation.entityName || "", this.fieldEntityInformation.entityName || "",
), ),
shouldComputeBestMatch, !fieldIsJSAction,
); );
const indexToBeSelected = const indexToBeSelected =
completions.length && completions[0].isHeader ? 1 : 0; completions.length && completions[0].isHeader ? 1 : 0;
@ -747,13 +753,23 @@ class CodeMirrorTernService {
libraryNamespace: selected.origin?.split("/")[1], libraryNamespace: selected.origin?.split("/")[1],
}); });
// Check if the completion ends with parentheses () or closing brackets }}
const hasParenthesis = selected.text.endsWith("()"); 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) { if (selected.type === AutocompleteDataType.FUNCTION && hasParenthesis) {
cm.setCursor({ cm.setCursor({
line: cm.getCursor().line, line: cm.getCursor().line,
ch: cm.getCursor().ch - 1, 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 CodeMirror = getCodeMirrorNamespaceFromDoc(cm.getDoc());
const inner = CodeMirror.innerMode(cm.getMode(), state); 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") { entityInformation.expectedType = getDataType(argType);
const argPos = lex.pos || 0; }
const args = this.cachedArgHints?.type?.args || [];
const arg = args[argPos];
const argType = arg?.type;
entityInformation.expectedType = getDataType(argType);
} }
this.fieldEntityInformation = entityInformation; this.fieldEntityInformation = entityInformation;

View File

@ -214,7 +214,7 @@ describe("Tern server", () => {
MockCodemirrorEditor.getValue.mockReturnValueOnce( MockCodemirrorEditor.getValue.mockReturnValueOnce(
testCase.input.codeEditor.value, testCase.input.codeEditor.value,
); );
MockCodemirrorEditor.getCursor.mockReturnValueOnce( MockCodemirrorEditor.getCursor.mockReturnValue(
testCase.input.codeEditor.cursor, testCase.input.codeEditor.cursor,
); );
MockCodemirrorEditor.getDoc.mockReturnValue( MockCodemirrorEditor.getDoc.mockReturnValue(
@ -689,6 +689,11 @@ describe("Tern server completion", () => {
ch: 30, ch: 30,
sticky: null, sticky: null,
}); });
MockCodemirrorEditor.getValue.mockReturnValue(
"\t\tconst users = await QueryMod",
);
MockCodemirrorEditor.getTokenAt.mockResolvedValue(mockToken); MockCodemirrorEditor.getTokenAt.mockResolvedValue(mockToken);
CodemirrorTernService.fieldEntityInformation = fieldEntityInformation; CodemirrorTernService.fieldEntityInformation = fieldEntityInformation;
CodemirrorTernService.entityDef = entityDef; CodemirrorTernService.entityDef = entityDef;

View File

@ -19,6 +19,7 @@ export const MockCodemirrorEditor = {
getDoc: jest.fn(), getDoc: jest.fn(),
getTokenAt: jest.fn(), getTokenAt: jest.fn(),
getMode: jest.fn(), getMode: jest.fn(),
getModeAt: jest.fn(),
constructor: MockCodemirrorNamespace, constructor: MockCodemirrorNamespace,
}; };