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);
_.propPane.EnterJSContext(
"sourcedata",
JSON.stringify(schema),
JSON.stringify(schema) + " ",
true,
false,
);

View File

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

View File

@ -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");

View File

@ -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;
};

View File

@ -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) {

View File

@ -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);
}

View File

@ -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<TernCompletionResult>[] = [];
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;

View File

@ -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;

View File

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