Compare commits

...

185 Commits

Author SHA1 Message Date
Romeo Van Snick
b3476a3dae Improve listbox and helptext height calculation 2025-02-07 17:03:14 +01:00
Romeo Van Snick
e194eb0d4e Add jsdoc to allowFastSet option 2025-02-07 10:19:34 +01:00
Romeo Van Snick
0acfd10317 Add tests for collapsing helptext 2025-02-06 18:56:45 +01:00
Romeo Van Snick
76119122eb Fix e2e tests 2025-02-06 14:51:50 +01:00
Romeo Van Snick
8c39bad7c5 Suggest more metrics inside [] 2025-02-06 14:51:50 +01:00
Romeo Van Snick
b15593006f Add tests for unsupported databases 2025-02-06 14:51:50 +01:00
Romeo Van Snick
bc29204118 Filter completions so they can be sorted better 2025-02-06 14:51:50 +01:00
Romeo Van Snick
0754ceeb76 Remove unused TODO 2025-02-06 14:51:50 +01:00
Romeo Van Snick
fecdb0a315 Add chevron to helptext header 2025-02-06 14:51:49 +01:00
Romeo Van Snick
5c3e771981 Allow maxheight to be calculated correctly 2025-02-06 14:51:49 +01:00
Romeo Van Snick
f06eb9255d Allow maxheight to be calculated correctly 2025-02-06 14:51:49 +01:00
Romeo Van Snick
1fdf263425 Memoize default shortcuts 2025-02-06 14:51:49 +01:00
Romeo Van Snick
01271b4de7 Memoize handlers in ExpressionWidget 2025-02-06 14:51:49 +01:00
Romeo Van Snick
2f1db7ccbc Show listbox when no helptext is found 2025-02-06 14:51:49 +01:00
Romeo Van Snick
1276b56224 Open and close helptext when the size is too small 2025-02-06 14:51:49 +01:00
Romeo Van Snick
9c5fcd33af Tweak height of popover 2025-02-06 14:51:49 +01:00
Romeo Van Snick
1b284facd0 Use theme color for background 2025-02-06 14:51:48 +01:00
Romeo Van Snick
1ba5617327 Remove max height from HelpText 2025-02-06 14:51:48 +01:00
Romeo Van Snick
a31de619b8 Split up Tooltip module 2025-02-06 14:51:48 +01:00
Romeo Van Snick
6496ec2f58 Move more state into Completions component 2025-02-06 14:51:48 +01:00
Romeo Van Snick
148cbf62d8 Add max height to popover 2025-02-06 14:51:48 +01:00
Romeo Van Snick
3f1862ed58 Make helptext and suggestions grow and shrink more proportionally 2025-02-06 14:51:48 +01:00
Romeo Van Snick
ce5cdc3392 Remove need for cut and paste keyboard shortcuts 2025-02-06 14:51:48 +01:00
Romeo Van Snick
aa7f058d79 Add key when displayLabel is missing 2025-02-06 14:51:47 +01:00
Romeo Van Snick
bbd24ef41b Add range to matched text 2025-02-06 14:51:47 +01:00
Romeo Van Snick
0237015a75 Add styles for snippet field 2025-02-06 14:51:47 +01:00
Romeo Van Snick
557ac188b7 Use templates in popular functions 2025-02-06 14:51:47 +01:00
Romeo Van Snick
822128ea93 Remove legacy expression prop to ExpressionWidget 2025-02-06 14:51:47 +01:00
Romeo Van Snick
1c1c05e3bb Add tests for suggestions 2025-02-06 14:51:47 +01:00
Romeo Van Snick
4e6a8646e3 Fix e2e tests 2025-02-06 14:51:47 +01:00
Romeo Van Snick
1248eaedc7 Add info icon to suggestions 2025-02-06 14:51:46 +01:00
Romeo Van Snick
4bac08caad Fix suggestions on focus 2025-02-06 14:51:46 +01:00
Romeo Van Snick
b637e295bf Increase timeout for clicking tooltip 2025-02-06 14:51:46 +01:00
Romeo Van Snick
fa6fd644a1 Allow ^ in helper 2025-02-06 14:51:46 +01:00
Romeo Van Snick
53ec7d975b Fix more tests 2025-02-06 14:51:46 +01:00
Romeo Van Snick
128b686061 Match broken up text in suggestions 2025-02-06 14:51:46 +01:00
Romeo Van Snick
c606b3a391 Handle clicks inside tooltip correctly 2025-02-06 14:51:46 +01:00
Romeo Van Snick
756ff4c11d Fix e2e test that has ace in it 2025-02-06 14:51:46 +01:00
Romeo Van Snick
1d8ef5b603 Do not expand template if next token is ( 2025-02-06 14:51:45 +01:00
Romeo Van Snick
9f9f6a8966 Add missing border 2025-02-06 14:51:45 +01:00
Romeo Van Snick
ea5b684883 Handle click on mantine popover 2025-02-06 14:51:45 +01:00
Romeo Van Snick
e84f324093 Fix Helptext tests 2025-02-06 14:51:45 +01:00
Romeo Van Snick
fc74c763ec Allow field reference highlight to run to end of line 2025-02-06 14:51:45 +01:00
Romeo Van Snick
615668925f Remove px units in HelpText 2025-02-06 14:51:45 +01:00
Romeo Van Snick
5dcfed8233 Fix arg color 2025-02-06 14:51:45 +01:00
Romeo Van Snick
70110d6372 Return when error was handled 2025-02-06 14:51:45 +01:00
Romeo Van Snick
51d8ffee14 Remove ace_text-input check in test 2025-02-06 14:51:44 +01:00
Romeo Van Snick
0b50f480de Autofocus mock CodeMirror editor 2025-02-06 14:51:44 +01:00
Romeo Van Snick
c86a8c89ad Add icons to metrics and segment completions 2025-02-06 14:51:44 +01:00
Romeo Van Snick
a2f0be8920 Highlight matches 2025-02-06 14:51:44 +01:00
Romeo Van Snick
b0e30ccbdf Add fuzzy matching to suggestions 2025-02-06 14:51:44 +01:00
Romeo Van Snick
4834afed3a Rerender tooltip position when cursor changes 2025-02-06 14:51:44 +01:00
Romeo Van Snick
dc409a06e1 Fix font-size in tooltip 2025-02-06 14:51:44 +01:00
Romeo Van Snick
39c4e3f29d Fix more e2e tests 2025-02-06 14:51:43 +01:00
Romeo Van Snick
06068f3761 Enable fast set where needed 2025-02-06 14:51:43 +01:00
Romeo Van Snick
9f63e711d5 Add a textarea mock for CodeMirror 2025-02-06 14:51:43 +01:00
Romeo Van Snick
1946d96fe5 Remove unused eslint disable 2025-02-06 14:51:43 +01:00
Romeo Van Snick
10b543c3dc Render tooltip in Mantine 2025-02-06 14:51:43 +01:00
Romeo Van Snick
b2e7b0abe7 Fix more e2e tests 2025-02-06 14:51:43 +01:00
Romeo Van Snick
32a641548b Add helper to get single completion 2025-02-06 14:51:43 +01:00
Romeo Van Snick
f96d009c7c Add helper for helptext popover 2025-02-06 14:51:43 +01:00
Romeo Van Snick
9762234d9c Use expansion to support more cases with realType 2025-02-06 14:51:42 +01:00
Romeo Van Snick
3f0dc8ea66 Expand -> to → while the user is typing 2025-02-06 14:51:42 +01:00
Romeo Van Snick
6132df32e3 Set cursor at end of the expression when focussing 2025-02-06 14:51:42 +01:00
Romeo Van Snick
6a71ad1f45 Add missing types 2025-02-06 14:51:42 +01:00
Romeo Van Snick
60146fa350 Break up the typeing in CustomExpressionEditor 2025-02-06 14:51:42 +01:00
Romeo Van Snick
b06bbb9a2f WIP: fix e2e tests 2025-02-06 14:51:42 +01:00
Romeo Van Snick
1a351f268b Do not show help text for unsupported clauses 2025-02-06 14:51:42 +01:00
Romeo Van Snick
33daaa7659 Pass metadata to diagnose 2025-02-06 14:51:42 +01:00
Romeo Van Snick
0cddf1d81f Handle commiting from expression editor 2025-02-06 14:51:41 +01:00
Romeo Van Snick
395e965b8c Add types to processSource 2025-02-06 14:51:41 +01:00
Romeo Van Snick
c7364c21d6 Make Editor generic to the type of clause 2025-02-06 14:51:41 +01:00
Romeo Van Snick
88b37a8987 Remove unused onRemoveExpression 2025-02-06 14:51:41 +01:00
Romeo Van Snick
3a646f0c75 Use CustomExpressionEditor helpers in tests 2025-02-06 14:51:41 +01:00
Romeo Van Snick
500de9f946 Add CustomExpressionEditor helpers 2025-02-06 14:51:41 +01:00
Romeo Van Snick
1b302b3352 Allow more characters in field reference name 2025-02-06 14:51:41 +01:00
Romeo Van Snick
8f52fefa8b Do not use import alias 2025-02-06 14:51:40 +01:00
Romeo Van Snick
292c2d5a24 Add id to expression editor textfield 2025-02-06 14:51:40 +01:00
Romeo Van Snick
ffe41ae603 Fix custom expression helper 2025-02-06 14:51:40 +01:00
Romeo Van Snick
82661c8aff Add shortcuts to suggestions 2025-02-06 14:51:40 +01:00
Romeo Van Snick
f3deb72e45 Reenable custom completions 2025-02-06 14:51:40 +01:00
Romeo Van Snick
26b11fd65b Use custom types for completions 2025-02-06 14:51:40 +01:00
Romeo Van Snick
06d51c4420 Remove legacy ExpressionEditorTextfield 2025-02-06 14:51:40 +01:00
Romeo Van Snick
99e315e396 Remove legacy ExpressionEditorSuggestions 2025-02-06 14:51:40 +01:00
Romeo Van Snick
517da1c8a0 Remove legacy ExpressionEditorHelpText 2025-02-06 14:51:39 +01:00
Romeo Van Snick
a6a3482033 Add HelpText tests 2025-02-06 14:51:39 +01:00
Romeo Van Snick
0ae3f0ef27 Add HelpText story 2025-02-06 14:51:39 +01:00
Romeo Van Snick
125cb8f281 Do not set font-size on HelpText 2025-02-06 14:51:39 +01:00
Romeo Van Snick
4cdf51a291 Simplify css 2025-02-06 14:51:39 +01:00
Romeo Van Snick
d1b30ca450 Rename css to S 2025-02-06 14:51:39 +01:00
Romeo Van Snick
db138752e1 Allow acception completion with tab 2025-02-06 14:51:39 +01:00
Romeo Van Snick
9a06321a31 Add missing import 2025-02-06 14:51:38 +01:00
Romeo Van Snick
dc1f6aa796 Find function name by structure 2025-02-06 14:51:38 +01:00
Romeo Van Snick
3d651fc98a Add more tests for enclosingFunction 2025-02-06 14:51:38 +01:00
Romeo Van Snick
a3f5b6961c Remove reference to ExpressionHighlight 2025-02-06 14:51:38 +01:00
Romeo Van Snick
c421c67a70 Move Highlight to a separate folder 2025-02-06 14:51:38 +01:00
Romeo Van Snick
59f9cd20ba Add keyboard handlers for tooltip 2025-02-06 14:51:38 +01:00
Romeo Van Snick
8ede3a23e6 Add keyboard handlers for tooltip 2025-02-06 14:51:38 +01:00
Romeo Van Snick
25d97319da Remove done todo's 2025-02-06 14:51:38 +01:00
Romeo Van Snick
c46f181335 Fix link styling 2025-02-06 14:51:37 +01:00
Romeo Van Snick
3d79acf8ce Use normal font in help text 2025-02-06 14:51:37 +01:00
Romeo Van Snick
513b4b06cd Add monospace font family to global styles 2025-02-06 14:51:37 +01:00
Romeo Van Snick
0ea82ba20c Highligh active argument 2025-02-06 14:51:37 +01:00
Romeo Van Snick
02273cf6fb Match the argument the cursor is on 2025-02-06 14:51:37 +01:00
Romeo Van Snick
d70b8b549a Match the argument that the cursor is on 2025-02-06 14:51:37 +01:00
Romeo Van Snick
63d33c6e49 Add tests for enclosingFunction 2025-02-06 14:51:37 +01:00
Romeo Van Snick
5c6b19e6a7 Move more helpers to util 2025-02-06 14:51:37 +01:00
Romeo Van Snick
65cb5a27ec Rename tooltip to custom-tooltip 2025-02-06 14:51:36 +01:00
Romeo Van Snick
88a96834a8 Move tooltip render into Editor component 2025-02-06 14:51:36 +01:00
Romeo Van Snick
6cfd1b0dcb Move Tooltip custom render into extensions 2025-02-06 14:51:36 +01:00
Romeo Van Snick
c30aefecba Move Tooltip custom render into extensions 2025-02-06 14:51:36 +01:00
Romeo Van Snick
4357eec0dd Pass reportTimezone to extensions 2025-02-06 14:51:36 +01:00
Romeo Van Snick
451cd513b5 Move all view calculations to Tooltip 2025-02-06 14:51:36 +01:00
Romeo Van Snick
37e7b1a716 Move hasFocus out of state 2025-02-06 14:51:36 +01:00
Romeo Van Snick
6535de4687 Move Tooltip to separate module 2025-02-06 14:51:35 +01:00
Romeo Van Snick
88055975ed Move enclosinFunction to util 2025-02-06 14:51:35 +01:00
Romeo Van Snick
bb34194b3b Move HelpText into separate file 2025-02-06 14:51:35 +01:00
Romeo Van Snick
ea561edc22 Handle focus and blur better 2025-02-06 14:51:35 +01:00
Romeo Van Snick
7fc856ea37 Render doc link in help text 2025-02-06 14:51:35 +01:00
Romeo Van Snick
aff09e98f7 Do not add template for ... args 2025-02-06 14:51:35 +01:00
Romeo Van Snick
db0c393f4e Allow toggling function usage 2025-02-06 14:51:35 +01:00
Romeo Van Snick
f571cc264d Use nicer braces for placeholders 2025-02-06 14:51:35 +01:00
Romeo Van Snick
9f603b9a8a Remove completion tooltip styles 2025-02-06 14:51:34 +01:00
Romeo Van Snick
307c88b006 Move highlight classes to highlight module 2025-02-06 14:51:34 +01:00
Romeo Van Snick
b2d336333f Add syntex nodes for arguments and argument lists 2025-02-06 14:51:34 +01:00
Romeo Van Snick
3411fb1075 Show help text when no opening ( has been typed 2025-02-06 14:51:34 +01:00
Romeo Van Snick
04494b93c9 Fix type issue 2025-02-06 14:51:34 +01:00
Romeo Van Snick
f0d96ea69b Reduce max height of completions 2025-02-06 14:51:34 +01:00
Romeo Van Snick
53cce73577 Do not overwrite expression 2025-02-06 14:51:34 +01:00
Romeo Van Snick
4769100532 Handle mousedown instead of click to make click work when blurring 2025-02-06 14:51:33 +01:00
Romeo Van Snick
dcb047a0e9 Do not trigger completion on focus 2025-02-06 14:51:33 +01:00
Romeo Van Snick
b45ff04abf Use correct label for function completions 2025-02-06 14:51:33 +01:00
Romeo Van Snick
3d7541861a Hide helptext when it is after the focus 2025-02-06 14:51:33 +01:00
Romeo Van Snick
705b3b060b Hide tooltip when focus is lost 2025-02-06 14:51:33 +01:00
Romeo Van Snick
a478a34653 Add more todos 2025-02-06 14:51:33 +01:00
Romeo Van Snick
58252b63d4 Match enclosing function more graciously 2025-02-06 14:51:33 +01:00
Romeo Van Snick
1f0251c93e Scroll active completion item into view 2025-02-06 14:51:33 +01:00
Romeo Van Snick
68c28c02c2 Add overflow to completions 2025-02-06 14:51:32 +01:00
Romeo Van Snick
bdce305f9f Handle clicking completion items 2025-02-06 14:51:32 +01:00
Romeo Van Snick
9e82e317e7 Add custom highligher for example 2025-02-06 14:51:32 +01:00
Romeo Van Snick
7d5b65670a WIP: render a custom tooltip for suggestions 2025-02-06 14:51:32 +01:00
Romeo Van Snick
828b7fc563 Disable diagnostic tooltips 2025-02-06 14:51:32 +01:00
Romeo Van Snick
f8c146bb0b Remove sections 2025-02-06 14:51:32 +01:00
Romeo Van Snick
dcf3732879 Sort sections 2025-02-06 14:51:32 +01:00
Romeo Van Snick
2ea5837ab4 Add column icon fields 2025-02-06 14:51:32 +01:00
Romeo Van Snick
31ef5cd8f3 Avoid rerendering view too much 2025-02-06 14:51:31 +01:00
Romeo Van Snick
efc4c416a9 Add snippets to function and aggregation suggestions 2025-02-06 14:51:31 +01:00
Romeo Van Snick
95651c34b6 Do not use custom suggestion for case( 2025-02-06 14:51:31 +01:00
Romeo Van Snick
ca8d48dc92 Add sections to completions 2025-02-06 14:51:31 +01:00
Romeo Van Snick
70f89f54df Do not add space to identifier 2025-02-06 14:51:31 +01:00
Romeo Van Snick
db4e1123dc Allow field completion when not typing field 2025-02-06 14:51:31 +01:00
Romeo Van Snick
8dd9d902bb Render suggestions more metabase-like 2025-02-06 14:51:31 +01:00
Romeo Van Snick
d679412835 Handle dangling strings in tokenAtPos 2025-02-06 14:51:31 +01:00
Romeo Van Snick
76cc6d1011 Trigger completions on focus to show popular expressions/aggregations 2025-02-06 14:51:30 +01:00
Romeo Van Snick
a216f63eb9 Split up suggest function into separate suggesions 2025-02-06 14:51:30 +01:00
Romeo Van Snick
edbb55b9b6 Handle closing tag for reference 2025-02-06 14:51:30 +01:00
Romeo Van Snick
03cb097b12 Add more cases to logical tokens 2025-02-06 14:51:30 +01:00
Romeo Van Snick
953ca89f9f Fix boolean casing 2025-02-06 14:51:30 +01:00
Romeo Van Snick
2acea12560 Remove suggestion delay 2025-02-06 14:51:30 +01:00
Romeo Van Snick
15c3223955 Close suggestions on blur 2025-02-06 14:51:30 +01:00
Romeo Van Snick
7f0cd7db6e Move expression logic into separate hook 2025-02-06 14:51:30 +01:00
Romeo Van Snick
844ffb72a1 Move data-autofocus to extensions 2025-02-06 14:51:29 +01:00
Romeo Van Snick
00936976eb Simplify onError handler 2025-02-06 14:51:29 +01:00
Romeo Van Snick
439aebc09d Remove unused props 2025-02-06 14:51:29 +01:00
Romeo Van Snick
47b579e65b Add initial autocomplete helper 2025-02-06 14:51:29 +01:00
Romeo Van Snick
723c88ef73 Add @codemirror/autocomplete package 2025-02-06 14:51:29 +01:00
Romeo Van Snick
480e824522 Add focus border color 2025-02-06 14:51:29 +01:00
Romeo Van Snick
f14a0730c8 Disable Enter key 2025-02-06 14:51:29 +01:00
Romeo Van Snick
0f538f26bd Disable autocorrect in codemirror editor 2025-02-06 14:51:29 +01:00
Romeo Van Snick
5571fa8f32 Add prefix and input styling for expression editor 2025-02-06 14:51:28 +01:00
Romeo Van Snick
5c1ff4aa01 Add tabIndex to codemirror editor 2025-02-06 14:51:28 +01:00
Romeo Van Snick
426c1d752a Add source to codemirror expression editor 2025-02-06 14:51:28 +01:00
Romeo Van Snick
d79b46ca25 Disable bracket matching for [] in custom expression editor 2025-02-06 14:51:28 +01:00
Romeo Van Snick
985a6773e2 Add linter to custom expression language 2025-02-06 14:51:28 +01:00
Romeo Van Snick
96df46fb00 Add @codemirror/lint package 2025-02-06 14:51:28 +01:00
Romeo Van Snick
c792073ff0 Add rudimentary editor 2025-02-06 14:51:28 +01:00
Romeo Van Snick
3f0b814bcc Add extensions 2025-02-06 14:51:27 +01:00
Romeo Van Snick
88ebc99750 Add language definition 2025-02-06 14:51:27 +01:00
Romeo Van Snick
6926426f87 Tweak grammar 2025-02-06 14:51:27 +01:00
Romeo Van Snick
54b6d26379 Allow numbers in column reference 2025-02-06 14:51:27 +01:00
Romeo Van Snick
57d2e36a32 Add @uiw/react-codemirror 2025-02-06 14:51:27 +01:00
Romeo Van Snick
e533ebdbfa Add @codemirror/language 2025-02-06 14:51:27 +01:00
Romeo Van Snick
ca1056d78b Add LR grammer for custom expressions 2025-02-06 14:51:27 +01:00
Romeo Van Snick
1e8605f2e7 Add script to build LR parser for grammar 2025-02-06 14:51:21 +01:00
Romeo Van Snick
8670fcc0de Add @lezer/generator 2025-02-06 14:51:21 +01:00
78 changed files with 4680 additions and 2525 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)", () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { HelpText } from "./HelpText";

View File

@@ -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" }),

View File

@@ -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" }),

View File

@@ -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" }),

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export { Highlight } from "./Highlight";
export { highlight, highlightStyle } from "./util";

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export { Listbox } from "./Listbox";
export { useCompletions } from "./util";

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { Tooltip } from "./Tooltip";

View File

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

View File

@@ -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])
}
"("
")"
"+"
"-"
"*"
"/"
"="
"!="
"<"
"<="
">"
">="
}

View File

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

View File

@@ -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: "→",
},
});
});
}

View File

@@ -0,0 +1,2 @@
export { Editor } from "./Editor";
export { type Shortcut } from "./suggestions";

View File

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

View File

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

View File

@@ -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) ?? [],
}))
);
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
export { ExpressionEditorHelpText } from "./ExpressionEditorHelpText";

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
export { ExpressionEditorSuggestions } from "./ExpressionEditorSuggestions";

View File

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

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
export {
ExpressionEditorTextfield,
type SuggestionFooter,
type SuggestionShortcut,
} from "./ExpressionEditorTextfield";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": [

View File

@@ -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":