From 2f0ef803ca8a1037f00f7a6f98a20366b1dd27c9 Mon Sep 17 00:00:00 2001
From: Ariya Hidayat <ariya@metabase.com>
Date: Wed, 5 Jan 2022 08:48:36 -0800
Subject: [PATCH] Custom expression editor: show error marker (#19520)

---
 frontend/src/metabase/css/core/colors.css     |  1 +
 .../src/metabase/lib/expressions/tokenizer.js |  8 ++++---
 .../expressions/ExpressionEditorTextfield.jsx | 21 +++++++++++++++++++
 .../components/expressions/expressions.css    |  7 +++++++
 .../lib/expressions/tokenizer.unit.spec.js    |  3 +++
 5 files changed, 37 insertions(+), 3 deletions(-)

diff --git a/frontend/src/metabase/css/core/colors.css b/frontend/src/metabase/css/core/colors.css
index 0700adbd558..5cffdd5e9ca 100644
--- a/frontend/src/metabase/css/core/colors.css
+++ b/frontend/src/metabase/css/core/colors.css
@@ -16,6 +16,7 @@
   --color-black: #2e353b;
   --color-success: #84bb4c;
   --color-error: #ed6e6e;
+  --color-error-background: #ed6e6e55;
   --color-warning: #f9cf48;
   --color-text-dark: #4c5773;
   --color-text-medium: #949aab;
diff --git a/frontend/src/metabase/lib/expressions/tokenizer.js b/frontend/src/metabase/lib/expressions/tokenizer.js
index 971ed5ab632..64ae04aae82 100644
--- a/frontend/src/metabase/lib/expressions/tokenizer.js
+++ b/frontend/src/metabase/lib/expressions/tokenizer.js
@@ -322,7 +322,8 @@ export function tokenize(expression) {
         if (error) {
           const message = error;
           const pos = t.start;
-          errors.push({ message, pos });
+          const len = t.end - t.start;
+          errors.push({ message, pos, len });
         }
       } else {
         const char = source[index];
@@ -330,6 +331,7 @@ export function tokenize(expression) {
           break;
         }
         const pos = index;
+        const len = 1;
         if (char === "]") {
           const prev = tokens[tokens.length - 1];
           const ref =
@@ -339,10 +341,10 @@ export function tokenize(expression) {
           const message = ref
             ? t`Missing an opening bracket for ${ref}`
             : t`Missing an opening bracket`;
-          errors.push({ message, pos });
+          errors.push({ message, pos, len });
         } else {
           const message = t`Invalid character: ${char}`;
-          errors.push({ message, pos });
+          errors.push({ message, pos, len });
         }
         ++index;
       }
diff --git a/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx b/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx
index 15a314cf8fb..62cf52ca593 100644
--- a/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx
+++ b/frontend/src/metabase/query_builder/components/expressions/ExpressionEditorTextfield.jsx
@@ -333,6 +333,26 @@ export default class ExpressionEditorTextfield extends React.Component {
     });
   }
 
+  errorAsMarkers(errorMessage = null) {
+    if (errorMessage) {
+      const { pos, len } = errorMessage;
+      // Because not every error message offers location info (yet)
+      if (typeof pos === "number") {
+        return [
+          {
+            startRow: 0,
+            startCol: pos,
+            endRow: 0,
+            endCol: pos + len,
+            className: "error",
+            type: "text",
+          },
+        ];
+      }
+    }
+    return [];
+  }
+
   commands = [
     {
       name: "arrowDown",
@@ -375,6 +395,7 @@ export default class ExpressionEditorTextfield extends React.Component {
             commands={this.commands}
             ref={this.input}
             value={source}
+            markers={this.errorAsMarkers(errorMessage)}
             focus={true}
             highlightActiveLine={false}
             wrapEnabled={true}
diff --git a/frontend/src/metabase/query_builder/components/expressions/expressions.css b/frontend/src/metabase/query_builder/components/expressions/expressions.css
index a6d977b1756..22d4089ed20 100644
--- a/frontend/src/metabase/query_builder/components/expressions/expressions.css
+++ b/frontend/src/metabase/query_builder/components/expressions/expressions.css
@@ -27,3 +27,10 @@
 .expression-editor-textfield .ace_hidden-cursors .ace_cursor {
   opacity: 0;
 }
+
+.expression-editor-textfield .ace_content .error {
+  position: absolute;
+  border-bottom: 2px solid var(--color-error);
+  border-radius: 0px;
+  background-color: var(--color-error-background);
+}
diff --git a/frontend/test/metabase/lib/expressions/tokenizer.unit.spec.js b/frontend/test/metabase/lib/expressions/tokenizer.unit.spec.js
index 5f96a8a9e46..87aa050f75a 100644
--- a/frontend/test/metabase/lib/expressions/tokenizer.unit.spec.js
+++ b/frontend/test/metabase/lib/expressions/tokenizer.unit.spec.js
@@ -50,10 +50,12 @@ describe("metabase/lib/expressions/tokenizer", () => {
     expect(errors("2e")[0].message).toEqual("Missing exponent");
     expect(errors("3e+")[0].message).toEqual("Missing exponent");
     expect(errors("4E-")[0].message).toEqual("Missing exponent");
+    expect(errors("4E-")[0].len).toEqual(3);
   });
 
   it("should catch a lone decimal point", () => {
     expect(errors(".")[0].message).toEqual("Invalid character: .");
+    expect(errors(".")[0].len).toEqual(1);
   });
 
   it("should tokenize string literals", () => {
@@ -157,5 +159,6 @@ describe("metabase/lib/expressions/tokenizer", () => {
     expect(errors("!")[0].message).toEqual("Invalid character: !");
     expect(errors(" % @")[1].message).toEqual("Invalid character: @");
     expect(errors("    #")[0].pos).toEqual(4);
+    expect(errors("    #")[0].len).toEqual(1);
   });
 });
-- 
GitLab