Compare commits
185 Commits
python-run
...
multiline-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3476a3dae | ||
|
|
e194eb0d4e | ||
|
|
0acfd10317 | ||
|
|
76119122eb | ||
|
|
8c39bad7c5 | ||
|
|
b15593006f | ||
|
|
bc29204118 | ||
|
|
0754ceeb76 | ||
|
|
fecdb0a315 | ||
|
|
5c3e771981 | ||
|
|
f06eb9255d | ||
|
|
1fdf263425 | ||
|
|
01271b4de7 | ||
|
|
2f1db7ccbc | ||
|
|
1276b56224 | ||
|
|
9c5fcd33af | ||
|
|
1b284facd0 | ||
|
|
1ba5617327 | ||
|
|
a31de619b8 | ||
|
|
6496ec2f58 | ||
|
|
148cbf62d8 | ||
|
|
3f1862ed58 | ||
|
|
ce5cdc3392 | ||
|
|
aa7f058d79 | ||
|
|
bbd24ef41b | ||
|
|
0237015a75 | ||
|
|
557ac188b7 | ||
|
|
822128ea93 | ||
|
|
1c1c05e3bb | ||
|
|
4e6a8646e3 | ||
|
|
1248eaedc7 | ||
|
|
4bac08caad | ||
|
|
b637e295bf | ||
|
|
fa6fd644a1 | ||
|
|
53ec7d975b | ||
|
|
128b686061 | ||
|
|
c606b3a391 | ||
|
|
756ff4c11d | ||
|
|
1d8ef5b603 | ||
|
|
9f9f6a8966 | ||
|
|
ea5b684883 | ||
|
|
e84f324093 | ||
|
|
fc74c763ec | ||
|
|
615668925f | ||
|
|
5dcfed8233 | ||
|
|
70110d6372 | ||
|
|
51d8ffee14 | ||
|
|
0b50f480de | ||
|
|
c86a8c89ad | ||
|
|
a2f0be8920 | ||
|
|
b0e30ccbdf | ||
|
|
4834afed3a | ||
|
|
dc409a06e1 | ||
|
|
39c4e3f29d | ||
|
|
06068f3761 | ||
|
|
9f63e711d5 | ||
|
|
1946d96fe5 | ||
|
|
10b543c3dc | ||
|
|
b2e7b0abe7 | ||
|
|
32a641548b | ||
|
|
f96d009c7c | ||
|
|
9762234d9c | ||
|
|
3f0dc8ea66 | ||
|
|
6132df32e3 | ||
|
|
6a71ad1f45 | ||
|
|
60146fa350 | ||
|
|
b06bbb9a2f | ||
|
|
1a351f268b | ||
|
|
33daaa7659 | ||
|
|
0cddf1d81f | ||
|
|
395e965b8c | ||
|
|
c7364c21d6 | ||
|
|
88b37a8987 | ||
|
|
3a646f0c75 | ||
|
|
500de9f946 | ||
|
|
1b302b3352 | ||
|
|
8f52fefa8b | ||
|
|
292c2d5a24 | ||
|
|
ffe41ae603 | ||
|
|
82661c8aff | ||
|
|
f3deb72e45 | ||
|
|
26b11fd65b | ||
|
|
06d51c4420 | ||
|
|
99e315e396 | ||
|
|
517da1c8a0 | ||
|
|
a6a3482033 | ||
|
|
0ae3f0ef27 | ||
|
|
125cb8f281 | ||
|
|
4cdf51a291 | ||
|
|
d1b30ca450 | ||
|
|
db138752e1 | ||
|
|
9a06321a31 | ||
|
|
dc1f6aa796 | ||
|
|
3d651fc98a | ||
|
|
a3f5b6961c | ||
|
|
c421c67a70 | ||
|
|
59f9cd20ba | ||
|
|
8ede3a23e6 | ||
|
|
25d97319da | ||
|
|
c46f181335 | ||
|
|
3d79acf8ce | ||
|
|
513b4b06cd | ||
|
|
0ea82ba20c | ||
|
|
02273cf6fb | ||
|
|
d70b8b549a | ||
|
|
63d33c6e49 | ||
|
|
5c6b19e6a7 | ||
|
|
65cb5a27ec | ||
|
|
88a96834a8 | ||
|
|
6cfd1b0dcb | ||
|
|
c30aefecba | ||
|
|
4357eec0dd | ||
|
|
451cd513b5 | ||
|
|
37e7b1a716 | ||
|
|
6535de4687 | ||
|
|
88055975ed | ||
|
|
bb34194b3b | ||
|
|
ea561edc22 | ||
|
|
7fc856ea37 | ||
|
|
aff09e98f7 | ||
|
|
db0c393f4e | ||
|
|
f571cc264d | ||
|
|
9f603b9a8a | ||
|
|
307c88b006 | ||
|
|
b2d336333f | ||
|
|
3411fb1075 | ||
|
|
04494b93c9 | ||
|
|
f0d96ea69b | ||
|
|
53cce73577 | ||
|
|
4769100532 | ||
|
|
dcb047a0e9 | ||
|
|
b45ff04abf | ||
|
|
3d7541861a | ||
|
|
705b3b060b | ||
|
|
a478a34653 | ||
|
|
58252b63d4 | ||
|
|
1f0251c93e | ||
|
|
68c28c02c2 | ||
|
|
bdce305f9f | ||
|
|
9e82e317e7 | ||
|
|
7d5b65670a | ||
|
|
828b7fc563 | ||
|
|
f8c146bb0b | ||
|
|
dcf3732879 | ||
|
|
2ea5837ab4 | ||
|
|
31ef5cd8f3 | ||
|
|
efc4c416a9 | ||
|
|
95651c34b6 | ||
|
|
ca8d48dc92 | ||
|
|
70f89f54df | ||
|
|
db4e1123dc | ||
|
|
8dd9d902bb | ||
|
|
d679412835 | ||
|
|
76cc6d1011 | ||
|
|
a216f63eb9 | ||
|
|
edbb55b9b6 | ||
|
|
03cb097b12 | ||
|
|
953ca89f9f | ||
|
|
2acea12560 | ||
|
|
15c3223955 | ||
|
|
7f0cd7db6e | ||
|
|
844ffb72a1 | ||
|
|
00936976eb | ||
|
|
439aebc09d | ||
|
|
47b579e65b | ||
|
|
723c88ef73 | ||
|
|
480e824522 | ||
|
|
f14a0730c8 | ||
|
|
0f538f26bd | ||
|
|
5571fa8f32 | ||
|
|
5c1ff4aa01 | ||
|
|
426c1d752a | ||
|
|
d79b46ca25 | ||
|
|
985a6773e2 | ||
|
|
96df46fb00 | ||
|
|
c792073ff0 | ||
|
|
3f0b814bcc | ||
|
|
88ebc99750 | ||
|
|
6926426f87 | ||
|
|
54b6d26379 | ||
|
|
57d2e36a32 | ||
|
|
e533ebdbfa | ||
|
|
ca1056d78b | ||
|
|
1e8605f2e7 | ||
|
|
8670fcc0de |
@@ -2,32 +2,43 @@ export function expressionEditorWidget() {
|
||||
return cy.findByTestId("expression-editor");
|
||||
}
|
||||
|
||||
export function expressionEditorTextfield() {
|
||||
return CustomExpressionEditor.get();
|
||||
}
|
||||
|
||||
const isMac = Cypress.platform === "darwin";
|
||||
const metaKey = isMac ? "Meta" : "Control";
|
||||
|
||||
/**
|
||||
* @param {Object} option
|
||||
* @param {string} option.formula
|
||||
* @param {string=} option.name
|
||||
* @param {boolean} option.blur true by default. However, if you need to examine the popover in the test, it should be set to false so the popover is not dismissed
|
||||
* @param {string} option.name
|
||||
* @param {boolean} [option.blur]
|
||||
* true by default. However, if you need to examine the popover in the test, it should be set to false so the popover is not dismissed
|
||||
* @param {boolean} [option.allowFastSet]
|
||||
* Because CodeMirror uses a contenteditable div, and it is not possible to use cy.type() on it, we emulate .type with realPress.
|
||||
* This does not always work, since realPress() does not support all characters. Setting this to true will enable an escape hatch
|
||||
* that uses cy.invoke('text') under the hood, to allow for formulas that contain unsupported characters.
|
||||
* This has some other sideeffects however, so use it sparingly.
|
||||
*/
|
||||
|
||||
export function enterCustomColumnDetails({
|
||||
formula,
|
||||
name,
|
||||
blur = true,
|
||||
allowFastSet = false,
|
||||
}: {
|
||||
formula: string;
|
||||
name?: string;
|
||||
blur?: boolean;
|
||||
allowFastSet?: boolean;
|
||||
}) {
|
||||
cy.get(".ace_text-input")
|
||||
.first()
|
||||
.as("formula")
|
||||
.should("exist")
|
||||
.focus()
|
||||
.clear()
|
||||
.type(formula);
|
||||
CustomExpressionEditor.get().as("formula");
|
||||
CustomExpressionEditor.clear();
|
||||
cy.wait(100);
|
||||
CustomExpressionEditor.type(formula, { allowFastSet });
|
||||
|
||||
if (blur) {
|
||||
cy.get("@formula").blur();
|
||||
CustomExpressionEditor.blur();
|
||||
}
|
||||
|
||||
if (name) {
|
||||
@@ -39,7 +50,7 @@ export function enterCustomColumnDetails({
|
||||
}
|
||||
|
||||
export function checkExpressionEditorHelperPopoverPosition() {
|
||||
cy.findByTestId("expression-editor-textfield").then($target => {
|
||||
expressionEditorTextfield().then($target => {
|
||||
const textfieldPosition = $target[0].getBoundingClientRect();
|
||||
|
||||
cy.findByTestId("expression-helper-popover").then($target => {
|
||||
@@ -52,3 +63,162 @@ export function checkExpressionEditorHelperPopoverPosition() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const CustomExpressionEditor = {
|
||||
get() {
|
||||
return cy.findByTestId("custom-expression-query-editor");
|
||||
},
|
||||
/**
|
||||
* @param {string} text the formula to type
|
||||
* @param {Object} options
|
||||
* @param {boolean} [options.focus]
|
||||
* true by default. Set to false to stop the helper from explicitly focussing the formula input.
|
||||
* @param {boolean} [options.allowFastSet]
|
||||
* true by default. However, if you need to examine the popover in the test, it should be set to false so the popover is not dismissed
|
||||
* Because CodeMirror uses a contenteditable div, and it is not possible to use cy.type() on it, we emulate .type with realPress.
|
||||
* This does not always work, since realPress() does not support all characters. Setting this to true will enable an escape hatch
|
||||
* that uses cy.invoke('text') under the hood, to allow for formulas that contain unsupported characters.
|
||||
* This has some other sideeffects however, so use it sparingly.
|
||||
*/
|
||||
type(
|
||||
text: string,
|
||||
{
|
||||
allowFastSet = false,
|
||||
focus = true,
|
||||
}: {
|
||||
focus?: boolean;
|
||||
allowFastSet?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
if (focus) {
|
||||
CustomExpressionEditor.focus();
|
||||
}
|
||||
|
||||
if (allowFastSet) {
|
||||
// Enter the formula in one go
|
||||
// HACK: we do invoke("text") instead of type() because type() does not work on
|
||||
// CodeMirror elements in Cypress. realType() would work but some of the formulas
|
||||
// contain special characters that are not supported by realType().
|
||||
CustomExpressionEditor.get().findByRole("textbox").invoke("text", text);
|
||||
return CustomExpressionEditor;
|
||||
}
|
||||
|
||||
const parts = text.replaceAll("{{", "{{}{{}").split(/(\{[^}]+\})/);
|
||||
|
||||
parts.forEach(part => {
|
||||
switch (part.toLowerCase()) {
|
||||
case "":
|
||||
return;
|
||||
|
||||
case "{clear}":
|
||||
return CustomExpressionEditor.clear();
|
||||
|
||||
case "{selectall}":
|
||||
return CustomExpressionEditor.selectAll();
|
||||
|
||||
case "{leftarrow}":
|
||||
return cy.realPress(["ArrowLeft"]);
|
||||
|
||||
case "{rightarrow}":
|
||||
return cy.realPress(["ArrowRight"]);
|
||||
|
||||
case "{downarrow}":
|
||||
return cy.realPress(["ArrowDown"]);
|
||||
|
||||
case "{uparrow}":
|
||||
return cy.realPress(["ArrowUp"]);
|
||||
|
||||
case "{enter}":
|
||||
return cy.realPress(["Enter"]);
|
||||
|
||||
case "{home}":
|
||||
case "{movetostart}":
|
||||
return cy.realPress(["Control", "A"]);
|
||||
|
||||
case "{end}":
|
||||
case "{movetoend}":
|
||||
return cy.realPress(["Control", "E"]);
|
||||
|
||||
case "{backspace}":
|
||||
return cy.realPress(["Backspace"]);
|
||||
|
||||
case "{tab}":
|
||||
return cy.realPress(["Tab"]);
|
||||
|
||||
case "{nextcompletion}":
|
||||
cy.wait(50);
|
||||
return cy.realPress([metaKey, "j"]);
|
||||
|
||||
case "{prevcompletion}":
|
||||
cy.wait(50);
|
||||
return cy.realPress([metaKey, "k"]);
|
||||
|
||||
case "{{}":
|
||||
return cy.realType("{");
|
||||
}
|
||||
|
||||
if (part.startsWith("{") && part.endsWith("}")) {
|
||||
// unknown escape sequence, let's try typing it
|
||||
throw new Error(
|
||||
`unknown escape sequence in CustomExpressionEditor.type: ${part}`,
|
||||
);
|
||||
}
|
||||
|
||||
// realType does not support → arrow, so we replace it with ->, which the editor
|
||||
// expands into →
|
||||
const unexpanded = part.replaceAll(/→/g, "->");
|
||||
|
||||
const alphabet =
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789^[]()-,.;_!@#$%&*+=/<>\" ':;";
|
||||
if (unexpanded.split("").some(char => !alphabet.includes(char))) {
|
||||
throw new Error(
|
||||
`unknown character in CustomExpressionEditor.type in ${part}`,
|
||||
);
|
||||
}
|
||||
|
||||
cy.realType(unexpanded);
|
||||
});
|
||||
return CustomExpressionEditor;
|
||||
},
|
||||
focus() {
|
||||
CustomExpressionEditor.get().click("right");
|
||||
return CustomExpressionEditor;
|
||||
},
|
||||
blur() {
|
||||
// click outside the expression editor
|
||||
cy.get("label[for='expression-content']").click();
|
||||
return CustomExpressionEditor;
|
||||
},
|
||||
selectAll() {
|
||||
CustomExpressionEditor.focus();
|
||||
cy.realPress([metaKey, "A"]);
|
||||
return CustomExpressionEditor;
|
||||
},
|
||||
clear() {
|
||||
CustomExpressionEditor.selectAll();
|
||||
cy.realPress(["Backspace"]);
|
||||
return CustomExpressionEditor;
|
||||
},
|
||||
shouldContain(formula: string) {
|
||||
return CustomExpressionEditor.get()
|
||||
.get("[role='textbox']")
|
||||
.invoke("text")
|
||||
.should("contain", formula);
|
||||
},
|
||||
|
||||
completions() {
|
||||
return cy.findByTestId("custom-expression-editor-suggestions");
|
||||
},
|
||||
completion(name: string) {
|
||||
return CustomExpressionEditor.completions()
|
||||
.findAllByRole("option")
|
||||
.contains(name)
|
||||
.first();
|
||||
},
|
||||
helpTextHeader() {
|
||||
return cy.findByTestId("expression-helper-popover-structure");
|
||||
},
|
||||
helpText() {
|
||||
return cy.findByTestId("expression-helper");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -70,6 +70,7 @@ describe("scenarios > custom column > boolean functions", () => {
|
||||
H.enterCustomColumnDetails({
|
||||
formula: newExpression,
|
||||
name: expressionName,
|
||||
allowFastSet: true,
|
||||
});
|
||||
H.popover().button("Done").click();
|
||||
H.visualize();
|
||||
@@ -85,6 +86,7 @@ describe("scenarios > custom column > boolean functions", () => {
|
||||
H.enterCustomColumnDetails({
|
||||
formula: modifiedExpression,
|
||||
name: expressionName,
|
||||
allowFastSet: true,
|
||||
});
|
||||
H.popover().button("Update").click();
|
||||
H.visualize();
|
||||
|
||||
@@ -36,8 +36,7 @@ describe("scenarios > question > custom column > expression shortcuts > combine"
|
||||
|
||||
cy.button("Done").click();
|
||||
|
||||
cy.findByTestId("expression-editor-textfield").should(
|
||||
"contain",
|
||||
H.CustomExpressionEditor.shouldContain(
|
||||
'concat([Total], "__", [Product → Rating])',
|
||||
);
|
||||
cy.findByTestId("expression-name").should(
|
||||
@@ -60,7 +59,7 @@ describe("scenarios > question > custom column > expression shortcuts > combine"
|
||||
cy.findByText("Select columns to combine").click();
|
||||
});
|
||||
|
||||
cy.get(".ace_text-input").should("have.value", "\n\n");
|
||||
H.CustomExpressionEditor.shouldContain("");
|
||||
cy.findByTestId("expression-name").should("have.value", "");
|
||||
});
|
||||
|
||||
@@ -159,9 +158,11 @@ H.describeWithSnowplow(
|
||||
);
|
||||
|
||||
function selectCombineColumns() {
|
||||
cy.findByTestId("expression-suggestions-list").within(() => {
|
||||
cy.findByText("Combine columns").click();
|
||||
});
|
||||
H.CustomExpressionEditor.completions().should("be.visible");
|
||||
cy.wait(300);
|
||||
H.CustomExpressionEditor.completion("Combine columns")
|
||||
.should("be.visible")
|
||||
.click();
|
||||
}
|
||||
|
||||
function selectColumn(index: number, table: string, name?: string) {
|
||||
|
||||
@@ -5,15 +5,15 @@ import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||||
const { ORDERS_ID, ORDERS } = SAMPLE_DATABASE;
|
||||
|
||||
function selectExtractColumn() {
|
||||
cy.findByTestId("expression-suggestions-list").within(() => {
|
||||
cy.findByText("Extract columns").click();
|
||||
});
|
||||
H.CustomExpressionEditor.completions().should("be.visible");
|
||||
cy.wait(300);
|
||||
H.CustomExpressionEditor.completion("Extract columns").click();
|
||||
}
|
||||
|
||||
function selectCombineColumns() {
|
||||
cy.findByTestId("expression-suggestions-list").within(() => {
|
||||
cy.findByText("Combine columns").click();
|
||||
});
|
||||
H.CustomExpressionEditor.completions().should("be.visible");
|
||||
cy.wait(300);
|
||||
H.CustomExpressionEditor.completion("Combine columns").realClick();
|
||||
}
|
||||
|
||||
function selectColumn(index: number, table: string, name?: string) {
|
||||
@@ -139,10 +139,7 @@ describe("scenarios > question > custom column > expression shortcuts > extract"
|
||||
.click();
|
||||
H.popover().findAllByRole("button").contains(extraction.name).click();
|
||||
|
||||
cy.findByTestId("expression-editor-textfield").should(
|
||||
"contain",
|
||||
`${extraction.fn}(`,
|
||||
);
|
||||
H.CustomExpressionEditor.shouldContain(`${extraction.fn}(`);
|
||||
|
||||
H.expressionEditorWidget()
|
||||
.findByTestId("expression-name")
|
||||
@@ -242,6 +239,7 @@ describe("scenarios > question > custom column > expression shortcuts > combine"
|
||||
it("should be possible to select a combine columns shortcut", () => {
|
||||
H.openOrdersTable({ mode: "notebook", limit: 5 });
|
||||
H.addCustomColumn();
|
||||
|
||||
selectCombineColumns();
|
||||
|
||||
selectColumn(0, "Total");
|
||||
@@ -268,8 +266,7 @@ describe("scenarios > question > custom column > expression shortcuts > combine"
|
||||
|
||||
cy.button("Done").click();
|
||||
|
||||
cy.findByTestId("expression-editor-textfield").should(
|
||||
"contain",
|
||||
H.CustomExpressionEditor.shouldContain(
|
||||
'concat([Total], "__", [Product → Rating])',
|
||||
);
|
||||
cy.findByTestId("expression-name").should(
|
||||
@@ -292,7 +289,7 @@ describe("scenarios > question > custom column > expression shortcuts > combine"
|
||||
cy.findByText("Select columns to combine").click();
|
||||
});
|
||||
|
||||
cy.get(".ace_text-input").should("have.value", "\n\n");
|
||||
H.CustomExpressionEditor.shouldContain("");
|
||||
cy.findByTestId("expression-name").should("have.value", "");
|
||||
});
|
||||
|
||||
|
||||
@@ -32,49 +32,42 @@ describe("scenarios > question > custom column > typing suggestion", () => {
|
||||
|
||||
it("should correctly accept the chosen function suggestion", () => {
|
||||
addCustomColumn();
|
||||
H.enterCustomColumnDetails({ formula: "LTRIM([Title])", blur: false });
|
||||
H.enterCustomColumnDetails({ formula: "le", blur: false });
|
||||
|
||||
// Place the cursor between "is" and "empty"
|
||||
cy.get("@formula").type("{leftarrow}".repeat(13));
|
||||
H.CustomExpressionEditor.completions().should("be.visible");
|
||||
|
||||
// accept the first suggested function, i.e. "length"
|
||||
cy.get("@formula").type("{enter}");
|
||||
cy.realPress("Enter");
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.contains("length([Title])");
|
||||
H.CustomExpressionEditor.helpText()
|
||||
.should("be.visible")
|
||||
.should("contain", "length([Comment])");
|
||||
});
|
||||
|
||||
it("should correctly insert function suggestion with the opening parenthesis", () => {
|
||||
it("should correctly insert function suggestion with the template", () => {
|
||||
addCustomColumn();
|
||||
H.enterCustomColumnDetails({ formula: "BET{enter}" });
|
||||
|
||||
cy.findByTestId("expression-editor-textfield").should(
|
||||
"contain",
|
||||
"between(",
|
||||
);
|
||||
H.enterCustomColumnDetails({ formula: "bet{enter}" });
|
||||
H.CustomExpressionEditor.shouldContain("between(column, start, end)");
|
||||
});
|
||||
|
||||
it("should show expression function helper if a proper function is typed", () => {
|
||||
addCustomColumn();
|
||||
H.enterCustomColumnDetails({ formula: "lower(", blur: false });
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.contains("lower(text)");
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Returns the string of text in all lower case.").should(
|
||||
"be.visible",
|
||||
);
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("lower([Status])").should("be.visible");
|
||||
H.CustomExpressionEditor.helpTextHeader()
|
||||
.should("be.visible")
|
||||
.should("contain", "lower(⟨text⟩)");
|
||||
|
||||
cy.findByTestId("expression-helper-popover-arguments")
|
||||
.findByText("text")
|
||||
.realHover();
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("The column with values to convert to lower case.").should(
|
||||
"be.visible",
|
||||
);
|
||||
H.CustomExpressionEditor.helpText()
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.findByText("Returns the string of text in all lower case.").should(
|
||||
"be.visible",
|
||||
);
|
||||
cy.findByText(
|
||||
"The column with values to convert to lower case.",
|
||||
).should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not show suggestions for an unfocused field (metabase#31643)", () => {
|
||||
@@ -82,30 +75,60 @@ describe("scenarios > question > custom column > typing suggestion", () => {
|
||||
H.popover().findByText("Custom Expression").click();
|
||||
H.enterCustomColumnDetails({ formula: "Count{enter}" });
|
||||
H.popover().findByLabelText("Name").focus();
|
||||
cy.findByTestId("expression-suggestions-list").should("not.exist");
|
||||
H.CustomExpressionEditor.completions().should("not.exist");
|
||||
});
|
||||
|
||||
it("should always show the help text popover on top of the custom expression widget (metabase#52711)", () => {
|
||||
addCustomColumn();
|
||||
H.enterCustomColumnDetails({ formula: "endsWith(", blur: false });
|
||||
H.enterCustomColumnDetails({ formula: "concat", blur: false });
|
||||
|
||||
/* It seems like cypress considers that this popover and its contents are visible,
|
||||
* even when it's under its parent popover because it is in a portal, so technically it's not being clipped by any element.
|
||||
* Weirdly enough, it refuses to click `Learn more` because it's covered, but should("be.visible") passes.
|
||||
*
|
||||
* So, the (hacky) solution for now is to click all 5 elements of the popover, and we will check if the
|
||||
* popover is still there at the end. Since the popover has onClickOutside behavior, the popover will
|
||||
* close if the user clicks on anything outside it, so we can use that to our advantage.
|
||||
* */
|
||||
cy.findByTestId("expression-helper-popover").within(() => {
|
||||
cy.findByTestId("expression-helper-popover-structure").click();
|
||||
cy.findByTestId("expression-helper-popover-arguments").click();
|
||||
cy.findByText("Example").click();
|
||||
H.CustomExpressionEditor.helpText().within(() => {
|
||||
cy.findByText(
|
||||
"Combine two or more strings of text together.",
|
||||
).realClick();
|
||||
cy.findByText("Example").realClick();
|
||||
|
||||
// We want to trigger the "covered element" error if this is true without actually clicking the external link
|
||||
cy.findByText("Learn more").trigger("mousemove");
|
||||
});
|
||||
cy.findByTestId("expression-helper-popover").should("exist");
|
||||
H.CustomExpressionEditor.helpText().should("be.visible");
|
||||
});
|
||||
|
||||
it("should be possible to collapse the help text popover", () => {
|
||||
addCustomColumn();
|
||||
H.enterCustomColumnDetails({ formula: "concat(", blur: false });
|
||||
|
||||
H.CustomExpressionEditor.helpText().should("be.visible");
|
||||
H.CustomExpressionEditor.helpTextHeader().click();
|
||||
H.CustomExpressionEditor.helpText().should("not.exist");
|
||||
H.CustomExpressionEditor.helpTextHeader().click();
|
||||
H.CustomExpressionEditor.helpText().should("be.visible");
|
||||
});
|
||||
|
||||
it("the help text popover should collapse when there is not enough space to render it and the completions", () => {
|
||||
addCustomColumn();
|
||||
H.enterCustomColumnDetails({ formula: "concat(", blur: false });
|
||||
cy.viewport(1280, 600);
|
||||
|
||||
H.CustomExpressionEditor.helpText().should("be.visible");
|
||||
|
||||
H.CustomExpressionEditor.type("[", { focus: false });
|
||||
H.CustomExpressionEditor.completions()
|
||||
.get("ul[role=listbox]")
|
||||
.should("be.visible");
|
||||
H.CustomExpressionEditor.helpText().should("not.exist");
|
||||
|
||||
H.CustomExpressionEditor.helpTextHeader().click();
|
||||
H.CustomExpressionEditor.helpText().should("be.visible");
|
||||
H.CustomExpressionEditor.completions()
|
||||
.get("ul[role=listbox]")
|
||||
.should("not.be.visible");
|
||||
|
||||
H.CustomExpressionEditor.type("I", { focus: false });
|
||||
H.CustomExpressionEditor.helpText().should("not.exist");
|
||||
H.CustomExpressionEditor.completions()
|
||||
.get("ul[role=listbox]")
|
||||
.should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ describe("issue 13751", { tags: "@external" }, () => {
|
||||
formula: 'regexextract([State], "^C[A-Z]")',
|
||||
name: CC_NAME,
|
||||
});
|
||||
cy.button("Done").click();
|
||||
cy.button("Done").should("not.be.disabled").click();
|
||||
|
||||
H.getNotebookStep("filter")
|
||||
.findByText(/Add filter/)
|
||||
@@ -1028,15 +1028,13 @@ describe("issue 49342", () => {
|
||||
H.openOrdersTable({ mode: "notebook" });
|
||||
cy.findByLabelText("Custom column").click();
|
||||
H.enterCustomColumnDetails({ formula: "[Tot{Enter}", blur: false });
|
||||
cy.get(".ace_text-input").first().focus().realPress("Tab");
|
||||
cy.realPress("Tab");
|
||||
cy.findByTestId("expression-name").should("be.focused");
|
||||
|
||||
cy.log("should contain focus within the popover");
|
||||
cy.findByTestId("expression-name").realPress(["Shift", "Tab"]);
|
||||
cy.get(".ace_text-input")
|
||||
.first()
|
||||
.should("be.focused")
|
||||
.realPress(["Shift", "Tab"]);
|
||||
cy.focused().should("have.attr", "class", "cm-content");
|
||||
cy.realPress(["Shift", "Tab"]);
|
||||
cy.button("Cancel").should("be.focused");
|
||||
});
|
||||
});
|
||||
@@ -1055,69 +1053,83 @@ describe("issue 49882", () => {
|
||||
it("should not eat up subsequent characters when applying a suggestion (metabase#49882-1)", () => {
|
||||
const moveCursorTo2ndCaseArgument = "{leftarrow}".repeat(6);
|
||||
H.enterCustomColumnDetails({
|
||||
formula: `case([Total] > 200, , "X")${moveCursorTo2ndCaseArgument}[tot{enter}`,
|
||||
formula: `case([Total] > 200, , "X")${moveCursorTo2ndCaseArgument}[tot`,
|
||||
blur: false,
|
||||
});
|
||||
|
||||
cy.get(".ace_text-input")
|
||||
.first()
|
||||
.should("have.value", 'case([Total] > 200, [Total] , "X")\n\n');
|
||||
H.CustomExpressionEditor.completions().should("be.visible");
|
||||
cy.realPress("Enter", { pressDelay: 10 });
|
||||
|
||||
H.CustomExpressionEditor.shouldContain('case([Total] > 200, [Total], "X")');
|
||||
H.popover()
|
||||
.findByText("Expecting a closing parenthesis")
|
||||
.should("not.exist");
|
||||
});
|
||||
|
||||
it("does not clear expression input when expression is invalid (metabase#49882-2, metabase#15892)", () => {
|
||||
const selectTax = `{leftarrow}${"{shift+leftarrow}".repeat(5)}`;
|
||||
const moveCursorBefore2ndCase = "{leftarrow}".repeat(41);
|
||||
H.enterCustomColumnDetails({
|
||||
formula: `case([Tax] > 1, case([Total] > 200, [Total], "Nothing"), [Tax])${selectTax}`,
|
||||
});
|
||||
cy.get(".ace_text-input").first().focus().realPress(["Control", "X"]);
|
||||
cy.get(".ace_text-input")
|
||||
.first()
|
||||
.focus()
|
||||
.type(moveCursorBefore2ndCase)
|
||||
.realPress(["Control", "V"]);
|
||||
cy.get(".ace_text-input").first().focus().type(" ").blur();
|
||||
// This test used to use keyboard shortcuts to cut and paste but this
|
||||
// seem impossible to emulate with CodeMirror in Cypress, so it's using allowFastSet
|
||||
// instead.
|
||||
|
||||
cy.get(".ace_text-input")
|
||||
.first()
|
||||
.focus()
|
||||
.should(
|
||||
"have.value",
|
||||
'case([Tax] > 1, [Tax] case([Total] > 200, [Total], "Nothing"), )\n\n',
|
||||
);
|
||||
H.enterCustomColumnDetails({
|
||||
formula:
|
||||
'case([Tax] > 1, case([Total] > 200, [Total], "Nothing"), [Tax])',
|
||||
blur: false,
|
||||
allowFastSet: true,
|
||||
});
|
||||
|
||||
// Cut [Tax]
|
||||
H.CustomExpressionEditor.type(
|
||||
'case([Tax] > 1, case([Total] > 200, [Total], "Nothing"), )',
|
||||
{
|
||||
allowFastSet: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Paste [Tax] before case
|
||||
H.CustomExpressionEditor.type(
|
||||
'case([Tax] > 1, [Tax] case([Total] > 200, [Total], "Nothing"), )',
|
||||
{
|
||||
allowFastSet: true,
|
||||
},
|
||||
);
|
||||
|
||||
H.CustomExpressionEditor.shouldContain(
|
||||
'case([Tax] > 1, [Tax] case([Total] > 200, [Total], "Nothing"), )',
|
||||
);
|
||||
H.CustomExpressionEditor.blur();
|
||||
|
||||
H.popover()
|
||||
.findByText("Expecting comma but got case instead")
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("should allow moving cursor between wrapped lines with arrow up and arrow down keys (metabase#49882-3)", () => {
|
||||
// TODO: we no longer have wrapped lines (for now)
|
||||
it.skip("should allow moving cursor between wrapped lines with arrow up and arrow down keys (metabase#49882-3)", () => {
|
||||
H.enterCustomColumnDetails({
|
||||
formula:
|
||||
'case([Tax] > 1, case([Total] > 200, [Total], "Nothing"), [Tax]){leftarrow}{leftarrow}{uparrow}x{downarrow}y',
|
||||
});
|
||||
|
||||
cy.get(".ace_text-input")
|
||||
.first()
|
||||
.should(
|
||||
"have.value",
|
||||
'case([Tax] > 1, xcase([Total] > 200, [Total], "Nothing"), [Tax]y)\n\n',
|
||||
);
|
||||
H.CustomExpressionEditor.shouldContain(
|
||||
'case([Tax] > 1, xcase([Total] > 200, [Total], "Nothing"), [Tax]y)',
|
||||
);
|
||||
});
|
||||
|
||||
it("should update currently selected suggestion when suggestions list is updated (metabase#49882-4)", () => {
|
||||
const selectProductVendor =
|
||||
"{downarrow}{downarrow}{downarrow}{downarrow}{downarrow}{enter}";
|
||||
"{downarrow}{downarrow}{downarrow}{downarrow}{downarrow}";
|
||||
H.enterCustomColumnDetails({
|
||||
formula: `[Product${selectProductVendor}{leftarrow}{leftarrow}`,
|
||||
formula: `[Produ${selectProductVendor}`,
|
||||
blur: false,
|
||||
});
|
||||
|
||||
cy.findByTestId("expression-suggestions-list-item")
|
||||
.should("have.text", "Product → Vendor")
|
||||
.and("have.css", "background-color", "rgb(80, 158, 227)");
|
||||
H.CustomExpressionEditor.completion("Product → Rating").should(
|
||||
"be.visible",
|
||||
);
|
||||
cy.realPress("Tab");
|
||||
|
||||
H.CustomExpressionEditor.shouldContain("[Product → Rating]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1153,8 +1165,7 @@ describe("issue 49304", () => {
|
||||
H.popover().within(() => {
|
||||
cy.button("Back").click();
|
||||
cy.findByText("Custom Expression").click();
|
||||
cy.get(".ace_content").should(
|
||||
"have.text",
|
||||
H.CustomExpressionEditor.shouldContain(
|
||||
'contains([Category], "gadget", "widget", "case-insensitive")',
|
||||
);
|
||||
});
|
||||
@@ -1192,8 +1203,7 @@ describe("issue 49304", () => {
|
||||
H.popover().within(() => {
|
||||
cy.button("Back").click();
|
||||
cy.findByText("Custom Expression").click();
|
||||
cy.get(".ace_content").should(
|
||||
"have.text",
|
||||
H.CustomExpressionEditor.shouldContain(
|
||||
'contains([Category], "gadget", "widget", "gizmo")',
|
||||
);
|
||||
});
|
||||
@@ -1259,33 +1269,31 @@ describe("issue 50925", () => {
|
||||
|
||||
cy.log("incomplete bracket identifier is followed by whitespace");
|
||||
H.getNotebookStep("expression").findByText("Custom").click();
|
||||
cy.get(".ace_text-input")
|
||||
.first()
|
||||
.focus()
|
||||
.type("{leftarrow}".repeat(9))
|
||||
.type(" [Pr{enter}");
|
||||
|
||||
cy.get(".ace_text-input")
|
||||
.first()
|
||||
.should(
|
||||
"have.value",
|
||||
"case([ID] = 1, [Price] * 1.21, [Price] [Price])\n\n",
|
||||
);
|
||||
H.CustomExpressionEditor.focus()
|
||||
.type("{leftarrow}".repeat(9))
|
||||
.type(" [Pr", { focus: false });
|
||||
|
||||
H.CustomExpressionEditor.completions().should("be.visible");
|
||||
H.CustomExpressionEditor.get().realPress("Enter", { pressDelay: 10 });
|
||||
|
||||
H.CustomExpressionEditor.blur().shouldContain(
|
||||
"case([ID] = 1, [Price] * 1.21, [Price] [Price])",
|
||||
);
|
||||
|
||||
cy.log("incomplete bracket identifier is followed by bracket identifier");
|
||||
H.popover().button("Cancel").click();
|
||||
H.getNotebookStep("expression").findByText("Custom").click();
|
||||
cy.get(".ace_text-input")
|
||||
.first()
|
||||
.focus()
|
||||
.type("{leftarrow}".repeat(8))
|
||||
.type("[Pr{enter}");
|
||||
|
||||
cy.get(".ace_text-input")
|
||||
.first()
|
||||
.should(
|
||||
"have.value",
|
||||
"case([ID] = 1, [Price] * 1.21, [Price] [Price])\n\n",
|
||||
);
|
||||
H.CustomExpressionEditor.focus()
|
||||
.type("{leftarrow}".repeat(9))
|
||||
.type(" [Pr", { focus: false });
|
||||
|
||||
H.CustomExpressionEditor.completions().should("be.visible");
|
||||
H.CustomExpressionEditor.get().realPress("Enter", { pressDelay: 10 });
|
||||
|
||||
H.CustomExpressionEditor.blur().shouldContain(
|
||||
"case([ID] = 1, [Price] * 1.21, [Price] [Price])",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,8 +65,8 @@ describe("scenarios > question > custom column", () => {
|
||||
cy.button("Custom column").click();
|
||||
H.enterCustomColumnDetails({ formula: "[cre", blur: false });
|
||||
|
||||
cy.findAllByTestId("expression-suggestions-list-item")
|
||||
.should("have.length", 1)
|
||||
H.CustomExpressionEditor.completions()
|
||||
.should("be.visible")
|
||||
.and("contain.text", "Created At")
|
||||
.and("not.contain.text", "Default period");
|
||||
});
|
||||
@@ -548,7 +548,7 @@ describe("scenarios > question > custom column", () => {
|
||||
cy.icon("chevronleft").click();
|
||||
cy.findByText("Custom Expression").click();
|
||||
});
|
||||
cy.get(".ace_line").contains("Sum([MyCC \\[2027\\]]");
|
||||
H.CustomExpressionEditor.shouldContain("Sum([MyCC \\[2027\\]]");
|
||||
});
|
||||
|
||||
it.skip("should work with `isNull` function (metabase#15922)", () => {
|
||||
@@ -637,7 +637,7 @@ describe("scenarios > question > custom column", () => {
|
||||
H.openOrdersTable({ mode: "notebook" });
|
||||
cy.findByLabelText("Custom column").click();
|
||||
|
||||
H.enterCustomColumnDetails({ formula: "1 + 2" });
|
||||
H.enterCustomColumnDetails({ formula: "1 + 2", blur: false });
|
||||
|
||||
// next focus: the textbox for the name
|
||||
cy.realPress("Tab");
|
||||
@@ -648,7 +648,7 @@ describe("scenarios > question > custom column", () => {
|
||||
|
||||
// Shift+Tab and we're back at the editor
|
||||
cy.realPress(["Shift", "Tab"]);
|
||||
cy.focused().should("have.attr", "class").and("eq", "ace_text-input");
|
||||
cy.focused().should("have.attr", "class").and("eq", "cm-content");
|
||||
});
|
||||
|
||||
it("should allow tabbing away from, then back to editor, while formatting expression and placing caret after reformatted expression", () => {
|
||||
@@ -661,32 +661,32 @@ describe("scenarios > question > custom column", () => {
|
||||
cy.realPress(["Shift", "Tab"]);
|
||||
|
||||
// `1+1` (3 chars) is reformatted to `1 + 1` (5 chars)
|
||||
cy.findByDisplayValue("1 + 1").type("2");
|
||||
H.CustomExpressionEditor.shouldContain("1 + 1");
|
||||
H.CustomExpressionEditor.type("2");
|
||||
|
||||
// Fix needed will prevent display value from being `1 +2 1`.
|
||||
// That's because the caret position after refocusing on textarea
|
||||
// would still be after the 3rd character
|
||||
cy.findByDisplayValue("1 + 12");
|
||||
H.CustomExpressionEditor.shouldContain("1 + 1");
|
||||
});
|
||||
|
||||
it("should allow choosing a suggestion with Tab", () => {
|
||||
H.openOrdersTable({ mode: "notebook" });
|
||||
cy.findByLabelText("Custom column").click();
|
||||
|
||||
H.enterCustomColumnDetails({ formula: "[C", blur: false });
|
||||
H.enterCustomColumnDetails({ formula: "[Cre", blur: false });
|
||||
|
||||
// Suggestion popover shows up and this select the first one ([Created At])
|
||||
H.CustomExpressionEditor.completions().should("be.visible");
|
||||
|
||||
// Suggestion popover shows up and this select the first one
|
||||
cy.realPress("Tab");
|
||||
|
||||
// Focus remains on the expression editor
|
||||
cy.focused().should("have.attr", "class").and("eq", "ace_text-input");
|
||||
|
||||
// This really shouldn't be needed, but without interacting with the field, we can't tab away from it.
|
||||
// TODO: Fix
|
||||
cy.get(".ace_text-input").first().type(" ");
|
||||
cy.focused().should("have.attr", "class").and("eq", "cm-content");
|
||||
|
||||
// Tab to focus on the name box
|
||||
cy.realPress("Tab");
|
||||
|
||||
cy.focused().should("have.attr", "value").and("eq", "");
|
||||
cy.focused()
|
||||
.should("have.attr", "placeholder")
|
||||
@@ -694,7 +694,7 @@ describe("scenarios > question > custom column", () => {
|
||||
|
||||
// Shift+Tab and we're back at the editor
|
||||
cy.realPress(["Shift", "Tab"]);
|
||||
cy.focused().should("have.attr", "class").and("eq", "ace_text-input");
|
||||
cy.focused().should("have.attr", "class").and("eq", "cm-content");
|
||||
});
|
||||
|
||||
// TODO: fixme!
|
||||
@@ -714,15 +714,13 @@ describe("scenarios > question > custom column", () => {
|
||||
|
||||
cy.log("custom columns");
|
||||
H.getNotebookStep("data").button("Custom column").click();
|
||||
H.popover()
|
||||
.findByTestId("expression-editor")
|
||||
.within(() => {
|
||||
H.enterCustomColumnDetails({
|
||||
formula: 'if([ID] = 1, "First", [ID] = 2, "Second", "Other")',
|
||||
name: "If",
|
||||
});
|
||||
cy.button("Done").click();
|
||||
H.expressionEditorWidget().within(() => {
|
||||
H.enterCustomColumnDetails({
|
||||
formula: 'if([ID] = 1, "First", [ID] = 2, "Second", "Other")',
|
||||
name: "If",
|
||||
});
|
||||
cy.button("Done").click();
|
||||
});
|
||||
H.getNotebookStep("expression").button("Filter").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("If").click();
|
||||
@@ -773,15 +771,13 @@ describe("scenarios > question > custom column", () => {
|
||||
|
||||
cy.log("custom columns - in");
|
||||
H.getNotebookStep("data").button("Custom column").click();
|
||||
H.popover()
|
||||
.findByTestId("expression-editor")
|
||||
.within(() => {
|
||||
H.enterCustomColumnDetails({
|
||||
formula: 'in("Gadget", [Vendor], [Category])',
|
||||
name: "InColumn",
|
||||
});
|
||||
cy.button("Done").click();
|
||||
H.expressionEditorWidget().within(() => {
|
||||
H.enterCustomColumnDetails({
|
||||
formula: 'in("Gadget", [Vendor], [Category])',
|
||||
name: "InColumn",
|
||||
});
|
||||
cy.button("Done").click();
|
||||
});
|
||||
H.getNotebookStep("expression").button("Filter").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("InColumn").click();
|
||||
@@ -793,15 +789,13 @@ describe("scenarios > question > custom column", () => {
|
||||
cy.log("custom columns - notIn");
|
||||
H.openNotebook();
|
||||
H.getNotebookStep("expression").findByText("InColumn").click();
|
||||
H.popover()
|
||||
.first()
|
||||
.within(() => {
|
||||
H.enterCustomColumnDetails({
|
||||
formula: 'notIn("Gadget", [Vendor], [Category])',
|
||||
name: "InColumn",
|
||||
});
|
||||
cy.button("Update").click();
|
||||
H.expressionEditorWidget().within(() => {
|
||||
H.enterCustomColumnDetails({
|
||||
formula: 'notIn("Gadget", [Vendor], [Category])',
|
||||
name: "InColumn",
|
||||
});
|
||||
cy.button("Update").should("not.be.disabled").click();
|
||||
});
|
||||
H.visualize();
|
||||
H.assertQueryBuilderRowCount(147);
|
||||
|
||||
@@ -861,7 +855,7 @@ describe("scenarios > question > custom column", () => {
|
||||
cy.log("aggregations - notIn");
|
||||
H.openNotebook();
|
||||
H.getNotebookStep("summarize").findByText("CountIfIn").click();
|
||||
H.popover().within(() => {
|
||||
H.expressionEditorWidget().within(() => {
|
||||
H.enterCustomColumnDetails({
|
||||
formula: "countIf(notIn([ID], 1, 2))",
|
||||
name: "CountIfIn",
|
||||
@@ -1068,31 +1062,16 @@ describe("scenarios > question > custom column > expression editor", () => {
|
||||
cy.button("Done").should("not.be.disabled");
|
||||
});
|
||||
|
||||
/**
|
||||
* We abuse {force: true} arguments below because AceEditor cannot be found
|
||||
* on a second click and type commands (the first ones happen in the beforeEach block above )
|
||||
*/
|
||||
it("should not accidentally delete Custom Column formula value and/or Custom Column name (metabase#15734)", () => {
|
||||
cy.get("@formula")
|
||||
.click({ force: true })
|
||||
.type("{movetoend}{leftarrow}{movetostart}{rightarrow}{rightarrow}", {
|
||||
force: true,
|
||||
});
|
||||
H.CustomExpressionEditor.type(
|
||||
"{movetoend}{leftarrow}{movetostart}{rightarrow}{rightarrow}",
|
||||
);
|
||||
cy.findByDisplayValue("Math").focus();
|
||||
cy.button("Done").should("not.be.disabled");
|
||||
});
|
||||
|
||||
/**
|
||||
* 1. Explanation for `cy.get("@formula").click();`
|
||||
* - Without it, test runner is too fast and the test results in false positive.
|
||||
* - This gives it enough time to update the DOM. The same result can be achieved with `cy.wait(1)`
|
||||
*/
|
||||
it("should not erase Custom column formula and Custom column name when expression is incomplete (metabase#16126)", () => {
|
||||
cy.get("@formula")
|
||||
.focus()
|
||||
.click({ force: true })
|
||||
.type("{movetoend}{backspace}", { force: true })
|
||||
.blur();
|
||||
H.CustomExpressionEditor.type("{movetoend}{backspace}").blur();
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Expected expression");
|
||||
@@ -1117,56 +1096,62 @@ describe("scenarios > question > custom column > help text", () => {
|
||||
});
|
||||
|
||||
it("should appear while inside a function", () => {
|
||||
H.enterCustomColumnDetails({ formula: "Lower(", blur: false });
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.contains("lower(text)");
|
||||
H.enterCustomColumnDetails({ formula: "lower(", blur: false });
|
||||
H.CustomExpressionEditor.helpTextHeader()
|
||||
.should("be.visible")
|
||||
.should("contain", "lower(⟨text⟩)");
|
||||
});
|
||||
|
||||
it("should appear after a field reference", () => {
|
||||
H.enterCustomColumnDetails({ formula: "Lower([Category]", blur: false });
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.contains("lower(text)");
|
||||
H.enterCustomColumnDetails({ formula: "lower([Category]", blur: false });
|
||||
H.CustomExpressionEditor.helpTextHeader()
|
||||
.should("be.visible")
|
||||
.should("contain", "lower(⟨text⟩)");
|
||||
});
|
||||
|
||||
it("should not appear while outside a function", () => {
|
||||
H.enterCustomColumnDetails({ formula: "Lower([Category])", blur: false });
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("lower(text)").should("not.exist");
|
||||
H.enterCustomColumnDetails({ formula: "lower([Category])", blur: false });
|
||||
H.CustomExpressionEditor.helpTextHeader().should("not.exist");
|
||||
});
|
||||
|
||||
it("should not appear when formula field is not in focus (metabase#15891)", () => {
|
||||
H.enterCustomColumnDetails({
|
||||
formula: "rou{enter}1.5){leftArrow}",
|
||||
formula: "rou{enter}1.5{leftArrow}",
|
||||
blur: false,
|
||||
});
|
||||
|
||||
cy.findByTestId("expression-helper-popover").findByText(
|
||||
"round([Temperature])",
|
||||
);
|
||||
H.CustomExpressionEditor.helpText()
|
||||
.should("be.visible")
|
||||
.should("contain", "round([Temperature])");
|
||||
|
||||
cy.log("Blur event should remove the expression helper popover");
|
||||
cy.get("@formula").blur();
|
||||
cy.findByTestId("expression-helper-popover").should("not.exist");
|
||||
H.CustomExpressionEditor.blur();
|
||||
H.CustomExpressionEditor.helpText().should("not.exist");
|
||||
|
||||
cy.get("@formula").focus();
|
||||
cy.findByTestId("expression-helper-popover").findByText(
|
||||
"round([Temperature])",
|
||||
);
|
||||
H.CustomExpressionEditor.focus().type("{leftArrow}");
|
||||
H.CustomExpressionEditor.helpText()
|
||||
.should("be.visible")
|
||||
.should("contain", "round([Temperature])");
|
||||
|
||||
cy.log(
|
||||
"Pressing `escape` key should also remove the expression helper popover",
|
||||
);
|
||||
cy.get("@formula").type("{esc}");
|
||||
cy.findByTestId("expression-helper-popover").should("not.exist");
|
||||
H.CustomExpressionEditor.helpText().should("not.exist");
|
||||
});
|
||||
|
||||
it("should not disappear when clicked on (metabase#17548)", () => {
|
||||
H.enterCustomColumnDetails({ formula: "rou{enter}", blur: false });
|
||||
|
||||
H.CustomExpressionEditor.helpText()
|
||||
.should("be.visible")
|
||||
.should("contain", "round([Temperature])");
|
||||
|
||||
// Shouldn't hide on click
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("round([Temperature])").click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("round([Temperature])");
|
||||
H.CustomExpressionEditor.helpText().click();
|
||||
|
||||
H.CustomExpressionEditor.helpText()
|
||||
.should("be.visible")
|
||||
.should("contain", "round([Temperature])");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -947,7 +947,7 @@ describe("issue 34794", () => {
|
||||
cy.findByText("Created At").click();
|
||||
cy.icon("chevronleft").click(); // go back to the main filter popover
|
||||
cy.findByText("Custom Expression").click();
|
||||
cy.findByLabelText("Expression").type("[Total] > 10").blur();
|
||||
H.CustomExpressionEditor.type("[Total] > 10").blur();
|
||||
cy.button("Done").click();
|
||||
});
|
||||
|
||||
|
||||
@@ -278,57 +278,52 @@ describe("scenarios > question > filter", () => {
|
||||
openExpressionEditorFromFreshlyLoadedPage();
|
||||
|
||||
H.enterCustomColumnDetails({ formula: "c", blur: false });
|
||||
H.popover().contains(/case/i);
|
||||
|
||||
cy.get("@formula").type("a");
|
||||
H.CustomExpressionEditor.completions().should("contain", "case");
|
||||
|
||||
H.CustomExpressionEditor.type("a");
|
||||
|
||||
// "case" is still there after typing a bit
|
||||
H.popover().contains(/case/i);
|
||||
H.CustomExpressionEditor.completions().should("contain", "case");
|
||||
});
|
||||
|
||||
it("should enable highlighting suggestions with keyboard up and down arrows (metabase#16210)", () => {
|
||||
const transparent = "rgba(0, 0, 0, 0)";
|
||||
|
||||
openExpressionEditorFromFreshlyLoadedPage();
|
||||
|
||||
H.enterCustomColumnDetails({ formula: "c", blur: false });
|
||||
|
||||
cy.findAllByTestId("expression-suggestions-list-item")
|
||||
.filter(":contains('case')")
|
||||
.should("have.css", "background-color")
|
||||
.and("not.eq", transparent);
|
||||
H.CustomExpressionEditor.completion("case")
|
||||
.parent()
|
||||
.should("have.attr", "aria-selected", "true");
|
||||
|
||||
cy.get("@formula").type("{downarrow}");
|
||||
cy.wait(100);
|
||||
cy.realPress("ArrowDown");
|
||||
|
||||
cy.findAllByTestId("expression-suggestions-list-item")
|
||||
.filter(":contains('case')")
|
||||
.should("have.css", "background-color")
|
||||
.and("eq", transparent);
|
||||
H.CustomExpressionEditor.completion("ceil")
|
||||
.parent()
|
||||
.should("have.attr", "aria-selected", "true");
|
||||
|
||||
cy.findAllByTestId("expression-suggestions-list-item")
|
||||
.filter(":contains('ceil')")
|
||||
.should("have.css", "background-color")
|
||||
.and("not.eq", transparent);
|
||||
H.CustomExpressionEditor.completion("case")
|
||||
.parent()
|
||||
.should("have.attr", "aria-selected", "false");
|
||||
});
|
||||
|
||||
it("should highlight the correct matching for suggestions", () => {
|
||||
openExpressionEditorFromFreshlyLoadedPage();
|
||||
|
||||
H.enterCustomColumnDetails({ formula: "[", blur: false });
|
||||
H.enterCustomColumnDetails({ formula: "[B", blur: false });
|
||||
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
H.popover().last().findByText("Body");
|
||||
H.CustomExpressionEditor.completion("Body").should("be.visible");
|
||||
|
||||
cy.get("@formula").type("p");
|
||||
H.CustomExpressionEditor.type("{backspace}p", { focus: false });
|
||||
|
||||
// only "P" (of Products etc) should be highlighted, and not "Pr"
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
H.popover()
|
||||
.last()
|
||||
.within(() => {
|
||||
cy.findAllByText("P").should("have.length.above", 1);
|
||||
cy.findByText("Pr").should("not.exist");
|
||||
});
|
||||
H.CustomExpressionEditor.completion("Product ID").should("be.visible");
|
||||
H.CustomExpressionEditor.completion("Product ID")
|
||||
.findByText("P")
|
||||
.should("be.visible");
|
||||
H.CustomExpressionEditor.completion("Product ID")
|
||||
.findByText("roduct ID")
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("should provide accurate auto-complete custom-expression suggestions based on the aggregated column name (metabase#14776)", () => {
|
||||
@@ -348,9 +343,12 @@ describe("scenarios > question > filter", () => {
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Custom Expression").click();
|
||||
H.enterCustomColumnDetails({ formula: "su", blur: false });
|
||||
H.popover().contains(/Sum of Total/i);
|
||||
cy.get("@formula").type("m");
|
||||
H.popover().contains(/Sum of Total/i);
|
||||
|
||||
H.CustomExpressionEditor.completion("Sum of Total").should("be.visible");
|
||||
|
||||
H.CustomExpressionEditor.type("m", { focus: false });
|
||||
|
||||
H.CustomExpressionEditor.completion("Sum of Total").should("be.visible");
|
||||
});
|
||||
|
||||
it("should filter using IsNull() and IsEmpty()", () => {
|
||||
@@ -402,9 +400,9 @@ describe("scenarios > question > filter", () => {
|
||||
cy.findByText("Custom Expression").click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.contains("isempty([Reviewer])");
|
||||
cy.get(".ace_text-input").clear().type("NOT IsEmpty([Reviewer])").blur();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Done").click();
|
||||
H.CustomExpressionEditor.clear().type("NOT IsEmpty([Reviewer])").blur();
|
||||
|
||||
cy.button("Done").click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.contains("Showing 1,112 rows");
|
||||
});
|
||||
@@ -430,12 +428,10 @@ describe("scenarios > question > filter", () => {
|
||||
cy.findByText("Custom Expression").click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.contains("isnull([Rating])");
|
||||
cy.get(".ace_text-input")
|
||||
.clear()
|
||||
H.CustomExpressionEditor.clear()
|
||||
.type("NOT IsNull([Rating])", { delay: 50 })
|
||||
.blur();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Done").click();
|
||||
cy.button("Done").should("not.be.disabled").click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.contains("Showing 1,112 rows");
|
||||
});
|
||||
@@ -488,10 +484,10 @@ describe("scenarios > question > filter", () => {
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Title does not contain Wallet").click();
|
||||
cy.get(".Icon-chevronleft").click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Custom Expression").click();
|
||||
H.popover().findByText("Custom Expression").click();
|
||||
|
||||
// Before we implement this feature, we can only assert that the input field for custom expression doesn't show at all
|
||||
cy.get(".ace_text-input");
|
||||
H.CustomExpressionEditor.focus().get().should("be.visible");
|
||||
});
|
||||
|
||||
it("should be able to convert time interval filter to custom expression (metabase#12457)", () => {
|
||||
@@ -630,37 +626,45 @@ describe("scenarios > question > filter", () => {
|
||||
|
||||
it("custom expression filter should allow the use of parentheses in combination with logical operators (metabase#15754)", () => {
|
||||
H.openOrdersTable({ mode: "notebook" });
|
||||
|
||||
H.filter({ mode: "notebook" });
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Custom Expression").click();
|
||||
cy.get(".ace_text-input")
|
||||
H.popover().findByText("Custom Expression").click();
|
||||
|
||||
H.CustomExpressionEditor.focus()
|
||||
.type("([ID] > 2 OR [Subtotal] = 100) and [Tax] < 4")
|
||||
.blur();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText(/^Expected closing parenthesis but found/).should(
|
||||
"not.exist",
|
||||
);
|
||||
cy.button("Done").should("not.be.disabled");
|
||||
|
||||
H.expressionEditorWidget()
|
||||
.findByText(/^Expected closing parenthesis but found/)
|
||||
.should("not.exist");
|
||||
|
||||
H.expressionEditorWidget().button("Done").should("not.be.disabled");
|
||||
});
|
||||
|
||||
it("custom expression filter should refuse to work with numeric value before an operator (metabase#15893)", () => {
|
||||
cy.intercept("POST", "/api/dataset").as("dataset");
|
||||
|
||||
H.openOrdersTable({ mode: "notebook" });
|
||||
|
||||
H.filter({ mode: "notebook" });
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Custom Expression").click();
|
||||
cy.get(".ace_text-input").type("0 < [ID]").blur();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Expecting field but found 0");
|
||||
H.popover().findByText("Custom Expression").click();
|
||||
|
||||
H.CustomExpressionEditor.focus().type("0 < [ID]").blur();
|
||||
|
||||
H.expressionEditorWidget()
|
||||
.findByText("Expecting field but found 0")
|
||||
.should("be.visible");
|
||||
|
||||
H.expressionEditorWidget().button("Done").should("be.disabled");
|
||||
});
|
||||
|
||||
it("should allow switching focus with Tab", () => {
|
||||
H.openOrdersTable({ mode: "notebook" });
|
||||
|
||||
H.filter({ mode: "notebook" });
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Custom Expression").click();
|
||||
cy.get(".ace_text-input").type("[Tax] > 0");
|
||||
H.popover().findByText("Custom Expression").click();
|
||||
|
||||
H.CustomExpressionEditor.focus().type("[Tax] > 0");
|
||||
|
||||
// Tab switches the focus to the "Cancel" button
|
||||
cy.realPress("Tab");
|
||||
@@ -670,21 +674,23 @@ describe("scenarios > question > filter", () => {
|
||||
|
||||
it("should allow choosing a suggestion with Tab", () => {
|
||||
H.openOrdersTable({ mode: "notebook" });
|
||||
|
||||
H.filter({ mode: "notebook" });
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Custom Expression").click();
|
||||
H.popover().findByText("Custom Expression").click();
|
||||
|
||||
// Try to auto-complete Tax
|
||||
cy.get(".ace_text-input").type("Ta");
|
||||
H.CustomExpressionEditor.focus().type("Ta");
|
||||
|
||||
// Suggestion popover shows up and this select the first one ([Created At])
|
||||
cy.realPress("Tab");
|
||||
H.CustomExpressionEditor.completions().should("be.visible");
|
||||
|
||||
// Suggestion popover shows up and this select the first one ([Tax])
|
||||
cy.realPress("Enter");
|
||||
|
||||
// Focus remains on the expression editor
|
||||
cy.focused().should("have.attr", "class").and("eq", "ace_text-input");
|
||||
cy.focused().should("have.attr", "class").and("eq", "cm-content");
|
||||
|
||||
// Finish to complete a valid expression, i.e. [Tax] > 42
|
||||
cy.get(".ace_text-input").type("> 42");
|
||||
H.CustomExpressionEditor.type(" > 42");
|
||||
|
||||
// Tab switches the focus to the "Cancel" button
|
||||
cy.realPress("Tab");
|
||||
@@ -699,16 +705,14 @@ describe("scenarios > question > filter", () => {
|
||||
cy.findByText("Custom Expression").click();
|
||||
|
||||
// Try to auto-complete Tax
|
||||
cy.get(".ace_text-input").type("Disc");
|
||||
H.CustomExpressionEditor.focus().type("Disc");
|
||||
|
||||
// the text here is split up so we try and find the suffix
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("ount");
|
||||
H.CustomExpressionEditor.completions().should("be.visible");
|
||||
|
||||
// Esc closes the suggestion popover
|
||||
cy.realPress("{esc}");
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("ount").should("not.exist");
|
||||
cy.realPress("Escape");
|
||||
|
||||
H.CustomExpressionEditor.completions().should("not.exist");
|
||||
});
|
||||
|
||||
it("should work on twice summarized questions and preserve both summaries (metabase#15620)", () => {
|
||||
@@ -845,7 +849,9 @@ describe("scenarios > question > filter", () => {
|
||||
H.filter({ mode: "notebook" });
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Custom Expression").click();
|
||||
cy.get(".ace_text-input").type("[Total] < [Product → Price]").blur();
|
||||
H.CustomExpressionEditor.type("[Total] < [Product → Price]", {
|
||||
allowFastSet: true,
|
||||
}).blur();
|
||||
cy.button("Done").click();
|
||||
// Filter currently says "Total is less than..." but it can change in https://github.com/metabase/metabase/pull/16174 to "Total < Price"
|
||||
// See: https://github.com/metabase/metabase/pull/16209#discussion_r638129099
|
||||
@@ -877,7 +883,9 @@ describe("scenarios > question > filter", () => {
|
||||
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Custom Expression").click();
|
||||
cy.get(".ace_text-input").type("[Total] < [Product → Price]").blur();
|
||||
H.CustomExpressionEditor.type("[Total] < [Product → Price]", {
|
||||
allowFastSet: true,
|
||||
}).blur();
|
||||
cy.button("Done").click();
|
||||
});
|
||||
|
||||
|
||||
@@ -407,8 +407,9 @@ describe("scenarios > metrics > editing", () => {
|
||||
H.enterCustomColumnDetails({
|
||||
formula: `[${ORDERS_SCALAR_METRIC.name}] / 2`,
|
||||
name: "",
|
||||
blur: true,
|
||||
});
|
||||
H.popover().button("Update").click();
|
||||
H.popover().button("Update").should("not.be.disabled").click();
|
||||
saveMetric();
|
||||
verifyScalarValue("9,380");
|
||||
});
|
||||
|
||||
@@ -93,8 +93,9 @@ describe("scenarios > metrics > question", () => {
|
||||
H.enterCustomColumnDetails({
|
||||
formula: `[${ORDERS_TIMESERIES_METRIC.name}] * 2`,
|
||||
name: "Expression",
|
||||
blur: true,
|
||||
});
|
||||
H.popover().button("Update").click();
|
||||
H.popover().button("Update").should("not.be.disabled").click();
|
||||
H.echartsContainer().findByText("Expression").should("be.visible");
|
||||
});
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ describe("issue 6239", () => {
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Custom Expression").click();
|
||||
|
||||
cy.get(".ace_text-input").type("CountIf([Total] > 0)").blur();
|
||||
H.CustomExpressionEditor.type("CountIf([Total] > 0)").blur();
|
||||
|
||||
cy.findByPlaceholderText("Something nice and descriptive").type("CE");
|
||||
cy.button("Done").click();
|
||||
|
||||
@@ -747,7 +747,7 @@ describe("issue 40064", () => {
|
||||
cy.log("update the expression and check the value");
|
||||
H.openNotebook();
|
||||
H.getNotebookStep("expression").findByText("Tax").click();
|
||||
H.enterCustomColumnDetails({ formula: "[Tax] * 3" });
|
||||
H.enterCustomColumnDetails({ formula: "[Tax] * 3", blur: true });
|
||||
H.popover().button("Update").click();
|
||||
H.visualize();
|
||||
H.tableInteractive().findByText("6.21").should("be.visible");
|
||||
@@ -755,10 +755,18 @@ describe("issue 40064", () => {
|
||||
cy.log("rename the expression and make sure you cannot create a cycle");
|
||||
H.openNotebook();
|
||||
H.getNotebookStep("expression").findByText("Tax").click();
|
||||
H.enterCustomColumnDetails({ formula: "[Tax] * 3", name: "Tax3" });
|
||||
H.popover().button("Update").click();
|
||||
H.enterCustomColumnDetails({
|
||||
formula: "[Tax] * 3",
|
||||
name: "Tax3",
|
||||
blur: true,
|
||||
});
|
||||
H.popover().button("Update").should("not.be.disabled").click();
|
||||
H.getNotebookStep("expression").findByText("Tax3").click();
|
||||
H.enterCustomColumnDetails({ formula: "[Tax3] * 3", name: "Tax3" });
|
||||
H.enterCustomColumnDetails({
|
||||
formula: "[Tax3] * 3",
|
||||
name: "Tax3",
|
||||
blur: true,
|
||||
});
|
||||
H.popover().within(() => {
|
||||
cy.findByText("Cycle detected: Tax3 → Tax3").should("be.visible");
|
||||
cy.button("Update").should("be.disabled");
|
||||
|
||||
@@ -740,6 +740,7 @@ describe("scenarios > question > multiple column breakouts", () => {
|
||||
formula: expression1,
|
||||
name: "Expression1",
|
||||
blur: true,
|
||||
allowFastSet: true,
|
||||
});
|
||||
H.popover().button("Done").click();
|
||||
|
||||
@@ -749,6 +750,7 @@ describe("scenarios > question > multiple column breakouts", () => {
|
||||
formula: expression2,
|
||||
name: "Expression2",
|
||||
blur: true,
|
||||
allowFastSet: true,
|
||||
});
|
||||
H.popover().button("Done").click();
|
||||
|
||||
|
||||
@@ -159,13 +159,12 @@ describe("scenarios > question > notebook", { tags: "@slow" }, () => {
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Custom Expression").click();
|
||||
|
||||
cy.get("@formula")
|
||||
.invoke("val", "") // this is a more reliable .clear()
|
||||
.type("[Price] > 1 AND [Price] < 5{enter}");
|
||||
H.CustomExpressionEditor.clear().type("[Price] > 1 AND [Price] < 5");
|
||||
cy.realPress("Enter");
|
||||
|
||||
// In case it does exist, it usually is an error in expression (caused by not clearing
|
||||
// the input properly before typing), and this check helps to highlight that.
|
||||
cy.findByTestId("expression-editor-textfield").should("not.exist");
|
||||
H.CustomExpressionEditor.get().should("not.exist");
|
||||
|
||||
H.getNotebookStep("filter")
|
||||
.contains("Price is greater than 1")
|
||||
@@ -225,16 +224,15 @@ describe("scenarios > question > notebook", { tags: "@slow" }, () => {
|
||||
|
||||
H.popover().contains("Custom Expression").click();
|
||||
|
||||
cy.findByTestId("expression-editor-textfield").within(() => {
|
||||
cy.get(".ace_text-input").focus().type("[");
|
||||
});
|
||||
H.CustomExpressionEditor.type("[");
|
||||
|
||||
// hover over option in the suggestion list
|
||||
cy.findByTestId("expression-suggestions-list")
|
||||
.findByText("Created At")
|
||||
.parents("li")
|
||||
.findByLabelText("More info")
|
||||
.realHover();
|
||||
H.CustomExpressionEditor.completions().within(() => {
|
||||
cy.findByText("Created At")
|
||||
.parents("li")
|
||||
.findByLabelText("More info")
|
||||
.realHover();
|
||||
});
|
||||
|
||||
H.hovercard().within(() => {
|
||||
cy.contains("The date and time an order was submitted.");
|
||||
@@ -849,9 +847,7 @@ describe("scenarios > question > notebook", { tags: "@slow" }, () => {
|
||||
|
||||
H.getNotebookStep("summarize").contains("Revenue").click();
|
||||
|
||||
H.popover()
|
||||
.findByTestId("expression-editor-textfield")
|
||||
.contains("[Revenue]");
|
||||
H.CustomExpressionEditor.shouldContain("[Revenue]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1149,8 +1145,7 @@ function assertTableRowCount(expectedCount) {
|
||||
}
|
||||
|
||||
function addSimpleCustomColumn(name) {
|
||||
H.enterCustomColumnDetails({ formula: "C", blur: false });
|
||||
cy.findByText("ategory").click();
|
||||
H.enterCustomColumnDetails({ formula: "[Category]", blur: true });
|
||||
cy.findByPlaceholderText("Something nice and descriptive").click().type(name);
|
||||
cy.button("Done").click();
|
||||
}
|
||||
|
||||
@@ -72,12 +72,12 @@ describe("scenarios > question > offset", () => {
|
||||
H.enterCustomColumnDetails({ formula: prefix });
|
||||
|
||||
cy.log("does not suggest offset() in custom columns");
|
||||
cy.findByTestId("expression-suggestions-list-item").should("not.exist");
|
||||
H.CustomExpressionEditor.completions().should("not.exist");
|
||||
|
||||
H.enterCustomColumnDetails({ formula: expression });
|
||||
cy.realPress("Tab");
|
||||
|
||||
H.popover().within(() => {
|
||||
H.expressionEditorWidget().within(() => {
|
||||
cy.button("Done").should("be.disabled");
|
||||
cy.findByText("OFFSET is not supported in custom columns").should(
|
||||
"exist",
|
||||
@@ -220,12 +220,12 @@ describe("scenarios > question > offset", () => {
|
||||
H.enterCustomColumnDetails({ formula: prefix });
|
||||
|
||||
cy.log("does not suggest offset() in filter expressions");
|
||||
cy.findByTestId("expression-suggestions-list-item").should("not.exist");
|
||||
H.CustomExpressionEditor.completions().should("not.exist");
|
||||
|
||||
H.enterCustomColumnDetails({ formula: expression });
|
||||
cy.realPress("Tab");
|
||||
|
||||
H.popover().within(() => {
|
||||
H.expressionEditorWidget().within(() => {
|
||||
cy.button("Done").should("be.disabled");
|
||||
cy.findByText("OFFSET is not supported in custom filters").should(
|
||||
"exist",
|
||||
@@ -254,14 +254,13 @@ describe("scenarios > question > offset", () => {
|
||||
H.enterCustomColumnDetails({ formula: prefix, blur: false });
|
||||
|
||||
cy.log("suggests offset() in aggregation expressions");
|
||||
cy.findByTestId("expression-suggestions-list-item")
|
||||
.should("exist")
|
||||
.and("have.text", "Offset");
|
||||
H.CustomExpressionEditor.completions().should("be.visible");
|
||||
H.CustomExpressionEditor.completion("Offset").should("exist");
|
||||
|
||||
H.enterCustomColumnDetails({ formula: expression, blur: false });
|
||||
cy.realPress("Tab");
|
||||
|
||||
H.popover().within(() => {
|
||||
H.expressionEditorWidget().within(() => {
|
||||
cy.button("Done").should("be.disabled");
|
||||
|
||||
cy.findByPlaceholderText("Something nice and descriptive")
|
||||
@@ -522,7 +521,7 @@ describe("scenarios > question > offset", () => {
|
||||
addCustomAggregation({ formula, name, isFirst: true });
|
||||
|
||||
cy.findAllByTestId("notebook-cell-item").findByText(name).click();
|
||||
cy.findByTestId("expression-editor-textfield").should("contain", formula);
|
||||
H.CustomExpressionEditor.shouldContain(formula);
|
||||
|
||||
cy.on("uncaught:exception", error => {
|
||||
// this check is intended to catch possible normalization errors if BE or FE code changes
|
||||
|
||||
@@ -170,12 +170,9 @@ describe("scenarios > question > summarize sidebar", () => {
|
||||
});
|
||||
});
|
||||
|
||||
H.popover().within(() => {
|
||||
cy.get(".ace_text-layer").should(
|
||||
"have.text",
|
||||
"Sum([Total]) / (Sum([Product → Price]) * Average([Quantity]))",
|
||||
);
|
||||
});
|
||||
H.CustomExpressionEditor.shouldContain(
|
||||
"Sum([Total]) / (Sum([Product → Price]) * Average([Quantity]))",
|
||||
);
|
||||
});
|
||||
|
||||
it("distinct inside custom expression should suggest non-numeric types (metabase#13469)", () => {
|
||||
@@ -183,15 +180,13 @@ describe("scenarios > question > summarize sidebar", () => {
|
||||
H.summarize({ mode: "notebook" });
|
||||
H.popover().contains("Custom Expression").click();
|
||||
|
||||
H.enterCustomColumnDetails({ formula: "Distinct([R" });
|
||||
H.enterCustomColumnDetails({ formula: "Distinct([R", blur: false });
|
||||
|
||||
cy.log(
|
||||
"**The point of failure for ANY non-numeric value reported in v0.36.4**",
|
||||
);
|
||||
// the default type for "Reviewer" is "No semantic type"
|
||||
cy.findByTestId("expression-suggestions-list").within(() => {
|
||||
cy.contains("Reviewer");
|
||||
});
|
||||
H.CustomExpressionEditor.completion("Reviewer").should("be.visible");
|
||||
});
|
||||
|
||||
it("summarizing by distinct datetime should allow granular selection (metabase#13098)", () => {
|
||||
|
||||
@@ -93,9 +93,9 @@ describe("scenarios > visualizations > waterfall", () => {
|
||||
cy.findByText("Filter").click();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Custom Expression").click();
|
||||
cy.get(".ace_text-input")
|
||||
.type("between([Created At: Month], '2022-01-01', '2022-08-01')")
|
||||
.blur();
|
||||
H.CustomExpressionEditor.type(
|
||||
"between([Created At: Month], '2022-01-01', '2022-08-01')",
|
||||
).blur();
|
||||
cy.button("Done").click();
|
||||
|
||||
H.visualize();
|
||||
|
||||
@@ -141,7 +141,7 @@ H.describeWithSnowplow("extract shortcut", () => {
|
||||
formula: "year([Created At]) + 2",
|
||||
blur: true,
|
||||
});
|
||||
H.popover().button("Update").click();
|
||||
H.popover().button("Update").should("not.be.disabled").click();
|
||||
H.visualize();
|
||||
cy.findByRole("gridcell", { name: "2,027" }).should("be.visible");
|
||||
});
|
||||
|
||||
@@ -222,8 +222,11 @@ H.describeWithSnowplow("extract action", () => {
|
||||
});
|
||||
H.openNotebook();
|
||||
H.getNotebookStep("expression").findByText("Year").click();
|
||||
H.enterCustomColumnDetails({ formula: "year([Created At]) + 2" });
|
||||
H.popover().button("Update").click();
|
||||
H.enterCustomColumnDetails({
|
||||
formula: "year([Created At]) + 2",
|
||||
blur: true,
|
||||
});
|
||||
H.popover().button("Update").should("not.be.disabled").click();
|
||||
H.visualize();
|
||||
cy.findByRole("gridcell", { name: "2,027" }).should("be.visible");
|
||||
});
|
||||
|
||||
@@ -1215,3 +1215,6 @@ export const getHelpDocsUrl = ({ docsPage }: HelpText): string => {
|
||||
? `questions/query-builder/expressions/${docsPage}`
|
||||
: "questions/query-builder/expressions";
|
||||
};
|
||||
|
||||
export const getFunctionByStructure = (structure: string) =>
|
||||
HELPER_TEXT_STRINGS.find(h => h.structure === structure)?.name;
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { c, t } from "ttag";
|
||||
|
||||
import * as Lib from "metabase-lib";
|
||||
import type { Expression } from "metabase-types/api";
|
||||
|
||||
import { adjustBooleans, parse } from "./recursive-parser";
|
||||
import { resolve } from "./resolver";
|
||||
|
||||
import { parseDimension, parseMetric, parseSegment } from "./index";
|
||||
|
||||
type ProcessedSource = {
|
||||
source: string;
|
||||
expression: Expression | null;
|
||||
expressionClause: Lib.ExpressionClause | null;
|
||||
compileError: unknown | null;
|
||||
};
|
||||
|
||||
export function processSource(options: {
|
||||
source: string;
|
||||
query: Lib.Query;
|
||||
@@ -14,7 +22,7 @@ export function processSource(options: {
|
||||
expressionIndex: number | undefined;
|
||||
startRule: string;
|
||||
name?: string;
|
||||
}) {
|
||||
}): ProcessedSource {
|
||||
const resolveMBQLField = (kind: string, name: string) => {
|
||||
if (kind === "metric") {
|
||||
const metric = parseMetric(name, options);
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
border: 1px solid var(--mb-color-border);
|
||||
border-radius: 8px;
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
padding-left: 0.6875rem;
|
||||
padding-right: 0.6875rem;
|
||||
transition: border-color 100ms ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--mb-color-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.prefix {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--mb-color-error);
|
||||
margin: 0.5rem 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--mb-color-text-dark);
|
||||
|
||||
:global {
|
||||
.cm-editor {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
background-color: none;
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
color: var(--mb-color-text-dark);
|
||||
background: none;
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.cm-gutters {
|
||||
display: none;
|
||||
background-color: var(--mb-color-bg-light);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cm-gutterElement {
|
||||
font-weight: bold;
|
||||
font-size: 10px;
|
||||
color: var(--mb-color-text-light);
|
||||
vertical-align: baseline;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
|
||||
span[title="Fold line"] {
|
||||
position: relative;
|
||||
top: -3px;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-activeLineGutter {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.cm-selectionBackground {
|
||||
background: var(--mb-color-focus) !important;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.cm-selectionMatch {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.cm-cursor {
|
||||
border-left-width: 1.6px;
|
||||
}
|
||||
|
||||
.cm-tooltip {
|
||||
border: none !important;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cm-snippetField {
|
||||
background-color: var(--mb-color-bg-medium);
|
||||
opacity: 0.65;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tooltips {
|
||||
:global {
|
||||
.cm-tooltip {
|
||||
border: none !important;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import type { EditorState } from "@codemirror/state";
|
||||
import CodeMirror, { type ReactCodeMirrorRef } from "@uiw/react-codemirror";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { getMetadata } from "metabase/selectors/metadata";
|
||||
import { Box } from "metabase/ui";
|
||||
import * as Lib from "metabase-lib";
|
||||
import { isExpression } from "metabase-lib/v1/expressions";
|
||||
import { diagnose } from "metabase-lib/v1/expressions/diagnostics";
|
||||
import { format } from "metabase-lib/v1/expressions/format";
|
||||
import { processSource } from "metabase-lib/v1/expressions/process";
|
||||
import type { ErrorWithMessage } from "metabase-lib/v1/expressions/types";
|
||||
import type Metadata from "metabase-lib/v1/metadata/Metadata";
|
||||
import type { Expression } from "metabase-types/api";
|
||||
|
||||
import type { ClauseType, StartRule } from "../types";
|
||||
|
||||
import S from "./Editor.module.css";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { useCustomTooltip } from "./custom-tooltip";
|
||||
import { useExtensions } from "./extensions";
|
||||
import type { Shortcut } from "./suggestions";
|
||||
import { tokenAtPos } from "./util";
|
||||
|
||||
type EditorProps<S extends StartRule> = {
|
||||
id?: string;
|
||||
clause?: ClauseType<S> | null;
|
||||
error: ErrorWithMessage | null;
|
||||
name: string;
|
||||
query: Lib.Query;
|
||||
stageIndex: number;
|
||||
startRule: S;
|
||||
expressionIndex?: number;
|
||||
reportTimezone?: string;
|
||||
readOnly?: boolean;
|
||||
|
||||
onChange: (clause: ClauseType<S> | null) => void;
|
||||
onCommit: (clause: ClauseType<S> | null) => void;
|
||||
onError: (error: ErrorWithMessage | null) => void;
|
||||
shortcuts?: Shortcut[];
|
||||
};
|
||||
|
||||
const DEFAULT_SHORTCUTS: Shortcut[] = [];
|
||||
|
||||
export function Editor<S extends StartRule = "expression">(
|
||||
props: EditorProps<S>,
|
||||
) {
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
startRule = "expression",
|
||||
stageIndex,
|
||||
query,
|
||||
expressionIndex,
|
||||
readOnly,
|
||||
error,
|
||||
reportTimezone,
|
||||
shortcuts = DEFAULT_SHORTCUTS,
|
||||
} = props;
|
||||
|
||||
const ref = useRef<ReactCodeMirrorRef>(null);
|
||||
const metadata = useSelector(getMetadata);
|
||||
|
||||
const { source, onSourceChange, onChange, onCommit, hasChanges } =
|
||||
useExpression({ ...props, metadata });
|
||||
|
||||
const [customTooltip, portal] = useCustomTooltip({
|
||||
getPosition: getTooltipPosition,
|
||||
render: props => (
|
||||
<Tooltip
|
||||
query={query}
|
||||
stageIndex={stageIndex}
|
||||
metadata={metadata}
|
||||
reportTimezone={reportTimezone}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
const extensions = useExtensions({
|
||||
startRule,
|
||||
query,
|
||||
stageIndex,
|
||||
name,
|
||||
expressionIndex,
|
||||
onCommit,
|
||||
reportTimezone,
|
||||
metadata,
|
||||
extensions: [customTooltip],
|
||||
shortcuts,
|
||||
});
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
onChange(source);
|
||||
}, [source, onChange]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={S.wrapper}>
|
||||
<div className={S.prefix}>=</div>
|
||||
<CodeMirror
|
||||
id={id}
|
||||
ref={ref}
|
||||
data-testid="custom-expression-query-editor"
|
||||
className={S.editor}
|
||||
extensions={extensions}
|
||||
readOnly={readOnly}
|
||||
value={source}
|
||||
onChange={onSourceChange}
|
||||
onBlur={handleBlur}
|
||||
height="100%"
|
||||
width="100%"
|
||||
indentWithTab={false}
|
||||
/>
|
||||
</div>
|
||||
{error && hasChanges && <Box className={S.error}>{error.message}</Box>}
|
||||
{portal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function useExpression<S extends StartRule = "expression">({
|
||||
name,
|
||||
clause,
|
||||
startRule,
|
||||
stageIndex,
|
||||
query,
|
||||
expressionIndex,
|
||||
metadata,
|
||||
onChange,
|
||||
onCommit,
|
||||
onError,
|
||||
}: EditorProps<S> & {
|
||||
metadata: Metadata;
|
||||
}) {
|
||||
const expression = useMemo(() => {
|
||||
const expressionFromClause =
|
||||
clause &&
|
||||
Lib.legacyExpressionForExpressionClause(query, stageIndex, clause);
|
||||
|
||||
return expressionFromClause;
|
||||
}, [clause, query, stageIndex]);
|
||||
|
||||
const formatExpression = useCallback(
|
||||
(expression: Expression | null) =>
|
||||
format(expression, {
|
||||
startRule,
|
||||
stageIndex,
|
||||
query,
|
||||
name,
|
||||
expressionIndex,
|
||||
}),
|
||||
[startRule, stageIndex, query, name, expressionIndex],
|
||||
);
|
||||
|
||||
const [source, setSource] = useState(formatExpression(expression));
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const handleSourceChange = useCallback((source: string) => {
|
||||
setSource(source);
|
||||
setHasChanges(true);
|
||||
}, []);
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
function (source: string, commit: boolean) {
|
||||
if (source.trim() === "") {
|
||||
onError({ message: t`Invalid expression` });
|
||||
return;
|
||||
}
|
||||
|
||||
const error = diagnose({
|
||||
source,
|
||||
startRule,
|
||||
query,
|
||||
stageIndex,
|
||||
expressionIndex,
|
||||
metadata,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
onError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const compiledExpression = processSource({
|
||||
source,
|
||||
name,
|
||||
query,
|
||||
stageIndex,
|
||||
startRule,
|
||||
expressionIndex,
|
||||
});
|
||||
|
||||
const { expression, expressionClause, compileError } = compiledExpression;
|
||||
if (
|
||||
compileError &&
|
||||
typeof compileError === "object" &&
|
||||
"message" in compileError &&
|
||||
typeof compileError.message === "string"
|
||||
) {
|
||||
onError({ message: compileError.message });
|
||||
return;
|
||||
} else if (compileError) {
|
||||
onError({ message: t`Invalid expression` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!expression || !isExpression(expression) || !expressionClause) {
|
||||
onError({ message: t`Invalid expression` });
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: can this be typed so we don't need to cast?
|
||||
const clause = expressionClause as ClauseType<S>;
|
||||
|
||||
if (commit) {
|
||||
onCommit(clause);
|
||||
} else {
|
||||
onChange(clause);
|
||||
}
|
||||
onError(null);
|
||||
setHasChanges(false);
|
||||
setSource(formatExpression(expression));
|
||||
},
|
||||
[
|
||||
name,
|
||||
query,
|
||||
stageIndex,
|
||||
startRule,
|
||||
metadata,
|
||||
expressionIndex,
|
||||
formatExpression,
|
||||
onChange,
|
||||
onCommit,
|
||||
onError,
|
||||
],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(source: string) => handleUpdate(source, false),
|
||||
[handleUpdate],
|
||||
);
|
||||
const handleCommit = useCallback(
|
||||
(source: string) => handleUpdate(source, true),
|
||||
[handleUpdate],
|
||||
);
|
||||
|
||||
return {
|
||||
expression,
|
||||
source,
|
||||
onSourceChange: handleSourceChange,
|
||||
onChange: handleChange,
|
||||
onCommit: handleCommit,
|
||||
hasChanges,
|
||||
};
|
||||
}
|
||||
|
||||
function getTooltipPosition(state: EditorState) {
|
||||
const pos = state.selection.main.head;
|
||||
const source = state.doc.toString();
|
||||
const token = tokenAtPos(source, pos);
|
||||
|
||||
return token?.start ?? pos;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
.usage {
|
||||
padding-left: 0.5rem;
|
||||
background-color: white;
|
||||
color: var(--mb-color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-family: var(--mb-default-monospace-font-family);
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
min-height: 2.5rem;
|
||||
line-height: 2.5rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
margin-left: auto;
|
||||
padding: 0 0.65rem;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--mb-color-text-secondary);
|
||||
padding-top: 1px;
|
||||
|
||||
&.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.arg {
|
||||
color: var(--mb-base-color-dubloon-50);
|
||||
font-family: var(--mb-default-monospace-font-family);
|
||||
font-size: 0.75rem;
|
||||
|
||||
&.active {
|
||||
background-color: var(--mb-base-color-dubloon-10);
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
color: var(--mb-color-text-primary);
|
||||
font-family: var(--mb-default-font-family);
|
||||
padding: 0.5rem;
|
||||
overflow: auto;
|
||||
border-top: 1px solid var(--mb-color-border);
|
||||
background-color: var(--mb-color-bg-light);
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--mb-color-text-secondary);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.arguments {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(2, minmax(4rem, max-content));
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.example {
|
||||
background-color: var(--mb-color-bg-medium);
|
||||
color: var(--mb-color-text-primary);
|
||||
border-radius: 4px;
|
||||
padding: 1rem 0.5rem;
|
||||
overflow: auto;
|
||||
font-size: 0.75rem;
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
font-family: Monaco, monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.documentationLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
color: var(--mb-color-brand);
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { StoryFn } from "@storybook/react";
|
||||
|
||||
import { createMockMetadata } from "__support__/metadata";
|
||||
import { ReduxProvider } from "__support__/storybook";
|
||||
import { createQuery } from "metabase-lib/test-helpers";
|
||||
import { createMockDatabase } from "metabase-types/api/mocks";
|
||||
|
||||
import { HelpText } from "./HelpText";
|
||||
|
||||
export default {
|
||||
title: "Query Builder/Editor/HelpText",
|
||||
component: HelpText,
|
||||
};
|
||||
|
||||
const Template: StoryFn<typeof HelpText> = () => {
|
||||
const database = createMockDatabase();
|
||||
const metadata = createMockMetadata({ databases: [database] });
|
||||
const query = createQuery({
|
||||
databaseId: database.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<ReduxProvider>
|
||||
<HelpText
|
||||
query={query}
|
||||
metadata={metadata}
|
||||
enclosingFunction={{
|
||||
name: "datetime-diff",
|
||||
arg: null,
|
||||
}}
|
||||
reportTimezone="America/Los_Angeles"
|
||||
/>
|
||||
</ReduxProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const Default = Template;
|
||||
@@ -0,0 +1,169 @@
|
||||
import cx from "classnames";
|
||||
import { Fragment, useCallback } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useDocsUrl } from "metabase/common/hooks";
|
||||
import ExternalLink from "metabase/core/components/ExternalLink";
|
||||
import { Box, Flex, Icon, UnstyledButton } from "metabase/ui";
|
||||
import * as Lib from "metabase-lib";
|
||||
import { MBQL_CLAUSES } from "metabase-lib/v1/expressions/config";
|
||||
import {
|
||||
getHelpDocsUrl,
|
||||
getHelpText,
|
||||
} from "metabase-lib/v1/expressions/helper-text-strings";
|
||||
import type { HelpText } from "metabase-lib/v1/expressions/types";
|
||||
import type Metadata from "metabase-lib/v1/metadata/Metadata";
|
||||
|
||||
import { Highlight } from "../Highlight";
|
||||
|
||||
import S from "./HelpText.module.css";
|
||||
|
||||
function wrapPlaceholder(name: string) {
|
||||
if (name === "…") {
|
||||
return name;
|
||||
}
|
||||
|
||||
return `⟨${name}⟩`;
|
||||
}
|
||||
|
||||
export type HelpTextProps = {
|
||||
open?: boolean;
|
||||
onToggle?: () => void;
|
||||
enclosingFunction?: {
|
||||
name: string;
|
||||
arg: {
|
||||
index: number;
|
||||
} | null;
|
||||
} | null;
|
||||
query: Lib.Query;
|
||||
metadata: Metadata;
|
||||
reportTimezone?: string;
|
||||
height?: string | number;
|
||||
};
|
||||
|
||||
function getDatabase(query: Lib.Query, metadata: Metadata) {
|
||||
const databaseId = Lib.databaseID(query);
|
||||
return metadata.database(databaseId);
|
||||
}
|
||||
|
||||
export function HelpText({
|
||||
open = true,
|
||||
onToggle,
|
||||
enclosingFunction,
|
||||
query,
|
||||
metadata,
|
||||
reportTimezone,
|
||||
height = "auto",
|
||||
}: HelpTextProps) {
|
||||
const database = getDatabase(query, metadata);
|
||||
const helpText =
|
||||
enclosingFunction && database
|
||||
? getHelpText(enclosingFunction.name, database, reportTimezone)
|
||||
: null;
|
||||
|
||||
const clause = helpText && MBQL_CLAUSES[helpText?.name];
|
||||
const isSupported = clause && database?.hasFeature(clause?.requiresFeature);
|
||||
|
||||
const { url: docsUrl, showMetabaseLinks } = useDocsUrl(
|
||||
helpText ? getHelpDocsUrl(helpText) : "",
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(evt: React.MouseEvent<HTMLDivElement>) => {
|
||||
evt.preventDefault();
|
||||
onToggle?.();
|
||||
},
|
||||
[onToggle],
|
||||
);
|
||||
|
||||
if (!helpText || !clause || !isSupported) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { description, structure, args, example } = helpText;
|
||||
const argIndex = enclosingFunction?.arg?.index ?? -1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex
|
||||
className={S.usage}
|
||||
onMouseDown={handleMouseDown}
|
||||
data-testid="expression-helper-popover-structure"
|
||||
>
|
||||
<Box>
|
||||
{structure}
|
||||
{args != null && (
|
||||
<>
|
||||
(
|
||||
{args.map(({ name }, index) => (
|
||||
<span key={name}>
|
||||
<span
|
||||
className={cx(S.arg, {
|
||||
[S.active]:
|
||||
argIndex === index ||
|
||||
(name === "…" && argIndex > args.length - 1),
|
||||
})}
|
||||
>
|
||||
{wrapPlaceholder(name)}
|
||||
</span>
|
||||
{index < args.length - 1 && ", "}
|
||||
</span>
|
||||
))}
|
||||
)
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
<UnstyledButton className={S.toggle} px="sm">
|
||||
<Icon
|
||||
name="chevronright"
|
||||
width={12}
|
||||
className={cx(S.chevron, { [S.open]: open })}
|
||||
/>
|
||||
</UnstyledButton>
|
||||
</Flex>
|
||||
|
||||
{open && (
|
||||
<Box className={S.info} data-testid="expression-helper" mah={height}>
|
||||
<Box>{description}</Box>
|
||||
|
||||
{args != null && (
|
||||
<Box
|
||||
className={S.arguments}
|
||||
data-testid="expression-helper-popover-arguments"
|
||||
>
|
||||
{args.map(({ name, description }) => (
|
||||
<Fragment key={name}>
|
||||
<Box className={S.arg}>{wrapPlaceholder(name)}</Box>
|
||||
<Box>{description}</Box>
|
||||
</Fragment>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{example && (
|
||||
<>
|
||||
<Box className={S.title}>{t`Example`}</Box>
|
||||
<Box
|
||||
className={S.example}
|
||||
data-testid="expression-helper-example"
|
||||
>
|
||||
<Highlight expression={example} />
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showMetabaseLinks && (
|
||||
<ExternalLink
|
||||
className={S.documentationLink}
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
>
|
||||
<Icon m="0.25rem 0.5rem" name="reference" size={12} />
|
||||
{t`Learn more`}
|
||||
</ExternalLink>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { HelpText } from "./HelpText";
|
||||
@@ -1,49 +1,43 @@
|
||||
import { screen, within } from "@testing-library/react";
|
||||
|
||||
import { createMockMetadata } from "__support__/metadata";
|
||||
import { getBrokenUpTextMatcher } from "__support__/ui";
|
||||
import { checkNotNull } from "metabase/lib/types";
|
||||
import { getHelpText } from "metabase-lib/v1/expressions/helper-text-strings";
|
||||
import {
|
||||
SAMPLE_DB_ID,
|
||||
createSampleDatabase,
|
||||
} from "metabase-types/api/mocks/presets";
|
||||
|
||||
import { setup } from "./setup";
|
||||
|
||||
describe("ExpressionEditorHelpText (OSS)", () => {
|
||||
const metadata = createMockMetadata({ databases: [createSampleDatabase()] });
|
||||
const database = checkNotNull(metadata.database(SAMPLE_DB_ID));
|
||||
|
||||
describe("HelpText (OSS)", () => {
|
||||
it("should render expression function info, example and documentation link", async () => {
|
||||
await setup({ helpText: getHelpText("datetime-diff", database, "UTC") });
|
||||
await setup({
|
||||
enclosingFunction: {
|
||||
name: "concat",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText('datetimeDiff([Created At], [Shipped At], "month")'),
|
||||
screen.getByText('concat([Last Name], ", ", [First Name])'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
getBrokenUpTextMatcher("datetimeDiff(datetime1, datetime2, unit)"),
|
||||
),
|
||||
screen.getByText(getBrokenUpTextMatcher("concat(⟨value1⟩, ⟨value2⟩, …)")),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Get the difference between two datetime values (datetime2 minus datetime1) using the specified unit of time.",
|
||||
),
|
||||
screen.getByText("Combine two or more strings of text together."),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const link = screen.getByRole("link");
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveProperty(
|
||||
"href",
|
||||
"https://www.metabase.com/docs/latest/questions/query-builder/expressions/datetimediff.html",
|
||||
"https://www.metabase.com/docs/latest/questions/query-builder/expressions/concat.html",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle expression function without arguments", async () => {
|
||||
await setup({ helpText: getHelpText("cum-count", database, "UTC") });
|
||||
await setup({
|
||||
enclosingFunction: {
|
||||
name: "cum-count",
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getAllByText("CumulativeCount")).toHaveLength(2);
|
||||
|
||||
@@ -60,24 +54,33 @@ describe("ExpressionEditorHelpText (OSS)", () => {
|
||||
});
|
||||
|
||||
it("should render function arguments", async () => {
|
||||
const helpText = getHelpText("concat", database, "UTC");
|
||||
await setup({ helpText });
|
||||
const { helpText } = await setup({
|
||||
enclosingFunction: {
|
||||
name: "concat",
|
||||
},
|
||||
});
|
||||
|
||||
const argumentsBlock = screen.getByTestId(
|
||||
"expression-helper-popover-arguments",
|
||||
);
|
||||
|
||||
helpText?.args?.forEach(({ name, description }) => {
|
||||
expect(within(argumentsBlock).getByText(name)).toBeInTheDocument();
|
||||
const expectedName = name === "…" ? "…" : `⟨${name}⟩`;
|
||||
expect(
|
||||
within(argumentsBlock).getByText(expectedName),
|
||||
).toBeInTheDocument();
|
||||
expect(within(argumentsBlock).getByText(description)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Metabase links", () => {
|
||||
const helpText = getHelpText("concat", database, "UTC");
|
||||
|
||||
it("should show a help link when `show-metabase-links: true`", async () => {
|
||||
await setup({ helpText, showMetabaseLinks: true });
|
||||
await setup({
|
||||
enclosingFunction: {
|
||||
name: "concat",
|
||||
},
|
||||
showMetabaseLinks: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole("img", { name: "reference icon" }),
|
||||
@@ -86,7 +89,12 @@ describe("ExpressionEditorHelpText (OSS)", () => {
|
||||
});
|
||||
|
||||
it("should show a help link when `show-metabase-links: false`", async () => {
|
||||
await setup({ helpText, showMetabaseLinks: false });
|
||||
await setup({
|
||||
enclosingFunction: {
|
||||
name: "concat",
|
||||
},
|
||||
showMetabaseLinks: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole("img", { name: "reference icon" }),
|
||||
@@ -1,13 +1,5 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
|
||||
import { createMockMetadata } from "__support__/metadata";
|
||||
import { checkNotNull } from "metabase/lib/types";
|
||||
import { getHelpText } from "metabase-lib/v1/expressions/helper-text-strings";
|
||||
import {
|
||||
SAMPLE_DB_ID,
|
||||
createSampleDatabase,
|
||||
} from "metabase-types/api/mocks/presets";
|
||||
|
||||
import type { SetupOpts } from "./setup";
|
||||
import { setup as baseSetup } from "./setup";
|
||||
|
||||
@@ -16,14 +8,12 @@ async function setup(opts: SetupOpts) {
|
||||
}
|
||||
|
||||
describe("ExpressionEditorHelpText (EE without token)", () => {
|
||||
const metadata = createMockMetadata({ databases: [createSampleDatabase()] });
|
||||
const database = checkNotNull(metadata.database(SAMPLE_DB_ID));
|
||||
|
||||
describe("Metabase links", () => {
|
||||
const helpText = getHelpText("concat", database, "UTC");
|
||||
|
||||
it("should show a help link when `show-metabase-links: true`", async () => {
|
||||
await setup({ helpText, showMetabaseLinks: true });
|
||||
await setup({
|
||||
enclosingFunction: { name: "concat" },
|
||||
showMetabaseLinks: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole("img", { name: "reference icon" }),
|
||||
@@ -32,7 +22,10 @@ describe("ExpressionEditorHelpText (EE without token)", () => {
|
||||
});
|
||||
|
||||
it("should show a help link when `show-metabase-links: false`", async () => {
|
||||
await setup({ helpText, showMetabaseLinks: false });
|
||||
await setup({
|
||||
enclosingFunction: { name: "concat" },
|
||||
showMetabaseLinks: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole("img", { name: "reference icon" }),
|
||||
@@ -1,13 +1,5 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
|
||||
import { createMockMetadata } from "__support__/metadata";
|
||||
import { checkNotNull } from "metabase/lib/types";
|
||||
import { getHelpText } from "metabase-lib/v1/expressions/helper-text-strings";
|
||||
import {
|
||||
SAMPLE_DB_ID,
|
||||
createSampleDatabase,
|
||||
} from "metabase-types/api/mocks/presets";
|
||||
|
||||
import type { SetupOpts } from "./setup";
|
||||
import { setup as baseSetup } from "./setup";
|
||||
|
||||
@@ -20,14 +12,12 @@ async function setup(opts: SetupOpts) {
|
||||
}
|
||||
|
||||
describe("ExpressionEditorHelpText (EE with token)", () => {
|
||||
const metadata = createMockMetadata({ databases: [createSampleDatabase()] });
|
||||
const database = checkNotNull(metadata.database(SAMPLE_DB_ID));
|
||||
|
||||
describe("Metabase links", () => {
|
||||
const helpText = getHelpText("concat", database, "UTC");
|
||||
|
||||
it("should show a help link when `show-metabase-links: true`", async () => {
|
||||
await setup({ helpText, showMetabaseLinks: true });
|
||||
await setup({
|
||||
enclosingFunction: { name: "concat" },
|
||||
showMetabaseLinks: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole("img", { name: "reference icon" }),
|
||||
@@ -36,7 +26,10 @@ describe("ExpressionEditorHelpText (EE with token)", () => {
|
||||
});
|
||||
|
||||
it("should not show a help link when `show-metabase-links: false`", async () => {
|
||||
await setup({ helpText, showMetabaseLinks: false });
|
||||
await setup({
|
||||
enclosingFunction: { name: "concat" },
|
||||
showMetabaseLinks: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByRole("img", { name: "reference icon" }),
|
||||
@@ -0,0 +1,73 @@
|
||||
import { setupEnterprisePlugins } from "__support__/enterprise";
|
||||
import { createMockMetadata } from "__support__/metadata";
|
||||
import { mockSettings } from "__support__/settings";
|
||||
import { renderWithProviders } from "__support__/ui";
|
||||
import { checkNotNull } from "metabase/lib/types";
|
||||
import { createQuery } from "metabase-lib/test-helpers";
|
||||
import { getHelpText } from "metabase-lib/v1/expressions/helper-text-strings";
|
||||
import type { TokenFeatures } from "metabase-types/api";
|
||||
import { createMockTokenFeatures } from "metabase-types/api/mocks";
|
||||
import {
|
||||
SAMPLE_DB_ID,
|
||||
createSampleDatabase,
|
||||
} from "metabase-types/api/mocks/presets";
|
||||
import { createMockState } from "metabase-types/store/mocks";
|
||||
|
||||
import { HelpText, type HelpTextProps } from "../HelpText";
|
||||
|
||||
export interface SetupOpts {
|
||||
enclosingFunction?: Partial<HelpTextProps["enclosingFunction"]>;
|
||||
reportTimezone?: string;
|
||||
|
||||
showMetabaseLinks?: boolean;
|
||||
hasEnterprisePlugins?: boolean;
|
||||
tokenFeatures?: Partial<TokenFeatures>;
|
||||
}
|
||||
|
||||
export async function setup({
|
||||
enclosingFunction,
|
||||
reportTimezone = "America/Los_Angeles",
|
||||
showMetabaseLinks = true,
|
||||
hasEnterprisePlugins,
|
||||
tokenFeatures = {},
|
||||
}: SetupOpts) {
|
||||
const state = createMockState({
|
||||
settings: mockSettings({
|
||||
"show-metabase-links": showMetabaseLinks,
|
||||
"token-features": createMockTokenFeatures(tokenFeatures),
|
||||
}),
|
||||
});
|
||||
|
||||
const metadata = createMockMetadata({ databases: [createSampleDatabase()] });
|
||||
const database = checkNotNull(metadata.database(SAMPLE_DB_ID));
|
||||
const query = createQuery({
|
||||
databaseId: database.id,
|
||||
});
|
||||
|
||||
const props: HelpTextProps = {
|
||||
enclosingFunction: {
|
||||
arg: null,
|
||||
name: "concat",
|
||||
...enclosingFunction,
|
||||
},
|
||||
query,
|
||||
metadata,
|
||||
reportTimezone,
|
||||
};
|
||||
|
||||
if (hasEnterprisePlugins) {
|
||||
setupEnterprisePlugins();
|
||||
}
|
||||
|
||||
renderWithProviders(<HelpText {...props} />, {
|
||||
storeInitialState: state,
|
||||
});
|
||||
|
||||
const helpText = getHelpText(
|
||||
checkNotNull(enclosingFunction?.name),
|
||||
database,
|
||||
reportTimezone,
|
||||
);
|
||||
|
||||
return { database, metadata, helpText };
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
.keyword {
|
||||
color: var(--mb-color-saturated-purple);
|
||||
}
|
||||
|
||||
.name,
|
||||
.function,
|
||||
.variable {
|
||||
color: var(--mb-color-saturated-blue);
|
||||
}
|
||||
|
||||
.constant,
|
||||
.typeName {
|
||||
color: var(--mb-color-saturated-red);
|
||||
}
|
||||
|
||||
.string {
|
||||
color: var(--mb-color-saturated-green);
|
||||
}
|
||||
|
||||
.number,
|
||||
.bool {
|
||||
color: var(--mb-color-saturated-red);
|
||||
}
|
||||
|
||||
.comment,
|
||||
.lineComment,
|
||||
.blockComment {
|
||||
color: var(--mb-color-text-light);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.squareBracket {
|
||||
color: var(--mb-color-brand);
|
||||
}
|
||||
|
||||
.callExpression {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.processingInstruction {
|
||||
color: var(--mb-color-brand);
|
||||
}
|
||||
|
||||
.logicOperator {
|
||||
color: var(--mb-color-saturated-purple);
|
||||
}
|
||||
|
||||
.arithmeticOperator,
|
||||
.compareOperator {
|
||||
color: var(--mb-color-text-dark);
|
||||
}
|
||||
|
||||
.escape {
|
||||
color: var(--mb-color-saturated-red);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { highlight } from "./util";
|
||||
|
||||
export function Highlight({ expression }: { expression: string }) {
|
||||
const __html = useMemo(() => highlight(expression), [expression]);
|
||||
return <pre dangerouslySetInnerHTML={{ __html }} />;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Highlight } from "./Highlight";
|
||||
export { highlight, highlightStyle } from "./util";
|
||||
@@ -0,0 +1,53 @@
|
||||
import { HighlightStyle } from "@codemirror/language";
|
||||
import { type Tag, highlightCode, tags } from "@lezer/highlight";
|
||||
|
||||
import { parser } from "../language";
|
||||
|
||||
import S from "./Highlight.module.css";
|
||||
|
||||
function className(tag: Tag | Tag[]): string {
|
||||
if (Array.isArray(tag)) {
|
||||
return tag.map(className).join(" ");
|
||||
}
|
||||
|
||||
const name = tag.toString();
|
||||
return S[name] ?? "";
|
||||
}
|
||||
|
||||
export const highlightStyle = HighlightStyle.define([
|
||||
{
|
||||
tag: tags.function(tags.variableName),
|
||||
class: S,
|
||||
},
|
||||
...Object.values(tags)
|
||||
.filter((tag): tag is Tag => typeof tag !== "function")
|
||||
.map((tag: Tag) => ({
|
||||
tag,
|
||||
class: className(tag),
|
||||
})),
|
||||
]);
|
||||
|
||||
const highlighter = {
|
||||
style: (tag: Tag[]) => className(tag),
|
||||
};
|
||||
|
||||
export function highlight(code: string): string {
|
||||
const tree = parser.parse(code);
|
||||
|
||||
let res = "";
|
||||
|
||||
function emit(text: string, className: string) {
|
||||
if (className) {
|
||||
res += `<span class="${className}">${text}</span>`;
|
||||
} else {
|
||||
res += text;
|
||||
}
|
||||
}
|
||||
function brk() {
|
||||
res += "\n";
|
||||
}
|
||||
|
||||
highlightCode(code, tree, highlighter, emit, brk);
|
||||
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
.listbox {
|
||||
border-top: 1px solid var(--mb-color-border);
|
||||
font-size: 14px;
|
||||
overflow-y: auto;
|
||||
height: calc(100% - 2.5rem);
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
height: 32px;
|
||||
|
||||
&:hover,
|
||||
&[aria-selected="true"] {
|
||||
color: inherit;
|
||||
background-color: var(--mb-color-focus);
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: 8px;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.footer {
|
||||
box-sizing: border-box;
|
||||
height: 40px;
|
||||
background-color: var(--mb-color-bg-light);
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--mb-color-text-secondary);
|
||||
border-top: 1px solid var(--mb-color-border);
|
||||
font-size: 12px;
|
||||
margin-top: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.key {
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--mb-base-color-orion-10);
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
margin: 0 4px;
|
||||
|
||||
& + .key {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
acceptCompletion,
|
||||
setSelectedCompletion,
|
||||
} from "@codemirror/autocomplete";
|
||||
import type { EditorState } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import { QueryColumnInfoIcon } from "metabase/components/MetadataInfo/ColumnInfoIcon";
|
||||
import { Box, DelayGroup, Icon } from "metabase/ui";
|
||||
import type * as Lib from "metabase-lib";
|
||||
|
||||
import type { Completion } from "../types";
|
||||
|
||||
import S from "./Listbox.module.css";
|
||||
import { MatchText } from "./MatchText";
|
||||
import { useCompletions } from "./util";
|
||||
|
||||
export function Listbox({
|
||||
state,
|
||||
view,
|
||||
query,
|
||||
stageIndex,
|
||||
height = "auto",
|
||||
}: {
|
||||
state: EditorState;
|
||||
view: EditorView;
|
||||
query: Lib.Query;
|
||||
stageIndex: number;
|
||||
height: string | number;
|
||||
}) {
|
||||
const { options, selectedOption } = useCompletions(state);
|
||||
|
||||
const onCompletionClick = useCallback(
|
||||
(index: number) => {
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
view.dispatch({
|
||||
effects: [setSelectedCompletion(index)],
|
||||
});
|
||||
acceptCompletion(view);
|
||||
},
|
||||
[view],
|
||||
);
|
||||
|
||||
if (options.length <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box h={height}>
|
||||
<ul role="listbox" className={S.listbox}>
|
||||
<DelayGroup>
|
||||
{options.map((completion, index) => (
|
||||
<CompletionItem
|
||||
key={completion.displayLabel ?? completion.label}
|
||||
completion={completion}
|
||||
index={index}
|
||||
selected={selectedOption === index}
|
||||
onCompletionClick={onCompletionClick}
|
||||
query={query}
|
||||
stageIndex={stageIndex}
|
||||
/>
|
||||
))}
|
||||
</DelayGroup>
|
||||
</ul>
|
||||
<Footer />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function CompletionItem({
|
||||
completion,
|
||||
selected,
|
||||
onCompletionClick,
|
||||
index,
|
||||
query,
|
||||
stageIndex,
|
||||
}: {
|
||||
completion: Completion;
|
||||
index: number;
|
||||
onCompletionClick: (index: number) => void;
|
||||
selected: boolean;
|
||||
query: Lib.Query;
|
||||
stageIndex: number;
|
||||
}) {
|
||||
const ref = useRef<HTMLLIElement>(null);
|
||||
const handleMouseDown = useCallback(
|
||||
(evt: React.MouseEvent<HTMLLIElement>) => {
|
||||
evt.preventDefault();
|
||||
onCompletionClick(index);
|
||||
},
|
||||
[index, onCompletionClick],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected || !ref.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.current.scrollIntoView({
|
||||
block: "nearest",
|
||||
});
|
||||
}, [selected]);
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={ref}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
onMouseDown={handleMouseDown}
|
||||
className={S.item}
|
||||
>
|
||||
{completion.column && (
|
||||
<QueryColumnInfoIcon
|
||||
query={query}
|
||||
stageIndex={stageIndex}
|
||||
column={completion.column}
|
||||
position="top"
|
||||
className={S.icon}
|
||||
/>
|
||||
)}
|
||||
{!completion.column && <Icon name={completion.icon} className={S.icon} />}
|
||||
<MatchText
|
||||
text={completion.displayLabel ?? completion.label}
|
||||
ranges={completion.matches}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<Box className={S.footer}>
|
||||
<Icon name="arrow_up" className={S.key} />
|
||||
<Icon name="arrow_down" className={S.key} />
|
||||
to navigate.
|
||||
<span />
|
||||
<Icon name="enter_or_return" className={S.key} /> to select.
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import S from "./Listbox.module.css";
|
||||
|
||||
export function MatchText({
|
||||
text,
|
||||
ranges = [],
|
||||
}: {
|
||||
text: string;
|
||||
ranges?: [number, number][];
|
||||
}) {
|
||||
const res = [];
|
||||
let prevIndex = 0;
|
||||
|
||||
for (const range of ranges) {
|
||||
if (range[0] >= 0) {
|
||||
res.push(text.slice(prevIndex, range[0]));
|
||||
}
|
||||
res.push(
|
||||
<span className={S.highlight} key={range[0]}>
|
||||
{text.slice(Math.max(0, range[0]), range[1] + 1)}
|
||||
</span>,
|
||||
);
|
||||
prevIndex = range[1] + 1;
|
||||
}
|
||||
res.push(text.slice(prevIndex, text.length));
|
||||
|
||||
return <span className={S.label}>{res}</span>;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Listbox } from "./Listbox";
|
||||
export { useCompletions } from "./util";
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
completionStatus,
|
||||
currentCompletions,
|
||||
selectedCompletionIndex,
|
||||
} from "@codemirror/autocomplete";
|
||||
import type { EditorState } from "@codemirror/state";
|
||||
import { useMemo } from "react";
|
||||
import { usePrevious } from "react-use";
|
||||
|
||||
import type { Completion } from "../types";
|
||||
|
||||
export function useCompletions(state: EditorState) {
|
||||
const completions = useMemo(() => {
|
||||
return {
|
||||
status: completionStatus(state),
|
||||
options: (currentCompletions(state) ?? []) as readonly Completion[],
|
||||
selectedOption: selectedCompletionIndex(state),
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
const prevCompletions = usePrevious(completions) ?? completions;
|
||||
|
||||
// when pending render the previous completions to avoid flickering
|
||||
return completions.status === "pending" ? prevCompletions : completions;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
.dropdown {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
background: var(--mb-color-background);
|
||||
z-index: 1000;
|
||||
width: 400px;
|
||||
box-shadow: rgba(0, 0, 0, 0.05) 0 4px 20px 0;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { EditorState } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { Popover } from "metabase/ui";
|
||||
import type * as Lib from "metabase-lib";
|
||||
import type Metadata from "metabase-lib/v1/metadata/Metadata";
|
||||
|
||||
import { HelpText } from "../HelpText";
|
||||
import { Listbox, useCompletions } from "../Listbox";
|
||||
import { enclosingFunction } from "../util";
|
||||
|
||||
import S from "./Tooltip.module.css";
|
||||
import { getHelpTextHeight, getListboxHeight } from "./util";
|
||||
|
||||
const HEIGHT_THRESHOLD = 320;
|
||||
|
||||
export function Tooltip({
|
||||
query,
|
||||
stageIndex,
|
||||
metadata,
|
||||
reportTimezone,
|
||||
tooltipRef,
|
||||
|
||||
state,
|
||||
view,
|
||||
}: {
|
||||
query: Lib.Query;
|
||||
stageIndex: number;
|
||||
metadata: Metadata;
|
||||
reportTimezone?: string;
|
||||
|
||||
// from tooltip extension
|
||||
tooltipRef: React.RefObject<HTMLDivElement>;
|
||||
state: EditorState;
|
||||
view: EditorView;
|
||||
}) {
|
||||
const doc = state.doc.toString();
|
||||
|
||||
const enclosingFn = useMemo(
|
||||
() => enclosingFunction(doc, state.selection.main.head),
|
||||
[doc, state.selection.main.head],
|
||||
);
|
||||
|
||||
const { options } = useCompletions(state);
|
||||
|
||||
const maxHeight = usePopoverHeight(tooltipRef);
|
||||
const canShowBoth = maxHeight > HEIGHT_THRESHOLD;
|
||||
|
||||
const [isHelpTextOpen, setIsHelpTextOpen] = useState(false);
|
||||
const isListboxOpen = canShowBoth || !isHelpTextOpen || !enclosingFn;
|
||||
|
||||
const handleToggleHelpText = useCallback(
|
||||
() => setIsHelpTextOpen(open => !open),
|
||||
[],
|
||||
);
|
||||
|
||||
const listboxHeight = getListboxHeight({
|
||||
maxHeight,
|
||||
isHelpTextOpen: isHelpTextOpen && Boolean(enclosingFn),
|
||||
hasHelpText: Boolean(enclosingFn),
|
||||
options,
|
||||
});
|
||||
const helpTextHeight = getHelpTextHeight({
|
||||
maxHeight,
|
||||
listboxHeight,
|
||||
isListboxOpen,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!canShowBoth && enclosingFn && options.length > 0) {
|
||||
setIsHelpTextOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!canShowBoth && options.length === 0) {
|
||||
setIsHelpTextOpen(true);
|
||||
return;
|
||||
}
|
||||
}, [canShowBoth, enclosingFn, options.length]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened
|
||||
position="bottom-start"
|
||||
returnFocus
|
||||
closeOnEscape
|
||||
middlewares={{ shift: false, flip: false }}
|
||||
positionDependencies={[doc, state.selection.main.head, options.length]}
|
||||
>
|
||||
<Popover.Target>
|
||||
<div />
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown
|
||||
data-testid="custom-expression-editor-suggestions"
|
||||
className={S.dropdown}
|
||||
>
|
||||
<div className={S.tooltip} ref={tooltipRef}>
|
||||
<HelpText
|
||||
enclosingFunction={enclosingFn}
|
||||
query={query}
|
||||
metadata={metadata}
|
||||
reportTimezone={reportTimezone}
|
||||
open={isHelpTextOpen}
|
||||
onToggle={handleToggleHelpText}
|
||||
height={helpTextHeight}
|
||||
/>
|
||||
{isListboxOpen && (
|
||||
<Listbox
|
||||
state={state}
|
||||
view={view}
|
||||
query={query}
|
||||
stageIndex={stageIndex}
|
||||
height={listboxHeight}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function usePopoverHeight(ref: React.RefObject<HTMLDivElement>) {
|
||||
const [maxHeight, setMaxHeight] = useState(0);
|
||||
// We want to explicitly read the max height everytime we render
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useLayoutEffect(() => {
|
||||
const px = ref.current?.parentElement?.style.maxHeight ?? "0";
|
||||
const parsed = parseInt(px, 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
setMaxHeight(parsed);
|
||||
}
|
||||
});
|
||||
return maxHeight;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Tooltip } from "./Tooltip";
|
||||
@@ -0,0 +1,48 @@
|
||||
const COMPLETION_HEIGHT = 32;
|
||||
const HEADER_HEIGHT = 42;
|
||||
const FOOTER_HEIGHT = 42;
|
||||
|
||||
export function getListboxHeight({
|
||||
maxHeight,
|
||||
isHelpTextOpen,
|
||||
hasHelpText,
|
||||
options,
|
||||
}: {
|
||||
maxHeight: number;
|
||||
isHelpTextOpen: boolean;
|
||||
hasHelpText: boolean;
|
||||
options: readonly unknown[];
|
||||
}) {
|
||||
const completionsHeight = options.length * COMPLETION_HEIGHT + FOOTER_HEIGHT;
|
||||
const innerHeight = maxHeight - HEADER_HEIGHT;
|
||||
|
||||
if (options.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (isHelpTextOpen) {
|
||||
return Math.min(completionsHeight, innerHeight / 2);
|
||||
}
|
||||
|
||||
if (hasHelpText) {
|
||||
return Math.min(completionsHeight, innerHeight);
|
||||
}
|
||||
|
||||
return Math.min(completionsHeight, maxHeight);
|
||||
}
|
||||
|
||||
export function getHelpTextHeight({
|
||||
maxHeight,
|
||||
listboxHeight,
|
||||
isListboxOpen,
|
||||
}: {
|
||||
maxHeight: number;
|
||||
listboxHeight: number;
|
||||
isListboxOpen: boolean;
|
||||
}) {
|
||||
const innerHeight = maxHeight - HEADER_HEIGHT;
|
||||
if (isListboxOpen) {
|
||||
return Math.max(innerHeight - listboxHeight, innerHeight / 2);
|
||||
}
|
||||
return innerHeight;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
@top Expression { expression }
|
||||
|
||||
@skip { space }
|
||||
|
||||
@precedence {
|
||||
not @left,
|
||||
sum @left,
|
||||
subtract @left,
|
||||
product @left,
|
||||
quotient @left,
|
||||
and @left,
|
||||
or @left,
|
||||
gt @left,
|
||||
gte @left,
|
||||
lt @left,
|
||||
lte @left,
|
||||
eq @left,
|
||||
neq @left
|
||||
}
|
||||
|
||||
expression {
|
||||
Number
|
||||
| String
|
||||
| Boolean
|
||||
| ColumnReference
|
||||
| BinaryExpression
|
||||
| ParenExpression
|
||||
| CallExpression
|
||||
| UnaryExpression
|
||||
}
|
||||
|
||||
BinaryExpression {
|
||||
expression !sum "+" expression
|
||||
| expression !subtract "-" expression
|
||||
| expression !product "*" expression
|
||||
| expression !quotient "/" expression
|
||||
| expression !and And expression
|
||||
| expression !or Or expression
|
||||
| expression !gt ">" expression
|
||||
| expression !gte ">=" expression
|
||||
| expression !lt "<" expression
|
||||
| expression !lte "<=" expression
|
||||
| expression !eq "=" expression
|
||||
| expression !neq "=" expression
|
||||
}
|
||||
|
||||
UnaryExpression {
|
||||
!not Not expression
|
||||
}
|
||||
|
||||
|
||||
ParenExpression {
|
||||
"(" expression ")"
|
||||
}
|
||||
|
||||
CallExpression {
|
||||
Identifier "(" ")"
|
||||
| Identifier "(" ArgList ")"
|
||||
}
|
||||
|
||||
ArgList {
|
||||
Arg ("," Arg)*
|
||||
}
|
||||
|
||||
Arg {
|
||||
expression
|
||||
}
|
||||
|
||||
@skip {} {
|
||||
String[isolate] {
|
||||
'"' (stringContentDouble | Escape)* ('"' | "\n")
|
||||
| "'" (stringContentSingle | Escape)* ("'" | "\n")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@tokens {
|
||||
space { @whitespace+ }
|
||||
hex { $[0-9a-fA-F] }
|
||||
|
||||
Number {
|
||||
(@digit ("_" | @digit)* ("." ("_" | @digit)*)?
|
||||
| "." @digit ("_" | @digit)*) (("e" | "E") ("+" | "-")? ("_" | @digit)+)?
|
||||
| "0x" ($[0-9a-fA-F] | "_")
|
||||
| "0b" $[01_]+ "n"?
|
||||
| "0o" $[0-7_]+ "n"?
|
||||
}
|
||||
|
||||
stringContentSingle { ![\\\n']+ }
|
||||
stringContentDouble { ![\\\n"]+ }
|
||||
|
||||
ColumnReference {
|
||||
"[" ![\]\n]* ("]" | "\n")?
|
||||
}
|
||||
|
||||
Identifier {
|
||||
$[a-zA-Z]$[a-zA-Z0-9_]*
|
||||
}
|
||||
|
||||
Boolean {
|
||||
"True"
|
||||
| "False"
|
||||
}
|
||||
|
||||
Not {
|
||||
"NOT"
|
||||
| "Not"
|
||||
| "not"
|
||||
}
|
||||
|
||||
And {
|
||||
"AND"
|
||||
| "And"
|
||||
| "and"
|
||||
}
|
||||
|
||||
Or {
|
||||
"OR"
|
||||
| "Or"
|
||||
| "or"
|
||||
}
|
||||
|
||||
@precedence { Not, Boolean, Identifier }
|
||||
|
||||
Escape {
|
||||
"\\" ("x" hex hex | "u" ("{" hex+ "}" | hex hex hex hex) | ![xu])
|
||||
}
|
||||
|
||||
"("
|
||||
")"
|
||||
"+"
|
||||
"-"
|
||||
"*"
|
||||
"/"
|
||||
"="
|
||||
"!="
|
||||
"<"
|
||||
"<="
|
||||
">"
|
||||
">="
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
type EditorState,
|
||||
type Extension,
|
||||
StateField,
|
||||
} from "@codemirror/state";
|
||||
import {
|
||||
EditorView,
|
||||
type Tooltip as TooltipView,
|
||||
type ViewUpdate,
|
||||
showTooltip,
|
||||
} from "@codemirror/view";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
type TooltipProps = {
|
||||
state: EditorState;
|
||||
view: EditorView;
|
||||
tooltipRef: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
type TooltipOptions = {
|
||||
render: (props: TooltipProps) => React.ReactNode;
|
||||
getPosition: (state: EditorState) => number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set up a custom tooltip that renders content with React but uses the CodeMirror
|
||||
* suggestions.
|
||||
*
|
||||
* Note: this is a bit hacky, but there is currently no other way to render custom
|
||||
* tooltips with suggestions in CodeMirror.
|
||||
*/
|
||||
export function useCustomTooltip({
|
||||
render,
|
||||
getPosition,
|
||||
}: TooltipOptions): [Extension[], React.ReactNode] {
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const element = useMemo(() => document.createElement("div"), []);
|
||||
|
||||
const [update, setUpdate] = useState<ViewUpdate | null>(null);
|
||||
const [hasFocus, setHasFocus] = useState(false);
|
||||
|
||||
const handleFocus = useCallback(() => setHasFocus(true), []);
|
||||
const handleBlur = useCallback(() => setHasFocus(false), []);
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
tooltip(element, getPosition),
|
||||
EditorView.domEventHandlers({
|
||||
focus: handleFocus,
|
||||
blur(evt) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
|
||||
const el = evt.relatedTarget as HTMLElement | null;
|
||||
if (
|
||||
tooltipRef.current === el ||
|
||||
tooltipRef.current?.contains(el) ||
|
||||
el?.contains(tooltipRef.current)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleBlur();
|
||||
},
|
||||
}),
|
||||
EditorView.updateListener.of(update => setUpdate(update)),
|
||||
],
|
||||
[element, handleBlur, handleFocus, getPosition],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
function (evt: React.KeyboardEvent) {
|
||||
if (!update?.view) {
|
||||
return;
|
||||
}
|
||||
// forward keys to the editor
|
||||
return update.view.contentDOM.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: evt.key,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[update],
|
||||
);
|
||||
|
||||
const children = (
|
||||
<div onBlur={handleBlur} onKeyDown={handleKeyDown} tabIndex={0}>
|
||||
{update &&
|
||||
hasFocus &&
|
||||
render({
|
||||
tooltipRef,
|
||||
state: update.state,
|
||||
view: update.view,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
return [extensions, createPortal(children, element)];
|
||||
}
|
||||
|
||||
function tooltip(
|
||||
element: HTMLElement,
|
||||
getPosition: (state: EditorState) => number,
|
||||
) {
|
||||
function create() {
|
||||
const dom = document.createElement("div");
|
||||
dom.append(element);
|
||||
return { dom, offset: { x: 0, y: 5 } };
|
||||
}
|
||||
|
||||
function getCursorTooltips(state: EditorState): readonly TooltipView[] {
|
||||
return [
|
||||
{
|
||||
pos: getPosition(state),
|
||||
above: false,
|
||||
strictSide: true,
|
||||
arrow: false,
|
||||
create,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return StateField.define<readonly TooltipView[]>({
|
||||
create: getCursorTooltips,
|
||||
update(_, transaction) {
|
||||
return getCursorTooltips(transaction.state);
|
||||
},
|
||||
provide: f => showTooltip.computeN([f], state => state.field(f)),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { acceptCompletion, startCompletion } from "@codemirror/autocomplete";
|
||||
import { bracketMatching, syntaxHighlighting } from "@codemirror/language";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { EditorView, drawSelection, keymap, tooltips } from "@codemirror/view";
|
||||
import { Prec } from "@uiw/react-codemirror";
|
||||
import { getNonce } from "get-nonce";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { isNotNull } from "metabase/lib/types";
|
||||
import type * as Lib from "metabase-lib";
|
||||
import type Metadata from "metabase-lib/v1/metadata/Metadata";
|
||||
|
||||
import S from "./Editor.module.css";
|
||||
import { highlightStyle } from "./Highlight";
|
||||
import { customExpression } from "./language";
|
||||
import { type Shortcut, suggestions } from "./suggestions";
|
||||
|
||||
type Options = {
|
||||
startRule: "expression" | "aggregation" | "boolean";
|
||||
query: Lib.Query;
|
||||
stageIndex: number;
|
||||
name?: string | null;
|
||||
expressionIndex: number | undefined;
|
||||
onCommit: (source: string) => void;
|
||||
metadata: Metadata;
|
||||
reportTimezone?: string;
|
||||
extensions?: Extension[];
|
||||
shortcuts?: Shortcut[];
|
||||
};
|
||||
|
||||
function getTooltipParent() {
|
||||
let el = document.getElementById("query-builder-tooltip-parent");
|
||||
if (el) {
|
||||
return el;
|
||||
}
|
||||
|
||||
el = document.createElement("div");
|
||||
el.id = "query-builder-tooltip-parent";
|
||||
el.className = S.tooltips;
|
||||
document.body.append(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
export function useExtensions(options: Options): Extension[] {
|
||||
const {
|
||||
startRule,
|
||||
query,
|
||||
stageIndex,
|
||||
name,
|
||||
expressionIndex,
|
||||
onCommit,
|
||||
reportTimezone,
|
||||
metadata,
|
||||
extensions: extra = [],
|
||||
shortcuts,
|
||||
} = options;
|
||||
|
||||
return useMemo(() => {
|
||||
return [
|
||||
nonce(),
|
||||
fonts(),
|
||||
bracketMatching({
|
||||
brackets: "()",
|
||||
}),
|
||||
drawSelection({
|
||||
cursorBlinkRate: 1000,
|
||||
drawRangeCursor: false,
|
||||
}),
|
||||
EditorView.contentAttributes.of({
|
||||
autocorrect: "off",
|
||||
// To be able to let make Mantine's FocusTrap work, the content
|
||||
// element needs a tabIndex and data-autofocus attribute.
|
||||
tabIndex: "0",
|
||||
"data-autofocus": "",
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
focus(_, view) {
|
||||
if (view.state.doc.toString() === "") {
|
||||
startCompletion(view);
|
||||
setTimeout(() => startCompletion(view), 0);
|
||||
}
|
||||
|
||||
const len = view.state.doc.length;
|
||||
view.dispatch({ selection: { anchor: len, head: len } });
|
||||
},
|
||||
}),
|
||||
highlighting(),
|
||||
customExpression({
|
||||
startRule,
|
||||
query,
|
||||
stageIndex,
|
||||
name,
|
||||
expressionIndex,
|
||||
}),
|
||||
expander(),
|
||||
|
||||
Prec.high(
|
||||
keymap.of([
|
||||
{
|
||||
key: "Enter",
|
||||
run(view: EditorView) {
|
||||
const source = view.state.doc.toString();
|
||||
onCommit(source);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Tab",
|
||||
run: acceptCompletion,
|
||||
},
|
||||
]),
|
||||
),
|
||||
suggestions({
|
||||
query,
|
||||
stageIndex,
|
||||
reportTimezone,
|
||||
startRule,
|
||||
expressionIndex,
|
||||
metadata,
|
||||
shortcuts,
|
||||
}),
|
||||
tooltips({
|
||||
position: "fixed",
|
||||
parent: getTooltipParent(),
|
||||
}),
|
||||
...extra,
|
||||
]
|
||||
.flat()
|
||||
.filter(isNotNull);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
startRule,
|
||||
query,
|
||||
stageIndex,
|
||||
name,
|
||||
expressionIndex,
|
||||
onCommit,
|
||||
metadata,
|
||||
reportTimezone,
|
||||
shortcuts,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
...extra,
|
||||
]);
|
||||
}
|
||||
|
||||
function nonce() {
|
||||
// CodeMirror injects css into the DOM,
|
||||
// to make this work, it needs the have the correct CSP nonce.
|
||||
const nonce = getNonce();
|
||||
if (!nonce) {
|
||||
return null;
|
||||
}
|
||||
return EditorView.cspNonce.of(nonce);
|
||||
}
|
||||
|
||||
function fonts() {
|
||||
const shared = {
|
||||
fontSize: "12px",
|
||||
lineHeight: "normal",
|
||||
fontFamily: "var(--mb-default-monospace-font-family)",
|
||||
};
|
||||
|
||||
return EditorView.theme({
|
||||
"&": shared,
|
||||
".cm-content": shared,
|
||||
});
|
||||
}
|
||||
|
||||
function highlighting() {
|
||||
return syntaxHighlighting(highlightStyle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands -> to → when the user is typing.
|
||||
*/
|
||||
function expander() {
|
||||
return EditorView.updateListener.of(update => {
|
||||
if (!update.docChanged) {
|
||||
return;
|
||||
}
|
||||
const { state, view } = update;
|
||||
const pos = state.selection.main.head;
|
||||
const before = state.doc.sliceString(pos - 2, pos);
|
||||
|
||||
if (before !== "->") {
|
||||
return;
|
||||
}
|
||||
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: pos - 2,
|
||||
to: pos,
|
||||
insert: "→",
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Editor } from "./Editor";
|
||||
export { type Shortcut } from "./suggestions";
|
||||
@@ -0,0 +1,74 @@
|
||||
import { LRLanguage, LanguageSupport } from "@codemirror/language";
|
||||
import { type Diagnostic, linter } from "@codemirror/lint";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { styleTags, tags as t } from "@lezer/highlight";
|
||||
|
||||
import type * as Lib from "metabase-lib";
|
||||
import { diagnose } from "metabase-lib/v1/expressions/diagnostics";
|
||||
|
||||
import { parser as baseParser } from "./parser";
|
||||
|
||||
export const tags = styleTags({
|
||||
Identifier: t.variableName,
|
||||
Boolean: t.bool,
|
||||
String: t.string,
|
||||
Number: t.number,
|
||||
ColumnReference: t.processingInstruction,
|
||||
Escape: t.escape,
|
||||
"CallExpression/Identifier": t.function(t.variableName),
|
||||
And: t.logicOperator,
|
||||
Or: t.logicOperator,
|
||||
Not: t.logicOperator,
|
||||
'+ - "*" "/"': t.arithmeticOperator,
|
||||
'< > =< => = "!-"': t.compareOperator,
|
||||
"( )": t.paren,
|
||||
"[ ]": t.bracket,
|
||||
"{ }": t.brace,
|
||||
});
|
||||
|
||||
export const parser = baseParser.configure({
|
||||
props: [tags],
|
||||
});
|
||||
|
||||
const expressionLanguage = LRLanguage.define({
|
||||
parser,
|
||||
languageData: {},
|
||||
});
|
||||
|
||||
type LintOptions = {
|
||||
startRule: "expression" | "aggregation" | "boolean";
|
||||
query: Lib.Query;
|
||||
stageIndex: number;
|
||||
name?: string | null;
|
||||
expressionIndex: number | undefined;
|
||||
};
|
||||
|
||||
const lint = (options: LintOptions) =>
|
||||
linter(
|
||||
(view: EditorView): Diagnostic[] => {
|
||||
const source = view.state.doc.toString();
|
||||
const error = diagnose({
|
||||
source,
|
||||
...options,
|
||||
});
|
||||
if (!error || error.pos == null || error.len == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
from: error.pos,
|
||||
to: error.pos + error.len,
|
||||
severity: "error",
|
||||
message: error.message,
|
||||
},
|
||||
];
|
||||
},
|
||||
{
|
||||
tooltipFilter: () => [],
|
||||
},
|
||||
);
|
||||
|
||||
export function customExpression(options: LintOptions) {
|
||||
return new LanguageSupport(expressionLanguage, [lint(options)]);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,17 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const Expression = 1,
|
||||
Number = 2,
|
||||
String = 3,
|
||||
Escape = 4,
|
||||
Boolean = 5,
|
||||
ColumnReference = 6,
|
||||
BinaryExpression = 7,
|
||||
And = 12,
|
||||
Or = 13,
|
||||
ParenExpression = 19,
|
||||
CallExpression = 22,
|
||||
Identifier = 23,
|
||||
ArgList = 24,
|
||||
Arg = 25,
|
||||
UnaryExpression = 26,
|
||||
Not = 27;
|
||||
@@ -0,0 +1,496 @@
|
||||
import {
|
||||
type CompletionContext,
|
||||
autocompletion,
|
||||
snippetCompletion,
|
||||
} from "@codemirror/autocomplete";
|
||||
import Fuse from "fuse.js";
|
||||
import _ from "underscore";
|
||||
|
||||
import { getColumnIcon } from "metabase/common/utils/columns";
|
||||
import { isNotNull } from "metabase/lib/types";
|
||||
import type { IconName } from "metabase/ui";
|
||||
import * as Lib from "metabase-lib";
|
||||
import { formatIdentifier } from "metabase-lib/v1/expressions";
|
||||
import {
|
||||
AGGREGATION_FUNCTIONS,
|
||||
EXPRESSION_FUNCTIONS,
|
||||
MBQL_CLAUSES,
|
||||
POPULAR_AGGREGATIONS,
|
||||
POPULAR_FILTERS,
|
||||
POPULAR_FUNCTIONS,
|
||||
} from "metabase-lib/v1/expressions/config";
|
||||
import { getHelpText } from "metabase-lib/v1/expressions/helper-text-strings";
|
||||
import type { SuggestArgs } from "metabase-lib/v1/expressions/suggest";
|
||||
import type {
|
||||
HelpText,
|
||||
MBQLClauseFunctionConfig,
|
||||
} from "metabase-lib/v1/expressions/types";
|
||||
import type Database from "metabase-lib/v1/metadata/Database";
|
||||
import type Metadata from "metabase-lib/v1/metadata/Metadata";
|
||||
|
||||
import type { Completion, CompletionResult } from "./types";
|
||||
import { isFieldReference, isIdentifier, tokenAtPos } from "./util";
|
||||
|
||||
export type Shortcut = {
|
||||
name: string;
|
||||
icon: IconName;
|
||||
action: () => void;
|
||||
};
|
||||
|
||||
export type SuggestOptions = Omit<
|
||||
SuggestArgs,
|
||||
"source" | "targetOffset" | "getColumnIcon"
|
||||
> & {
|
||||
shortcuts?: Shortcut[];
|
||||
};
|
||||
|
||||
// TODO: use namespaced suggestion for fk sparator (eg. products.|
|
||||
|
||||
export function suggestions(options: SuggestOptions) {
|
||||
return autocompletion({
|
||||
closeOnBlur: false,
|
||||
activateOnTyping: true,
|
||||
activateOnTypingDelay: 0,
|
||||
override: [
|
||||
suggestLiterals(),
|
||||
suggestFunctions(options),
|
||||
suggestAggregations(options),
|
||||
suggestFields(options),
|
||||
suggestMetrics(options),
|
||||
suggestSegments(options),
|
||||
suggestPopular(options),
|
||||
suggestShortcuts(options),
|
||||
].filter(isNotNull),
|
||||
});
|
||||
}
|
||||
|
||||
export function suggestFields({
|
||||
query,
|
||||
stageIndex,
|
||||
expressionIndex,
|
||||
}: SuggestOptions) {
|
||||
const columns = Lib.expressionableColumns(
|
||||
query,
|
||||
stageIndex,
|
||||
expressionIndex,
|
||||
)?.map(column => {
|
||||
const displayInfo = Lib.displayInfo(query, stageIndex, column);
|
||||
return {
|
||||
type: "field",
|
||||
label: formatIdentifier(displayInfo.longDisplayName),
|
||||
displayLabel: displayInfo.longDisplayName,
|
||||
icon: getColumnIcon(column),
|
||||
column,
|
||||
};
|
||||
});
|
||||
|
||||
if (!columns || columns.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matcher = fuzzyMatcher(columns);
|
||||
|
||||
return function (context: CompletionContext): CompletionResult | null {
|
||||
const source = context.state.doc.toString();
|
||||
const token = tokenAtPos(source, context.pos);
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const word = token.text.replace(/^\[/, "").replace(/\]$/, "");
|
||||
if (word === "") {
|
||||
return {
|
||||
from: token.start,
|
||||
to: token.end,
|
||||
options: columns,
|
||||
filter: false,
|
||||
};
|
||||
}
|
||||
|
||||
const options = matcher(word);
|
||||
|
||||
return {
|
||||
from: token.start,
|
||||
to: token.end,
|
||||
options,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function suggestFunctions({
|
||||
startRule,
|
||||
query,
|
||||
metadata,
|
||||
reportTimezone,
|
||||
}: SuggestOptions) {
|
||||
if (startRule !== "expression" && startRule !== "boolean") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const database = getDatabase(query, metadata);
|
||||
const ALIASES = ["case"];
|
||||
const functions = [...EXPRESSION_FUNCTIONS, ...ALIASES]
|
||||
.map(name => MBQL_CLAUSES[name])
|
||||
.filter(clause => clause && database?.hasFeature(clause.requiresFeature))
|
||||
.filter(function disableOffsetInFilterExpressions(clause) {
|
||||
const isOffset = clause.name === "offset";
|
||||
const isFilterExpression = startRule === "boolean";
|
||||
const isOffsetInFilterExpression = isOffset && isFilterExpression;
|
||||
return !isOffsetInFilterExpression;
|
||||
})
|
||||
.map(func =>
|
||||
expressionClauseCompletion(func, {
|
||||
type: "function",
|
||||
database,
|
||||
reportTimezone,
|
||||
}),
|
||||
);
|
||||
|
||||
const matcher = fuzzyMatcher(functions);
|
||||
|
||||
return function (context: CompletionContext) {
|
||||
const source = context.state.doc.toString();
|
||||
const token = tokenAtPos(source, context.pos);
|
||||
|
||||
if (!token || !isIdentifier(token) || isFieldReference(token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// do not expand template if the next token is a (
|
||||
const next = tokenAtPos(source, token.end + 1);
|
||||
const options = matcher(token.text).map(option => ({
|
||||
...option,
|
||||
apply: next?.op === "(" ? undefined : option.apply,
|
||||
}));
|
||||
|
||||
return {
|
||||
from: token.start,
|
||||
to: token.end,
|
||||
options,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function suggestAggregations({
|
||||
startRule,
|
||||
query,
|
||||
metadata,
|
||||
reportTimezone,
|
||||
}: SuggestOptions) {
|
||||
if (startRule !== "aggregation") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const database = getDatabase(query, metadata);
|
||||
const aggregations = Array.from(AGGREGATION_FUNCTIONS)
|
||||
.map(name => MBQL_CLAUSES[name])
|
||||
.filter(clause => clause && database?.hasFeature(clause.requiresFeature))
|
||||
.map(agg =>
|
||||
expressionClauseCompletion(agg, {
|
||||
type: "aggregation",
|
||||
database,
|
||||
reportTimezone,
|
||||
}),
|
||||
);
|
||||
|
||||
const matcher = fuzzyMatcher(aggregations);
|
||||
|
||||
return function (context: CompletionContext) {
|
||||
const source = context.state.doc.toString();
|
||||
const token = tokenAtPos(source, context.pos);
|
||||
if (!token || !isIdentifier(token) || isFieldReference(token)) {
|
||||
// Cursor is inside a field reference tag
|
||||
return null;
|
||||
}
|
||||
|
||||
// do not expand template if the next token is a (
|
||||
const next = tokenAtPos(source, token.end + 1);
|
||||
const options = matcher(token.text).map(option => ({
|
||||
...option,
|
||||
apply: next?.op === "(" ? undefined : option.apply,
|
||||
}));
|
||||
|
||||
return {
|
||||
from: token.start,
|
||||
to: token.end,
|
||||
options,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function getPopular(startRule: string) {
|
||||
if (startRule === "expression") {
|
||||
return POPULAR_FUNCTIONS;
|
||||
}
|
||||
if (startRule === "boolean") {
|
||||
return POPULAR_FILTERS;
|
||||
}
|
||||
if (startRule === "aggregation") {
|
||||
return POPULAR_AGGREGATIONS;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function suggestPopular({
|
||||
startRule,
|
||||
query,
|
||||
reportTimezone,
|
||||
metadata,
|
||||
}: SuggestOptions) {
|
||||
const popular = getPopular(startRule);
|
||||
if (!popular) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const database = getDatabase(query, metadata);
|
||||
const clauses = popular
|
||||
.map(name => MBQL_CLAUSES[name])
|
||||
.filter(isNotNull)
|
||||
.filter(clause => !database || database?.hasFeature(clause.requiresFeature))
|
||||
.map(clause =>
|
||||
expressionClauseCompletion(clause, {
|
||||
type: startRule,
|
||||
database,
|
||||
reportTimezone,
|
||||
}),
|
||||
);
|
||||
|
||||
return function (context: CompletionContext) {
|
||||
const source = context.state.doc.toString();
|
||||
if (source !== "") {
|
||||
// we only want to show popular functions and suggestions when
|
||||
// the source is empty
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
from: context.pos,
|
||||
options: clauses,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function suggestLiterals() {
|
||||
return function (context: CompletionContext) {
|
||||
const source = context.state.doc.toString();
|
||||
const token = tokenAtPos(source, context.pos);
|
||||
|
||||
if (!token || !isIdentifier(token) || isFieldReference(token)) {
|
||||
// Cursor is inside a field reference tag
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
from: token.start,
|
||||
to: token.end,
|
||||
options: [
|
||||
{
|
||||
label: "True",
|
||||
type: "literal",
|
||||
icon: "boolean",
|
||||
},
|
||||
{
|
||||
label: "False",
|
||||
type: "literal",
|
||||
icon: "boolean",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function suggestSegments({ query, stageIndex }: SuggestOptions) {
|
||||
const segments = Lib.availableSegments(query, stageIndex)?.map(segment => {
|
||||
const displayInfo = Lib.displayInfo(query, stageIndex, segment);
|
||||
return {
|
||||
type: "segment",
|
||||
displayLabel: displayInfo.longDisplayName,
|
||||
label: formatIdentifier(displayInfo.longDisplayName),
|
||||
icon: "segment" as const,
|
||||
};
|
||||
});
|
||||
|
||||
if (!segments) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matcher = fuzzyMatcher(segments);
|
||||
|
||||
return function (context: CompletionContext) {
|
||||
const source = context.state.doc.toString();
|
||||
const token = tokenAtPos(source, context.pos);
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const word = token.text.replace(/^\[/, "").replace(/\]$/, "");
|
||||
if (word === "") {
|
||||
return {
|
||||
from: token.start,
|
||||
to: token.end,
|
||||
options: segments,
|
||||
filter: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
from: token.start,
|
||||
to: token.end,
|
||||
options: matcher(token.text),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function suggestMetrics({
|
||||
startRule,
|
||||
query,
|
||||
stageIndex,
|
||||
}: SuggestOptions) {
|
||||
const metrics = Lib.availableMetrics(query, stageIndex)?.map(metric => {
|
||||
const displayInfo = Lib.displayInfo(query, stageIndex, metric);
|
||||
return {
|
||||
type: "metric",
|
||||
displayLabel: displayInfo.longDisplayName,
|
||||
label: formatIdentifier(displayInfo.longDisplayName),
|
||||
icon: "metric" as const,
|
||||
};
|
||||
});
|
||||
|
||||
if (startRule !== "aggregation" || metrics.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matcher = fuzzyMatcher(metrics);
|
||||
|
||||
return function (context: CompletionContext) {
|
||||
const source = context.state.doc.toString();
|
||||
const token = tokenAtPos(source, context.pos);
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const word = token.text.replace(/^\[/, "").replace(/\]$/, "");
|
||||
if (word === "") {
|
||||
return {
|
||||
from: token.start,
|
||||
to: token.end,
|
||||
options: metrics,
|
||||
filter: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
from: token.start,
|
||||
to: token.end,
|
||||
options: matcher(token.text),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const suggestionText = (func: MBQLClauseFunctionConfig) => {
|
||||
const { displayName, args } = func;
|
||||
const suffix = args.length > 0 ? "(" : " ";
|
||||
return displayName + suffix;
|
||||
};
|
||||
|
||||
function getDatabase(query: Lib.Query, metadata: Metadata) {
|
||||
const databaseId = Lib.databaseID(query);
|
||||
return metadata.database(databaseId);
|
||||
}
|
||||
|
||||
function getSnippet(helpText: HelpText) {
|
||||
const args = helpText.args
|
||||
?.filter(arg => arg.name !== "…")
|
||||
?.map(arg => "${" + arg.name + "}")
|
||||
.join(", ");
|
||||
return `${helpText.structure}(${args})`;
|
||||
}
|
||||
|
||||
function expressionClauseCompletion(
|
||||
clause: MBQLClauseFunctionConfig,
|
||||
{
|
||||
type,
|
||||
database,
|
||||
reportTimezone,
|
||||
matches,
|
||||
}: {
|
||||
type: string;
|
||||
database: Database | null;
|
||||
reportTimezone?: string;
|
||||
matches?: [number, number][];
|
||||
},
|
||||
): Completion {
|
||||
const helpText =
|
||||
clause.name &&
|
||||
database &&
|
||||
getHelpText(clause.name, database, reportTimezone);
|
||||
|
||||
if (helpText) {
|
||||
const completion = snippetCompletion(getSnippet(helpText), {
|
||||
type,
|
||||
label: clause.displayName,
|
||||
displayLabel: clause.displayName,
|
||||
detail: helpText.description,
|
||||
});
|
||||
return { ...completion, icon: "function", matches };
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
label: suggestionText(clause),
|
||||
displayLabel: clause.displayName,
|
||||
icon: "function",
|
||||
matches,
|
||||
};
|
||||
}
|
||||
|
||||
export function suggestShortcuts(options: SuggestOptions) {
|
||||
const { shortcuts = [] } = options;
|
||||
|
||||
const completions: Completion[] = shortcuts.map(shortcut => ({
|
||||
label: shortcut.name,
|
||||
icon: shortcut.icon,
|
||||
apply: shortcut.action,
|
||||
section: "shortcuts",
|
||||
}));
|
||||
|
||||
if (completions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return function (context: CompletionContext): CompletionResult | null {
|
||||
if (context.state.doc.toString() !== "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
from: context.pos,
|
||||
options: completions,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function fuzzyMatcher(options: Completion[]) {
|
||||
const keys = ["displayLabel"];
|
||||
|
||||
const fuse = new Fuse(options, {
|
||||
keys,
|
||||
includeScore: true,
|
||||
includeMatches: true,
|
||||
});
|
||||
|
||||
return function (word: string) {
|
||||
return (
|
||||
fuse
|
||||
.search(word)
|
||||
// .filter(result => (result.score ?? 0) <= 1)
|
||||
.sort((a, b) => (a.score ?? 0) - (b.score ?? 0))
|
||||
.map(result => ({
|
||||
...result.item,
|
||||
matches: result.matches?.flatMap(match => match.indices) ?? [],
|
||||
}))
|
||||
);
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
import type {
|
||||
Completion as CodeMirrorCompletion,
|
||||
CompletionResult as CodeMirrorCompletionResult,
|
||||
} from "@codemirror/autocomplete";
|
||||
|
||||
import type { IconName } from "metabase/ui";
|
||||
import type * as Lib from "metabase-lib";
|
||||
|
||||
export type Completion = CodeMirrorCompletion & {
|
||||
icon: IconName;
|
||||
matches?: [number, number][];
|
||||
column?: Lib.ColumnMetadata;
|
||||
};
|
||||
|
||||
export type CompletionResult = CodeMirrorCompletionResult & {
|
||||
options: Completion[];
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { getFunctionByStructure } from "metabase-lib/v1/expressions/helper-text-strings";
|
||||
import { TOKEN, tokenize } from "metabase-lib/v1/expressions/tokenizer";
|
||||
import type { Token } from "metabase-lib/v1/expressions/types";
|
||||
|
||||
import { parser } from "./language";
|
||||
|
||||
export function enclosingFunction(doc: string, pos: number) {
|
||||
const tree = parser.parse(doc);
|
||||
|
||||
const cursor = tree.cursor();
|
||||
let res = null;
|
||||
|
||||
do {
|
||||
if (
|
||||
cursor.name === "CallExpression" &&
|
||||
cursor.from <= pos &&
|
||||
cursor.to >= pos
|
||||
) {
|
||||
const value = doc.slice(cursor.from, cursor.to);
|
||||
const structure = value.replace(/\(.*\)?$/, "");
|
||||
|
||||
const args =
|
||||
cursor.node.getChildren("ArgList")?.[0]?.getChildren("Arg") ?? [];
|
||||
const argIndex = args.findIndex(arg => arg.from <= pos && arg.to >= pos);
|
||||
|
||||
if (value.endsWith(")") && cursor.to === pos) {
|
||||
// do not show help when cursor is placed after closing )
|
||||
break;
|
||||
}
|
||||
|
||||
const fn = getFunctionByStructure(structure);
|
||||
if (fn) {
|
||||
res = {
|
||||
name: fn,
|
||||
from: cursor.from,
|
||||
to: cursor.to,
|
||||
arg:
|
||||
argIndex >= 0
|
||||
? {
|
||||
index: argIndex,
|
||||
from: args[argIndex].from,
|
||||
to: args[argIndex].to,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
} while (cursor.next());
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export function tokenAtPos(source: string, pos: number): TokenWithText | null {
|
||||
const { tokens } = tokenize(source);
|
||||
|
||||
const idx = tokens.findIndex(token => token.start <= pos && token.end >= pos);
|
||||
if (idx === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = tokens[idx];
|
||||
const prevToken = tokens[idx - 1];
|
||||
|
||||
if (
|
||||
prevToken &&
|
||||
prevToken.type === TOKEN.String &&
|
||||
prevToken.end - prevToken.start === 1
|
||||
) {
|
||||
// dangling single- or double-quote
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = source.slice(token.start, token.end);
|
||||
return { ...token, text };
|
||||
}
|
||||
|
||||
export type TokenWithText = Token & { text: string };
|
||||
|
||||
export function isIdentifier(token: TokenWithText | null) {
|
||||
return token != null && token.type === TOKEN.Identifier;
|
||||
}
|
||||
|
||||
export function isFieldReference(token: TokenWithText | null) {
|
||||
return token != null && isIdentifier(token) && token.text.startsWith("[");
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { enclosingFunction } from "./util";
|
||||
|
||||
describe("enclosingFunction", () => {
|
||||
function setup(query: string) {
|
||||
const pos = query.indexOf("|");
|
||||
if (pos === -1) {
|
||||
throw new Error("please provide a cursor in position using |");
|
||||
}
|
||||
|
||||
const doc = query.slice(0, pos) + query.slice(pos + 1);
|
||||
return enclosingFunction(doc, pos);
|
||||
}
|
||||
|
||||
it("should find the enclosing function when it doesn't have an opening parenthesis", () => {
|
||||
const result = {
|
||||
name: "concat",
|
||||
from: 0,
|
||||
to: 6,
|
||||
arg: null,
|
||||
};
|
||||
|
||||
expect(setup("|concat")).toEqual(result);
|
||||
expect(setup("conc|at")).toEqual(result);
|
||||
expect(setup("concat|")).toEqual(result);
|
||||
});
|
||||
|
||||
it("should find the enclosing function when it doesn't have args", () => {
|
||||
const result = {
|
||||
name: "concat",
|
||||
from: 0,
|
||||
to: 7,
|
||||
arg: null,
|
||||
};
|
||||
|
||||
expect(setup("|concat(")).toEqual(result);
|
||||
expect(setup("conca|t(")).toEqual(result);
|
||||
expect(setup("concat(|")).toEqual(result);
|
||||
});
|
||||
|
||||
it("should find the enclosing function when it doesn't have a closing parenthesis", () => {
|
||||
const result = {
|
||||
name: "concat",
|
||||
from: 0,
|
||||
to: 11,
|
||||
arg: null,
|
||||
};
|
||||
|
||||
expect(setup("|concat(a, b")).toEqual(result);
|
||||
expect(setup("conca|t(a, b")).toEqual(result);
|
||||
expect(setup("concat(a|, b")).toEqual({
|
||||
...result,
|
||||
arg: {
|
||||
index: 0,
|
||||
from: 7,
|
||||
to: 8,
|
||||
},
|
||||
});
|
||||
expect(setup("concat(a, b|")).toEqual({
|
||||
...result,
|
||||
arg: {
|
||||
index: 1,
|
||||
from: 10,
|
||||
to: 11,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should find the enclosing function when it has a closing parenthesis", () => {
|
||||
const result = {
|
||||
name: "concat",
|
||||
from: 0,
|
||||
to: 14,
|
||||
arg: null,
|
||||
};
|
||||
|
||||
expect(setup("|concat(10, 20)")).toEqual(result);
|
||||
expect(setup("conca|t(10, 20)")).toEqual(result);
|
||||
expect(setup("concat(1|0, 20)")).toEqual({
|
||||
...result,
|
||||
arg: {
|
||||
index: 0,
|
||||
from: 7,
|
||||
to: 9,
|
||||
},
|
||||
});
|
||||
expect(setup("concat(10, 2|0)")).toEqual({
|
||||
...result,
|
||||
arg: {
|
||||
index: 1,
|
||||
from: 11,
|
||||
to: 13,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should not find an enclosing function when it the cursor is set after the closing parenthesis", () => {
|
||||
expect(setup("concat(a, b)|")).toBe(null);
|
||||
expect(setup("concat(a, b) |")).toBe(null);
|
||||
});
|
||||
|
||||
it("should find the innermost enclosing function", () => {
|
||||
expect(setup("case([X] > 10, concat(a, b|), coalesce(c, d))")).toEqual({
|
||||
name: "concat",
|
||||
from: 15,
|
||||
to: 27,
|
||||
arg: {
|
||||
index: 1,
|
||||
from: 25,
|
||||
to: 26,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should work when the function is a part of another expression", () => {
|
||||
expect(setup("10 + case([X] > | 10, 20, 30)")).toEqual({
|
||||
name: "case",
|
||||
from: 5,
|
||||
to: 28,
|
||||
arg: {
|
||||
index: 0,
|
||||
from: 10,
|
||||
to: 19,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should mark the correct argument when the cursor is inside the argument", () => {
|
||||
const base = {
|
||||
name: "concat",
|
||||
from: 0,
|
||||
to: 16,
|
||||
};
|
||||
|
||||
[
|
||||
"concat(|aaa, bbb)",
|
||||
"concat(a|aa, bbb)",
|
||||
"concat(aa|a, bbb)",
|
||||
"concat(aaa|, bbb)",
|
||||
].forEach(doc => {
|
||||
expect(setup(doc)).toEqual({
|
||||
...base,
|
||||
arg: {
|
||||
index: 0,
|
||||
from: 7,
|
||||
to: 10,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
[
|
||||
"concat(aaa, |bbb)",
|
||||
"concat(aaa, b|bb)",
|
||||
"concat(aaa, bb|b)",
|
||||
"concat(aaa, bbb|)",
|
||||
].forEach(doc => {
|
||||
expect(setup(doc)).toEqual({
|
||||
...base,
|
||||
arg: {
|
||||
index: 1,
|
||||
from: 12,
|
||||
to: 15,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
[
|
||||
"concat(aaa, |bbb)",
|
||||
"concat(aaa, b|bb)",
|
||||
"concat(aaa, bb|b)",
|
||||
"concat(aaa, bbb|)",
|
||||
].forEach(doc => {
|
||||
expect(setup(doc)).toEqual({
|
||||
...base,
|
||||
arg: {
|
||||
index: 1,
|
||||
from: 12,
|
||||
to: 15,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should find the enclosing function when it the structure is different from the name", () => {
|
||||
expect(setup(`datetimeDiff|(date, date, "day")`)).toEqual({
|
||||
name: "datetime-diff",
|
||||
from: 0,
|
||||
to: 31,
|
||||
arg: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
.ArgumentsGrid {
|
||||
margin: 1rem 0;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(2, minmax(4rem, max-content));
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.BlockSubtitleText {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--mb-color-text-light);
|
||||
}
|
||||
|
||||
.ExampleCode {
|
||||
padding: 0.5rem;
|
||||
background-color: var(--mb-color-bg-light);
|
||||
color: var(--mb-color-text-primary);
|
||||
border-radius: 8px;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.065rem;
|
||||
}
|
||||
|
||||
.DocumentationLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
color: var(--mb-color-brand);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.Divider {
|
||||
height: 1px;
|
||||
background-color: var(--mb-color-border);
|
||||
margin: 1.25rem -1rem 1rem -1rem;
|
||||
}
|
||||
|
||||
.Container {
|
||||
padding: 1.25rem 1rem 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.FunctionHelpCode {
|
||||
color: var(--mb-color-text-dark);
|
||||
font-family: Monaco, monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.065rem;
|
||||
}
|
||||
|
||||
.FunctionHelpCodeArgument {
|
||||
color: var(--mb-base-color-lobster-50);
|
||||
}
|
||||
|
||||
.ArgumentTitle {
|
||||
color: var(--mb-base-color-lobster-50);
|
||||
font-family: Monaco, monospace;
|
||||
font-size: 0.8125rem;
|
||||
text-align: right;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { StoryFn } from "@storybook/react";
|
||||
|
||||
import { createMockMetadata } from "__support__/metadata";
|
||||
import { checkNotNull } from "metabase/lib/types";
|
||||
import { getHelpText } from "metabase-lib/v1/expressions/helper-text-strings";
|
||||
import { createMockDatabase } from "metabase-types/api/mocks";
|
||||
|
||||
import type { ExpressionEditorHelpTextProps } from "./ExpressionEditorHelpText";
|
||||
import { ExpressionEditorHelpText } from "./ExpressionEditorHelpText";
|
||||
|
||||
export default {
|
||||
title: "Query Builder/ExpressionEditorHelpText",
|
||||
component: ExpressionEditorHelpText,
|
||||
};
|
||||
|
||||
const Template: StoryFn<typeof ExpressionEditorHelpText> = () => {
|
||||
const database = createMockDatabase();
|
||||
const metadata = createMockMetadata({ databases: [database] });
|
||||
|
||||
const props: ExpressionEditorHelpTextProps = {
|
||||
helpText: getHelpText(
|
||||
"datetime-diff",
|
||||
checkNotNull(metadata.database(database.id)),
|
||||
"UTC",
|
||||
),
|
||||
};
|
||||
|
||||
return <ExpressionEditorHelpText {...props} />;
|
||||
};
|
||||
|
||||
export const Default = Template;
|
||||
@@ -1,114 +0,0 @@
|
||||
import cx from "classnames";
|
||||
import { Fragment } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useDocsUrl } from "metabase/common/hooks";
|
||||
import ExternalLink from "metabase/core/components/ExternalLink";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { Box, Icon, Text } from "metabase/ui";
|
||||
import { getHelpDocsUrl } from "metabase-lib/v1/expressions/helper-text-strings";
|
||||
import type { HelpText } from "metabase-lib/v1/expressions/types";
|
||||
|
||||
import ExpressionEditorHelpTextS from "./ExpressionEditorHelpText.module.css";
|
||||
|
||||
export type ExpressionEditorHelpTextProps = {
|
||||
helpText: HelpText | null | undefined;
|
||||
};
|
||||
|
||||
export const ExpressionEditorHelpText = ({
|
||||
helpText,
|
||||
}: ExpressionEditorHelpTextProps) => {
|
||||
const { url: docsUrl, showMetabaseLinks } = useDocsUrl(
|
||||
helpText ? getHelpDocsUrl(helpText) : "",
|
||||
);
|
||||
|
||||
if (!helpText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { description, structure, args } = helpText;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Prevent stealing focus from input box causing the help text to be closed (metabase#17548) */}
|
||||
<Box
|
||||
className={ExpressionEditorHelpTextS.Container}
|
||||
onMouseDown={evt => evt.preventDefault()}
|
||||
data-testid="expression-helper-popover"
|
||||
>
|
||||
<Box
|
||||
className={ExpressionEditorHelpTextS.FunctionHelpCode}
|
||||
data-testid="expression-helper-popover-structure"
|
||||
>
|
||||
{structure}
|
||||
{args != null && (
|
||||
<>
|
||||
(
|
||||
{args.map(({ name }, index) => (
|
||||
<span key={name}>
|
||||
<Text
|
||||
component="span"
|
||||
className={
|
||||
ExpressionEditorHelpTextS.FunctionHelpCodeArgument
|
||||
}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
{index + 1 < args.length && ", "}
|
||||
</span>
|
||||
))}
|
||||
)
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
<Box className={ExpressionEditorHelpTextS.Divider} />
|
||||
|
||||
<Text>{description}</Text>
|
||||
|
||||
{args != null && (
|
||||
<Box
|
||||
className={ExpressionEditorHelpTextS.ArgumentsGrid}
|
||||
data-testid="expression-helper-popover-arguments"
|
||||
>
|
||||
{args.map(({ name, description: argDescription }) => (
|
||||
<Fragment key={name}>
|
||||
<Box
|
||||
className={cx(
|
||||
ExpressionEditorHelpTextS.ArgumentTitle,
|
||||
CS.textMonospace,
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</Box>
|
||||
<Text lh="normal">{argDescription}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
className={ExpressionEditorHelpTextS.BlockSubtitleText}
|
||||
data-testid="argument-example"
|
||||
>{t`Example`}</Box>
|
||||
<Box
|
||||
className={cx(
|
||||
ExpressionEditorHelpTextS.ExampleCode,
|
||||
CS.textMonospace,
|
||||
)}
|
||||
>
|
||||
{helpText.example}
|
||||
</Box>
|
||||
{showMetabaseLinks && (
|
||||
<ExternalLink
|
||||
className={ExpressionEditorHelpTextS.DocumentationLink}
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
>
|
||||
<Icon m="0.25rem 0.5rem" name="reference" size={12} />
|
||||
{t`Learn more`}
|
||||
</ExternalLink>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { ExpressionEditorHelpText } from "./ExpressionEditorHelpText";
|
||||
@@ -1,42 +0,0 @@
|
||||
import { setupEnterprisePlugins } from "__support__/enterprise";
|
||||
import { mockSettings } from "__support__/settings";
|
||||
import { renderWithProviders } from "__support__/ui";
|
||||
import type { TokenFeatures } from "metabase-types/api";
|
||||
import { createMockTokenFeatures } from "metabase-types/api/mocks";
|
||||
import { createMockState } from "metabase-types/store/mocks";
|
||||
|
||||
import type { ExpressionEditorHelpTextProps } from "../ExpressionEditorHelpText";
|
||||
import { ExpressionEditorHelpText } from "../ExpressionEditorHelpText";
|
||||
|
||||
export interface SetupOpts {
|
||||
helpText?: ExpressionEditorHelpTextProps["helpText"];
|
||||
showMetabaseLinks?: boolean;
|
||||
hasEnterprisePlugins?: boolean;
|
||||
tokenFeatures?: Partial<TokenFeatures>;
|
||||
}
|
||||
|
||||
export async function setup({
|
||||
helpText,
|
||||
showMetabaseLinks = true,
|
||||
hasEnterprisePlugins,
|
||||
tokenFeatures = {},
|
||||
}: SetupOpts) {
|
||||
const props: ExpressionEditorHelpTextProps = {
|
||||
helpText,
|
||||
};
|
||||
|
||||
const state = createMockState({
|
||||
settings: mockSettings({
|
||||
"show-metabase-links": showMetabaseLinks,
|
||||
"token-features": createMockTokenFeatures(tokenFeatures),
|
||||
}),
|
||||
});
|
||||
|
||||
if (hasEnterprisePlugins) {
|
||||
setupEnterprisePlugins();
|
||||
}
|
||||
|
||||
renderWithProviders(<ExpressionEditorHelpText {...props} />, {
|
||||
storeInitialState: state,
|
||||
});
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
.ExpressionListItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.875rem;
|
||||
padding-right: 0.5rem;
|
||||
cursor: pointer;
|
||||
height: 2rem;
|
||||
color: var(--mb-color-text-dark);
|
||||
|
||||
&.isHighlighted {
|
||||
color: var(--mb-color-text-white);
|
||||
background-color: var(--mb-color-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.ExpressionListFooter {
|
||||
height: 2rem;
|
||||
color: var(--mb-color-text-medium);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 0.875rem;
|
||||
margin-top: 12px;
|
||||
|
||||
&.isHighlighted {
|
||||
color: var(--mb-color-text-white);
|
||||
background-color: var(--mb-color-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.GroupTitle {
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
color: var(--mb-color-text-medium);
|
||||
pointer-events: none;
|
||||
margin-top: 12px;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.PopoverHoverTarget {
|
||||
margin-left: auto;
|
||||
padding: 0.3125rem 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
@@ -1,390 +0,0 @@
|
||||
import { useMergedRef } from "@mantine/hooks";
|
||||
import cx from "classnames";
|
||||
import {
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { QueryColumnInfoIcon } from "metabase/components/MetadataInfo/ColumnInfoIcon";
|
||||
import {
|
||||
HoverParent,
|
||||
PopoverHoverTarget,
|
||||
} from "metabase/components/MetadataInfo/InfoIcon";
|
||||
import { Popover as InfoPopover } from "metabase/components/MetadataInfo/Popover";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { color } from "metabase/lib/colors";
|
||||
import { isObscured } from "metabase/lib/dom";
|
||||
import {
|
||||
Box,
|
||||
DelayGroup,
|
||||
Flex,
|
||||
Icon,
|
||||
type IconName,
|
||||
Popover,
|
||||
} from "metabase/ui";
|
||||
import type * as Lib from "metabase-lib";
|
||||
import type {
|
||||
GroupName,
|
||||
Suggestion,
|
||||
} from "metabase-lib/v1/expressions/suggest";
|
||||
import { GROUPS } from "metabase-lib/v1/expressions/suggest";
|
||||
|
||||
import { ExpressionEditorHelpText } from "../ExpressionEditorHelpText";
|
||||
import type {
|
||||
SuggestionFooter,
|
||||
SuggestionShortcut,
|
||||
} from "../ExpressionEditorTextfield";
|
||||
|
||||
import ExpressionEditorSuggestionsS from "./ExpressionEditorSuggestions.module.css";
|
||||
|
||||
type WithIndex<T> = T & {
|
||||
index: number;
|
||||
};
|
||||
|
||||
export const ExpressionEditorSuggestions = forwardRef<
|
||||
HTMLUListElement,
|
||||
{
|
||||
query: Lib.Query;
|
||||
stageIndex: number;
|
||||
suggestions?: (Suggestion | SuggestionFooter | SuggestionShortcut)[];
|
||||
onSuggestionMouseDown: (index: number) => void;
|
||||
open: boolean;
|
||||
highlightedIndex: number;
|
||||
onHighlightSuggestion: (index: number) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
>(function ExpressionEditorSuggestions(
|
||||
{
|
||||
query,
|
||||
stageIndex,
|
||||
suggestions = [],
|
||||
onSuggestionMouseDown,
|
||||
open,
|
||||
highlightedIndex,
|
||||
onHighlightSuggestion,
|
||||
children,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const listRef = useRef(null);
|
||||
const mergedRef = useMergedRef(ref, listRef);
|
||||
const withIndex = suggestions.map((suggestion, index) => ({
|
||||
...suggestion,
|
||||
index,
|
||||
}));
|
||||
|
||||
const items = withIndex.filter(
|
||||
(suggestion): suggestion is WithIndex<Suggestion> =>
|
||||
!("footer" in suggestion),
|
||||
);
|
||||
|
||||
const footers = withIndex.filter(
|
||||
(suggestion): suggestion is WithIndex<SuggestionFooter> =>
|
||||
"footer" in suggestion,
|
||||
);
|
||||
|
||||
const groups = group(items);
|
||||
|
||||
function handleMouseDown(evt: MouseEvent) {
|
||||
if (evt.target === listRef.current) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
position="bottom-start"
|
||||
opened={open && suggestions.length > 0}
|
||||
radius="xs"
|
||||
withinPortal
|
||||
middlewares={{
|
||||
flip: false,
|
||||
shift: false,
|
||||
inline: false,
|
||||
}}
|
||||
>
|
||||
<Popover.Target>{children}</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<DelayGroup>
|
||||
<Box
|
||||
component="ul"
|
||||
miw={250}
|
||||
data-testid="expression-suggestions-list"
|
||||
ref={mergedRef}
|
||||
onMouseDownCapture={handleMouseDown}
|
||||
>
|
||||
<ExpressionEditorSuggestionsListGroup
|
||||
suggestions={groups._none}
|
||||
query={query}
|
||||
stageIndex={stageIndex}
|
||||
highlightedIndex={highlightedIndex}
|
||||
onSuggestionMouseDown={onSuggestionMouseDown}
|
||||
onHighlightSuggestion={onHighlightSuggestion}
|
||||
/>
|
||||
<ExpressionEditorSuggestionsListGroup
|
||||
name="popularAggregations"
|
||||
suggestions={groups.popularAggregations}
|
||||
query={query}
|
||||
stageIndex={stageIndex}
|
||||
highlightedIndex={highlightedIndex}
|
||||
onSuggestionMouseDown={onSuggestionMouseDown}
|
||||
onHighlightSuggestion={onHighlightSuggestion}
|
||||
/>
|
||||
<ExpressionEditorSuggestionsListGroup
|
||||
name="popularExpressions"
|
||||
suggestions={groups.popularExpressions}
|
||||
query={query}
|
||||
stageIndex={stageIndex}
|
||||
highlightedIndex={highlightedIndex}
|
||||
onSuggestionMouseDown={onSuggestionMouseDown}
|
||||
onHighlightSuggestion={onHighlightSuggestion}
|
||||
/>
|
||||
<ExpressionEditorSuggestionsListGroup
|
||||
name="shortcuts"
|
||||
suggestions={groups.shortcuts}
|
||||
query={query}
|
||||
stageIndex={stageIndex}
|
||||
highlightedIndex={highlightedIndex}
|
||||
onSuggestionMouseDown={onSuggestionMouseDown}
|
||||
onHighlightSuggestion={onHighlightSuggestion}
|
||||
/>
|
||||
</Box>
|
||||
{footers.map(suggestion => (
|
||||
<Footer
|
||||
key={suggestion.index}
|
||||
suggestion={suggestion}
|
||||
highlightedIndex={highlightedIndex}
|
||||
onHighlightSuggestion={onHighlightSuggestion}
|
||||
/>
|
||||
))}
|
||||
</DelayGroup>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
function ExpressionEditorSuggestionsListGroup({
|
||||
name,
|
||||
query,
|
||||
stageIndex,
|
||||
suggestions = [],
|
||||
onSuggestionMouseDown,
|
||||
onHighlightSuggestion,
|
||||
highlightedIndex,
|
||||
}: {
|
||||
name?: GroupName;
|
||||
query: Lib.Query;
|
||||
stageIndex: number;
|
||||
suggestions?: Suggestion[];
|
||||
onSuggestionMouseDown: (index: number) => void;
|
||||
onHighlightSuggestion: (index: number) => void;
|
||||
highlightedIndex: number;
|
||||
}) {
|
||||
const definition = name && GROUPS[name];
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{definition?.displayName && (
|
||||
<Box
|
||||
component="li"
|
||||
className={cx(
|
||||
ExpressionEditorSuggestionsS.ExpressionListItem,
|
||||
ExpressionEditorSuggestionsS.GroupTitle,
|
||||
)}
|
||||
>
|
||||
{definition.displayName}
|
||||
</Box>
|
||||
)}
|
||||
{suggestions.map((suggestion: SuggestionWithIndex) => (
|
||||
<ExpressionEditorSuggestionsListItem
|
||||
key={`suggestion-${suggestion.index}`}
|
||||
query={query}
|
||||
stageIndex={stageIndex}
|
||||
suggestion={suggestion}
|
||||
isHighlighted={suggestion.index === highlightedIndex}
|
||||
index={suggestion.index}
|
||||
onMouseDown={onSuggestionMouseDown}
|
||||
onHighlightSuggestion={onHighlightSuggestion}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpressionEditorSuggestionsListItem({
|
||||
query,
|
||||
stageIndex,
|
||||
suggestion,
|
||||
onHighlightSuggestion,
|
||||
isHighlighted,
|
||||
onMouseDown,
|
||||
index,
|
||||
}: {
|
||||
query: Lib.Query;
|
||||
stageIndex: number;
|
||||
index: number;
|
||||
isHighlighted: boolean;
|
||||
onMouseDown: (index: number) => void;
|
||||
onHighlightSuggestion: (index: number) => void;
|
||||
suggestion: Suggestion;
|
||||
}) {
|
||||
const { icon, helpText, range = [] } = suggestion;
|
||||
const [start = 0, end = 0] = range;
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!isHighlighted || !ref.current || !isObscured(ref.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.current.scrollIntoView({ block: "nearest" });
|
||||
}, [isHighlighted]);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onMouseDown?.(index);
|
||||
},
|
||||
[index, onMouseDown],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(() => {
|
||||
onHighlightSuggestion(index);
|
||||
}, [index, onHighlightSuggestion]);
|
||||
|
||||
return (
|
||||
<HoverParent as="li">
|
||||
<Flex
|
||||
className={cx(ExpressionEditorSuggestionsS.ExpressionListItem, {
|
||||
[ExpressionEditorSuggestionsS.isHighlighted]: isHighlighted,
|
||||
})}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
ref={ref}
|
||||
data-testid="expression-suggestions-list-item"
|
||||
>
|
||||
{icon && (helpText || !suggestion.column) && (
|
||||
<Icon
|
||||
name={icon as IconName}
|
||||
color={isHighlighted ? color("brand-white") : color("text-light")}
|
||||
className={CS.mr1}
|
||||
/>
|
||||
)}
|
||||
{!helpText && suggestion.column && (
|
||||
<QueryColumnInfoIcon
|
||||
query={query}
|
||||
stageIndex={stageIndex}
|
||||
column={suggestion.column}
|
||||
position="top-start"
|
||||
color={isHighlighted ? color("brand-white") : color("text-light")}
|
||||
className={CS.mr1}
|
||||
/>
|
||||
)}
|
||||
<Box mr="1.5rem" component="span">
|
||||
{suggestion.name.slice(0, start)}
|
||||
<Box component="span" fw="bold">
|
||||
{suggestion.name.slice(start, end)}
|
||||
</Box>
|
||||
{suggestion.name.slice(end)}
|
||||
</Box>
|
||||
{helpText && (
|
||||
<InfoPopover
|
||||
position="right"
|
||||
content={<ExpressionEditorHelpText helpText={helpText} />}
|
||||
width={450}
|
||||
>
|
||||
<PopoverHoverTarget
|
||||
className={ExpressionEditorSuggestionsS.PopoverHoverTarget}
|
||||
name="info_filled"
|
||||
aria-label={t`More info`}
|
||||
/>
|
||||
</InfoPopover>
|
||||
)}
|
||||
</Flex>
|
||||
</HoverParent>
|
||||
);
|
||||
}
|
||||
|
||||
function Footer({
|
||||
suggestion,
|
||||
highlightedIndex,
|
||||
onHighlightSuggestion,
|
||||
}: {
|
||||
suggestion: WithIndex<SuggestionFooter>;
|
||||
highlightedIndex: number;
|
||||
onHighlightSuggestion: (index: number) => void;
|
||||
}) {
|
||||
function handleMouseDownCapture(evt: MouseEvent) {
|
||||
// prevent the dropdown from closing
|
||||
evt.preventDefault();
|
||||
}
|
||||
|
||||
const handleMouseMove = useCallback(() => {
|
||||
if (suggestion.index !== highlightedIndex) {
|
||||
onHighlightSuggestion(suggestion.index);
|
||||
}
|
||||
}, [suggestion.index, onHighlightSuggestion, highlightedIndex]);
|
||||
|
||||
const isHighlighted = highlightedIndex === suggestion.index;
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="a"
|
||||
className={cx(ExpressionEditorSuggestionsS.ExpressionListFooter, {
|
||||
[ExpressionEditorSuggestionsS.isHighlighted]: isHighlighted,
|
||||
})}
|
||||
target="_blank"
|
||||
href={suggestion.href}
|
||||
onMouseDownCapture={handleMouseDownCapture}
|
||||
onMouseMove={handleMouseMove}
|
||||
data-testid="expression-suggestions-list-item"
|
||||
>
|
||||
<Icon
|
||||
name="reference"
|
||||
color={isHighlighted ? color("brand-white") : color("text-light")}
|
||||
className={CS.mr1}
|
||||
/>
|
||||
<Box mr="1.5rem" component="span">
|
||||
{suggestion.name}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type SuggestionWithIndex = Suggestion & {
|
||||
index: number;
|
||||
};
|
||||
|
||||
type Groups = {
|
||||
[key in GroupName | "_none"]: SuggestionWithIndex[];
|
||||
};
|
||||
|
||||
function group(suggestions: Suggestion[]): Groups {
|
||||
const groups: Groups = {
|
||||
_none: [],
|
||||
popularAggregations: [],
|
||||
popularExpressions: [],
|
||||
shortcuts: [],
|
||||
};
|
||||
|
||||
suggestions.forEach(suggestion => {
|
||||
if (suggestion.group) {
|
||||
groups[suggestion.group].push(suggestion);
|
||||
} else {
|
||||
groups._none.push(suggestion);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import { createMockMetadata } from "__support__/metadata";
|
||||
import {
|
||||
fireEvent,
|
||||
renderWithProviders,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "__support__/ui";
|
||||
import { getColumnIcon } from "metabase/common/utils/columns";
|
||||
import type * as Lib from "metabase-lib";
|
||||
import { createQuery } from "metabase-lib/test-helpers";
|
||||
import type { Suggestion } from "metabase-lib/v1/expressions/suggest";
|
||||
import { suggest } from "metabase-lib/v1/expressions/suggest";
|
||||
import { createSampleDatabase } from "metabase-types/api/mocks/presets";
|
||||
|
||||
import { ExpressionEditorSuggestions } from "./ExpressionEditorSuggestions";
|
||||
|
||||
const METADATA = createMockMetadata({
|
||||
databases: [createSampleDatabase()],
|
||||
});
|
||||
|
||||
type WrapperProps = {
|
||||
query: Lib.Query;
|
||||
stageIndex: number;
|
||||
suggestions?: Suggestion[];
|
||||
highlightedIndex: number;
|
||||
onSuggestionMouseDown: () => void;
|
||||
onHighlightSuggestion: (index: number) => void;
|
||||
startRule: string;
|
||||
};
|
||||
|
||||
function Wrapper(props: WrapperProps) {
|
||||
return (
|
||||
<ExpressionEditorSuggestions {...props} open>
|
||||
<div>target</div>
|
||||
</ExpressionEditorSuggestions>
|
||||
);
|
||||
}
|
||||
|
||||
type SetupOpts = {
|
||||
source?: string;
|
||||
startRule: string;
|
||||
expressionIndex?: number;
|
||||
};
|
||||
|
||||
function setup({ source = "", startRule, expressionIndex }: SetupOpts) {
|
||||
const query = createQuery({ metadata: METADATA });
|
||||
const stageIndex = 0;
|
||||
const { suggestions } = suggest({
|
||||
source,
|
||||
query,
|
||||
stageIndex,
|
||||
metadata: METADATA,
|
||||
startRule: "expression",
|
||||
getColumnIcon,
|
||||
expressionIndex,
|
||||
});
|
||||
|
||||
const onHighlightSuggestion = jest.fn();
|
||||
|
||||
const props = {
|
||||
query,
|
||||
stageIndex,
|
||||
suggestions,
|
||||
onSuggestionMouseDown: jest.fn(),
|
||||
onHighlightSuggestion,
|
||||
highlightedIndex: -1,
|
||||
startRule,
|
||||
};
|
||||
|
||||
const { rerender } = renderWithProviders(<Wrapper {...props} />);
|
||||
|
||||
// force rerender to make sure the target prop has a value
|
||||
rerender(<Wrapper {...props} />);
|
||||
|
||||
return {
|
||||
onHighlightSuggestion,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ExpressionEditorSuggestions", () => {
|
||||
it("should render with the column info icon", async () => {
|
||||
setup({ source: "[", startRule: "expression" });
|
||||
|
||||
await waitFor(() =>
|
||||
screen.findAllByTestId("expression-suggestions-list-item"),
|
||||
);
|
||||
|
||||
expect(screen.getAllByLabelText("More info").length).toBeGreaterThanOrEqual(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("suggestions items should show function helptext info icons", async () => {
|
||||
setup({ source: "con", startRule: "expression" });
|
||||
|
||||
await waitFor(() =>
|
||||
screen.findAllByTestId("expression-suggestions-list-item"),
|
||||
);
|
||||
|
||||
expect(screen.getAllByLabelText("More info").length).toBeGreaterThanOrEqual(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("should show functions when first opened", () => {
|
||||
setup({ startRule: "expression" });
|
||||
expect(screen.getByText("Common functions")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("case")).toBeInTheDocument();
|
||||
expect(screen.getByText("coalesce")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not include popular functions when text has been typed", () => {
|
||||
setup({ source: "[", startRule: "expression" });
|
||||
expect(screen.queryByText("Common functions")).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText("case")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("coalesce")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should highlight a suggestion when hovering it", () => {
|
||||
const { onHighlightSuggestion } = setup({ startRule: "expression" });
|
||||
expect(screen.getByText("Common functions")).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseMove(screen.getByText("case"));
|
||||
expect(onHighlightSuggestion).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { ExpressionEditorSuggestions } from "./ExpressionEditorSuggestions";
|
||||
@@ -1,87 +0,0 @@
|
||||
.EditorEqualsSign {
|
||||
font:
|
||||
12px / normal Monaco,
|
||||
monospace;
|
||||
height: 12px;
|
||||
font-weight: 700;
|
||||
margin: 0 3px 0 4px;
|
||||
}
|
||||
|
||||
.ErrorMessageContainer {
|
||||
color: var(--mb-color-error);
|
||||
margin: 0.5rem 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.EditorContainer {
|
||||
border: 1px solid;
|
||||
border-color: var(--mb-color-border);
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
position: relative;
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.625rem 0.75rem;
|
||||
transition: border 0.3s linear;
|
||||
background: var(--mb-color-background);
|
||||
|
||||
&.isFocused {
|
||||
border-color: var(--mb-color-brand);
|
||||
}
|
||||
|
||||
&.hasError {
|
||||
border-color: var(--mb-color-error);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
:global(.ace_editor) {
|
||||
overflow: initial;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 5px;
|
||||
}
|
||||
|
||||
:global(.ace_scroller) {
|
||||
background: var(--mb-color-background);
|
||||
}
|
||||
|
||||
:global(.ace_content *) {
|
||||
font-family: monospace !important;
|
||||
}
|
||||
|
||||
:global(.ace_hidpi .ace_content) {
|
||||
color: var(--mb-color-text-dark);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:global(.ace-tm .ace_keyword),
|
||||
:global(.ace-tm .ace_constant.ace_numeric) {
|
||||
color: var(--mb-color-text-dark);
|
||||
}
|
||||
|
||||
:global(.ace-tm .ace_variable) {
|
||||
color: var(--mb-color-brand);
|
||||
}
|
||||
|
||||
:global(.ace-tm .ace_string) {
|
||||
color: var(--mb-color-syntax-parameters);
|
||||
}
|
||||
|
||||
:global(.ace_cursor) {
|
||||
border-left-width: 1px;
|
||||
}
|
||||
|
||||
:global(.ace_hidden-cursors .ace_cursor) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
:global(.ace_content .error) {
|
||||
position: absolute;
|
||||
border-bottom: 2px solid var(--mb-color-error);
|
||||
border-radius: 0;
|
||||
background-color: var(--mb-color-bg-error);
|
||||
}
|
||||
}
|
||||
@@ -1,856 +0,0 @@
|
||||
import type { Ace } from "ace-builds";
|
||||
import * as ace from "ace-builds/src-noconflict/ace";
|
||||
import cx from "classnames";
|
||||
import * as React from "react";
|
||||
import type { ICommand, IMarker } from "react-ace";
|
||||
import AceEditor from "react-ace";
|
||||
import { t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import { getColumnIcon } from "metabase/common/utils/columns";
|
||||
import ExplicitSize from "metabase/components/ExplicitSize";
|
||||
import { connect } from "metabase/lib/redux";
|
||||
import { getMetadata } from "metabase/selectors/metadata";
|
||||
import { getShowMetabaseLinks } from "metabase/selectors/whitelabel";
|
||||
import { Box, Flex, type IconName, Popover } from "metabase/ui";
|
||||
import * as Lib from "metabase-lib";
|
||||
import { isExpression } from "metabase-lib/v1/expressions";
|
||||
import {
|
||||
diagnose,
|
||||
isErrorWithMessage,
|
||||
} from "metabase-lib/v1/expressions/diagnostics";
|
||||
import { format } from "metabase-lib/v1/expressions/format";
|
||||
import { processSource } from "metabase-lib/v1/expressions/process";
|
||||
import type {
|
||||
GroupName,
|
||||
SuggestArgs,
|
||||
Suggestion,
|
||||
} from "metabase-lib/v1/expressions/suggest";
|
||||
import { suggest } from "metabase-lib/v1/expressions/suggest";
|
||||
import { tokenize } from "metabase-lib/v1/expressions/tokenizer";
|
||||
import type {
|
||||
ErrorWithMessage,
|
||||
HelpText,
|
||||
} from "metabase-lib/v1/expressions/types";
|
||||
import type Metadata from "metabase-lib/v1/metadata/Metadata";
|
||||
import type { Expression } from "metabase-types/api";
|
||||
import type { State } from "metabase-types/store";
|
||||
|
||||
import { ExpressionEditorHelpText } from "../ExpressionEditorHelpText";
|
||||
import { ExpressionEditorSuggestions } from "../ExpressionEditorSuggestions";
|
||||
import ExpressionMode from "../ExpressionMode";
|
||||
|
||||
import ExpressionEditorTextfieldS from "./ExpressionEditorTextfield.module.css";
|
||||
|
||||
ace.config.set("basePath", "/assets/ui/");
|
||||
ace.config.set("useStrictCSP", true);
|
||||
|
||||
export type SuggestionFooter = {
|
||||
footer: true;
|
||||
name: string;
|
||||
icon: IconName;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export type SuggestionShortcut = {
|
||||
shortcut: true;
|
||||
name: string;
|
||||
icon: IconName;
|
||||
group: GroupName;
|
||||
action: () => void;
|
||||
};
|
||||
|
||||
type SuggestWithExtras = {
|
||||
suggestions: (Suggestion | SuggestionFooter | SuggestionShortcut)[];
|
||||
helpText?: HelpText;
|
||||
};
|
||||
|
||||
export function suggestWithExtras(
|
||||
args: SuggestArgs & {
|
||||
showMetabaseLinks: boolean;
|
||||
shortcuts?: SuggestionShortcut[];
|
||||
},
|
||||
): SuggestWithExtras {
|
||||
const res = suggest(args);
|
||||
|
||||
const suggestions: (Suggestion | SuggestionFooter | SuggestionShortcut)[] =
|
||||
res.suggestions ?? [];
|
||||
|
||||
if (args.showMetabaseLinks && args.source === "") {
|
||||
suggestions.push(...(args.shortcuts ?? []));
|
||||
|
||||
if (args.startRule === "aggregation") {
|
||||
suggestions.push({
|
||||
footer: true,
|
||||
name: t`Documentation`,
|
||||
icon: "external",
|
||||
href: "https://www.metabase.com/docs/latest/questions/query-builder/expressions-list#aggregations",
|
||||
});
|
||||
} else {
|
||||
suggestions.push({
|
||||
footer: true,
|
||||
name: t`Documentation`,
|
||||
icon: "external",
|
||||
href: "https://www.metabase.com/docs/latest/questions/query-builder/expressions-list#functions",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...res,
|
||||
suggestions,
|
||||
};
|
||||
}
|
||||
|
||||
const ACE_OPTIONS = {
|
||||
behavioursEnabled: false,
|
||||
indentedSoftWrap: false,
|
||||
minLines: 1,
|
||||
maxLines: 9,
|
||||
showLineNumbers: false,
|
||||
showGutter: false,
|
||||
showFoldWidgets: false,
|
||||
showPrintMargin: false,
|
||||
};
|
||||
|
||||
interface ExpressionEditorTextfieldProps {
|
||||
expression: Expression | undefined | null;
|
||||
clause: Lib.ExpressionClause | undefined | null;
|
||||
name: string;
|
||||
query: Lib.Query;
|
||||
stageIndex: number;
|
||||
startRule?: "expression" | "aggregation" | "boolean";
|
||||
expressionIndex?: number;
|
||||
width: number;
|
||||
reportTimezone?: string;
|
||||
textAreaId?: string;
|
||||
|
||||
onChange: (
|
||||
expression: Expression | null,
|
||||
expressionClause: Lib.ExpressionClause | null,
|
||||
) => void;
|
||||
onError: (error: ErrorWithMessage | string | null) => void;
|
||||
onCommit: (
|
||||
expression: Expression | null,
|
||||
expressionClause: Lib.ExpressionClause | null,
|
||||
) => void;
|
||||
shortcuts?: SuggestionShortcut[];
|
||||
|
||||
forwardedRef?: React.ForwardedRef<HTMLDivElement>;
|
||||
}
|
||||
|
||||
interface StateProps {
|
||||
metadata: Metadata;
|
||||
showMetabaseLinks: boolean;
|
||||
}
|
||||
|
||||
interface ExpressionEditorTextfieldState {
|
||||
source: string;
|
||||
suggestions: (Suggestion | SuggestionFooter | SuggestionShortcut)[];
|
||||
highlightedSuggestionIndex: number;
|
||||
isFocused: boolean;
|
||||
errorMessage: ErrorWithMessage | null;
|
||||
helpText: HelpText | null;
|
||||
hasChanges: boolean;
|
||||
}
|
||||
|
||||
function transformPropsToState(
|
||||
props: ExpressionEditorTextfieldProps & StateProps,
|
||||
): ExpressionEditorTextfieldState {
|
||||
const {
|
||||
expression: legacyExpression = ExpressionEditorTextfieldInner.defaultProps
|
||||
.expression,
|
||||
startRule = ExpressionEditorTextfieldInner.defaultProps.startRule,
|
||||
clause,
|
||||
query,
|
||||
stageIndex,
|
||||
expressionIndex,
|
||||
metadata,
|
||||
reportTimezone,
|
||||
showMetabaseLinks,
|
||||
shortcuts = [],
|
||||
} = props;
|
||||
const expressionFromClause = clause
|
||||
? Lib.legacyExpressionForExpressionClause(query, stageIndex, clause)
|
||||
: undefined;
|
||||
const expression = expressionFromClause ?? legacyExpression;
|
||||
const source = format(expression, {
|
||||
startRule,
|
||||
stageIndex,
|
||||
query,
|
||||
expressionIndex,
|
||||
});
|
||||
|
||||
const { suggestions = [], helpText = null } = suggestWithExtras({
|
||||
reportTimezone,
|
||||
startRule,
|
||||
source,
|
||||
targetOffset: 0,
|
||||
expressionIndex,
|
||||
query,
|
||||
stageIndex,
|
||||
metadata,
|
||||
getColumnIcon,
|
||||
showMetabaseLinks,
|
||||
shortcuts,
|
||||
});
|
||||
|
||||
return {
|
||||
source,
|
||||
highlightedSuggestionIndex: 0,
|
||||
helpText,
|
||||
suggestions,
|
||||
isFocused: false,
|
||||
errorMessage: null,
|
||||
hasChanges: false,
|
||||
};
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: State) => ({
|
||||
metadata: getMetadata(state),
|
||||
showMetabaseLinks: getShowMetabaseLinks(state),
|
||||
});
|
||||
|
||||
const CURSOR_DEBOUNCE_INTERVAL = 10;
|
||||
|
||||
class ExpressionEditorTextfieldInner extends React.Component<
|
||||
ExpressionEditorTextfieldProps & StateProps,
|
||||
ExpressionEditorTextfieldState
|
||||
> {
|
||||
input = React.createRef<AceEditor>();
|
||||
suggestionTarget = React.createRef<HTMLDivElement>();
|
||||
popupMenuTarget = React.createRef<HTMLUListElement>();
|
||||
|
||||
static defaultProps = {
|
||||
expression: "",
|
||||
startRule: "expression",
|
||||
} as const;
|
||||
|
||||
constructor(props: ExpressionEditorTextfieldProps & StateProps) {
|
||||
super(props);
|
||||
|
||||
this.state = transformPropsToState(props);
|
||||
}
|
||||
|
||||
UNSAFE_componentWillMount() {
|
||||
this.UNSAFE_componentWillReceiveProps(this.props);
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(
|
||||
newProps: Readonly<ExpressionEditorTextfieldProps & StateProps>,
|
||||
) {
|
||||
// we only refresh our state if we had no previous state OR if our expression changed
|
||||
const {
|
||||
expression,
|
||||
clause,
|
||||
startRule = ExpressionEditorTextfieldInner.defaultProps.startRule,
|
||||
query,
|
||||
stageIndex,
|
||||
expressionIndex,
|
||||
} = newProps;
|
||||
const hasLegacyExpressionChanged = !_.isEqual(
|
||||
this.props.expression,
|
||||
expression,
|
||||
);
|
||||
const hasClauseChanged = !_.isEqual(this.props.clause, clause);
|
||||
const hasExpressionChanged = hasLegacyExpressionChanged || hasClauseChanged;
|
||||
const expressionFromClause = clause
|
||||
? Lib.legacyExpressionForExpressionClause(query, stageIndex, clause)
|
||||
: undefined;
|
||||
const newExpression = expressionFromClause ?? expression;
|
||||
|
||||
if (!this.state || hasExpressionChanged) {
|
||||
const source = format(newExpression, {
|
||||
startRule,
|
||||
stageIndex,
|
||||
query,
|
||||
expressionIndex,
|
||||
});
|
||||
const currentSource = this.state.source;
|
||||
this.setState(transformPropsToState(newProps));
|
||||
|
||||
// Reset caret position due to reformatting
|
||||
if (currentSource !== source && this.input.current) {
|
||||
const { editor } = this.input.current;
|
||||
setTimeout(() => editor.gotoLine(1, source.length, false), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleKeypress = (evt: KeyboardEvent) => {
|
||||
if (evt.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
this.handleEnter();
|
||||
};
|
||||
|
||||
handleKeyDownCapture = (event: KeyboardEvent) => {
|
||||
// We want the Tab key to cause focus change when there are no suggestions shown.
|
||||
// If there are suggestions shown, it means 1 of them is selected, and in that case
|
||||
// we want the Tab key to apply that suggestion - we let Ace take care of that.
|
||||
// Ace handles Shift + Tab correctly, so we don't handle that case here.
|
||||
if (
|
||||
event.key === "Tab" &&
|
||||
!event.shiftKey &&
|
||||
this.state.suggestions.length === 0
|
||||
) {
|
||||
// Do not let Ace editor get this event.
|
||||
event.stopPropagation();
|
||||
|
||||
// Redispatch the event from parent node of the Ace editor
|
||||
// so that listeners up in the tree can still handle it, e.g.
|
||||
// to contain focus within the popover/modal.
|
||||
this.suggestionTarget.current?.dispatchEvent(
|
||||
new KeyboardEvent("keydown", event),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
textarea() {
|
||||
return this.input.current?.refEditor?.getElementsByTagName("textarea")[0];
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.input.current) {
|
||||
const { editor } = this.input.current;
|
||||
// "ExpressionMode" constructor is not typed, so cast it here explicitly
|
||||
const mode = new ExpressionMode() as unknown as Ace.SyntaxMode;
|
||||
|
||||
// HACK: manually register the keypress event for the enter key,
|
||||
// since ACE does not seem to call the event handlers in time for
|
||||
// them to do certain things, like window.open.
|
||||
//
|
||||
// Without this hack, popups get blocked since they are not
|
||||
// considered by the browser to be in response to a user action.
|
||||
this.textarea()?.addEventListener("keypress", this.handleKeypress);
|
||||
|
||||
// HACK: Ace will sometimes unexpectedly prevent changing focus with the Tab key.
|
||||
// See https://github.com/metabase/metabase/issues/49036
|
||||
this.textarea()?.addEventListener("keydown", this.handleKeyDownCapture, {
|
||||
capture: true, // otherwise Ace will call preventDefault() on this event in its own keydown handler
|
||||
});
|
||||
|
||||
editor.getSession().setMode(mode);
|
||||
|
||||
editor.setOptions({
|
||||
fontFamily: "Monaco, monospace",
|
||||
fontSize: "12px",
|
||||
});
|
||||
|
||||
const passKeysToBrowser = editor.commands.byName.passKeysToBrowser;
|
||||
editor.commands.bindKey("Tab", passKeysToBrowser);
|
||||
editor.commands.bindKey("Shift-Tab", passKeysToBrowser);
|
||||
editor.commands.removeCommand(editor.commands.byName.indent);
|
||||
editor.commands.removeCommand(editor.commands.byName.outdent);
|
||||
|
||||
if (this.state.source.length === 0) {
|
||||
setTimeout(() => this.triggerAutosuggest());
|
||||
}
|
||||
|
||||
this.triggerAutosuggest();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const { textAreaId } = this.props;
|
||||
if (this.input.current && textAreaId) {
|
||||
const textArea = this.input.current.editor.textInput.getElement?.();
|
||||
textArea?.setAttribute?.("id", textAreaId);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.textarea()?.removeEventListener("keypress", this.handleKeypress);
|
||||
}
|
||||
|
||||
onSuggestionSelected = (index: number) => {
|
||||
const { source, suggestions } = this.state;
|
||||
const suggestion = suggestions && suggestions[index];
|
||||
|
||||
if ("footer" in suggestion) {
|
||||
// open link in new window
|
||||
window.open(suggestion.href, "_blank");
|
||||
return;
|
||||
}
|
||||
|
||||
if ("shortcut" in suggestion) {
|
||||
// run the shortcut
|
||||
suggestion.action();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.input.current && suggestion) {
|
||||
const { editor } = this.input.current;
|
||||
const { tokens } = tokenize(source);
|
||||
const token = tokens.find(t => t.end >= suggestion.index);
|
||||
|
||||
const { row } = editor.getCursorPosition();
|
||||
|
||||
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.trimStart()) === "(";
|
||||
const extraTrim = openParen && alreadyOpenParen ? 1 : 0;
|
||||
const replacement = suggested.slice(0, suggested.length - extraTrim);
|
||||
|
||||
const updatedExpression = prefix + replacement + postfix;
|
||||
this.handleExpressionChange(updatedExpression);
|
||||
const caretPos = updatedExpression.length - postfix.length;
|
||||
|
||||
// setTimeout solves a race condition that happens only
|
||||
// when a suggestion has been selected by
|
||||
// clicking on the autocomplete
|
||||
setTimeout(() => editor.moveCursorTo(row, caretPos));
|
||||
} else {
|
||||
const updatedExpression = source + suggestion.text;
|
||||
this.handleExpressionChange(updatedExpression);
|
||||
const caretPos = updatedExpression.length;
|
||||
setTimeout(() => editor.moveCursorTo(row, caretPos));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleArrowUp = () => {
|
||||
const { highlightedSuggestionIndex, suggestions } = this.state;
|
||||
|
||||
// Do not hijack up/down arrow keys for autocomplete suggestions navigation when there's only 1 suggestion
|
||||
if (suggestions.length > 1) {
|
||||
this.setState({
|
||||
highlightedSuggestionIndex:
|
||||
(highlightedSuggestionIndex + suggestions.length - 1) %
|
||||
suggestions.length,
|
||||
});
|
||||
} else {
|
||||
this.input.current?.editor.navigateUp();
|
||||
}
|
||||
};
|
||||
|
||||
handleArrowDown = () => {
|
||||
const { highlightedSuggestionIndex, suggestions } = this.state;
|
||||
|
||||
// Do not hijack up/down arrow keys for autocomplete suggestions navigation when there's only 1 suggestion
|
||||
if (suggestions.length > 1) {
|
||||
this.setState({
|
||||
highlightedSuggestionIndex:
|
||||
(highlightedSuggestionIndex + suggestions.length + 1) %
|
||||
suggestions.length,
|
||||
});
|
||||
} else {
|
||||
this.input.current?.editor.navigateDown();
|
||||
}
|
||||
};
|
||||
|
||||
handleEnter = () => {
|
||||
const { highlightedSuggestionIndex, suggestions } = this.state;
|
||||
|
||||
if (suggestions.length) {
|
||||
this.onSuggestionSelected(highlightedSuggestionIndex);
|
||||
} else {
|
||||
this.commitExpression();
|
||||
}
|
||||
};
|
||||
|
||||
handleHighlightSuggestion = (index: number) => {
|
||||
this.setState({
|
||||
highlightedSuggestionIndex: index,
|
||||
});
|
||||
};
|
||||
|
||||
chooseSuggestion = () => {
|
||||
const { highlightedSuggestionIndex, suggestions } = this.state;
|
||||
|
||||
if (suggestions.length) {
|
||||
this.onSuggestionSelected(highlightedSuggestionIndex);
|
||||
}
|
||||
};
|
||||
|
||||
handleFocus = () => {
|
||||
this.setState({ isFocused: true });
|
||||
if (this.input.current) {
|
||||
const { editor } = this.input.current;
|
||||
this.handleCursorChange(editor.selection);
|
||||
|
||||
// workaround some unknown issue on Firefox
|
||||
// without explicit focus, the editor is vertically shifted
|
||||
setTimeout(() => {
|
||||
editor.focus();
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
handleInputBlur = (e: React.FocusEvent) => {
|
||||
// Ensure there is no active popup menu before we blur or
|
||||
// that user didn't interact with the popup menu
|
||||
if (
|
||||
this.popupMenuTarget.current &&
|
||||
e.relatedTarget?.contains(this.popupMenuTarget.current)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ isFocused: false });
|
||||
|
||||
// Switching to another window also triggers the blur event.
|
||||
// When our window gets focus again, the input will automatically
|
||||
// get focus, so ignore the blue event to avoid showing an
|
||||
// error message when the user is not actually done.
|
||||
if (e.target === document.activeElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { onChange, onError } = this.props;
|
||||
|
||||
this.clearSuggestions();
|
||||
|
||||
const errorMessage = this.diagnoseExpression();
|
||||
this.setState({ errorMessage });
|
||||
|
||||
// whenever our input blurs we push the updated expression to our parent if valid
|
||||
if (errorMessage) {
|
||||
onError(errorMessage);
|
||||
} else {
|
||||
const compiledExpression = this.compileExpression();
|
||||
|
||||
if (
|
||||
compiledExpression &&
|
||||
compiledExpression.expression != null &&
|
||||
compiledExpression.expressionClause != null
|
||||
) {
|
||||
const { expression, expressionClause } = compiledExpression;
|
||||
|
||||
if (!isExpression(expression)) {
|
||||
console.warn("isExpression=false", expression);
|
||||
}
|
||||
|
||||
onChange(expression, expressionClause);
|
||||
} else {
|
||||
const error = compiledExpression?.compileError;
|
||||
const errorWithMessage = isErrorWithMessage(error)
|
||||
? error
|
||||
: { message: t`Invalid expression` };
|
||||
this.setState({ errorMessage: errorWithMessage });
|
||||
onError(errorWithMessage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
clearSuggestions() {
|
||||
this.setState({
|
||||
highlightedSuggestionIndex: 0,
|
||||
helpText: null,
|
||||
});
|
||||
this.updateSuggestions([]);
|
||||
}
|
||||
|
||||
updateSuggestions(
|
||||
suggestions:
|
||||
| (Suggestion | SuggestionFooter | SuggestionShortcut)[]
|
||||
| undefined = [],
|
||||
) {
|
||||
this.setState({ suggestions });
|
||||
|
||||
const { highlightedSuggestionIndex } = this.state;
|
||||
|
||||
if (highlightedSuggestionIndex >= suggestions.length) {
|
||||
this.setState({ highlightedSuggestionIndex: 0 });
|
||||
}
|
||||
|
||||
// Correctly bind Tab depending on whether suggestions are available or not
|
||||
if (this.input.current) {
|
||||
const { editor } = this.input.current;
|
||||
const tabBinding = editor.commands.commandKeyBinding.tab;
|
||||
if (suggestions.length > 0) {
|
||||
// Something to suggest? Tab is for choosing one of them
|
||||
editor.commands.bindKey("Tab", editor.commands.byName.chooseSuggestion);
|
||||
} else {
|
||||
if (Array.isArray(tabBinding) && tabBinding.length > 1) {
|
||||
// No more suggestions? Keep a single binding and remove the
|
||||
// second one (added to choose a suggestion)
|
||||
editor.commands.commandKeyBinding.tab = tabBinding.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compileExpression() {
|
||||
const { source } = this.state;
|
||||
const {
|
||||
query,
|
||||
stageIndex,
|
||||
startRule = ExpressionEditorTextfieldInner.defaultProps.startRule,
|
||||
name,
|
||||
expressionIndex,
|
||||
} = this.props;
|
||||
if (!source || source.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const { expression, expressionClause, compileError } = processSource({
|
||||
name,
|
||||
source,
|
||||
query,
|
||||
stageIndex,
|
||||
startRule,
|
||||
expressionIndex,
|
||||
});
|
||||
|
||||
return { expression, expressionClause, compileError };
|
||||
}
|
||||
|
||||
diagnoseExpression(): ErrorWithMessage | null {
|
||||
const { source } = this.state;
|
||||
const {
|
||||
startRule = ExpressionEditorTextfieldInner.defaultProps.startRule,
|
||||
name,
|
||||
query,
|
||||
stageIndex,
|
||||
expressionIndex,
|
||||
metadata,
|
||||
} = this.props;
|
||||
|
||||
if (!source || source.length === 0) {
|
||||
return { message: t`Empty expression` };
|
||||
}
|
||||
|
||||
return diagnose({
|
||||
source,
|
||||
startRule,
|
||||
name,
|
||||
query,
|
||||
stageIndex,
|
||||
expressionIndex,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
|
||||
commitExpression() {
|
||||
const {
|
||||
query,
|
||||
stageIndex,
|
||||
startRule = ExpressionEditorTextfieldInner.defaultProps.startRule,
|
||||
onCommit,
|
||||
onError,
|
||||
expressionIndex,
|
||||
} = this.props;
|
||||
const { source } = this.state;
|
||||
|
||||
const errorMessage = diagnose({
|
||||
source,
|
||||
startRule,
|
||||
query,
|
||||
stageIndex,
|
||||
expressionIndex,
|
||||
});
|
||||
|
||||
this.setState({ errorMessage });
|
||||
|
||||
if (errorMessage) {
|
||||
onError(errorMessage);
|
||||
} else {
|
||||
const compiledExpression = this.compileExpression();
|
||||
|
||||
if (compiledExpression) {
|
||||
const { expression, expressionClause } = compiledExpression;
|
||||
|
||||
if (isExpression(expression)) {
|
||||
onCommit(expression, expressionClause);
|
||||
}
|
||||
} else {
|
||||
onError({ message: t`Invalid expression` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
triggerAutosuggest = () => {
|
||||
this.handleExpressionChange(this.state.source);
|
||||
};
|
||||
|
||||
handleExpressionChange = (source: string) => {
|
||||
if (source) {
|
||||
this.setState({ hasChanges: true });
|
||||
}
|
||||
|
||||
this.setState({ source, errorMessage: null });
|
||||
};
|
||||
|
||||
handleCursorChange = _.debounce((selection: Ace.Selection) => {
|
||||
const cursor = selection.getCursor();
|
||||
|
||||
const {
|
||||
query,
|
||||
reportTimezone,
|
||||
stageIndex,
|
||||
metadata,
|
||||
expressionIndex,
|
||||
startRule = ExpressionEditorTextfieldInner.defaultProps.startRule,
|
||||
showMetabaseLinks,
|
||||
shortcuts = [],
|
||||
} = this.props;
|
||||
const { source } = this.state;
|
||||
const { suggestions, helpText } = suggestWithExtras({
|
||||
reportTimezone,
|
||||
startRule,
|
||||
source,
|
||||
targetOffset: cursor.column,
|
||||
expressionIndex,
|
||||
query,
|
||||
stageIndex,
|
||||
metadata,
|
||||
getColumnIcon,
|
||||
showMetabaseLinks,
|
||||
shortcuts,
|
||||
});
|
||||
|
||||
this.setState({ helpText: helpText || null });
|
||||
if (this.state.isFocused) {
|
||||
this.updateSuggestions(suggestions);
|
||||
}
|
||||
}, CURSOR_DEBOUNCE_INTERVAL);
|
||||
|
||||
errorAsMarkers(errorMessage: ErrorWithMessage | null = null): IMarker[] {
|
||||
if (errorMessage) {
|
||||
const { pos, len } = errorMessage;
|
||||
// Because not every error message offers location info (yet)
|
||||
if (typeof pos === "number" && typeof len === "number") {
|
||||
return [
|
||||
{
|
||||
startRow: 0,
|
||||
startCol: pos,
|
||||
endRow: 0,
|
||||
endCol: pos + len,
|
||||
className: "error",
|
||||
type: "text",
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
commands: ICommand[] = [
|
||||
// Note: Enter is handled manually (see componentDidMount)
|
||||
{
|
||||
name: "arrowDown",
|
||||
bindKey: { win: "Down", mac: "Down" },
|
||||
exec: () => {
|
||||
this.handleArrowDown();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "arrowUp",
|
||||
bindKey: { win: "Up", mac: "Up" },
|
||||
exec: () => {
|
||||
this.handleArrowUp();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "chooseSuggestion",
|
||||
// @ts-expect-error Based on typings null is not a valid value, but bindKey is assigned dynamically if there are suggestions available.
|
||||
bindKey: null,
|
||||
exec: () => {
|
||||
this.chooseSuggestion();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "clearSuggestions",
|
||||
bindKey: { win: "Esc", mac: "Esc" },
|
||||
exec: () => {
|
||||
this.clearSuggestions();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
render() {
|
||||
const { width, query, stageIndex, forwardedRef } = this.props;
|
||||
const {
|
||||
source,
|
||||
suggestions,
|
||||
errorMessage,
|
||||
hasChanges,
|
||||
isFocused,
|
||||
highlightedSuggestionIndex,
|
||||
helpText,
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
<Popover opened={!!helpText} position="bottom-start">
|
||||
<Popover.Target>
|
||||
<Box ref={forwardedRef}>
|
||||
<ExpressionEditorSuggestions
|
||||
query={query}
|
||||
stageIndex={stageIndex}
|
||||
suggestions={suggestions}
|
||||
onSuggestionMouseDown={this.onSuggestionSelected}
|
||||
highlightedIndex={highlightedSuggestionIndex}
|
||||
onHighlightSuggestion={this.handleHighlightSuggestion}
|
||||
open={isFocused}
|
||||
ref={this.popupMenuTarget}
|
||||
>
|
||||
<Flex
|
||||
className={cx(
|
||||
"expression-editor-textfield",
|
||||
ExpressionEditorTextfieldS.EditorContainer,
|
||||
{
|
||||
[ExpressionEditorTextfieldS.isFocused]: isFocused,
|
||||
[ExpressionEditorTextfieldS.hasError]: errorMessage,
|
||||
},
|
||||
)}
|
||||
ref={this.suggestionTarget}
|
||||
data-testid="expression-editor-textfield"
|
||||
>
|
||||
<Box className={ExpressionEditorTextfieldS.EditorEqualsSign}>
|
||||
=
|
||||
</Box>
|
||||
<AceEditor
|
||||
commands={this.commands}
|
||||
mode="text"
|
||||
ref={this.input}
|
||||
value={source}
|
||||
markers={this.errorAsMarkers(errorMessage)}
|
||||
focus={true}
|
||||
highlightActiveLine={false}
|
||||
wrapEnabled={true}
|
||||
fontSize={12}
|
||||
onBlur={this.handleInputBlur}
|
||||
onFocus={this.handleFocus}
|
||||
setOptions={ACE_OPTIONS}
|
||||
onChange={this.handleExpressionChange}
|
||||
onCursorChange={this.handleCursorChange}
|
||||
width="100%"
|
||||
/>
|
||||
</Flex>
|
||||
</ExpressionEditorSuggestions>
|
||||
{errorMessage && hasChanges && (
|
||||
<Box className={ExpressionEditorTextfieldS.ErrorMessageContainer}>
|
||||
{errorMessage.message}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Popover.Target>
|
||||
{/* similar to overlays, Mantine keeps overriding the max-width property on the `style` attribute */}
|
||||
<Popover.Dropdown maw={`${width}px !important`}>
|
||||
<ExpressionEditorHelpText helpText={helpText} />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ExpressionEditorTextfieldWithRef = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ExpressionEditorTextfieldProps & StateProps
|
||||
>((props, ref) => (
|
||||
<ExpressionEditorTextfieldInner {...props} forwardedRef={ref} />
|
||||
));
|
||||
|
||||
export const ExpressionEditorTextfield = _.compose(
|
||||
connect(mapStateToProps),
|
||||
ExplicitSize<ExpressionEditorTextfieldProps>(),
|
||||
)(ExpressionEditorTextfieldWithRef);
|
||||
@@ -1,65 +0,0 @@
|
||||
import { createMockMetadata } from "__support__/metadata";
|
||||
import { getColumnIcon } from "metabase/common/utils/columns";
|
||||
import { createQuery } from "metabase-lib/test-helpers";
|
||||
import { createSampleDatabase } from "metabase-types/api/mocks/presets";
|
||||
|
||||
import { suggestWithExtras } from "./ExpressionEditorTextfield";
|
||||
|
||||
const METADATA = createMockMetadata({
|
||||
databases: [createSampleDatabase()],
|
||||
});
|
||||
|
||||
type SetupOpts = {
|
||||
startRule: string;
|
||||
expressionIndex?: number;
|
||||
};
|
||||
|
||||
function setup({ startRule, expressionIndex }: SetupOpts) {
|
||||
const query = createQuery({ metadata: METADATA });
|
||||
const stageIndex = 0;
|
||||
const { suggestions } = suggestWithExtras({
|
||||
source: "",
|
||||
query,
|
||||
stageIndex,
|
||||
expressionIndex,
|
||||
metadata: METADATA,
|
||||
startRule,
|
||||
getColumnIcon,
|
||||
showMetabaseLinks: true,
|
||||
});
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
describe("suggestWithFooters", () => {
|
||||
it("should return correct functions link for expressions", () => {
|
||||
const suggestions = setup({ startRule: "expression" });
|
||||
|
||||
expect(suggestions.find(suggestion => "footer" in suggestion)).toEqual({
|
||||
footer: true,
|
||||
name: "Documentation",
|
||||
icon: "external",
|
||||
href: "https://www.metabase.com/docs/latest/questions/query-builder/expressions-list#functions",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return correct functions link for filters", () => {
|
||||
const suggestions = setup({ startRule: "boolean" });
|
||||
expect(suggestions.find(suggestion => "footer" in suggestion)).toEqual({
|
||||
footer: true,
|
||||
name: "Documentation",
|
||||
icon: "external",
|
||||
href: "https://www.metabase.com/docs/latest/questions/query-builder/expressions-list#functions",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return correct functions link for aggregations", () => {
|
||||
const suggestions = setup({ startRule: "aggregation" });
|
||||
expect(suggestions.find(suggestion => "footer" in suggestion)).toEqual({
|
||||
footer: true,
|
||||
name: "Documentation",
|
||||
icon: "external",
|
||||
href: "https://www.metabase.com/docs/latest/questions/query-builder/expressions-list#aggregations",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
export {
|
||||
ExpressionEditorTextfield,
|
||||
type SuggestionFooter,
|
||||
type SuggestionShortcut,
|
||||
} from "./ExpressionEditorTextfield";
|
||||
@@ -1,54 +0,0 @@
|
||||
import "ace-builds/src-noconflict/mode-java";
|
||||
|
||||
class ExpressionHighlight extends window.ace.acequire(
|
||||
"ace/mode/text_highlight_rules",
|
||||
).TextHighlightRules {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.$rules = {
|
||||
start: [
|
||||
{
|
||||
token: "constant.numeric",
|
||||
regex: "[+-]?\\d+(?:(?:\\.\\d*)?(?:[eE][+-]?\\d+)?)?\\b",
|
||||
},
|
||||
{
|
||||
token: "string",
|
||||
regex: '["](?:(?:\\\\.)|(?:[^"\\\\]))*?["]',
|
||||
},
|
||||
{
|
||||
token: "string",
|
||||
regex: "['](?:(?:\\\\.)|(?:[^'\\\\]))*?[']",
|
||||
},
|
||||
{
|
||||
token: "variable",
|
||||
regex: "\\[.*?\\]",
|
||||
},
|
||||
{
|
||||
token: "paren.lparen",
|
||||
regex: "[(]",
|
||||
},
|
||||
{
|
||||
token: "paren.rparen",
|
||||
regex: "[)]",
|
||||
},
|
||||
{
|
||||
token: "keyword",
|
||||
regex: "or|and|not|OR|AND|NOT",
|
||||
},
|
||||
{
|
||||
token: "keyword.operator",
|
||||
regex: "[+-/*=<>]",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default class ExpressionMode extends window.ace.acequire("ace/mode/java")
|
||||
.Mode {
|
||||
constructor() {
|
||||
super();
|
||||
this.HighlightRules = ExpressionHighlight;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import DeprecatedButton from "metabase/core/components/Button";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { isNotNull } from "metabase/lib/types";
|
||||
import { Box, Button, Flex, TextInput } from "metabase/ui";
|
||||
import * as Lib from "metabase-lib";
|
||||
import { isExpression } from "metabase-lib/v1/expressions";
|
||||
import type * as Lib from "metabase-lib";
|
||||
import type { ErrorWithMessage } from "metabase-lib/v1/expressions/types";
|
||||
import type { Expression } from "metabase-types/api";
|
||||
|
||||
import {
|
||||
trackColumnCombineViaShortcut,
|
||||
@@ -17,139 +14,154 @@ import {
|
||||
} from "../../analytics";
|
||||
|
||||
import { CombineColumns, hasCombinations } from "./CombineColumns";
|
||||
import {
|
||||
ExpressionEditorTextfield,
|
||||
type SuggestionShortcut,
|
||||
} from "./ExpressionEditorTextfield";
|
||||
import { Editor, type Shortcut } from "./Editor";
|
||||
import ExpressionWidgetS from "./ExpressionWidget.module.css";
|
||||
import { ExpressionWidgetHeader } from "./ExpressionWidgetHeader";
|
||||
import { ExpressionWidgetInfo } from "./ExpressionWidgetInfo";
|
||||
import { ExtractColumn, hasExtractions } from "./ExtractColumn";
|
||||
import type { ClauseType, StartRule } from "./types";
|
||||
|
||||
const WIDGET_WIDTH = 472;
|
||||
|
||||
export type ExpressionWidgetProps<Clause = Lib.ExpressionClause> = {
|
||||
export type ExpressionWidgetProps<S extends StartRule = "expression"> = {
|
||||
startRule?: S;
|
||||
|
||||
query: Lib.Query;
|
||||
stageIndex: number;
|
||||
/**
|
||||
* expression should not be present in components migrated to MLv2
|
||||
*/
|
||||
expression?: Expression | undefined;
|
||||
/**
|
||||
* Presence of this prop is not enforced due to backwards-compatibility
|
||||
* with ExpressionWidget usages outside of GUI editor.
|
||||
*/
|
||||
clause?: Clause | undefined;
|
||||
clause?: ClauseType<S> | undefined;
|
||||
name?: string;
|
||||
withName?: boolean;
|
||||
startRule?: "expression" | "aggregation" | "boolean";
|
||||
reportTimezone?: string;
|
||||
header?: ReactNode;
|
||||
expressionIndex?: number;
|
||||
|
||||
onChangeExpression?: (name: string, expression: Expression) => void;
|
||||
onChangeClause?: (
|
||||
name: string,
|
||||
clause: Clause | Lib.ExpressionClause,
|
||||
) => void;
|
||||
onRemoveExpression?: (name: string) => void;
|
||||
onChangeClause?: (name: string, clause: ClauseType<S>) => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export const ExpressionWidget = <Clause extends object = Lib.ExpressionClause>(
|
||||
props: ExpressionWidgetProps<Clause>,
|
||||
): JSX.Element => {
|
||||
export const ExpressionWidget = <S extends StartRule = "expression">(
|
||||
props: ExpressionWidgetProps<S>,
|
||||
) => {
|
||||
type Clause = ClauseType<S>;
|
||||
|
||||
const {
|
||||
query,
|
||||
stageIndex,
|
||||
name: initialName,
|
||||
expression: initialExpression,
|
||||
clause: initialClause,
|
||||
withName = false,
|
||||
startRule,
|
||||
startRule = "expression",
|
||||
reportTimezone,
|
||||
header,
|
||||
expressionIndex,
|
||||
onChangeExpression,
|
||||
onChangeClause,
|
||||
onRemoveExpression,
|
||||
onClose,
|
||||
} = props;
|
||||
|
||||
const [name, setName] = useState(initialName || "");
|
||||
const [expression, setExpression] = useState<Expression | null>(
|
||||
initialExpression ?? null,
|
||||
);
|
||||
const [clause, setClause] = useState<Clause | Lib.ExpressionClause | null>(
|
||||
initialClause ?? null,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isCombiningColumns, setIsCombiningColumns] = useState(false);
|
||||
const [clause, setClause] = useState<Clause | null>(initialClause ?? null);
|
||||
const [error, setError] = useState<ErrorWithMessage | null>(null);
|
||||
|
||||
const [isCombiningColumns, setIsCombiningColumns] = useState(false);
|
||||
const [isExtractingColumn, setIsExtractingColumn] = useState(false);
|
||||
|
||||
const isValidName = withName ? name.trim().length > 0 : true;
|
||||
const isValidExpression = isNotNull(expression) && isExpression(expression);
|
||||
const isValidExpressionClause = isNotNull(clause);
|
||||
const isValid =
|
||||
!error && isValidName && (isValidExpression || isValidExpressionClause);
|
||||
const isValid = !error && isValidName && isValidExpressionClause;
|
||||
|
||||
const handleCommit = (
|
||||
expression: Expression | null,
|
||||
clause: Clause | Lib.ExpressionClause | null,
|
||||
) => {
|
||||
const isValidExpression = isNotNull(expression) && isExpression(expression);
|
||||
const isValidExpressionClause = isNotNull(clause);
|
||||
const isValid =
|
||||
!error && isValidName && (isValidExpression || isValidExpressionClause);
|
||||
const handleCommit = useCallback(
|
||||
(clause: Clause | null) => {
|
||||
const isValidExpressionClause = isNotNull(clause);
|
||||
const isValid = !error && isValidName && isValidExpressionClause;
|
||||
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isValidExpression) {
|
||||
onChangeExpression?.(name, expression);
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
if (isValidExpressionClause) {
|
||||
onChangeClause?.(name, clause);
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
},
|
||||
[name, isValidName, error, onChangeClause, onClose],
|
||||
);
|
||||
|
||||
const handleError = (error: ErrorWithMessage | string | null) => {
|
||||
if (error) {
|
||||
setError(typeof error === "string" ? error : error.message);
|
||||
}
|
||||
};
|
||||
const handleSubmit = useCallback(() => {
|
||||
handleCommit(clause);
|
||||
}, [clause, handleCommit]);
|
||||
|
||||
const handleExpressionChange = (
|
||||
expression: Expression | null,
|
||||
clause: Lib.ExpressionClause | null,
|
||||
) => {
|
||||
setExpression(expression);
|
||||
const handleError = useCallback((error: ErrorWithMessage | null) => {
|
||||
setError(error);
|
||||
}, []);
|
||||
|
||||
const handleExpressionChange = useCallback((clause: Clause | null) => {
|
||||
setClause(clause);
|
||||
setError(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isCombiningColumns) {
|
||||
const handleSubmit = (name: string, clause: Lib.ExpressionClause) => {
|
||||
const handleNameChange = useCallback(
|
||||
(evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(evt.target.value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const shortcuts = useMemo(
|
||||
() =>
|
||||
[
|
||||
startRule === "expression" &&
|
||||
hasCombinations(query, stageIndex) && {
|
||||
name: t`Combine columns`,
|
||||
icon: "combine",
|
||||
action: () => setIsCombiningColumns(true),
|
||||
},
|
||||
startRule === "expression" &&
|
||||
hasExtractions(query, stageIndex) && {
|
||||
name: t`Extract columns`,
|
||||
icon: "arrow_split",
|
||||
action: () => setIsExtractingColumn(true),
|
||||
},
|
||||
].filter((x): x is Shortcut => Boolean(x)),
|
||||
[startRule, query, stageIndex],
|
||||
);
|
||||
|
||||
const handleCombineColumnsSubmit = useCallback(
|
||||
(name: string, clause: Lib.ExpressionClause) => {
|
||||
trackColumnCombineViaShortcut(query);
|
||||
const expression = Lib.legacyExpressionForExpressionClause(
|
||||
query,
|
||||
stageIndex,
|
||||
clause,
|
||||
);
|
||||
handleExpressionChange(expression, clause);
|
||||
handleExpressionChange(clause as Clause);
|
||||
setName(name);
|
||||
setIsCombiningColumns(false);
|
||||
};
|
||||
},
|
||||
[query, handleExpressionChange],
|
||||
);
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsCombiningColumns(false);
|
||||
};
|
||||
const handleExtractColumnSubmit = useCallback(
|
||||
(
|
||||
clause: Lib.ExpressionClause,
|
||||
name: string,
|
||||
extraction: Lib.ColumnExtraction,
|
||||
) => {
|
||||
trackColumnExtractViaShortcut(query, stageIndex, extraction);
|
||||
handleExpressionChange(clause as Clause);
|
||||
setName(name);
|
||||
setIsExtractingColumn(false);
|
||||
},
|
||||
[query, stageIndex, handleExpressionChange],
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setIsCombiningColumns(false);
|
||||
setIsExtractingColumn(false);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(evt: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (evt.key === "Enter") {
|
||||
handleCommit(clause);
|
||||
}
|
||||
},
|
||||
[handleCommit, clause],
|
||||
);
|
||||
|
||||
if (startRule === "expression" && isCombiningColumns) {
|
||||
return (
|
||||
<Box w={WIDGET_WIDTH} data-testid="expression-editor">
|
||||
<ExpressionWidgetHeader
|
||||
@@ -159,36 +171,20 @@ export const ExpressionWidget = <Clause extends object = Lib.ExpressionClause>(
|
||||
<CombineColumns
|
||||
query={query}
|
||||
stageIndex={stageIndex}
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={handleCombineColumnsSubmit}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isExtractingColumn) {
|
||||
const handleSubmit = (
|
||||
clause: Lib.ExpressionClause,
|
||||
name: string,
|
||||
extraction: Lib.ColumnExtraction,
|
||||
) => {
|
||||
trackColumnExtractViaShortcut(query, stageIndex, extraction);
|
||||
const expression = Lib.legacyExpressionForExpressionClause(
|
||||
query,
|
||||
stageIndex,
|
||||
clause,
|
||||
);
|
||||
handleExpressionChange(expression, clause);
|
||||
setName(name);
|
||||
setIsExtractingColumn(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box w={WIDGET_WIDTH} data-testid="expression-editor">
|
||||
<ExtractColumn
|
||||
query={query}
|
||||
stageIndex={stageIndex}
|
||||
onCancel={() => setIsExtractingColumn(false)}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
onSubmit={handleExtractColumnSubmit}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
@@ -206,52 +202,32 @@ export const ExpressionWidget = <Clause extends object = Lib.ExpressionClause>(
|
||||
{t`Expression`}
|
||||
<ExpressionWidgetInfo />
|
||||
</Box>
|
||||
<ExpressionEditorTextfield
|
||||
expression={expression}
|
||||
<Editor
|
||||
id="expression-content"
|
||||
expressionIndex={expressionIndex}
|
||||
/**
|
||||
* TODO: Ideally ExpressionEditorTextfield should be generic and support all
|
||||
* three: Lib.ExpressionClause, Lib.AggregationClause, and Lib.FilterableClause.
|
||||
*/
|
||||
clause={clause as Lib.ExpressionClause | null}
|
||||
startRule={startRule}
|
||||
startRule={startRule as S}
|
||||
clause={clause}
|
||||
name={name}
|
||||
query={query}
|
||||
stageIndex={stageIndex}
|
||||
reportTimezone={reportTimezone}
|
||||
textAreaId="expression-content"
|
||||
onChange={handleExpressionChange}
|
||||
onCommit={handleCommit}
|
||||
error={error}
|
||||
onError={handleError}
|
||||
shortcuts={[
|
||||
!startRule &&
|
||||
hasCombinations(query, stageIndex) && {
|
||||
shortcut: true,
|
||||
name: t`Combine columns`,
|
||||
action: () => setIsCombiningColumns(true),
|
||||
group: "shortcuts",
|
||||
icon: "combine",
|
||||
},
|
||||
!startRule &&
|
||||
hasExtractions(query, stageIndex) && {
|
||||
shortcut: true,
|
||||
name: t`Extract columns`,
|
||||
icon: "arrow_split",
|
||||
group: "shortcuts",
|
||||
action: () => setIsExtractingColumn(true),
|
||||
},
|
||||
].filter((shortcut): shortcut is SuggestionShortcut => {
|
||||
return Boolean(shortcut);
|
||||
})}
|
||||
shortcuts={shortcuts}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{withName && (
|
||||
<Box p="0 1.5rem 1.5rem">
|
||||
<Box
|
||||
component="label"
|
||||
className={ExpressionWidgetS.FieldLabel}
|
||||
htmlFor="expression-name"
|
||||
>{t`Name`}</Box>
|
||||
>
|
||||
{t`Name`}
|
||||
</Box>
|
||||
<TextInput
|
||||
classNames={{
|
||||
input: CS.textBold,
|
||||
@@ -263,12 +239,8 @@ export const ExpressionWidget = <Clause extends object = Lib.ExpressionClause>(
|
||||
placeholder={t`Something nice and descriptive`}
|
||||
w="100%"
|
||||
radius="md"
|
||||
onChange={event => setName(event.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter") {
|
||||
handleCommit(expression, clause);
|
||||
}
|
||||
}}
|
||||
onChange={handleNameChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
@@ -276,24 +248,9 @@ export const ExpressionWidget = <Clause extends object = Lib.ExpressionClause>(
|
||||
<Flex className={ExpressionWidgetS.Footer}>
|
||||
<Flex ml="auto" gap="md">
|
||||
{onClose && <Button onClick={onClose}>{t`Cancel`}</Button>}
|
||||
<Button
|
||||
variant={isValid ? "filled" : "default"}
|
||||
disabled={!isValid}
|
||||
onClick={() => handleCommit(expression, clause)}
|
||||
>
|
||||
<Button variant="filled" disabled={!isValid} onClick={handleSubmit}>
|
||||
{initialName ? t`Update` : t`Done`}
|
||||
</Button>
|
||||
|
||||
{initialName && onRemoveExpression ? (
|
||||
<DeprecatedButton
|
||||
className={ExpressionWidgetS.RemoveLink}
|
||||
onlyText
|
||||
onClick={() => {
|
||||
onRemoveExpression(initialName);
|
||||
onClose && onClose();
|
||||
}}
|
||||
>{t`Remove`}</DeprecatedButton>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
@@ -3,11 +3,11 @@ import userEvent from "@testing-library/user-event";
|
||||
import { getIcon, renderWithProviders, screen } from "__support__/ui";
|
||||
import * as Lib from "metabase-lib";
|
||||
import { createQuery } from "metabase-lib/test-helpers";
|
||||
import type { Expression } from "metabase-types/api";
|
||||
|
||||
import type { ExpressionWidgetProps } from "./ExpressionWidget";
|
||||
import { ExpressionWidget } from "./ExpressionWidget";
|
||||
import { ExpressionWidgetHeader } from "./ExpressionWidgetHeader";
|
||||
import type { StartRule } from "./types";
|
||||
|
||||
describe("ExpressionWidget", () => {
|
||||
it("should render proper controls", () => {
|
||||
@@ -49,26 +49,6 @@ describe("ExpressionWidget", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should trigger onChangeExpression if expression is valid", async () => {
|
||||
const { onChangeExpression } = setup();
|
||||
|
||||
const doneButton = screen.getByRole("button", { name: "Done" });
|
||||
expect(doneButton).toBeDisabled();
|
||||
|
||||
const expressionInput = screen.getByRole("textbox");
|
||||
expect(expressionInput).toHaveClass("ace_text-input");
|
||||
|
||||
await userEvent.type(expressionInput, "1 + 1");
|
||||
await userEvent.tab();
|
||||
|
||||
expect(doneButton).toBeEnabled();
|
||||
|
||||
await userEvent.click(doneButton);
|
||||
|
||||
expect(onChangeExpression).toHaveBeenCalledTimes(1);
|
||||
expect(onChangeExpression).toHaveBeenCalledWith("", ["+", 1, 1]);
|
||||
});
|
||||
|
||||
it("should trigger onChangeClause if expression is valid", async () => {
|
||||
const { getRecentExpressionClauseInfo, onChangeClause } = setup();
|
||||
|
||||
@@ -76,7 +56,6 @@ describe("ExpressionWidget", () => {
|
||||
expect(doneButton).toBeDisabled();
|
||||
|
||||
const expressionInput = screen.getByRole("textbox");
|
||||
expect(expressionInput).toHaveClass("ace_text-input");
|
||||
|
||||
await userEvent.type(expressionInput, "1 + 1");
|
||||
await userEvent.tab();
|
||||
@@ -114,14 +93,10 @@ describe("ExpressionWidget", () => {
|
||||
});
|
||||
|
||||
it("should validate name value", async () => {
|
||||
const expression: Expression = ["+", 1, 1];
|
||||
const {
|
||||
getRecentExpressionClauseInfo,
|
||||
onChangeExpression,
|
||||
onChangeClause,
|
||||
} = setup({
|
||||
expression,
|
||||
const clause = Lib.expressionClause("+", [1, 1]);
|
||||
const { getRecentExpressionClauseInfo, onChangeClause } = setup({
|
||||
withName: true,
|
||||
clause,
|
||||
});
|
||||
|
||||
const doneButton = screen.getByRole("button", { name: "Done" });
|
||||
@@ -133,10 +108,9 @@ describe("ExpressionWidget", () => {
|
||||
|
||||
await userEvent.type(screen.getByDisplayValue("1 + 1"), "{enter}");
|
||||
|
||||
// enter in expression editor should not trigger "onChangeClause" or "onChangeExpression"
|
||||
// enter in expression editor should not trigger "onChangeClause"
|
||||
// as popover is not valid with empty "name"
|
||||
expect(onChangeClause).toHaveBeenCalledTimes(0);
|
||||
expect(onChangeExpression).toHaveBeenCalledTimes(0);
|
||||
|
||||
// The name must not be empty
|
||||
await userEvent.clear(expressionNameInput);
|
||||
@@ -161,11 +135,6 @@ describe("ExpressionWidget", () => {
|
||||
|
||||
await userEvent.click(doneButton);
|
||||
|
||||
expect(onChangeExpression).toHaveBeenCalledTimes(1);
|
||||
expect(onChangeExpression).toHaveBeenCalledWith(
|
||||
"Some n_am!e 2q$w&YzT(6i~#sLXv7+HjP}Ku1|9c*RlF@4o5N=e8;G*-bZ3/U0:Qa'V,t(W-_D",
|
||||
expression,
|
||||
);
|
||||
expect(onChangeClause).toHaveBeenCalledTimes(1);
|
||||
expect(onChangeClause).toHaveBeenCalledWith(
|
||||
"Some n_am!e 2q$w&YzT(6i~#sLXv7+HjP}Ku1|9c*RlF@4o5N=e8;G*-bZ3/U0:Qa'V,t(W-_D",
|
||||
@@ -222,10 +191,11 @@ describe("ExpressionWidget", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function setup(additionalProps?: Partial<ExpressionWidgetProps>) {
|
||||
function setup<S extends StartRule = "expression">(
|
||||
additionalProps?: Partial<ExpressionWidgetProps<S>>,
|
||||
) {
|
||||
const query = createQuery();
|
||||
const stageIndex = 0;
|
||||
const onChangeExpression = jest.fn();
|
||||
const onChangeClause = jest.fn();
|
||||
const onClose = jest.fn();
|
||||
|
||||
@@ -241,13 +211,11 @@ function setup(additionalProps?: Partial<ExpressionWidgetProps>) {
|
||||
|
||||
renderWithProviders(
|
||||
<ExpressionWidget
|
||||
expression={undefined}
|
||||
clause={undefined}
|
||||
name={undefined}
|
||||
query={query}
|
||||
reportTimezone="UTC"
|
||||
stageIndex={stageIndex}
|
||||
onChangeExpression={onChangeExpression}
|
||||
onChangeClause={onChangeClause}
|
||||
onClose={onClose}
|
||||
{...additionalProps}
|
||||
@@ -256,7 +224,6 @@ function setup(additionalProps?: Partial<ExpressionWidgetProps>) {
|
||||
|
||||
return {
|
||||
getRecentExpressionClauseInfo,
|
||||
onChangeExpression,
|
||||
onChangeClause,
|
||||
onClose,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type * as Lib from "metabase-lib";
|
||||
|
||||
type ClauseByStartRule = {
|
||||
expression: Lib.ExpressionClause;
|
||||
aggregation: Lib.AggregationClause;
|
||||
boolean: Lib.FilterClause;
|
||||
};
|
||||
|
||||
export type StartRule = keyof ClauseByStartRule;
|
||||
export type ClauseType<S extends StartRule> = ClauseByStartRule[S];
|
||||
@@ -4,6 +4,7 @@ import { getIn } from "icepick";
|
||||
import { CSS_VARIABLES_TO_SDK_THEME_MAP } from "metabase/embedding-sdk/theme/css-vars-to-sdk-theme";
|
||||
import { getDynamicCssVariables } from "metabase/embedding-sdk/theme/dynamic-css-vars";
|
||||
import { SDK_TO_MAIN_APP_COLORS_MAPPING } from "metabase/embedding-sdk/theme/embedding-color-palette";
|
||||
import { monospaceFontFamily } from "metabase/styled-components/theme";
|
||||
import type { MantineTheme } from "metabase/ui";
|
||||
|
||||
/**
|
||||
@@ -13,6 +14,7 @@ export function getMetabaseCssVariables(theme: MantineTheme) {
|
||||
return css`
|
||||
:root {
|
||||
--mb-default-font-family: "${theme.fontFamily}";
|
||||
--mb-default-monospace-font-family: ${monospaceFontFamily};
|
||||
|
||||
/* Semantic colors */
|
||||
--mb-color-brand: ${theme.fn.themeColor("brand")};
|
||||
|
||||
@@ -36,3 +36,27 @@ jest.mock("ace/snippets/sql", () => {}, { virtual: true });
|
||||
jest.mock("ace/snippets/json", () => {}, { virtual: true });
|
||||
jest.mock("ace/snippets/json", () => {}, { virtual: true });
|
||||
jest.mock("ace/ext-language_tools", () => {}, { virtual: true });
|
||||
|
||||
jest.mock("@uiw/react-codemirror", () => {
|
||||
const { forwardRef } = jest.requireActual("react");
|
||||
|
||||
const MockEditor = forwardRef((props, ref) => {
|
||||
return (
|
||||
// @ts-expect-error: some props types are different on CodeMirror
|
||||
<textarea
|
||||
ref={ref}
|
||||
{...props}
|
||||
value={props.value ?? ""}
|
||||
// @ts-expect-error: We cannot provide the update argument to onChange
|
||||
onChange={evt => props.onChange?.(evt.target.value, undefined)}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
...jest.requireActual("@uiw/react-codemirror"),
|
||||
default: MockEditor,
|
||||
};
|
||||
});
|
||||
|
||||
10
package.json
10
package.json
@@ -10,9 +10,11 @@
|
||||
"yarn": "^1.12.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.3",
|
||||
"@codemirror/autocomplete": "^6.18.4",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-sql": "^6.8.0",
|
||||
"@codemirror/language": "^6.10.7",
|
||||
"@codemirror/lint": "^6.8.4",
|
||||
"@dnd-kit/core": "^6.0.8",
|
||||
"@dnd-kit/modifiers": "^6.0.1",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
@@ -32,7 +34,7 @@
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@types/react-router": "3.0.27",
|
||||
"@types/redux-auth-wrapper": "^2.0.16",
|
||||
"@uiw/react-codemirror": "^4.23.6",
|
||||
"@uiw/react-codemirror": "^4.23.7",
|
||||
"@visx/axis": "^3.10.1",
|
||||
"@visx/clip-path": "^3.3.0",
|
||||
"@visx/grid": "^3.5.0",
|
||||
@@ -149,6 +151,7 @@
|
||||
"@emotion/babel-plugin": "^11.11.0",
|
||||
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
|
||||
"@inquirer/prompts": "^5.3.8",
|
||||
"@lezer/generator": "^1.7.2",
|
||||
"@loki/create-async-callback": "^0.35.0",
|
||||
"@peculiar/webcrypto": "^1.5.0",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "0.5.10",
|
||||
@@ -459,7 +462,8 @@
|
||||
"type-check": "yarn clean:cljs && yarn build:cljs && yarn type-check-pure",
|
||||
"type-check-pure": "tsc --noEmit",
|
||||
"validate-e2e-filenames": "node e2e/validate-e2e-test-files.js",
|
||||
"wait:cljs": "echo Waiting for first CLJS build; until [ -f target/cljs_dev/cljs.core.js ]; do sleep 1; done; echo CLJS build done"
|
||||
"wait:cljs": "echo Waiting for first CLJS build; until [ -f target/cljs_dev/cljs.core.js ]; do sleep 1; done; echo CLJS build done",
|
||||
"generate:expression-parser": "lezer-generator frontend/src/metabase/query_builder/components/expressions/Editor/custom-expression.grammar -o frontend/src/metabase/query_builder/components/expressions/Editor/parser.js"
|
||||
},
|
||||
"lint-staged": {
|
||||
"+(frontend|enterprise)/**/*.styled.tsx": [
|
||||
|
||||
85
yarn.lock
85
yarn.lock
@@ -1152,10 +1152,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
|
||||
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
|
||||
|
||||
"@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.18.3":
|
||||
version "6.18.3"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.18.3.tgz#f9ea79a2f369662516f71bc0b2f819454d3c8e00"
|
||||
integrity sha512-1dNIOmiM0z4BIBwxmxEfA1yoxh1MF/6KPBbh20a5vphGV0ictKlgQsbJs6D6SkR6iJpGbpwRsa6PFMNlg9T9pQ==
|
||||
"@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.18.4":
|
||||
version "6.18.4"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.18.4.tgz#4394f55d6771727179f2e28a871ef46bbbeb11b1"
|
||||
integrity sha512-sFAphGQIqyQZfP2ZBsSHV7xQvo9Py0rV0dW7W3IMRdS+zDuNb2l3no78CvUaWKGfzFjI4FTrLdUSj86IGb2hRA==
|
||||
dependencies:
|
||||
"@codemirror/language" "^6.0.0"
|
||||
"@codemirror/state" "^6.0.0"
|
||||
@@ -1192,10 +1192,10 @@
|
||||
"@lezer/highlight" "^1.0.0"
|
||||
"@lezer/lr" "^1.0.0"
|
||||
|
||||
"@codemirror/language@^6.0.0":
|
||||
version "6.10.3"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.10.3.tgz#eb25fc5ade19032e7bf1dcaa957804e5f1660585"
|
||||
integrity sha512-kDqEU5sCP55Oabl6E7m5N+vZRoc0iWqgDVhEKifcHzPzjqCegcO4amfrYVL9PmPZpl4G0yjkpTpUO/Ui8CzO8A==
|
||||
"@codemirror/language@^6.0.0", "@codemirror/language@^6.10.7":
|
||||
version "6.10.7"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.10.7.tgz#415ba3bb983416daa98084c010f4db59db45920e"
|
||||
integrity sha512-aOswhVOLYhMNeqykt4P7+ukQSpGL0ynZYaEyFDVHE7fl2xgluU3yuE9MdgYNfw6EmaNidoFMIQ2iTh1ADrnT6A==
|
||||
dependencies:
|
||||
"@codemirror/state" "^6.0.0"
|
||||
"@codemirror/view" "^6.23.0"
|
||||
@@ -1204,28 +1204,30 @@
|
||||
"@lezer/lr" "^1.0.0"
|
||||
style-mod "^4.0.0"
|
||||
|
||||
"@codemirror/lint@^6.0.0":
|
||||
version "6.8.2"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.8.2.tgz#7864b03583e9efd18554cff1dd4504da10338ab1"
|
||||
integrity sha512-PDFG5DjHxSEjOXk9TQYYVjZDqlZTFaDBfhQixHnQOEVDDNHUbEh/hstAjcQJaA6FQdZTD1hquXTK0rVBLADR1g==
|
||||
"@codemirror/lint@^6.0.0", "@codemirror/lint@^6.8.4":
|
||||
version "6.8.4"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.8.4.tgz#7d8aa5d1a6dec89ffcc23ad45ddca2e12e90982d"
|
||||
integrity sha512-u4q7PnZlJUojeRe8FJa/njJcMctISGgPQ4PnWsd9268R4ZTtU+tfFYmwkBvgcrK2+QQ8tYFVALVb5fVJykKc5A==
|
||||
dependencies:
|
||||
"@codemirror/state" "^6.0.0"
|
||||
"@codemirror/view" "^6.0.0"
|
||||
"@codemirror/view" "^6.35.0"
|
||||
crelt "^1.0.5"
|
||||
|
||||
"@codemirror/search@^6.0.0":
|
||||
version "6.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.5.7.tgz#fb60f6637437a8264f86079621a56290dc3814c4"
|
||||
integrity sha512-6+iLsXvITWKHYlkgHPCs/qiX4dNzn8N78YfhOFvPtPYCkuXqZq10rAfsUMhOq7O/1VjJqdXRflyExlfVcu/9VQ==
|
||||
version "6.5.8"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.5.8.tgz#b59b3659b46184cc75d6108d7c050a4ca344c3a0"
|
||||
integrity sha512-PoWtZvo7c1XFeZWmmyaOp2G0XVbOnm+fJzvghqGAktBW3cufwJUWvSCcNG0ppXiBEM05mZu6RhMtXPv2hpllig==
|
||||
dependencies:
|
||||
"@codemirror/state" "^6.0.0"
|
||||
"@codemirror/view" "^6.0.0"
|
||||
crelt "^1.0.5"
|
||||
|
||||
"@codemirror/state@^6.0.0", "@codemirror/state@^6.1.1", "@codemirror/state@^6.4.0":
|
||||
version "6.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.4.1.tgz#da57143695c056d9a3c38705ed34136e2b68171b"
|
||||
integrity sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==
|
||||
"@codemirror/state@^6.0.0", "@codemirror/state@^6.1.1", "@codemirror/state@^6.4.0", "@codemirror/state@^6.5.0":
|
||||
version "6.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.5.0.tgz#e98dde85620618651543152fe1c2483300a0ccc9"
|
||||
integrity sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==
|
||||
dependencies:
|
||||
"@marijn/find-cluster-break" "^1.0.0"
|
||||
|
||||
"@codemirror/theme-one-dark@^6.0.0":
|
||||
version "6.1.2"
|
||||
@@ -1237,12 +1239,12 @@
|
||||
"@codemirror/view" "^6.0.0"
|
||||
"@lezer/highlight" "^1.0.0"
|
||||
|
||||
"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0":
|
||||
version "6.34.3"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.34.3.tgz#ed6300dc4e0368fc9a5ecd0417dc28234bfacaff"
|
||||
integrity sha512-Ph5d+u8DxIeSgssXEakaakImkzBV4+slwIbcxl9oc9evexJhImeu/G8TK7+zp+IFK9KuJ0BdSn6kTBJeH2CHvA==
|
||||
"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0", "@codemirror/view@^6.35.0":
|
||||
version "6.36.0"
|
||||
resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.36.0.tgz#4f4a24002a58ab4e478b5497cd8316f642d7a2f6"
|
||||
integrity sha512-aMePDnkNNKE8dSOo1w689xYa3dijREbRajiRcgjSGc2TWN7MTdE+9pm5fxwdz0C4D9Di1VZomrn2M+xDe7tTVg==
|
||||
dependencies:
|
||||
"@codemirror/state" "^6.4.0"
|
||||
"@codemirror/state" "^6.5.0"
|
||||
style-mod "^4.1.0"
|
||||
w3c-keyname "^2.2.4"
|
||||
|
||||
@@ -2526,6 +2528,14 @@
|
||||
resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.2.3.tgz#138fcddab157d83da557554851017c6c1e5667fd"
|
||||
integrity sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==
|
||||
|
||||
"@lezer/generator@^1.7.2":
|
||||
version "1.7.2"
|
||||
resolved "https://registry.yarnpkg.com/@lezer/generator/-/generator-1.7.2.tgz#a491c91eb9f117ea803e748fa97574514156a2a3"
|
||||
integrity sha512-CwgULPOPPmH54tv4gki18bElLCdJ1+FBC+nGVSVD08vFWDsMjS7KEjNTph9JOypDnet90ujN3LzQiW3CyVODNQ==
|
||||
dependencies:
|
||||
"@lezer/common" "^1.1.0"
|
||||
"@lezer/lr" "^1.3.0"
|
||||
|
||||
"@lezer/highlight@^1.0.0", "@lezer/highlight@^1.2.1":
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.2.1.tgz#596fa8f9aeb58a608be0a563e960c373cbf23f8b"
|
||||
@@ -2542,7 +2552,7 @@
|
||||
"@lezer/highlight" "^1.0.0"
|
||||
"@lezer/lr" "^1.0.0"
|
||||
|
||||
"@lezer/lr@^1.0.0":
|
||||
"@lezer/lr@^1.0.0", "@lezer/lr@^1.3.0":
|
||||
version "1.4.2"
|
||||
resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.4.2.tgz#931ea3dea8e9de84e90781001dae30dea9ff1727"
|
||||
integrity sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==
|
||||
@@ -2762,6 +2772,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@mantine/utils/-/utils-6.0.21.tgz#6185506e91cba3e308aaa8ea9ababc8e767995d6"
|
||||
integrity sha512-33RVDRop5jiWFao3HKd3Yp7A9mEq4HAJxJPTuYm1NkdqX6aTKOQK7wT8v8itVodBp+sb4cJK6ZVdD1UurK/txQ==
|
||||
|
||||
"@marijn/find-cluster-break@^1.0.0":
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz#775374306116d51c0c500b8c4face0f9a04752d8"
|
||||
integrity sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==
|
||||
|
||||
"@mdx-js/react@^3.0.0":
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@mdx-js/react/-/react-3.1.0.tgz#c4522e335b3897b9a845db1dbdd2f966ae8fb0ed"
|
||||
@@ -5275,10 +5290,10 @@
|
||||
"@typescript-eslint/types" "7.18.0"
|
||||
eslint-visitor-keys "^3.4.3"
|
||||
|
||||
"@uiw/codemirror-extensions-basic-setup@4.23.6":
|
||||
version "4.23.6"
|
||||
resolved "https://registry.yarnpkg.com/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.23.6.tgz#a15afa85ad12e08fdaca40e89828bced78beeef5"
|
||||
integrity sha512-bvtq8IOvdkLJMhoJBRGPEzU51fMpPDwEhcAHp9xCR05MtbIokQgsnLXrmD1aZm6e7s/3q47H+qdSfAAkR5MkLA==
|
||||
"@uiw/codemirror-extensions-basic-setup@4.23.7":
|
||||
version "4.23.7"
|
||||
resolved "https://registry.yarnpkg.com/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.23.7.tgz#8fce5d6190a755c889805d2edc5b85d7f29cd322"
|
||||
integrity sha512-9/2EUa1Lck4kFKkR2BkxlZPpgD/EWuKHnOlysf1yHKZGraaZmZEaUw+utDK4QcuJc8Iz097vsLz4f4th5EU27g==
|
||||
dependencies:
|
||||
"@codemirror/autocomplete" "^6.0.0"
|
||||
"@codemirror/commands" "^6.0.0"
|
||||
@@ -5288,16 +5303,16 @@
|
||||
"@codemirror/state" "^6.0.0"
|
||||
"@codemirror/view" "^6.0.0"
|
||||
|
||||
"@uiw/react-codemirror@^4.23.6":
|
||||
version "4.23.6"
|
||||
resolved "https://registry.yarnpkg.com/@uiw/react-codemirror/-/react-codemirror-4.23.6.tgz#e77f302a39a28c644164ed59bdc1a9edeb587cdf"
|
||||
integrity sha512-caYKGV6TfGLRV1HHD3p0G3FiVzKL1go7wes5XT2nWjB0+dTdyzyb81MKRSacptgZcotujfNO6QXn65uhETRAMw==
|
||||
"@uiw/react-codemirror@^4.23.7":
|
||||
version "4.23.7"
|
||||
resolved "https://registry.yarnpkg.com/@uiw/react-codemirror/-/react-codemirror-4.23.7.tgz#b7fe2085936c593514f5e238865989bfef65e504"
|
||||
integrity sha512-Nh/0P6W+kWta+ARp9YpnKPD9ick5teEnwmtNoPQnyd6NPv0EQP3Ui4YmRVNj1nkUEo+QjrAUaEfcejJ2up/HZA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.18.6"
|
||||
"@codemirror/commands" "^6.1.0"
|
||||
"@codemirror/state" "^6.1.1"
|
||||
"@codemirror/theme-one-dark" "^6.0.0"
|
||||
"@uiw/codemirror-extensions-basic-setup" "4.23.6"
|
||||
"@uiw/codemirror-extensions-basic-setup" "4.23.7"
|
||||
codemirror "^6.0.0"
|
||||
|
||||
"@ungap/structured-clone@^1.0.0":
|
||||
|
||||
Reference in New Issue
Block a user