diff --git a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/BugTests/Autocomplete_JS_spec.ts b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Autocomplete/Autocomplete_JS_spec.ts similarity index 100% rename from app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/BugTests/Autocomplete_JS_spec.ts rename to app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Autocomplete/Autocomplete_JS_spec.ts diff --git a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/BugTests/Autocomplete_Spec.ts b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Autocomplete/Autocomplete_Spec.ts similarity index 100% rename from app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/BugTests/Autocomplete_Spec.ts rename to app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Autocomplete/Autocomplete_Spec.ts diff --git a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Autocomplete/PropertyPaneSuggestion_spec.ts b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Autocomplete/PropertyPaneSuggestion_spec.ts new file mode 100644 index 0000000000..85bee13666 --- /dev/null +++ b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Autocomplete/PropertyPaneSuggestion_spec.ts @@ -0,0 +1,40 @@ +import { ObjectsRegistry } from "../../../../support/Objects/Registry"; + +const { + AggregateHelper, + CommonLocators, + EntityExplorer, + PropertyPane, +} = ObjectsRegistry; + +describe("Property Pane Suggestions", () => { + before(() => { + cy.fixture("buttondsl").then((val: any) => { + AggregateHelper.AddDsl(val); + }); + }); + + it("1. Should show Property Pane Suggestions on / command", () => { + EntityExplorer.SelectEntityByName("Button1", "Widgets"); + PropertyPane.TypeTextIntoField("Label", "/"); + AggregateHelper.GetNAssertElementText(CommonLocators._hints, "Bind Data"); + AggregateHelper.GetNAssertElementText( + CommonLocators._hints, + "New Binding", + "have.text", + 1, + ); + AggregateHelper.GetNClickByContains(CommonLocators._hints, "New Binding"); + + PropertyPane.ValidatePropertyFieldValue("Label", "{{}}"); + }); + + it("2. Should show Property Pane Suggestions on typing {{}}", () => { + EntityExplorer.SelectEntityByName("Button1", "Widgets"); + PropertyPane.TypeTextIntoField("Label", "{{"); + AggregateHelper.GetNAssertElementText(CommonLocators._hints, "appsmith"); + AggregateHelper.GetNClickByContains(CommonLocators._hints, "appsmith"); + + PropertyPane.ValidatePropertyFieldValue("Label", "{{appsmith}}"); + }); +}); diff --git a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Binding/autocomplete_spec.js b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Autocomplete/autocomplete_spec.js similarity index 100% rename from app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Binding/autocomplete_spec.js rename to app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Autocomplete/autocomplete_spec.js diff --git a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/CodeComment/PropertyPaneCodeComment_spec.ts b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/CodeComment/PropertyPaneCodeComment_spec.ts new file mode 100644 index 0000000000..e5c1d5013f --- /dev/null +++ b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/CodeComment/PropertyPaneCodeComment_spec.ts @@ -0,0 +1,27 @@ +import { ObjectsRegistry } from "../../../../support/Objects/Registry"; + +const { AggregateHelper, EntityExplorer, PropertyPane } = ObjectsRegistry; + +describe("Property Pane Code Commenting", () => { + before(() => { + cy.fixture("buttondsl").then((val: any) => { + AggregateHelper.AddDsl(val); + }); + }); + + it("1. Should comment code in Property Pane", () => { + EntityExplorer.SelectEntityByName("Button1", "Widgets"); + PropertyPane.TypeTextIntoField("Label", "{{appsmith}}"); + PropertyPane.ToggleCommentInTextField("Label"); + + PropertyPane.ValidatePropertyFieldValue("Label", "{{// appsmith}}"); + }); + + it("2. Should uncomment code in Property Pane", () => { + EntityExplorer.SelectEntityByName("Button1", "Widgets"); + PropertyPane.TypeTextIntoField("Label", "{{// appsmith}}"); + PropertyPane.ToggleCommentInTextField("Label"); + + PropertyPane.ValidatePropertyFieldValue("Label", "{{appsmith}}"); + }); +}); diff --git a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/VisualTests/JSEditorComment_spec.js b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/VisualTests/JSEditorComment_spec.js new file mode 100644 index 0000000000..4f6f8b3b1b --- /dev/null +++ b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/VisualTests/JSEditorComment_spec.js @@ -0,0 +1,49 @@ +import { ObjectsRegistry } from "../../../../support/Objects/Registry"; + +let jsEditor = ObjectsRegistry.JSEditor, + agHelper = ObjectsRegistry.AggregateHelper; + +describe("JSEditor Comment - Visual tests", () => { + it("1. comments code on the editor", () => { + jsEditor.CreateJSObject( + `export default { + myFun1: () => { + function hi(a,b) { + console.log(a,b); + } + hi(1,2); + }, + myFun2: async () => { + //use async-await or promises + } +}`, + { + paste: true, + completeReplace: true, + toRun: false, + shouldCreateNewJSObj: true, + prettify: false, + }, + ); + agHelper.GetNClick("[name='expand-more']", 1, true, 100); + agHelper.WaitUntilAllToastsDisappear(); + + cy.get("div.CodeMirror").matchImageSnapshot("jsObjBeforeCommenting1"); + + // Comment out lines 2,3,4 + for (let i = 2; i < 5; i++) { + agHelper.GetNClick(jsEditor._lineinJsEditor(i)); + + agHelper.Sleep(100); + + cy.get(jsEditor._lineinJsEditor(i)).type( + agHelper.isMac ? "{meta} /" : "{ctrl} /", + ); + } + + // Allow time to comment out lines + agHelper.Sleep(1000); + + cy.get("div.CodeMirror").matchImageSnapshot("jsObjAfterCommenting1"); + }); +}); diff --git a/app/client/cypress/integration/Smoke_TestSuite_Fat/ClientSideTests/Refactoring/Refactoring_spec.ts b/app/client/cypress/integration/Smoke_TestSuite_Fat/ClientSideTests/Refactoring/Refactoring_spec.ts index 801b3b08fd..7e6569169c 100644 --- a/app/client/cypress/integration/Smoke_TestSuite_Fat/ClientSideTests/Refactoring/Refactoring_spec.ts +++ b/app/client/cypress/integration/Smoke_TestSuite_Fat/ClientSideTests/Refactoring/Refactoring_spec.ts @@ -26,7 +26,7 @@ const refactorInput = { }, }; -describe("Validate JS Object Refactoring does not affect the comments & variables", () => { +describe.skip("Validate JS Object Refactoring does not affect the comments & variables", () => { before(() => { cy.fixture("Datatypes/RefactorDTdsl").then((val: any) => { _.agHelper.AddDsl(val); diff --git a/app/client/cypress/snapshots/Smoke_TestSuite/ClientSideTests/VisualTests/JSEditorComment_spec.js/jsObjAfterCommenting1.snap.png b/app/client/cypress/snapshots/Smoke_TestSuite/ClientSideTests/VisualTests/JSEditorComment_spec.js/jsObjAfterCommenting1.snap.png new file mode 100644 index 0000000000..e02faa71c1 Binary files /dev/null and b/app/client/cypress/snapshots/Smoke_TestSuite/ClientSideTests/VisualTests/JSEditorComment_spec.js/jsObjAfterCommenting1.snap.png differ diff --git a/app/client/cypress/snapshots/Smoke_TestSuite/ClientSideTests/VisualTests/JSEditorComment_spec.js/jsObjBeforeCommenting1.snap.png b/app/client/cypress/snapshots/Smoke_TestSuite/ClientSideTests/VisualTests/JSEditorComment_spec.js/jsObjBeforeCommenting1.snap.png new file mode 100644 index 0000000000..a41fa9ae84 Binary files /dev/null and b/app/client/cypress/snapshots/Smoke_TestSuite/ClientSideTests/VisualTests/JSEditorComment_spec.js/jsObjBeforeCommenting1.snap.png differ diff --git a/app/client/cypress/support/Pages/AggregateHelper.ts b/app/client/cypress/support/Pages/AggregateHelper.ts index 5f1cf8502d..830376d468 100644 --- a/app/client/cypress/support/Pages/AggregateHelper.ts +++ b/app/client/cypress/support/Pages/AggregateHelper.ts @@ -19,7 +19,7 @@ const DEFAULT_ENTERVALUE_OPTIONS = { export class AggregateHelper { private locator = ObjectsRegistry.CommonLocators; - private isMac = Cypress.platform === "darwin"; + public isMac = Cypress.platform === "darwin"; private selectLine = `${ this.isMac ? "{cmd}{shift}{leftArrow}" : "{shift}{home}" }`; diff --git a/app/client/cypress/support/Pages/PropertyPane.ts b/app/client/cypress/support/Pages/PropertyPane.ts index 19e8737764..ef8204567d 100644 --- a/app/client/cypress/support/Pages/PropertyPane.ts +++ b/app/client/cypress/support/Pages/PropertyPane.ts @@ -224,6 +224,17 @@ export class PropertyPane { toVerifySave && this.agHelper.AssertAutoSave(); //Allowing time for saving entered value } + public ValidatePropertyFieldValue( + propFieldName: string, + valueToValidate: string, + ) { + cy.xpath(this.locator._existingFieldTextByName(propFieldName)).then( + ($field: any) => { + this.agHelper.ValidateCodeEditorContent($field, valueToValidate); + }, + ); + } + public RemoveText(endp: string, toVerifySave = true) { cy.get( this.locator._propertyControl + @@ -264,6 +275,21 @@ export class PropertyPane { this.agHelper.AssertAutoSave(); //Allowing time for saving entered value } + public ToggleCommentInTextField(endp: string) { + cy.get( + this.locator._propertyControl + + endp.replace(/ +/g, "").toLowerCase() + + " " + + this.locator._codeMirrorTextArea, + ) + .first() + .then((el: any) => { + cy.get(el).type(this.agHelper.isMac ? "{meta}/" : "{ctrl}/"); + }); + + this.agHelper.AssertAutoSave(); //Allowing time for saving entered value + } + public EnterJSContext( endp: string, value: string, diff --git a/app/client/src/components/editorComponents/CodeEditor/index.tsx b/app/client/src/components/editorComponents/CodeEditor/index.tsx index 5d221d995a..3cb1993f90 100644 --- a/app/client/src/components/editorComponents/CodeEditor/index.tsx +++ b/app/client/src/components/editorComponents/CodeEditor/index.tsx @@ -18,6 +18,7 @@ import "codemirror/addon/mode/multiplex"; import "codemirror/addon/tern/tern.css"; import "codemirror/addon/lint/lint"; import "codemirror/addon/lint/lint.css"; +import "codemirror/addon/comment/comment"; import { getDataTreeForAutocomplete } from "selectors/dataTreeSelectors"; import EvaluatedValuePopup from "components/editorComponents/CodeEditor/EvaluatedValuePopup"; @@ -114,6 +115,7 @@ import { import { updateCustomDef } from "utils/autocomplete/customDefUtils"; import { shouldFocusOnPropertyControl } from "utils/editorContextUtils"; import { getEntityLintErrors } from "selectors/lintingSelectors"; +import { getCodeCommentKeyMap, handleCodeComment } from "./utils/codeComment"; import { EntityNavigationData, getEntitiesForNavigation, @@ -201,6 +203,7 @@ export type EditorProps = EditorStyleProps & // On focus and blur event handler onEditorBlur?: () => void; onEditorFocus?: () => void; + lineCommentString?: string; }; interface Props extends ReduxStateProps, EditorProps, ReduxDispatchProps {} @@ -223,6 +226,7 @@ class CodeEditor extends Component { static defaultProps = { marking: [bindingMarker, entityMarker], hinting: [bindingHint, commandsHelper], + lineCommentString: "//", }; // this is the higlighted element for any highlighted text in the codemirror highlightedUrlElement: HTMLElement | undefined; @@ -290,6 +294,11 @@ class CodeEditor extends Component { const moveCursorLeftKey = getMoveCursorLeftKey(); options.extraKeys = { [moveCursorLeftKey]: "goLineStartSmart", + [getCodeCommentKeyMap()]: handleCodeComment( + // We've provided the default props value for lineCommentString + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.props.lineCommentString!, + ), }; if (this.props.tabBehaviour === TabBehaviour.INPUT) { @@ -344,7 +353,7 @@ class CodeEditor extends Component { // editor.on("beforeChange", this.handleBeforeChange); editor.on("change", this.startChange); - editor.on("keyup", this.handleAutocompleteKeyup); + editor.on("keydown", this.handleAutocompleteKeydown); editor.on("focus", this.handleEditorFocus); editor.on("cursorActivity", this.handleCursorMovement); editor.on("blur", this.handleEditorBlur); @@ -535,7 +544,7 @@ class CodeEditor extends Component { this.editor.off("beforeChange", this.handleBeforeChange); this.editor.off("change", this.startChange); - this.editor.off("keyup", this.handleAutocompleteKeyup); + this.editor.off("keydown", this.handleAutocompleteKeydown); this.editor.off("focus", this.handleEditorFocus); this.editor.off("cursorActivity", this.handleCursorMovement); this.editor.off("blur", this.handleEditorBlur); @@ -903,8 +912,16 @@ class CodeEditor extends Component { this.setState({ hinterOpen }); }; - handleAutocompleteKeyup = (cm: CodeMirror.Editor, event: KeyboardEvent) => { + handleAutocompleteKeydown = (cm: CodeMirror.Editor, event: KeyboardEvent) => { const key = event.key; + + // Since selection from AutoComplete list is also done using the Enter keydown event + // we need to return from here so that autocomplete selection works fine + if (key === "Enter") return; + + // Check if the user is trying to comment out the line, in that case we should not show autocomplete + const isCtrlOrCmdPressed = event.metaKey || event.ctrlKey; + if (isModifierKey(key)) return; const code = `${event.ctrlKey ? "Ctrl+" : ""}${event.code}`; if (isCloseKey(code) || isCloseKey(key)) { @@ -916,20 +933,25 @@ class CodeEditor extends Component { const line = cm.getLine(cursor.line); let showAutocomplete = false; /* Check if the character before cursor is completable to show autocomplete which backspacing */ - if (key === "/") { + if (key === "/" && !isCtrlOrCmdPressed) { showAutocomplete = true; } else if (event.code === "Backspace") { const prevChar = line[cursor.ch - 1]; showAutocomplete = !!prevChar && /[a-zA-Z_0-9.]/.test(prevChar); } else if (key === "{") { - /* Autocomplete for "{" should show up only when a user attempts to write {{}} and not a code block. */ - const prevChar = line[cursor.ch - 2]; + /* Autocomplete for { should show up only when a user attempts to write {{}} and not a code block. */ + const prevChar = line[cursor.ch - 1]; showAutocomplete = prevChar === "{"; } else if (key.length == 1) { showAutocomplete = /[a-zA-Z_0-9.]/.test(key); /* Autocomplete should be triggered only for characters that make up valid variable names */ } - showAutocomplete && this.handleAutocompleteVisibility(cm); + + // Allow keydown event to enter the text to the editor before firing autocomplete + // otherwise it'll not work for the first character + setTimeout(() => { + showAutocomplete && this.handleAutocompleteVisibility(cm); + }, 10); }; lintCode(editor: CodeMirror.Editor) { diff --git a/app/client/src/components/editorComponents/CodeEditor/utils/codeComment.ts b/app/client/src/components/editorComponents/CodeEditor/utils/codeComment.ts new file mode 100644 index 0000000000..f0cd830411 --- /dev/null +++ b/app/client/src/components/editorComponents/CodeEditor/utils/codeComment.ts @@ -0,0 +1,334 @@ +import CodeMirror from "codemirror"; +import { isMacOrIOS } from "utils/helpers"; +import { EditorModes } from "../EditorConfig"; + +export const getCodeCommentKeyMap = () => { + return isMacOrIOS() ? "Cmd-/" : "Ctrl-/"; +}; + +export function getLineCommentString(mode: EditorModes) { + switch (mode) { + case EditorModes.SQL: + case EditorModes.SQL_WITH_BINDING: + return "--"; + default: + return "//"; + } +} + +// Most of the code below is copied from https://github.com/codemirror/codemirror5/blob/master/addon/comment/comment.js +// with minor modifications to support commenting in JS fields with {{ }} syntax +// CodeMirror's APIs don't allow such things, so copied functions and overrode them + +/** Get end of line for line comment */ +function getEndLineForLineComment( + from: CodeMirror.Position, + to: CodeMirror.Position, + cm: CodeMirror.Editor, +) { + return Math.min( + to.ch != 0 || to.line == from.line ? to.line + 1 : to.line, + cm.lastLine() + 1, + ); +} + +/** Get end of line for line comment */ +function getEndLineForLineUncomment( + from: CodeMirror.Position, + to: CodeMirror.Position, + cm: CodeMirror.Editor, +) { + return Math.min( + to.ch != 0 || to.line == from.line ? to.line : to.line - 1, + cm.lastLine() + 1, + ); +} + +const JS_FIELD_BEGIN = "{{"; +const JS_FIELD_END = "}}"; + +const nonWhitespace = /[^\s\u00a0]/; + +const noOptions: CodeMirror.CommentOptions = {}; + +/** + * Gives index of the first non whitespace character in the line + **/ +function firstNonWhitespace(str: string, mode: EditorModes) { + const found = str.search( + [EditorModes.JAVASCRIPT, EditorModes.TEXT_WITH_BINDING].includes(mode) && + str.includes(JS_FIELD_BEGIN) + ? JS_FIELD_BEGIN + : nonWhitespace, + ); + return found === -1 ? 0 : found; +} + +// Rough heuristic to try and detect lines that are part of multi-line string +function probablyInsideString( + cm: CodeMirror.Editor, + pos: CodeMirror.Position, + line: string, +) { + return ( + /\bstring\b/.test(cm.getTokenTypeAt(CodeMirror.Pos(pos.line, 0))) && + !/^[\'\"\`]/.test(line) + ); +} + +function performLineCommenting( + // this is a fake parameter to specify type for this + // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-0.html#specifying-the-type-of-this-for-functions + this: CodeMirror.Editor, + from: CodeMirror.Position, + to: CodeMirror.Position, + options = noOptions, +) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self: CodeMirror.Editor = this as any; + const mode = self.getMode(); + const firstLine = self.getLine(from.line); + if (firstLine === null || probablyInsideString(self, from, firstLine)) return; + + // When mode is TEXT, the name is null string, we skip commenting + const commentString = + mode.name === EditorModes.TEXT_WITH_BINDING && + !(firstLine.includes(JS_FIELD_BEGIN) || firstLine.includes(JS_FIELD_END)) + ? "" + : options.lineComment || mode.lineComment; + + if (!commentString) { + if (options.blockCommentStart || mode.blockCommentStart) { + options.fullLines = true; + self.blockComment(from, to, options); + } + return; + } + + const end = getEndLineForLineComment(from, to, self); + const padding = options.padding || " "; + const blankLines = options.commentBlankLines || from.line === to.line; + + self.operation(function() { + if (options.indent) { + for (let i = from.line; i < end; ++i) { + const line = self.getLine(i); + + const baseString = + line.search(nonWhitespace) === -1 + ? line + : line.slice( + 0, + firstNonWhitespace( + line, + // When there is JS bindings inside SQL, the mode is JAVASCRIPT instead of SQL + // we need to explicitly check if the SQL comment string is passed, make the mode SQL + commentString === getLineCommentString(EditorModes.SQL) + ? EditorModes.SQL + : (mode.name as EditorModes), + ), + ); + + const offset = (baseString || "").length; + + if (!blankLines && !nonWhitespace.test(line)) continue; + + // Handle JS field lines starting with {{ + if (line.slice(offset).startsWith(JS_FIELD_BEGIN)) { + self.replaceRange( + baseString + JS_FIELD_BEGIN + commentString + padding, + CodeMirror.Pos(i, 0), + CodeMirror.Pos(i, offset + JS_FIELD_BEGIN.length), + ); + continue; + } + + self.replaceRange( + baseString + commentString + padding, + CodeMirror.Pos(i, 0), + CodeMirror.Pos(i, offset), + ); + } + } else { + for (let i = from.line; i < end; ++i) { + const line = self.getLine(i); + if (blankLines || nonWhitespace.test(line)) { + // Handle JS field lines starting with {{ + if (line.startsWith(JS_FIELD_BEGIN)) { + self.replaceRange( + commentString + padding, + CodeMirror.Pos(i, JS_FIELD_BEGIN.length), + ); + continue; + } + + self.replaceRange(commentString + padding, CodeMirror.Pos(i, 0)); + } + } + } + }); +} + +function performLineUncommenting( + // this is a fake parameter to specify type for this + // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-0.html#specifying-the-type-of-this-for-functions + this: CodeMirror.Editor, + from: CodeMirror.Position, + to: CodeMirror.Position, + options = noOptions, +) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + const mode = self.getMode(); + const end = getEndLineForLineUncomment(from, to, self); + const start = Math.min(from.line, end); + + // Try finding line comments + const lineString = options.lineComment || mode.lineComment; + const lines: string[] = []; + const padding = options.padding || " "; + let didCommentCode; + lineComment: { + if (!lineString) break lineComment; + for (let i = start; i <= end; ++i) { + const line = self.getLine(i); + const found = line.indexOf(lineString); + + if (found == -1 && nonWhitespace.test(line)) break lineComment; + if ( + found > -1 && + // Handle JS fields with {{}} + !line.trim().includes(JS_FIELD_BEGIN) && + nonWhitespace.test(line.slice(0, found)) + ) + break lineComment; + lines.push(line); + } + self.operation(function() { + for (let i = start; i <= end; ++i) { + const line = lines[i - start]; + const pos = line.indexOf(lineString); + let endPos = pos + lineString.length; + if (pos < 0) continue; + if (line.slice(endPos, endPos + padding.length) == padding) + endPos += padding.length; + didCommentCode = true; + self.replaceRange( + "", + CodeMirror.Pos(i, pos), + CodeMirror.Pos(i, endPos), + ); + } + }); + if (didCommentCode) return true; + } + + // Try block comments + const startString = options.blockCommentStart || mode.blockCommentStart; + const endString = options.blockCommentEnd || mode.blockCommentEnd; + if (!startString || !endString) return false; + const blockCommentLead = options.blockCommentLead || mode.blockCommentLead; + const startLine = self.getLine(start); + const open = startLine.indexOf(startString); + if (open == -1) return false; + const endLine = end === start ? startLine : self.getLine(end); + const close = endLine.indexOf( + endString, + end === start ? open + startString.length : 0, + ); + const insideStart = CodeMirror.Pos(start, open + 1), + insideEnd = CodeMirror.Pos(end, close + 1); + if ( + close === -1 || + !/comment/.test(self.getTokenTypeAt(insideStart)) || + !/comment/.test(self.getTokenTypeAt(insideEnd)) || + self.getRange(insideStart, insideEnd, "\n").indexOf(endString) > -1 + ) + return false; + + // Avoid killing block comments completely outside the selection. + // Positions of the last startString before the start of the selection, and the first endString after it. + let lastStart = startLine.lastIndexOf(startString, from.ch); + let firstEnd = + lastStart === -1 + ? -1 + : startLine + .slice(0, from.ch) + .indexOf(endString, lastStart + startString.length); + if ( + lastStart !== -1 && + firstEnd !== -1 && + firstEnd + endString.length != from.ch + ) + return false; + // Positions of the first endString after the end of the selection, and the last startString before it. + firstEnd = endLine.indexOf(endString, to.ch); + const almostLastStart = endLine + .slice(to.ch) + .lastIndexOf(startString, firstEnd - to.ch); + lastStart = + firstEnd === -1 || almostLastStart === -1 ? -1 : to.ch + almostLastStart; + if (firstEnd !== -1 && lastStart != -1 && lastStart !== to.ch) return false; + + self.operation(function() { + self.replaceRange( + "", + CodeMirror.Pos( + end, + close - + (padding && endLine.slice(close - padding.length, close) == padding + ? padding.length + : 0), + ), + CodeMirror.Pos(end, close + endString.length), + ); + let openEnd = open + startString.length; + if ( + padding && + startLine.slice(openEnd, openEnd + padding.length) == padding + ) + openEnd += padding.length; + self.replaceRange( + "", + CodeMirror.Pos(start, open), + CodeMirror.Pos(start, openEnd), + ); + if (blockCommentLead) { + for (let i = start + 1; i <= end; ++i) { + const line = self.getLine(i); + const found = line.indexOf(blockCommentLead); + if (found == -1 || nonWhitespace.test(line.slice(0, found))) continue; + let foundEnd = found + blockCommentLead.length; + if ( + padding && + line.slice(foundEnd, foundEnd + padding.length) == padding + ) + foundEnd += padding.length; + self.replaceRange( + "", + CodeMirror.Pos(i, found), + CodeMirror.Pos(i, foundEnd), + ); + } + } + }); + return true; +} + +/** This function handles commenting which includes functions copied from comment add on with modifications */ +export const handleCodeComment = (lineCommentingString: string) => ( + cm: CodeMirror.Editor, +) => { + cm.lineComment = performLineCommenting; + + cm.uncomment = performLineUncommenting; + + // This is the actual command that does the comment toggling + cm.toggleComment({ + commentBlankLines: true, + // Always provide the line comment, otherwise it'll not work for JS fields when + // the mode is set to text/plain (when whole text wrapped in {{}} is selected) + lineComment: lineCommentingString, + indent: true, + }); +}; diff --git a/app/client/src/components/editorComponents/CodeEditor/utils/codeComments.test.ts b/app/client/src/components/editorComponents/CodeEditor/utils/codeComments.test.ts new file mode 100644 index 0000000000..ac4b895c23 --- /dev/null +++ b/app/client/src/components/editorComponents/CodeEditor/utils/codeComments.test.ts @@ -0,0 +1,335 @@ +import CodeMirror from "codemirror"; +import "components/editorComponents/CodeEditor/modes"; +import "codemirror/addon/comment/comment"; +import { EditorModes } from "../EditorConfig"; +import { handleCodeComment } from "./codeComment"; + +const JS_LINE_COMMENT = "//"; +const SQL_LINE_COMMENT = "--"; + +describe("handleCodeComment", () => { + it("should handle code comment for single line", () => { + const editor = CodeMirror(document.body, { mode: EditorModes.JAVASCRIPT }); + + const code = `const a = 1;`; + editor.setValue(code); + + // Select the code before commenting + editor.setSelection( + { line: 0, ch: 0 }, + { line: editor.lastLine() + 1, ch: 0 }, + ); + + handleCodeComment(JS_LINE_COMMENT)(editor); + + expect(editor.getValue()).toEqual(`// const a = 1;`); + }); + + it("should handle code comment for multiple lines", () => { + const editor = CodeMirror(document.body, { mode: EditorModes.JAVASCRIPT }); + + const code = `const a = 1; + const b = 2;`; + editor.setValue(code); + + // Select the code before commenting + editor.setSelection( + { line: 0, ch: 0 }, + { line: editor.lastLine() + 1, ch: 0 }, + ); + + handleCodeComment(JS_LINE_COMMENT)(editor); + + expect(editor.getValue()).toEqual(`// const a = 1; + // const b = 2;`); + }); + + it("should handle code uncomment for multiple lines", () => { + const editor = CodeMirror(document.body, { mode: EditorModes.JAVASCRIPT }); + + const code = `// const a = 1; + // const b = 2;`; + editor.setValue(code); + + // Select the code before commenting + editor.setSelection( + { line: 0, ch: 0 }, + { line: editor.lastLine() + 1, ch: 0 }, + ); + + handleCodeComment(JS_LINE_COMMENT)(editor); + + expect(editor.getValue()).toEqual(`const a = 1; + const b = 2;`); + }); + + it("should handle code comment for multiple lines in between", () => { + const editor = CodeMirror(document.body, { mode: EditorModes.JAVASCRIPT }); + + const code = `const a = 1; + const b = 2; + const c = 3; + const d = 4;`; + editor.setValue(code); + + // Select the code before commenting + editor.setSelection({ line: 1, ch: 0 }, { line: 3, ch: 0 }); + + handleCodeComment(JS_LINE_COMMENT)(editor); + + expect(editor.getValue()).toEqual(`const a = 1; + // const b = 2; + // const c = 3; + const d = 4;`); + }); + + it("should not code comment for JS fields with plain text only", () => { + const editor = CodeMirror(document.body, { + mode: EditorModes.TEXT_WITH_BINDING, + }); + + const code = `hello world`; + editor.setValue(code); + + // Select the code before commenting + editor.setSelection( + { line: 0, ch: 0 }, + { line: editor.lastLine() + 1, ch: 0 }, + ); + + handleCodeComment(JS_LINE_COMMENT)(editor); + + expect(editor.getValue()).toEqual(`hello world`); + }); + + it("should handle code uncomment for JS fields with plain text", () => { + const editor = CodeMirror(document.body, { + mode: EditorModes.TEXT_WITH_BINDING, + }); + + const code = `// hello world`; + editor.setValue(code); + + // Select the code before commenting + editor.setSelection( + { line: 0, ch: 0 }, + { line: editor.lastLine() + 1, ch: 0 }, + ); + + handleCodeComment(JS_LINE_COMMENT)(editor); + + expect(editor.getValue()).toEqual(`hello world`); + }); + + it("should handle code comment in JS fields with single line", () => { + const editor = CodeMirror(document.body, { mode: EditorModes.JAVASCRIPT }); + + const code = `{{ appsmith.store.id }}`; + + editor.setValue(code); + + // Select the code before commenting + editor.setSelection( + { line: 0, ch: 0 }, + { line: editor.lastLine() + 1, ch: 0 }, + ); + + handleCodeComment(JS_LINE_COMMENT)(editor); + + expect(editor.getValue()).toEqual(`{{// appsmith.store.id }}`); + }); + + it("should handle code comment in JS fields with text", () => { + const editor = CodeMirror(document.body, { + mode: EditorModes.JAVASCRIPT, + }); + + const code = `Hello {{ appsmith.store.id }}`; + + editor.setValue(code); + + // Select the code before commenting + editor.setSelection( + { line: 0, ch: 0 }, + { line: editor.lastLine() + 1, ch: 0 }, + ); + + handleCodeComment(JS_LINE_COMMENT)(editor); + + expect(editor.getValue()).toEqual(`Hello {{// appsmith.store.id }}`); + }); + + it("should handle code uncomment in JS fields with text", () => { + const editor = CodeMirror(document.body, { + mode: EditorModes.JAVASCRIPT, + }); + + const code = `Hello {{// appsmith.store.id }}`; + + editor.setValue(code); + + // Select the code before commenting + editor.setSelection( + { line: 0, ch: 0 }, + { line: editor.lastLine() + 1, ch: 0 }, + ); + + handleCodeComment(JS_LINE_COMMENT)(editor); + + expect(editor.getValue()).toEqual(`Hello {{ appsmith.store.id }}`); + }); + + it("should handle code comment in TEXT_WITH_BINDING fields with text", () => { + const editor = CodeMirror(document.body, { + mode: EditorModes.TEXT_WITH_BINDING, + }); + + const code = `"label": {{ appsmith.store.id }}`; + + editor.setValue(code); + + // Select the code before commenting + editor.setSelection( + { line: 0, ch: 0 }, + { line: editor.lastLine() + 1, ch: 0 }, + ); + + handleCodeComment(JS_LINE_COMMENT)(editor); + + expect(editor.getValue()).toEqual(`"label": {{// appsmith.store.id }}`); + }); + + it("should handle code comment in TEXT_WITH_BINDING fields with text in multiple lines", () => { + const editor = CodeMirror(document.body, { + mode: EditorModes.TEXT_WITH_BINDING, + }); + + const code = `"label": {{ 2 + + 2 }}`; + + editor.setValue(code); + + // Select the code before commenting + editor.setSelection( + { line: 1, ch: 0 }, + { line: editor.lastLine() + 1, ch: 0 }, + ); + + handleCodeComment(JS_LINE_COMMENT)(editor); + + expect(editor.getValue()).toEqual(`"label": {{ 2 + // + 2 }}`); + }); + + it("should handle code comment in JS fields with multiple lines", () => { + const editor = CodeMirror(document.body, { mode: EditorModes.JAVASCRIPT }); + + const code = ` {{ (() => { + const a = "hello"; + return "Text"; + })()}}`; + + editor.setValue(code); + + // Select the code before commenting + editor.setSelection( + { line: 0, ch: 0 }, + { line: editor.lastLine() + 1, ch: 0 }, + ); + + handleCodeComment(JS_LINE_COMMENT)(editor); + + expect(editor.getValue()).toEqual(` {{// (() => { + // const a = "hello"; + // return "Text"; + // })()}}`); + }); + + it("should handle code uncomment in JS fields with multiple lines", () => { + const editor = CodeMirror(document.body, { mode: EditorModes.JAVASCRIPT }); + + const code = ` {{// (() => { + // const a = "hello"; + // return "Text"; + // })()}}`; + + editor.setValue(code); + + // Select the code before commenting + editor.setSelection( + { line: 0, ch: 0 }, + { line: editor.lastLine() + 1, ch: 0 }, + ); + + handleCodeComment(JS_LINE_COMMENT)(editor); + + expect(editor.getValue()).toEqual(` {{(() => { + const a = "hello"; + return "Text"; + })()}}`); + }); + + it("should handle code comment for SQL queries", () => { + const editor = CodeMirror(document.body, { + mode: EditorModes.SQL, + }); + + const code = `Select * from users;`; + + editor.setValue(code); + + // Select the code before commenting + editor.setSelection( + { line: 0, ch: 0 }, + { line: editor.lastLine() + 1, ch: 0 }, + ); + + handleCodeComment(SQL_LINE_COMMENT)(editor); + + expect(editor.getValue()).toEqual(`-- Select * from users;`); + }); + + it("should handle code comment for SQL queries with JS bindings when cursor is placed outside JS bindings", () => { + const editor = CodeMirror(document.body, { + mode: EditorModes.SQL, + }); + + const code = `Select * from users where name={{Select.selectedOptionValue}};`; + + editor.setValue(code); + + // Select the code before commenting + editor.setSelection( + { line: 0, ch: 0 }, + { line: editor.lastLine() + 1, ch: 0 }, + ); + + handleCodeComment(SQL_LINE_COMMENT)(editor); + + expect(editor.getValue()).toEqual( + `-- Select * from users where name={{Select.selectedOptionValue}};`, + ); + }); + + it("should handle code comment for SQL queries with JS bindings when cursor is placed inside JS bindings", () => { + const editor = CodeMirror(document.body, { + mode: EditorModes.SQL, + }); + + const code = `Select * from users where name={{Select.selectedOptionValue}};`; + + editor.setValue(code); + + // Select the code before commenting + editor.setSelection( + { line: 0, ch: 18 }, + { line: editor.lastLine() + 1, ch: 0 }, + ); + + handleCodeComment(SQL_LINE_COMMENT)(editor); + + expect(editor.getValue()).toEqual( + `-- Select * from users where name={{Select.selectedOptionValue}};`, + ); + }); +}); diff --git a/app/client/src/components/editorComponents/form/fields/DynamicTextField.tsx b/app/client/src/components/editorComponents/form/fields/DynamicTextField.tsx index 5ce1d19f2f..9302e1953c 100644 --- a/app/client/src/components/editorComponents/form/fields/DynamicTextField.tsx +++ b/app/client/src/components/editorComponents/form/fields/DynamicTextField.tsx @@ -23,6 +23,7 @@ class DynamicTextField extends React.Component< showLightningMenu?: boolean; height?: string; disabled?: boolean; + lineCommentString?: string; } > { render() { @@ -31,6 +32,7 @@ class DynamicTextField extends React.Component< tabBehaviour: this.props.tabBehaviour || TabBehaviour.INPUT, theme: this.props.theme || EditorTheme.LIGHT, size: this.props.size || EditorSize.COMPACT, + lineCommentString: this.props.lineCommentString, }; return ; diff --git a/app/client/src/components/formControls/DynamicTextFieldControl.tsx b/app/client/src/components/formControls/DynamicTextFieldControl.tsx index 633f6afa27..8917de5f41 100644 --- a/app/client/src/components/formControls/DynamicTextFieldControl.tsx +++ b/app/client/src/components/formControls/DynamicTextFieldControl.tsx @@ -15,6 +15,7 @@ import styled from "styled-components"; import { getPluginResponseTypes } from "selectors/entitiesSelector"; import { actionPathFromName } from "components/formControls/utils"; import { EvaluationSubstitutionType } from "entities/DataTree/dataTreeFactory"; +import { getLineCommentString } from "components/editorComponents/CodeEditor/utils/codeComment"; const Wrapper = styled.div` width: 872px; @@ -65,6 +66,8 @@ class DynamicTextControl extends BaseControl< ? EditorModes.SQL_WITH_BINDING : EditorModes.JSON_WITH_BINDING; + const lineCommentString = getLineCommentString(mode); + return (