From 8df9d954ec8a007e284db44c2622feb866783ebb Mon Sep 17 00:00:00 2001 From: Ariya Hidayat <ariya@metabase.com> Date: Fri, 30 Apr 2021 10:27:54 -0700 Subject: [PATCH] Custom expression editor: correctly accept the suggestion (#15872) This is carried out via lexical replacement, i.e. finding the token to be replaced, instead of the rather-brittle and often-incorrect regex for prefix/postfix. * Always place the caret after the replacement text * Only accept suggested "(" when there is no forthcoming "(" already --- .../src/metabase/lib/expressions/suggest.js | 19 --------- .../expressions/ExpressionEditorTextfield.jsx | 39 ++++++++++------- .../scenarios/question/notebook.cy.spec.js | 42 +++++++++++++++++++ 3 files changed, 66 insertions(+), 34 deletions(-) diff --git a/frontend/src/metabase/lib/expressions/suggest.js b/frontend/src/metabase/lib/expressions/suggest.js index d34397440bc..1558e6961f2 100644 --- a/frontend/src/metabase/lib/expressions/suggest.js +++ b/frontend/src/metabase/lib/expressions/suggest.js @@ -90,16 +90,6 @@ export function suggest({ partialSuggestionMode = true; } - const identifierTrimOptions = lastInputTokenIsUnclosedIdentifierString - ? { - // use the last token's pattern anchored to the end of the text - prefixTrim: new RegExp(lastInputToken.tokenType.PATTERN.source + "$"), - } - : { - prefixTrim: new RegExp(Identifier.PATTERN.source + "$"), - postfixTrim: new RegExp("^" + Identifier.PATTERN.source + "\\s*"), - }; - const context = getContext({ cst, tokenVector, @@ -174,7 +164,6 @@ export function suggest({ alternates: EDITOR_FK_SYMBOLS.symbols.map(symbol => getDimensionName(dimension, symbol), ), - ...identifierTrimOptions, })), ); } @@ -184,7 +173,6 @@ export function suggest({ type: "segments", name: segment.name, text: formatSegmentName(segment), - ...identifierTrimOptions, })), ); } @@ -194,7 +182,6 @@ export function suggest({ type: "metrics", name: metric.name, text: formatMetricName(metric), - ...identifierTrimOptions, })), ); } @@ -248,9 +235,6 @@ export function suggest({ type: "functions", name: "case", text: "case(", - postfixText: ")", - prefixTrim: /\w+$/, - postfixTrim: /^\w+(\(\)?|$)/, }; finalSuggestions.push(caseSuggestion); } @@ -317,9 +301,6 @@ function functionSuggestion(type, clause, parens = true) { type: type, name: name, text: name + (parens ? "(" : " "), - postfixText: parens ? ")" : " ", - prefixTrim: /\w+$/, - postfixTrim: parens ? /^\w+(\(\)?|$)/ : /^\w+\s*/, }; } diff --git a/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx b/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx index 654064ce3ed..4a7b1973bc3 100644 --- a/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx +++ b/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx @@ -172,22 +172,31 @@ export default class ExpressionEditorTextfield extends React.Component { const suggestion = suggestions && suggestions[index]; if (suggestion) { - let prefix = source.slice(0, suggestion.index); - if (suggestion.prefixTrim) { - prefix = prefix.replace(suggestion.prefixTrim, ""); - } - let postfix = source.slice(suggestion.index); - if (suggestion.postfixTrim) { - postfix = postfix.replace(suggestion.postfixTrim, ""); - } - if (!postfix && suggestion.postfixText) { - postfix = suggestion.postfixText; + const { tokens } = tokenize(source); + const token = tokens.find(t => t.end >= suggestion.index); + if (token) { + const prefix = source.slice(0, token.start); + const postfix = source.slice(token.end); + const suggested = suggestion.text; + + // e.g. source is "isnull(A" and suggested is "isempty(" + // the result should be "isempty(A" and NOT "isempty((A" + const openParen = _.last(suggested) === "("; + const alreadyOpenParen = _.first(postfix.trimLeft()) === "("; + const extraTrim = openParen && alreadyOpenParen ? 1 : 0; + const replacement = suggested.slice(0, suggested.length - extraTrim); + + const updatedExpression = prefix + replacement.trim() + postfix; + this.onExpressionChange(updatedExpression); + const caretPos = updatedExpression.length - postfix.length; + setTimeout(() => { + this._setCaretPosition(caretPos, true); + }); + } else { + const newExpression = source + suggestion.text; + this.onExpressionChange(newExpression); + setTimeout(() => this._setCaretPosition(newExpression.length, true)); } - - this.onExpressionChange(prefix + suggestion.text + postfix); - setTimeout(() => - this._setCaretPosition((prefix + suggestion.text).length, true), - ); } }; diff --git a/frontend/test/metabase/scenarios/question/notebook.cy.spec.js b/frontend/test/metabase/scenarios/question/notebook.cy.spec.js index 8f70940e991..4f9d731a097 100644 --- a/frontend/test/metabase/scenarios/question/notebook.cy.spec.js +++ b/frontend/test/metabase/scenarios/question/notebook.cy.spec.js @@ -907,6 +907,48 @@ describe("scenarios > question > notebook", () => { cy.get("[contenteditable='true']").type("[Price] "); cy.contains("/").should("not.exist"); }); + + it("should correctly accept the chosen field suggestion", () => { + openProductsTable({ mode: "notebook" }); + cy.findByText("Custom column").click(); + cy.get("[contenteditable='true']").type( + "[Rating]{leftarrow}{leftarrow}{leftarrow}", + ); + + // accept the only suggested item, i.e. "[Rating]" + cy.get("[contenteditable='true']").type("{enter}"); + + // if the replacement is correct -> "[Rating]" + // if the replacement is wrong -> "[Rating] ng" + cy.get("[contenteditable='true']") + .contains("[Rating] ng") + .should("not.exist"); + }); + + it("should correctly accept the chosen function suggestion", () => { + openProductsTable({ mode: "notebook" }); + cy.findByText("Custom column").click(); + cy.get("[contenteditable='true']").type("LTRIM([Title])"); + + // Place the cursor between "is" and "empty" + cy.get("[contenteditable='true']").type( + Array(13) + .fill("{leftarrow}") + .join(""), + ); + + // accept the first suggested function, i.e. "length" + cy.get("[contenteditable='true']").type("{enter}"); + + cy.get("[contenteditable='true']").contains("length([Title])"); + }); + }); + + it("should correctly insert function suggestion with the opening parenthesis", () => { + openProductsTable({ mode: "notebook" }); + cy.findByText("Custom column").click(); + cy.get("[contenteditable='true']").type("LOW{enter}"); + cy.get("[contenteditable='true']").contains("lower("); }); }); -- GitLab